From 8c6db38fab6c16f0197e7c5caa12a42f3317f2fa Mon Sep 17 00:00:00 2001 From: olcxja Date: Sun, 4 Oct 2026 15:40:09 +0200 Subject: [PATCH 01/29] fix dll overrides & add disable UMU --- README.md | 3 +- development.md | 28 +++++++ files/translations/en-us.json | 3 + files/translations/pl.json | 3 + renderer/app.js | 48 ++++++++++-- renderer/index.html | 7 ++ src/importer.js | 19 ++++- src/library.js | 3 + src/openwith.js | 1 + src/prefixes.js | 57 ++++++++++++-- src/runner.js | 144 +++++++++++++++++++++++++++++----- src/symlinks.js | 24 ++++-- 12 files changed, 295 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 9d42d9b..b120c29 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,8 @@ A sync also carries **how the apps are set up**: the Wine/Proton build and prefi - **System Wine** — uses the `wine` binary from PATH. - **Imported builds** — drop any wine or Proton build into `files/wines/`. The folder may contain the build directly, or one level of nesting (zip-extracted layouts are detected automatically). After the copy, PLWL rewires the build's absolute symlinks to relative ones and restores the executable bit on the binaries it needs to run (`proton`, `wine`, `wineserver`, `winetricks`), so a build that arrived from a zip or another machine works without further fiddling. A build can also be given a **displayed name** of its own (Wine versions): it is used everywhere the build is offered — library config, settings and the *Open with PLWL* popup — while the folder in `files/wines/` keeps its name and the symlinks inside prefixes stay valid. -- **Proton without Steam** — fully supported. Proton is invoked like Steam does it: `STEAM_COMPAT_CLIENT_INSTALL_PATH` points to a fake steam client folder, `STEAM_COMPAT_DATA_PATH` is a per-build compat folder, and the prefix lives at `/pfx` which PLWL links to its own prefix folder. No Steam installation required (GE builds work out of the box; for a few Valve builds that refuse to run without a client, PLWL fakes a minimal steam folder in `files/data/fake-steam`). +- **Proton without Steam** — fully supported. Proton is invoked like Steam does it: `STEAM_COMPAT_DATA_PATH` is a per-build compat folder linked to the launcher's own prefix, and `STEAM_COMPAT_CLIENT_INSTALL_PATH` points at an installed Steam client when there is one (Proton reads its legacy DLLs and `steamclient.so` from there, read-only) and at the empty `files/data/fake-steam` otherwise. GE builds work out of the box: PLWL launches through Proton's built-in UMU path (`UMU_ID`), which needs no Steam client at all. An app that breaks under umu.exe has a per-app **Disable UMU** switch in its config — PLWL then runs the build's own wine directly (Proton's `runinprefix` verb): no umu.exe/steam.exe launcher and no Proton prefix setup take part, so the run stays as close to plain wine as possible. (Proton's session init still reads `STEAM_COMPAT_CLIENT_INSTALL_PATH` on every verb; a disableUmu run gets the empty `fake-steam` folder and nothing is copied out of it.) +- **One C: drive per prefix** — Proton normally keeps its prefix one level deeper (`/pfx/drive_c`) than wine (`/drive_c`), which left a prefix used with both kinds with two separate C: drives (two registries, two sets of saves). Whenever a prefix has no `pfx` yet (a fresh prefix, or a plain-wine prefix first used through Proton), PLWL makes `pfx` a relative symlink back to the prefix itself, so Proton and wine share one `drive_c`, one registry and one `dosdevices`. Existing layouts are left exactly as they are — prefixes from older versions keep their real `pfx/` folder, and a prefix with both `drive_c` trees keeps both, because two live Windows installs cannot be merged safely (both stay visible to the prefix import picker). ## Translations diff --git a/development.md b/development.md index ebafbfd..3331162 100644 --- a/development.md +++ b/development.md @@ -122,6 +122,34 @@ handlers in src/ipc/. Keep new APIs in that pattern. relative target) and after a unit lands (healUnitRoots). dosdevices drive mappings and targets that leave files/ (a mount, a system path, the user's home) stay absolute - they are machine-specific by design. +- A prefix that has NO `pfx` yet gets ONE C: drive: `pfx` is a relative + symlink to the prefix itself, so Proton's `/pfx` + (runner.linkPrefix) resolves to `` and shares `drive_c`, the + registry and `dosdevices` with plain wine. `prefixes.ensureUnifiedPrefix` + only ever CREATES that link; an existing layout (a real `pfx/` folder from + an older PLWL, a both-drives prefix) is left exactly as it is. Hoisting a + prefix's files one level up was tried and removed: it breaks every relative + link written against the old depth (the `umu.exe` link into the build is the + one that kills a Proton launch), and moving a live Windows install is not + the launcher's call. `healPrefix`/`rewireSymlinks` repairs such a prefix by + re-anchoring broken relative links through files/. Never assume a single + drive_c path: use `prefixDriveRoots()` (deduplicated) and compare realpaths + when deciding whether a folder is inside a prefix's C: drive. +- App launch env is built in runner.buildEnv only. WINEDLLOVERRIDES is an + ordered semicolon list: inherited value first, the app's overrides last, + because Wine lets a later entry replace an earlier one for the same module. + An empty setting (`dll=`) is Wine's "disabled" and must survive the filter. + Proton appends its own dxvk/nvapi/steam.exe defaults AFTER everything, so an + app override only wins where Proton has no default. `disableUmu` (per app) + drops UMU_ID and switches Proton to its `runinprefix` verb, so no + umu.exe/steam.exe launcher and no Proton prefix setup take part - the + build's own wine runs as close to plain wine as it gets. + STEAM_COMPAT_CLIENT_INSTALL_PATH must be set in BOTH modes: Proton's session + init reads it on every verb (sync_appcache runs even for runinprefix, and + with SteamGameId set a missing variable aborts the launch with a KeyError + before wine starts). On the default path it prefers a real Steam client + found on the machine; a disableUmu run always gets the empty + files/data/fake-steam, whose contents are never read. - Wine and Proton create prefix links with ABSOLUTE local targets whenever they update a prefix (font/dll stubs, Steam appcache), so on-disk links are not always relative after a run: the sync payload relativizes them anyway, and diff --git a/files/translations/en-us.json b/files/translations/en-us.json index f3648ff..e017c1a 100644 --- a/files/translations/en-us.json +++ b/files/translations/en-us.json @@ -81,11 +81,14 @@ "config.prefixDefault": "Default prefix ({0})", "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.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.save": "Save changes", "config.shortcut": "Create shortcut", "config.shortcutCreated": "Shortcut for {0} created (menu + desktop)", diff --git a/files/translations/pl.json b/files/translations/pl.json index e00eae0..26cc0cd 100644 --- a/files/translations/pl.json +++ b/files/translations/pl.json @@ -81,11 +81,14 @@ "config.prefixDefault": "Prefiks domyślny ({0})", "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.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.save": "Zapisz zmiany", "config.shortcut": "Utwórz skrót", "config.shortcutCreated": "Skrót dla {0} utworzony (menu + pulpit)", diff --git a/renderer/app.js b/renderer/app.js index 5e4888b..771180f 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1235,6 +1235,7 @@ async function openGameConfig(id) { const lockHint = qs('#gc-prefix-lock-hint') if (lockHint) lockHint.style.display = prefixLocked ? '' : 'none' renderOverrides(game.dllOverrides || []) + qs('#gc-disable-umu').checked = !!game.disableUmu renderTools() } @@ -1257,10 +1258,12 @@ 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) const OVERRIDE_OPTIONS = [ + { value: 'native,builtin', label: 'native,builtin' }, { value: 'native', label: 'native' }, { value: 'builtin', label: 'builtin' }, - { value: 'native,builtin', label: 'native,builtin' }, { value: 'builtin,native', label: 'builtin,native' }, { value: '', label: 'disabled' } ] @@ -1274,23 +1277,48 @@ function buildOverrideRow(o) { ` row.querySelector('.dll').value = o.dll || '' const selectHost = document.createElement('span') - new CustomSelect(selectHost, OVERRIDE_OPTIONS, o.setting || 'native') + // a stored empty setting IS the "disabled" choice - a `||` fallback would + // turn every disabled override back into the default on reopen + 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() return row } function addOverrideRow() { - qs('#gc-overrides').appendChild(buildOverrideRow({ dll: '', setting: 'native' })) + qs('#gc-overrides').appendChild(buildOverrideRow({ dll: '', setting: 'native,builtin' })) const rows = qsa('#gc-overrides .override-row') 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. +function normalizeOverrideSetting(raw) { + const v = String(raw || '').trim().toLowerCase().replace(/\s+/g, '') + if (!v || v === 'd' || v === 'disabled') return '' + if (v === 'n' || v === 'native') return 'native' + if (v === 'b' || v === 'builtin') return 'builtin' + if (v === 'n,b' || v === 'native,builtin') return 'native,builtin' + if (v === 'b,n' || v === 'builtin,native') return 'builtin,native' + return null +} + function readOverrides() { - return qsa('#gc-overrides .override-row').map(row => ({ - dll: row.querySelector('.dll').value.trim(), - setting: row.querySelector('.custom-select').dataset.value ?? 'native' - })).filter(o => o.dll) + 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 + const eq = dll.indexOf('=') + if (eq > 0) { + const pasted = normalizeOverrideSetting(dll.slice(eq + 1)) + if (pasted !== null) setting = pasted + dll = dll.slice(0, eq).trim() + } + dll = dll.replace(/\.dll$/i, '') + return { dll, setting } + }).filter(o => o.dll) } async function saveGameConfig() { @@ -1322,10 +1350,14 @@ async function saveGameConfig() { wine: selects['gc-wine'].value, prefix, env, - dllOverrides: readOverrides() + dllOverrides: readOverrides(), + disableUmu: qs('#gc-disable-umu').checked } 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 + renderOverrides(patch.dllOverrides) toast(t('config.saved'), 'success') if (prefixLockedHint) toast(t('config.prefixLocked'), 'error') if (state.currentView === 'gameconfig') { diff --git a/renderer/index.html b/renderer/index.html index 40f3631..714e1c4 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -180,12 +180,19 @@ + +

+
+

diff --git a/src/importer.js b/src/importer.js index 78cba15..5a19765 100644 --- a/src/importer.js +++ b/src/importer.js @@ -1,6 +1,7 @@ 'use strict' -const fsp = require('fs').promises +const fs = require('fs') +const fsp = fs.promises const path = require('path') const { DIRS, resolvePortablePath } = require('./paths') const { newGame, newGameId, addGame } = require('./library') @@ -82,8 +83,22 @@ async function importGameFromPrefix(prefix, srcDir, onProgress) { 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) + let realSrc + try { + realSrc = fs.realpathSync(src) + } catch { + return { ok: false, error: 'outside-drive-c' } + } const insideDrive = driveRoots.some(root => { - const rel = path.relative(root, src) + let realRoot + try { + realRoot = fs.realpathSync(root) + } catch { + return false + } + const rel = path.relative(realRoot, realSrc) return rel && !rel.startsWith('..') && !path.isAbsolute(rel) }) if (!insideDrive) return { ok: false, error: 'outside-drive-c' } diff --git a/src/library.js b/src/library.js index 309d3c9..7aa98af 100644 --- a/src/library.js +++ b/src/library.js @@ -217,6 +217,9 @@ 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) + disableUmu: false, env: {}, iconSource: '', playSeconds: 0, diff --git a/src/openwith.js b/src/openwith.js index fb256a1..520c4de 100644 --- a/src/openwith.js +++ b/src/openwith.js @@ -73,6 +73,7 @@ function makeTarget(openPath, wineId, prefixName) { prefix: prefixName || '', args: '', dllOverrides: [], + disableUmu: false, env: {}, iconSource: '', playSeconds: 0, diff --git a/src/prefixes.js b/src/prefixes.js index 1a7b237..854b5e5 100644 --- a/src/prefixes.js +++ b/src/prefixes.js @@ -15,6 +15,7 @@ function seed() { if (!defs || !Array.isArray(defs.prefixes) || !defs.prefixes.length) { const seeded = { default: MAIN, prefixes: [{ name: MAIN, created: Date.now(), locked: true }] } if (!fs.existsSync(prefixPath(MAIN))) fs.mkdirSync(prefixPath(MAIN), { recursive: true }) + ensureUnifiedPrefix(MAIN) writeJson(REGISTRY, seeded) } } @@ -42,22 +43,65 @@ function prefixExists(name) { } // Every Windows C: drive a prefix has: plain wine keeps one at the top -// (WINEPREFIX=), Proton-managed prefixes nest one under pfx/ -// (WINEPREFIX=/pfx). A prefix used with BOTH wine kinds has both, -// and an import may come from either one - so callers that can accept -// several get them all, top level first. +// (WINEPREFIX=), legacy Proton-managed prefixes nest one under pfx/ +// (WINEPREFIX=/pfx). A prefix used with BOTH wine kinds has both, and +// an import may come from either one - so callers that can accept several get +// them all, top level first. A unified prefix (pfx -> .) reaches the same +// drive twice; it is reported once. function prefixDriveRoots(name) { const root = prefixPath(name) const found = [] + const seen = new Set() for (const base of [root, path.join(root, 'pfx')]) { try { const driveC = path.join(base, 'drive_c') - if (fs.statSync(driveC).isDirectory()) found.push(driveC) + if (!fs.statSync(driveC).isDirectory()) continue + const real = fs.realpathSync(driveC) + if (seen.has(real)) continue + seen.add(real) + found.push(driveC) } catch {} } return found } +function lstatOrNull(p) { + try { + return fs.lstatSync(p) + } catch { + return null + } +} + +// Wine keeps its C: drive at /drive_c, Proton always runs its prefix +// from /pfx - and PLWL points compatdata at the launcher's prefix +// folder, so without a link a prefix used with both wine kinds grows TWO C: +// drives with separate registries, saves and installed DLLs. Making `pfx` a +// relative symlink back to the prefix itself collapses them: Proton's +// /pfx resolves to the prefix root, the exact folder wine uses. +// Every walker PLWL owns (fsutil, symlinks, LAN sync) treats symlinks as +// links and never follows them, so the self-reference is safe. +// +// Only a prefix that has no pfx AT ALL gets the link - that covers a fresh +// prefix and a plain-wine prefix first used through Proton. An existing +// layout is left exactly as it is: a prefix from an older PLWL keeps its real +// pfx/ folder (and here the two wine kinds keep two C: drives), and a prefix +// with both real drive_c trees keeps them - moving Windows installs around +// behind the user's back loses data and breaks the absolute/relative links +// inside them. +function ensureUnifiedPrefix(name) { + if (!name) return false + const pfx = path.join(prefixPath(name), 'pfx') + if (lstatOrNull(pfx)) return false + try { + fs.symlinkSync('.', pfx, 'dir') + return true + } catch { + // the prefix folder may not exist yet - the link is added on creation + return false + } +} + // 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 @@ -82,6 +126,7 @@ function createPrefix(name) { if (clean.toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'alreadyExists' } if (fs.existsSync(prefixPath(clean))) return { ok: false, error: 'alreadyExists' } fs.mkdirSync(prefixPath(clean), { recursive: true }) + ensureUnifiedPrefix(clean) const defs = listPrefixes() defs.prefixes.push({ name: clean, created: Date.now() }) if (!defs.prefixes.some(p => p.name === defs.default)) defs.default = clean @@ -153,4 +198,4 @@ function setDefaultPrefix(name) { return { ok: true } } -module.exports = { listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoots, isPrefixRoot, healPrefix, MAIN } +module.exports = { listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoots, isPrefixRoot, healPrefix, ensureUnifiedPrefix, MAIN } diff --git a/src/runner.js b/src/runner.js index 635febc..441fe2b 100644 --- a/src/runner.js +++ b/src/runner.js @@ -2,13 +2,14 @@ const path = require('path') const fs = require('fs') +const os = require('os') const { spawn } = require('child_process') const { DIRS } = require('./paths') -const { prefixPath } = require('./prefixes') +const { prefixPath, ensureUnifiedPrefix } = require('./prefixes') const { prefixNameForGame } = require('./library') -// The fake Steam install Proton is pointed at through -// STEAM_COMPAT_CLIENT_INSTALL_PATH. Both folders Proton links into its prefix +// The fake Steam install PLWL falls back to when no real Steam client is +// found (see findSteamClientDir). Both folders Proton links into its prefix // have to exist: it symlinks /drive_c/Program Files (x86)/Steam/ // appcache -> /appcache while setting the prefix up, and a link into a // missing folder is a dangling link in a tree that gets synced. @@ -19,6 +20,73 @@ function ensureFakeSteam() { return fake } +// Proton expects the Steam client it is normally started by at +// STEAM_COMPAT_CLIENT_INSTALL_PATH: it copies the client's legacy DLLs from +// /legacycompat into the prefix, its built-in steam.exe loads the +// client's own steamclient.so, and media libs come from /ubuntu12_*. +// A client installed on this machine is worth using (read-only) - without one +// a Valve build's launch dies in a "steamclient_main.c" assert dialog - and +// only when there is none does PLWL fall back to the empty fake folder. GE +// builds don't need any of it: their UMU path never starts steam.exe. +const STEAM_CLIENT_MARKERS = [ + ['legacycompat', 'steamclient.dll'], + ['ubuntu12_32', 'steamclient.so'] +] + +function findSteamClientDir() { + const home = os.homedir() + const dataHome = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share') + const candidates = [ + process.env.STEAM_COMPAT_CLIENT_INSTALL_PATH, + path.join(home, '.steam', 'steam'), + path.join(home, '.steam', 'root'), + path.join(dataHome, 'Steam'), + // flatpak Steam keeps its install inside the sandbox's data dir + path.join(home, '.var', 'app', 'com.valvesoftware.Steam', '.local', 'share', 'Steam') + ] + for (const dir of candidates) { + if (!dir) continue + for (const marker of STEAM_CLIENT_MARKERS) { + if (fs.existsSync(path.join(dir, ...marker))) return dir + } + } + return null +} + +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. +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. +function dllOverridesFor(game, env) { + const parts = [] + const inherited = String(env.WINEDLLOVERRIDES || '').trim().replace(/;+$/, '') + if (inherited) parts.push(inherited) + for (const entry of game.dllOverrides || []) { + if (!entry) continue + const dll = sanitizeDllName(entry.dll) + if (!dll) continue + const setting = String(entry.setting == null ? '' : entry.setting).trim() + if (!/^[a-z,]*$/i.test(setting)) continue + parts.push(`${dll}=${setting}`) + } + 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. @@ -74,23 +142,41 @@ function buildEnv({ game, wine }) { 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 - const prefixDir = prefixPath(prefixNameForGame(game)) + 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) + try { + ensureUnifiedPrefix(prefixName) + } catch {} + const prefixDir = prefixPath(prefixName) if (isProton) { - const fakeSteam = ensureFakeSteam() - const buildRoot = path.resolve(wine.path, '..') - env.STEAM_COMPAT_CLIENT_INSTALL_PATH = fakeSteam env.STEAM_COMPAT_DATA_PATH = path.join(DIRS.DATA, 'compat', game.id) env.STEAM_COMPAT_APP_ID = '0' env.SteamAppId = '0' env.SteamGameId = '0' - // Launch like the UMU protocol does (UMU/PortProton/Heroic): with UMU_ID - // set, newer Protons (incl. DW Proton 11, GE-Proton 10+) run the game via - // their built-in umu.exe instead of the steam.exe helper, which would - // otherwise try to load Steam's steamclient.so and crash with an - // "Assertion failed \"!status\"" dialog (steamclient_main.c) when no - // Steam client is installed. - if (!env.UMU_ID) env.UMU_ID = 'umu-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). + 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). + 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 }) @@ -108,16 +194,25 @@ function buildEnv({ game, wine }) { env.WINEPREFIX = prefixDir } - env.WINEDLLOVERRIDES = (game.dllOverrides || []) - .filter(o => o && o.dll && o.setting) - .map(o => `${o.dll}=${o.setting}`) - .join(';') - if (!env.WINEDLLOVERRIDES) delete env.WINEDLLOVERRIDES + const overrides = dllOverridesFor(game, env) + 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 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. +function protonVerb(game) { + return game.disableUmu ? 'runinprefix' : 'run' +} + function parseArgs(args) { const out = [] const re = /"([^"]*)"|'([^']*)'|(\S+)/g @@ -167,7 +262,7 @@ async function runGame({ game, wine, systemWine, onExit }) { let base if (isProton) { cmd = wine.wineBin || path.join(wine.path, 'proton') - base = ['run', exe, ...argList] + base = [protonVerb(game), exe, ...argList] } else { cmd = systemWine ? 'wine' : wine.wineBin base = [exe, ...argList] @@ -200,6 +295,13 @@ async function runGame({ game, wine, systemWine, onExit }) { started = true 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 + ws.write(`[plwl] ${cmd} ${base.join(' ')}\n`) + ws.write(`[plwl] WINEPREFIX=${env.WINEPREFIX}\n`) + if (env.WINEDLLOVERRIDES) ws.write(`[plwl] WINEDLLOVERRIDES=${env.WINEDLLOVERRIDES}\n`) + if (isProton) ws.write(`[plwl] proton launcher: ${game.disableUmu ? 'direct wine (no UMU, no Steam)' : `umu.exe (UMU_ID=${env.UMU_ID})`}\n`) child.stdout.pipe(ws) child.stderr.pipe(ws) child.unref() @@ -237,7 +339,7 @@ async function runTool({ game, wine, tool, args, systemWine }) { let base if (isProton) { cmd = wine.wineBin || path.join(wine.path, 'proton') - base = ['run', tool, ...argList] + base = [protonVerb(game), tool, ...argList] } else if (systemWine) { cmd = tool base = argList diff --git a/src/symlinks.js b/src/symlinks.js index cf724d9..e1f774c 100644 --- a/src/symlinks.js +++ b/src/symlinks.js @@ -16,7 +16,11 @@ // // 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. +// the executable bit on the binaries a wine/proton build needs. An absolute +// link is always rewritten; a relative one is only touched when it no longer +// resolves, because a tree that changed layout (a prefix whose contents moved +// one level up, see prefixes.ensureUnifiedPrefix) keeps relative links written +// against the old depth and every one of them breaks at once. const fsp = require('fs').promises const fs = require('fs') @@ -116,10 +120,11 @@ function relativeTarget(link, target, roots) { return null } -// Rewire every absolute symlink under `root` that can be expressed relative to -// `roots` (usually [root, DIRS.FILES]). Returns how many links changed. A link -// may only become rewirable once another link it chains through has been -// fixed, so the pass repeats until nothing changes. +// 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. async function rewireSymlinks(root, roots = []) { if (!root) return 0 const candidates = [root, ...roots.filter(r => r && r !== root)] @@ -135,8 +140,13 @@ async function rewireSymlinks(root, roots = []) { } catch { continue } - if (!path.isAbsolute(target)) continue // already relocatable - const next = relativeTarget(link, path.normalize(target), candidates) + 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 + 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) if (!next || next === target) continue try { await fsp.unlink(link) From 59ff0b108842c1a3fb0569c46dffdf9314cfeeb6 Mon Sep 17 00:00:00 2001 From: olcxja Date: Sun, 4 Oct 2026 19:12:33 +0200 Subject: [PATCH 02/29] improvemets & fixes: - added 'open with' handling to desktop shortcuts - move logs button to app configuration - fix sync progress disappearing after long hashing - fix sync double file counter - merge wine and proton prefix folders via symlink --- README.md | 8 +- development.md | 44 +++++- files/translations/en-us.json | 5 +- files/translations/pl.json | 5 +- renderer/app.js | 47 ++++-- renderer/index.html | 1 + src/argv.js | 35 ++++- src/fsutil.js | 27 ++++ src/ipc/games.js | 18 ++- src/lansync.js | 280 +++++++++++++++++++++++++++------- src/lansyncpush.js | 64 ++++++-- src/main.js | 14 +- src/runner.js | 38 ++++- src/shortcuts.js | 10 +- src/wines.js | 37 ++++- 15 files changed, 520 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index b120c29..0c00d17 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ Besides copy/move/leave, an app can be imported **from a wine prefix**: pick the The desktop entry (`MimeType` + `Exec %f`) makes PLWL show up in Dolphin/Nautilus under **Open With** for Windows executables (`.exe`, `.msi`, `.bat`, `.cmd`, `.lnk`). Opening a file that way shows a small popup in the launcher where you pick the **Wine/Proton version** and the **prefix** for this run — both default to the launcher's defaults, nothing is imported into the library. A second launcher instance hands the path to the running one and exits (same single-instance mechanism the shortcuts use). +The app shortcuts created in an app's configuration work as **Open With** targets too: pick one in the file manager (right-click a photo → Open With → your Photoshop shortcut) and that app starts with the file, receiving it as the Windows path it would get on Windows — `C:\...` when the file sits inside its prefix, `Z:\...` for the rest of the machine. Giving a file to an app that is already running hands it to that instance instead of starting a second tracked run. + ## LAN syncing Two PLWL installs on the same LAN can mirror each other's content - no cloud, no accounts. Open **LAN syncing** in the sidebar (under Settings): @@ -55,15 +57,15 @@ Two PLWL installs on the same LAN can mirror each other's content - no cloud, no - **Sync code** - 8 characters, letters and digits, case-insensitive. A device only accepts a connection when the caller proves it knows *its own* code; each verification costs two seconds and a device may only have one in flight, so guessing over the LAN is pointless. Both the ID and the code can be re-rolled. A correct code is still not enough: the receiving device shows an **Accept / Decline popup** before anything moves, and without a click within a minute the sender is turned down. Declining (or ignoring) the popup never touches this machine and does not unpair the two devices. - **Devices on your network** - other PLWL installs, found over UDP; a device shows up only while PLWL is running there, and disappears a few seconds after it is closed. -Pick a device and choose what to push: **portable apps**, **apps imported from a prefix**, **Wine/Proton builds**, **prefixes** and **Default settings**. Only what is ticked travels - a sync without **Default settings** never touches the other machine's defaults. The code has to be typed once - an accepted code is stored next to that device ID, so every later sync needs no typing. If the other side re-rolled its code, the stored one is forgotten and the launcher asks for the new one instead of failing silently. +Pick a device and choose what to push: **portable apps**, **apps imported from a prefix**, **Wine/Proton builds**, **prefixes** and **Sync settings**. Only what is ticked travels - a sync without **Sync settings** never touches the other machine's defaults, names or app configurations. The code has to be typed once - an accepted code is stored next to that device ID, so every later sync needs no typing. If the other side re-rolled its code, the stored one is forgotten and the launcher asks for the new one instead of failing silently. -**Both machines show what is happening.** The sender gets a progress popup and the receiving device gets the same popup titled *Receiving from X…*, driven by the transfer as it arrives. Both show the current phase and unit, the files being moved, a sent/total counter per category (`Apps: 2/5 · Prefixes: 1/2`) and the transferred vs. planned size. The bar follows **bytes, not file counts**, and it belongs to the phase being worked on: hashing fills its own bar and the transfer starts again from empty, so nothing ever looks finished while it has just begun (the byte line next to it keeps the overall transferred vs. planned total). Both popups warn that the launcher must stay open, and a running sync is not cancellable from the outside: clicking the backdrop or pressing Escape does nothing. If the other device goes quiet for a few seconds (it hashes before it sends) the popup says *Waiting for the other device…* rather than closing; it closes itself when the transfer ends, or after fifteen minutes of complete silence when the other side has vanished. +**Both machines show what is happening.** The sender gets a progress popup and the receiving device gets the same popup titled *Receiving from X…*, driven by the transfer as it arrives. Both show the current phase and unit, the files being moved, a sent/total counter per category (`Apps: 2/5 · Prefixes: 1/2`) and the transferred vs. planned size. The bar follows **bytes, not file counts**, and it belongs to the phase being worked on: hashing fills its own bar and the transfer starts again from empty, so nothing ever looks finished while it has just begun (the byte line next to it keeps the overall transferred vs. planned total). Both popups warn that the launcher must stay open, and a running sync is not cancellable from the outside: clicking the backdrop or pressing Escape does nothing. If the other device goes quiet for a few seconds (it hashes before it sends) the popup says *Waiting for the other device…* rather than closing, and the sending side keeps signalling while it works, so even a long hashing phase never looks like a dead peer. The popup closes itself when the transfer ends, or after fifteen minutes of complete silence when the other side has really vanished — and comes back if the sync turns out to be alive after all. The **sending device is the source of truth**: files that differ are overwritten, missing ones are written, and anything living only on the receiving side inside the synced trees (`files/apps/`, `files/wines/`, `files/wineprefixes/`, `files/data/icons/`) is deleted. Files are compared with a fast, collision-resistant hash (BLAKE2b-512, SHA-512/SHA-256 fallback); **Force sync all files** skips the comparison and overwrites everything instead. Hashes are remembered per file (size + modification time), so a repeat sync of a library that did not change reads nothing at all. **Wine builds and prefixes survive the trip.** Permissions travel with a sync, so the `proton`, `wine` and `wineserver` binaries are still executable on the other machine (a build whose executable bit was lost is additionally repaired at startup, without re-importing it), and symlinks are sent as RELATIVE paths whenever the file they point at belongs to the launcher: a Proton prefix is full of links into its build, and the receiving side gets links that resolve to its own copy instead of the sender's `/home/...` folders. Links that leave the launcher on purpose (`dosdevices` drive mappings, a mounted drive, the user's home folder) stay absolute, and links into a folder that does not exist yet on the other machine are still written relative, so they start working the moment that folder appears. Wine and Proton rewrite a prefix's links with local absolute targets while they update it; PLWL relativizes them again on the wire, on import and before every run. -A sync also carries **how the apps are set up**: the Wine/Proton build and prefix each app runs in, its arguments, environment variables and DLL overrides, plus playtime and last played. The launcher's **default Wine build and its default prefix** travel with the **Default settings** category, and only if that build or prefix exists on the other machine - an id it does not have is ignored instead of leaving a dangling default behind. Playtime only ever grows - the higher counter wins - so pushing from a freshly installed machine cannot wipe a library's play history. Everything else stays local: language and window settings, logs, translations, and external (leave) apps - prefixes are always kept usable, so app entries pointing inside a prefix keep their prefix-relative paths and the receiving side never loses its own `Main` prefix. +A sync also carries **how the apps are set up**: the Wine/Proton build and prefix each app runs in, its arguments, environment variables and DLL overrides, plus playtime and last played. The **Sync settings** category carries the registry-level part of that even when no files move: the launcher's **default Wine build and default prefix**, the custom **names** you gave Wine/Proton builds (and their disabled flags), the **prefix registry**, every **app's configuration**, and the **order of the library**. It only patches what already exists on the other machine - an unknown Wine id or prefix is ignored instead of leaving a dangling default or a broken app behind, apps and prefixes the sender does not know stay where they are, and a settings-only sync deletes nothing (that is what the file categories are for). Playtime only ever grows - the higher counter wins - so pushing from a freshly installed machine cannot wipe a library's play history. Everything else stays local: language and window settings, logs, translations, and external (leave) apps - prefixes are always kept usable, so app entries pointing inside a prefix keep their prefix-relative paths and the receiving side never loses its own `Main` prefix. ## Wine & Proton support diff --git a/development.md b/development.md index 3331162..51bf94b 100644 --- a/development.md +++ b/development.md @@ -102,9 +102,20 @@ handlers in src/ipc/. Keep new APIs in that pattern. looks finished the moment it starts. Hashing and copying walk tens of thousands of files, so every per-file update goes through progressGate (~10/s, phase changes forced) - never send one IPC message per file. -- The launcher's own defaults are opt-in (the "defaults" sync category): - payload.defaults carries the default wine and prefix, and the receiver only - applies values that exist there (an unknown wine id or prefix is skipped). +- Registry-level settings are opt-in (the "settings" sync category, shown as + "Sync settings"; the old name "defaults" is still accepted as an alias on + BOTH sides - the receiver normalizes payload.categories, so a sender from an + older version keeps working). payload carries defaults (default wine and + prefix), wineSettings (displayName + disabled per build id), prefixSettings + (the registry names; a settings sync never renames or creates folders - + that is the prefixes category), appSettings (name, wine, prefix, args, env, + dllOverrides, disableUmu per app id) and appOrder. The receiver only patches + things that already exist there: an unknown wine id / prefix / app is + skipped, nothing is created or deleted, and the order merge keeps local + entries the sender does not know at the end. Values are written through the + normal helpers (setWineDisplayName clears a custom label when the sender has + none, library.updateGame pins the prefix of prefix imports, reorderGames + merges by id). - LAN sync remembers file hashes in files/data/lansync-hashes.json, keyed by path with size + mtime (machine-local, never synced): equal size and mtime means equal bytes, so a repeat sync only stat's the tree. Hashing and the @@ -160,6 +171,33 @@ handlers in src/ipc/. Keep new APIs in that pattern. - File permissions travel with a sync (manifest entry `mode`): the receiving side applies them after writing the bytes and repairs a file that has the same hash but the wrong mode without re-uploading it. +- A sync must never walk through a symlinked ROOT. pruneTree/healUnitRoots + (sync), removeTree/treeSize (fsutil) and healBuild (wines) lstat their root + first and treat anything that is not a real directory as the link (or file) + it is: a build, prefix or app folder kept on another disk must not have its + target deleted, pruned or rewritten through the link. `listDirs()` in the + sync counts symlinked folders as units, so such a folder still travels - as + the link it is (`wineUnits` maps nested builds to their TOP folder under + wines/, otherwise a zip-extracted build becomes a phantom unit whose root + does not exist and the "Wine versions" totals grow). +- Generated app shortcuts are file handlers too: `Exec= + --launch --file %f` (the field code is appended after escaping - a + literal % is `%%`). argv.js takes the file from `--file` or from a bare + trailing path (KDE appends it to Exec lines without a file field), and + runner.windowsPathFor passes it to the app the way Windows would: `C:\...` + when it sits inside the prefix's drive_c, `Z:\...` otherwise. Giving a file + to an app that is ALREADY running is a handoff (runGame `handoff: true`): + an extra untracked process, no log/playtime writes, because the app's own + single-instance logic is what actually opens the file. +- A sender hashing a big unit is minutes of network silence: it pings + `/plwl/ping` (+token) every HEARTBEAT_MS for the whole sync, the receiver's + handler re-arms its popup watchdog (stage 'wait' changes nothing on screen), + and a receiving popup its watchdog closed reopens on the next real event - + only the 'done' stage ends a sync for good, and reopening NEVER asks for + consent again: the Accept/Decline gate is the onIncoming popup at verify + time (a fresh session), while the progress popup is driven by the same + token's events. A 'wait' event that reopens a closed popup fills the phase + line (`recvWaiting`) instead of leaving every line blank. - Tests are plain assert-by-print scripts (see test/): set PLWL_BUILD_DIR to a temp dir, check(), exit 1 on failure. Add one when fixing a bug. diff --git a/files/translations/en-us.json b/files/translations/en-us.json index e017c1a..c5b1806 100644 --- a/files/translations/en-us.json +++ b/files/translations/en-us.json @@ -35,7 +35,6 @@ "library.removeFilesNote": "\nIts imported files in files/apps will also be deleted.", "library.removePrefixNote": "\nFiles stay in the prefix, only the launcher entry is removed.", "library.tools": "Wine tools", - "library.viewLogs": "View logs", "library.reorderHint": "Drag to change the order", "import.question": "Should the app files be brought into the launcher so it stays portable, or left where they are?", "import.mode.copy": "Copy into launcher (portable)", @@ -93,6 +92,8 @@ "config.shortcut": "Create shortcut", "config.shortcutCreated": "Shortcut for {0} created (menu + desktop)", "config.shortcutFailed": "Failed to create the shortcut", + "config.viewLogs": "View logs", + "config.noLogs": "No logs yet - run the app first", "config.saved": "Configuration saved", "config.back": "Back to library", "config.browse": "Browse", @@ -193,7 +194,7 @@ "lansync.appsPrefix": "Apps imported from a prefix", "lansync.wines": "Wine / Proton versions", "lansync.prefixes": "Wine prefixes", - "lansync.defaults": "Default settings (default Wine and prefix)", + "lansync.settings": "Sync settings (names, app configs and order)", "lansync.force": "Force sync all files (skip the hash check and overwrite everything)", "lansync.mirrorWarning": "This device is the source of truth: on the other device, files and items that do not exist here will be deleted.", "lansync.incomingMirror": "The other device is the source of truth: files and items on this device that do not exist there will be deleted.", diff --git a/files/translations/pl.json b/files/translations/pl.json index 26cc0cd..4329d0e 100644 --- a/files/translations/pl.json +++ b/files/translations/pl.json @@ -35,7 +35,6 @@ "library.removeFilesNote": "\nJej zaimportowane pliki w files/apps również zostaną usunięte.", "library.removePrefixNote": "\nPliki zostają w prefiksie, usuwany jest tylko wpis w launcherze.", "library.tools": "Narzędzia Wine", - "library.viewLogs": "Podejrzyj logi", "library.reorderHint": "Przeciągnij, aby zmienić kolejność", "import.question": "Czy pliki aplikacji mają trafić do launchera, aby był przenośny, czy mają zostać tam, gdzie są?", "import.mode.copy": "Skopiuj do launchera (przenośne)", @@ -93,6 +92,8 @@ "config.shortcut": "Utwórz skrót", "config.shortcutCreated": "Skrót dla {0} utworzony (menu + pulpit)", "config.shortcutFailed": "Nie udało się utworzyć skrótu", + "config.viewLogs": "Podejrzyj logi", + "config.noLogs": "Brak logów — uruchom najpierw aplikację", "config.saved": "Konfiguracja zapisana", "config.back": "Wróć do biblioteki", "config.browse": "Przeglądaj", @@ -193,7 +194,7 @@ "lansync.appsPrefix": "Aplikacje importowane z prefiksu", "lansync.wines": "Wersje Wine / Proton", "lansync.prefixes": "Prefiksy Wine", - "lansync.defaults": "Ustawienia domyślne (domyślne Wine i prefiks)", + "lansync.settings": "Ustawienia (nazwy, konfiguracje i kolejność aplikacji)", "lansync.force": "Wymuś synchronizację wszystkich plików (pomiń sprawdzanie hashy i nadpisz wszystko)", "lansync.mirrorWarning": "To urządzenie jest źródłem prawdy: na drugim urządzeniu pliki i elementy, których tu nie ma, zostaną usunięte.", "lansync.incomingMirror": "Drugie urządzenie jest źródłem prawdy: pliki i elementy na tym urządzeniu, których tam nie ma, zostaną usunięte.", diff --git a/renderer/app.js b/renderer/app.js index 771180f..0f8aca2 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -45,7 +45,6 @@ const ICONS = { folder: () => '', trash: () => '', terminal: () => '', - logs: () => '', star: () => '', download: () => '', chevronDown: () => '' @@ -598,7 +597,6 @@ function renderLibrary() { -
` @@ -619,11 +617,6 @@ function renderLibrary() { } card.querySelector('[data-act=config]').onclick = () => openGameConfig(game.id) card.querySelector('[data-act=folder]').onclick = () => window.plwl.shellOpenPath(game.workdir) - card.querySelector('[data-act=logs]').onclick = async () => { - const logs = await window.plwl.gamesGetLogs(game.id) - if (logs.length) window.plwl.shellShowItemInFolder(logs[logs.length - 1]) - else toast(t('library.viewLogs')) - } card.querySelector('[data-act=remove]').onclick = async () => { const note = game.mode === 'copy' || game.mode === 'move' ? t('library.removeFilesNote') @@ -1250,6 +1243,16 @@ 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. +async function showAppLogs() { + const game = state.currentGame + if (!game) return + const logs = await window.plwl.gamesGetLogs(game.id) + if (logs.length) window.plwl.shellShowItemInFolder(logs[logs.length - 1]) + else toast(t('config.noLogs'), 'error') +} + function renderOverrides(overrides) { const box = qs('#gc-overrides') box.innerHTML = '' @@ -1401,8 +1404,10 @@ const LAN_CATEGORIES = [ { id: 'apps-prefix', label: 'lansync.appsPrefix' }, { id: 'wines', label: 'lansync.wines' }, { id: 'prefixes', label: 'lansync.prefixes' }, - // opt-in: without it a sync never touches the other machine's defaults - { id: 'defaults', label: 'lansync.defaults' } + // 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. + { id: 'settings', label: 'lansync.settings' } ] // Byte sizes for the progress popups. Decimal units, the way a file manager @@ -1742,7 +1747,11 @@ function onLanIncoming(info) { } let receiving = false -let receiveClosed = 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. +let receiveEnded = false let receiveWatchdog = null let receiveStall = null let receiveCloseTimer = null @@ -1777,7 +1786,6 @@ function endReceiving(message) { } const wasReceiving = receiving receiving = false - receiveClosed = true if (!wasReceiving) return if (message) toast(message, 'error') closeModal() @@ -1801,13 +1809,14 @@ function armReceiveWatchdog() { function onLanReceiving(info) { if (!info || !info.stage) return if (state.lanPushing) return // our own push owns the (single) popup - if (info.stage === 'start') receiveClosed = false + if (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 has to come back for the rest of the transfer. A sync + // timeout closed comes back for the rest of the transfer. Only a sync // that already ended stays closed. - if (receiveClosed && info.stage !== 'start') return + if (receiveEnded) return if (qs('#modal-overlay').style.display !== 'none') return receiving = true const modal = showModal(` @@ -1831,6 +1840,15 @@ 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. + 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. const phaseKey = RECV_PHASE_KEYS[info.stage] || 'lansync.phase.' + info.stage @@ -1955,6 +1973,7 @@ function wireEvents() { qs('#btn-gc-back').addEventListener('click', () => showView('library')) qs('#btn-gc-save').addEventListener('click', saveGameConfig) qs('#btn-gc-shortcut').addEventListener('click', createAppShortcut) + 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 diff --git a/renderer/index.html b/renderer/index.html index 714e1c4..d654566 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -196,6 +196,7 @@
+
diff --git a/src/argv.js b/src/argv.js index cf93cc0..7ad29d3 100644 --- a/src/argv.js +++ b/src/argv.js @@ -1,14 +1,18 @@ 'use strict' +const path = require('path') +const fs = require('fs') + // Shared command-line parsing for the launcher process itself. Both boot // (process.argv) and the single-instance handoff (second-instance argv) use // it, so a shortcut, a "Open with PLWL" file-open and Chromium's own injected // flags are handled the same way everywhere. // // Supported forms: -// --launch | --launch= auto-start that library app -// --open | --open= open an .exe with the wine picker -// a bare existing-file path same as --open (plumbing for Exec %f) +// --launch | --launch= auto-start that library app +// --file hand this file to the --launch app +// --open | --open= open an .exe with the wine picker +// a bare existing-file path same as --open (plumbing for Exec %f) function firstNonFlagAfter(args, i) { // Chromium injects its own flags into the second instance's argv and they @@ -34,9 +38,32 @@ function findValue(args, names) { // PLWL's own options must not swallow Chromium's flags (--flag=... style), // so anything after a flag that itself carries a value is skipped here by // looking only at the KNOWN option names above. +// The file an app shortcut was asked to open: `--file ` when the +// generated shortcut passes it explicitly, or the path a file manager appends +// on its own to an Exec line without a file field (KDE does that). Only an +// existing absolute FILE counts in the fallback, so Chromium's injected flags +// and the launcher's own path can never be mistaken for the opened document. +const FIELD_CODE_RE = /^%[fFuUdDnNickvm]?$/ +function launchFileOf(args, launchId) { + const explicit = findValue(args, ['--file']) + // a desktop environment that does not expand field codes would hand over + // the literal token - never treat that as a file path + if (explicit && !FIELD_CODE_RE.test(explicit)) return explicit + if (!launchId) return null + for (let i = args.length - 1; i > 1; i--) { + const arg = args[i] + if (typeof arg !== 'string' || arg.startsWith('-') || !path.isAbsolute(arg)) continue + try { + if (fs.statSync(arg).isFile()) return arg + } catch {} + } + return null +} + function parseArgv(argv) { const args = Array.isArray(argv) ? argv : process.argv const launchId = findValue(args, ['--launch']) + const launchFile = launchFileOf(args, launchId) let openPath = findValue(args, ['--open']) if (!openPath && !launchId) { // "Open with" plumbing (Exec %f): the file path arrives as the only @@ -51,7 +78,7 @@ function parseArgv(argv) { ) if (bare) openPath = bare } - return { launchId, openPath } + return { launchId, openPath, launchFile } } module.exports = { parseArgv, findValue } diff --git a/src/fsutil.js b/src/fsutil.js index 119dce1..6966007 100644 --- a/src/fsutil.js +++ b/src/fsutil.js @@ -57,6 +57,15 @@ function progressGate(emit) { 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 + let rootStat = null + try { + rootStat = await fsp.lstat(root) + } catch { + return { files, bytes } + } + if (!rootStat.isDirectory()) return { files: 1, bytes: rootStat.isSymbolicLink() ? 0 : rootStat.size } const walk = async dir => { let entries try { @@ -150,6 +159,24 @@ 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. + let rootStat = null + try { + rootStat = await fsp.lstat(root) + } catch { + return { files, dirs, bytes } + } + if (!rootStat.isDirectory()) { + files = 1 + bytes = rootStat.isSymbolicLink() ? 0 : rootStat.size + try { + await fsp.unlink(root) + } catch {} + if (gate) gate({ files, dirs, bytes }, true) + return { files, dirs, bytes } + } const walk = async dir => { let entries try { diff --git a/src/ipc/games.js b/src/ipc/games.js index e9d620d..29e44fc 100644 --- a/src/ipc/games.js +++ b/src/ipc/games.js @@ -107,21 +107,33 @@ function createGamesService({ getWindow, onGamesChanged }) { if (onGameExit) onGameExit(id) } - async function start(win, id) { + async function start(win, id, openFile) { const game = library.getGame(id) if (!game) return { ok: false, error: 'game-not-found' } prune() - if (running.has(id)) return { ok: false, error: 'already-running' } let wine try { wine = resolveWine(game) } catch { 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. + if (!openFile) return { ok: false, error: 'already-running' } + try { + await runGame({ game, wine, systemWine: wine.type === 'system', openFile, handoff: true }) + return { ok: true, pid: null, logFile: null } + } catch (err) { + return { ok: false, error: String((err && err.message) || err) } + } + } running.set(id, { pid: null, prefix: library.prefixNameForGame(game), wine: wine.id }) startedAt.set(id, Date.now()) try { - const res = await runGame({ game, wine, systemWine: wine.type === 'system', onExit: () => handleExit(id) }) + 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 if (killRequests.has(id)) { diff --git a/src/lansync.js b/src/lansync.js index 0d39e71..55b1c90 100644 --- a/src/lansync.js +++ b/src/lansync.js @@ -53,7 +53,7 @@ const SYNC_ROOTS = ['apps/', 'wines/', 'wineprefixes/', 'data/icons/'] // What a sync can carry, plus lenient aliases so callers can name the // categories the way the UI/README does. -const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'defaults'] +const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'settings'] const CATEGORY_ALIASES = { // plain "apps" means the whole app library: portable ones AND the ones @@ -74,12 +74,15 @@ const CATEGORY_ALIASES = { wineprefixes: ['prefixes'], prefixes: ['prefixes'], prefix: ['prefixes'], - // the launcher's own defaults ("Default settings"): which wine everything - // falls back to and where a new import lands - defaults: ['defaults'], - default: ['defaults'], - defaultsettings: ['defaults'], - settings: ['defaults'] + // 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. + settings: ['settings'], + syncsettings: ['settings'], + defaults: ['settings'], + default: ['settings'], + defaultsettings: ['settings'] } // Which sync category an app entry belongs to. Portable copy/move imports own @@ -316,26 +319,33 @@ function saveHashCache() { } 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. +function hashCacheHit(rel, size, mtimeMs) { + const cache = loadHashCache() + const known = cache[rel] + if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') { + hashStats.misses++ + return null + } + hashStats.hits++ + if (Date.now() - (known[3] || 0) > 86400000) { + known[3] = Date.now() + hashCacheDirty = true + } + return known[2] +} + // Hash of a file we already stat'ed: size and mtime unchanged means the bytes // are the same, and the digest from an earlier sync still describes them. function cachedHash(rel, abs, size, mtimeMs) { - const cache = loadHashCache() - const known = cache[rel] - if (known && known[0] === size && known[1] === mtimeMs && typeof known[2] === 'string') { - hashStats.hits++ - // a hit only marks the cache dirty when its timestamp is actually stale: - // marking it every time would force a full rewrite of the file per sync - // even though nothing a second sync could reuse has changed - if (Date.now() - (known[3] || 0) > 86400000) { - known[3] = Date.now() - hashCacheDirty = true - } - return Promise.resolve(known[2]) - } - hashStats.misses++ + const known = hashCacheHit(rel, size, mtimeMs) + if (known) return Promise.resolve(known) return hashFile(abs).then(hash => { if (hash) { - cache[rel] = [size, mtimeMs, hash, Date.now()] + loadHashCache()[rel] = [size, mtimeMs, hash, Date.now()] hashCacheDirty = true } return hash @@ -499,26 +509,18 @@ async function buildManifest(roots, force, onProgress) { // start empty and fill with the bytes actually read const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0) if (!force && files.length) { - const cache = loadHashCache() let done = 0 let hashed = 0 await mapLimit(files, HASH_CONCURRENCY, async entry => { const abs = filesPath(entry.path) try { - // inline cache hit: identical size and mtime means identical bytes, so - // the digest from an earlier sync still describes this file - the bar - // still advances by the file's size, because the check WAS the work + // identical size and mtime means identical bytes, so the digest from + // an earlier sync still describes this file const mtimeMs = mtimes.get(entry.path) || 0 - const known = cache[entry.path] - if (abs && known && known[0] === entry.size && known[1] === mtimeMs && typeof known[2] === 'string') { - hashStats.hits++ - if (Date.now() - (known[3] || 0) > 86400000) { - known[3] = Date.now() - hashCacheDirty = true - } - entry.hash = known[2] + const known = hashCacheHit(entry.path, entry.size, mtimeMs) + if (known && abs) { + entry.hash = known } else { - hashStats.misses++ entry.hash = abs ? await hashFile(abs, bytes => { hashed += bytes @@ -526,7 +528,7 @@ async function buildManifest(roots, force, onProgress) { }) : null if (entry.hash) { - cache[entry.path] = [entry.size, mtimeMs, entry.hash, Date.now()] + loadHashCache()[entry.path] = [entry.size, mtimeMs, entry.hash, Date.now()] hashCacheDirty = true } } @@ -732,6 +734,10 @@ async function healUnitRoots(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 + if (!fs.lstatSync(abs).isDirectory()) continue healed.links += await rewireSymlinks(abs, [DIRS.FILES]) if (String(rel).startsWith('wines/')) healed.executables += await restoreExecutableBits(abs) } catch {} @@ -795,19 +801,43 @@ 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). function listDirs(dir) { + const out = [] + let entries try { - return fs - .readdirSync(dir, { withFileTypes: true }) - .filter(e => e.isDirectory()) - .map(e => e.name) + entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { - return [] + return out } + for (const entry of entries) { + if (entry.isDirectory()) { + out.push(entry.name) + continue + } + if (!entry.isSymbolicLink()) continue + try { + if (fs.statSync(path.join(dir, entry.name)).isDirectory()) out.push(entry.name) + } catch {} + } + return out } // 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. + let rootStat = null + try { + rootStat = await fsp.lstat(abs) + } catch { + return 0 + } + if (!rootStat.isDirectory()) return 0 let entries try { entries = await fsp.readdir(abs, { withFileTypes: true }) @@ -950,12 +980,129 @@ function applyPrefixes(payload, useSenderDefault) { return removed } -// The launcher-wide defaults, asked for with the "Default settings" category: -// the wine every app falls back to and the prefix new imports land in. Only -// values that exist HERE are taken - a default pointing at a build or prefix -// this device does not have would be worse than keeping the current one. -function applyDefaults(payload, cats) { - if (!cats.has('defaults')) return null +// 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. +function applyWineSettings(payload) { + const incoming = Array.isArray(payload.wineSettings) ? payload.wineSettings : [] + let applied = 0 + for (const entry of incoming) { + if (!entry || !entry.name) continue + const id = String(entry.name) + const wine = wines.getWine(id) + if (!wine || wine.type === 'system') continue + let changed = false + const wanted = typeof entry.displayName === 'string' ? entry.displayName.trim() : '' + if (wanted !== (wine.displayName || '')) { + // a sender that never renamed the build clears the custom label here + if (wanted) wines.setWineDisplayName(id, wanted) + else wines.clearWineDisplayName(id) + changed = true + } + if (entry.disabled !== undefined && !!entry.disabled !== !!wine.disabled) { + wines.setWineDisabled(id, !!entry.disabled) + changed = true + } + if (changed) applied++ + } + 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. +function applyPrefixSettings(payload) { + const sender = payload.prefixSettings && typeof payload.prefixSettings === 'object' ? payload.prefixSettings : null + if (!sender) return 0 + const incoming = (Array.isArray(sender.prefixes) ? sender.prefixes : []).filter(p => p && p.name) + if (!incoming.length) return 0 + const defs = prefixes.listPrefixes() + const known = new Set(defs.prefixes.map(p => p.name)) + let added = 0 + for (const entry of incoming) { + const name = String(entry.name) + if (known.has(name)) continue + known.add(name) + defs.prefixes.push({ name, created: Number(entry.created) || Date.now(), locked: name === prefixes.MAIN ? true : !!entry.locked }) + added++ + } + if (added) prefixes.savePrefixes(defs) + 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). +function applyAppSettings(payload) { + const incoming = Array.isArray(payload.appSettings) ? payload.appSettings : [] + if (!incoming.length) return 0 + const byId = new Map(incoming.filter(a => a && a.id).map(a => [String(a.id), a])) + const knownPrefixes = new Set(prefixes.listPrefixes().prefixes.map(p => p.name)) + let patched = 0 + for (const game of library.loadGames()) { + const from = byId.get(game.id) + if (!from) continue + const patch = {} + if (typeof from.name === 'string' && from.name && from.name !== game.name) patch.name = from.name + if (from.wine !== undefined) { + const wanted = String(from.wine || 'default') + if ((wanted === 'default' || wanted === 'system' || wines.getWine(wanted)) && wanted !== (game.wine || 'default')) patch.wine = wanted + } + if (from.prefix !== undefined && game.importSource !== 'prefix') { + const wanted = String(from.prefix || '') + if ((!wanted || knownPrefixes.has(wanted)) && wanted !== (game.prefix || '')) patch.prefix = wanted + } + if (typeof from.args === 'string' && from.args !== (game.args || '')) patch.args = from.args + if (from.env && typeof from.env === 'object' && JSON.stringify(from.env) !== JSON.stringify(game.env || {})) patch.env = from.env + if (Array.isArray(from.dllOverrides) && JSON.stringify(from.dllOverrides) !== JSON.stringify(game.dllOverrides || [])) patch.dllOverrides = from.dllOverrides + if (from.disableUmu !== undefined && !!from.disableUmu !== !!game.disableUmu) patch.disableUmu = !!from.disableUmu + if (Object.keys(patch).length) { + library.updateGame(game.id, patch) + patched++ + } + } + 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. +function applyAppOrder(payload) { + const order = Array.isArray(payload.appOrder) ? payload.appOrder.filter(id => typeof id === 'string' && id) : [] + if (!order.length) return 0 + const current = library.loadGames().map(g => g.id) + if (current.length === order.length && current.every((id, i) => id === order[i])) return 0 + library.reorderGames(order) + 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. +function applySettings(payload, cats) { + if (!cats.has('settings')) return null + return { + prefixes: applyPrefixSettings(payload), + wines: applyWineSettings(payload), + apps: applyAppSettings(payload), + order: applyAppOrder(payload), + defaults: applyDefaults(payload) + } +} + +// 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. +function applyDefaults(payload) { const wanted = payload.defaults && typeof payload.defaults === 'object' ? payload.defaults : {} const applied = {} if (wanted.defaultWine) { @@ -1067,6 +1214,7 @@ function createHttpServer(state, hooks) { if (route === '/plwl/verify' && req.method === 'POST') return handleVerify(state, req, res, hooks) const session = authorize(state, url.searchParams.get('token')) if (!session) return sendJson(res, 401, { ok: false, error: 'unauthorized' }) + if (route === '/plwl/ping' && req.method === 'POST') return handlePing(session, res, hooks) if (route === '/plwl/unit/plan' && req.method === 'POST') return handlePlan(state, session, url, req, res, hooks) if (route === '/plwl/unit/file' && req.method === 'PUT') return handlePut(session, url, req, res, hooks) if (route === '/plwl/unit/commit' && req.method === 'POST') return handleCommit(session, url, res, hooks) @@ -1216,7 +1364,11 @@ async function handlePlan(state, session, url, req, res, hooks) { compared++ comparedBytes += entry.size || 0 session.barBytes = comparedBytes - announce({ unit: label, done: compared, total: allowed.length }) + // 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 + announce({ unit: label }) return same === true }) saveHashCache() @@ -1258,7 +1410,7 @@ async function handlePlan(state, session, url, req, res, hooks) { // is planned, so the later file events can name it in the popup session.unitLabel = label sendJson(res, 200, { ok: true, upload, same, entries: paths.size }) - announce({ unit: label, done: allowed.length, total: allowed.length }, true) + announce({ unit: label }, true) } async function handlePut(session, url, req, res, hooks) { @@ -1325,13 +1477,15 @@ 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' }) - const cats = new Set(Array.isArray(payload.categories) ? payload.categories : []) + // the categories come from another machine (and an older PLWL names the + // settings category "defaults"), so they go through the aliases here too + 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('defaults')) : 0 - // after the registries are in place, so a synced build/prefix can actually be - // chosen as the default - const defaults = applyDefaults(payload, cats) + const 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 + const settings = applySettings(payload, cats) const removed = appsDeleted + winesDeleted + prefixesDeleted emitReceive(hooks, session, 'registry', { deleted: removed }) if (hooks.onApplied) { @@ -1343,7 +1497,8 @@ async function handleApply(session, req, res, hooks) { bytes: session.bytes || 0, items: { ...(session.items || {}) }, deleted: removed, - defaults + settings, + defaults: settings ? settings.defaults : null }) } sendJson(res, 200, { @@ -1352,7 +1507,8 @@ async function handleApply(session, req, res, hooks) { apps: { deleted: appsDeleted }, wines: { deleted: winesDeleted }, prefixes: { deleted: prefixesDeleted }, - defaults + settings, + defaults: settings ? settings.defaults : null }) } @@ -1363,6 +1519,18 @@ 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. +function handlePing(session, res, hooks) { + session.lastAt = Date.now() + emitReceive(hooks, session, 'wait') + sendJson(res, 200, { ok: true }) +} + // --- discovery -------------------------------------------------------------- let beaconSocket = null diff --git a/src/lansyncpush.js b/src/lansyncpush.js index 7f5b7d8..da34f4a 100644 --- a/src/lansyncpush.js +++ b/src/lansyncpush.js @@ -17,6 +17,12 @@ const path = require('path') // small files a game or prefix is mostly made of. 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. +const HEARTBEAT_MS = Number(process.env.PLWL_LANSYNC_HEARTBEAT) || 20000 + function createPusher(api) { const { CATEGORIES, @@ -131,9 +137,15 @@ function createPusher(api) { units.set(root, { key: `wine:${root.slice('wines/'.length)}`, kind: 'wine', id: root.slice('wines/'.length), label: label || root, roots: [root] }) } for (const wine of wines.getWines()) { - const rel = toPortablePath(wine.path) - const parts = String(rel).split('/') - add(parts.length === 2 && parts[0] === 'wines' ? rel : `wines/${path.basename(wine.path)}`, wine.label) + 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. + 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}`) return [...units.values()] @@ -178,14 +190,39 @@ function createPusher(api) { // the receiver prunes its own builds down to this list and rescans payload.wines = wineUnits().map(unit => ({ name: unit.id })) } - if (cats.has('defaults')) { - // "Default settings": which wine everything falls back to and where a new - // import lands. Opt-in on purpose - a sync of apps must not silently - // change the other machine's defaults. + 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. + const defs = prefixes.listPrefixes() + const games = library.loadGames() payload.defaults = { defaultWine: (library.loadSettings().defaultWine) || 'system', - defaultPrefix: prefixes.listPrefixes().default || '' + defaultPrefix: defs.default || '' } + payload.wineSettings = wines + .getWines() + .filter(w => w.type !== 'system') + .map(w => ({ name: w.id, displayName: w.displayName || '', disabled: !!w.disabled })) + payload.prefixSettings = { + default: defs.default || '', + prefixes: defs.prefixes.map(p => ({ name: p.name, created: p.created, locked: !!p.locked })) + } + payload.appSettings = games.map(g => ({ + id: g.id, + name: g.name, + wine: g.wine || 'default', + prefix: g.prefix || '', + args: g.args || '', + env: g.env || {}, + dllOverrides: g.dllOverrides || [], + disableUmu: !!g.disableUmu + })) + payload.appOrder = games.map(g => g.id) } if (cats.has('prefixes')) { const defs = prefixes.listPrefixes() @@ -291,6 +328,14 @@ function createPusher(api) { // typing (a rejected code is never stored) 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. + const heartbeat = setInterval(() => { + httpJson(device, 'POST', `/plwl/ping?token=${encodeURIComponent(token)}`, { json: {}, timeout: 10000 }).catch(() => {}) + }, HEARTBEAT_MS) + if (heartbeat.unref) heartbeat.unref() + 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 @@ -381,7 +426,6 @@ function createPusher(api) { pending.push(entry) plannedBytes += entry.size || 0 } - allTotal += pending.length // 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 @@ -439,6 +483,8 @@ function createPusher(api) { } catch (err) { await end() return fail(String((err && err.message) || err), summary, events) + } finally { + clearInterval(heartbeat) } return syncResult(true, null, summary, events) } diff --git a/src/main.js b/src/main.js index 01f39ba..bb8fbe0 100644 --- a/src/main.js +++ b/src/main.js @@ -26,6 +26,7 @@ let tray = null let quitting = false let headless = false // started via --launch: no window until the user asks let autoLaunchId = null // game id from --launch +let autoLaunchFile = null // file from --file ("Open with" on an app shortcut) let autoOpenPath = null // file path from --open ("Open with PLWL") let autoQuitWhenDone = false // headless boot: quit after that game exits const getWindow = () => win @@ -54,10 +55,10 @@ if (!gotLock) { } else { app.on('second-instance', (e, argv) => { trace(`second-instance argv=${argv.join(' ')}`) - const { launchId, openPath } = parseArgv(argv) + const { launchId, openPath, launchFile } = parseArgv(argv) lastHandoffAt = Date.now() if (openPath) openWith(openPath) - else if (launchId) launchGame(launchId) + else if (launchId) launchGame(launchId, launchFile) else showMainWindow() }) } @@ -80,6 +81,7 @@ async function openWith(filePath) { const auto = parseArgv(process.argv) autoLaunchId = auto.launchId +autoLaunchFile = auto.launchFile autoOpenPath = auto.openPath headless = !!autoLaunchId autoQuitWhenDone = headless // booting just to run one game -> exit when it ends @@ -199,10 +201,10 @@ function wireWindow(w) { // --- game auto-launch (shortcuts) ----------------------------------------------- let gamesService = null -async function launchGame(id) { - trace(`launchGame ${id} serviceReady=${!!gamesService}`) +async function launchGame(id, openFile) { + trace(`launchGame ${id} openFile=${openFile || ''} serviceReady=${!!gamesService}`) if (!gamesService) return - const res = await gamesService.start(getWindow(), id) + const res = await gamesService.start(getWindow(), id, openFile) trace(`launchGame ${id} result=${JSON.stringify(res).slice(0, 200)}`) if (res.ok) { rebuildTrayMenu() @@ -262,7 +264,7 @@ app.whenReady().then(async () => { } if (headless) { // shortcut boot: run the game without opening the launcher window - launchGame(autoLaunchId) + launchGame(autoLaunchId, autoLaunchFile) } else { win = createMainWindow() wireWindow(win) diff --git a/src/runner.js b/src/runner.js index 441fe2b..867aa38 100644 --- a/src/runner.js +++ b/src/runner.js @@ -221,6 +221,25 @@ 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. +function windowsPathFor(file, prefixDir) { + let abs = String(file || '') + try { + abs = fs.realpathSync(abs) + } catch {} + try { + const driveC = fs.realpathSync(path.join(prefixDir, 'drive_c')) + const rel = path.relative(driveC, abs) + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) { + return 'C:\\' + rel.split(path.sep).join('\\') + } + } catch {} + return `Z:${abs.split(path.sep).join('\\')}` +} + const GRACE_MS = 60000 // Live playtime: while the game runs, update ONLY the tiny playtime file @@ -250,13 +269,15 @@ function openLatestLog(gameId) { return fs.createWriteStream(logFile, { flags: 'w' }) } -async function runGame({ game, wine, systemWine, onExit }) { +async function runGame({ game, wine, systemWine, onExit, openFile, handoff = false }) { await healPrefixOnce(game) const isProton = wine && wine.type === 'proton' - const { env } = buildEnv({ game, wine }) + const { env, prefixDir } = buildEnv({ game, wine }) const exe = game.exePath const workdir = game.workdir || path.dirname(exe) const argList = parseArgs(game.args) + // an opened file is an argument to the exe, exactly like Windows' "Open with" + if (openFile) argList.push(windowsPathFor(openFile, prefixDir)) let cmd let base @@ -275,7 +296,10 @@ async function runGame({ game, wine, systemWine, onExit }) { child = spawn(cmd, base, { cwd: workdir, env, - stdio: ['ignore', 'pipe', 'pipe'], + // 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 + stdio: handoff ? 'ignore' : ['ignore', 'pipe', 'pipe'], detached: true }) } catch (err) { @@ -293,6 +317,12 @@ async function runGame({ game, wine, systemWine, onExit }) { }) 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 + 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 @@ -365,4 +395,4 @@ function runAdHoc({ openPath, wine, systemWine, prefix, onExit }) { return runGame({ game, wine, systemWine, onExit }) } -module.exports = { buildEnv, runGame, runTool, runAdHoc, parseArgs, latestLogPath } +module.exports = { buildEnv, runGame, runTool, runAdHoc, parseArgs, latestLogPath, windowsPathFor } diff --git a/src/shortcuts.js b/src/shortcuts.js index 831e556..33899c0 100644 --- a/src/shortcuts.js +++ b/src/shortcuts.js @@ -44,8 +44,14 @@ function execToken(token) { 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. function execLine(extraArgs) { - return [...launcherArgv(), ...extraArgs.split(' ').filter(Boolean).map(execToken)].map(execToken).join(' ') + const tokens = [...launcherArgv(), ...String(extraArgs).split(' ').filter(Boolean)] + return [...tokens.map(execToken), '%f'].join(' ') } function sanitizeName(name) { @@ -71,7 +77,7 @@ function buildDesktopEntry(game) { 'Type=Application', `Name=${name}`, 'Comment=Launch via PLWL', - `Exec=${execLine(`--launch ${game.id}`)}`, + `Exec=${execLine(`--launch ${game.id} --file`)}`, 'Terminal=false', 'Categories=Game;' ] diff --git a/src/wines.js b/src/wines.js index 5f7bf72..671b37a 100644 --- a/src/wines.js +++ b/src/wines.js @@ -167,14 +167,31 @@ function applyDisplayName(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. 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). +function clearWineDisplayName(id) { + return writeWineLabel(id, '') +} + +function writeWineLabel(id, clean) { const wines = getWines() const w = wines.find(x => x.id === id) if (!w) return { ok: false, error: 'notFound' } if (w.type === 'system') return { ok: false, error: 'isSystem' } - const clean = normalizeLabel(name) - if (!clean) return { ok: false, error: 'invalidName' } - w.displayName = clean - applyDisplayName(w) + if (clean) { + w.displayName = clean + w.label = clean + } else { + if (!w.displayName) return { ok: true, displayName: '' } + delete w.displayName + w.label = labelFor(w.type, w.path, w.version) + } writeJson(REGISTRY, wines.map(wineRel)) return { ok: true, displayName: clean } } @@ -241,6 +258,16 @@ async function fixBuildSymlinks(root) { // Everything a freshly arrived build needs before it can run: relative links // and executable binaries. Returns what was changed, for the callers' reports. async function healBuild(root) { + // 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 + let st = null + try { + st = fs.lstatSync(root) + } catch { + return { links: 0, executables: 0 } + } + if (!st.isDirectory()) return { links: 0, executables: 0 } const links = await fixBuildSymlinks(root) const executables = await restoreExecutableBits(root) return { links, executables } @@ -316,4 +343,4 @@ async function importWineFolder(srcDir, onProgress) { return { ok: true, path: toPortablePath(dst), symlinksFixed: healed.links, executablesFixed: healed.executables } } -module.exports = { refreshWines, getWines, getWine, resolveWine, setWineDisabled, setWineDisplayName, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild } +module.exports = { refreshWines, getWines, getWine, resolveWine, setWineDisabled, setWineDisplayName, clearWineDisplayName, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild } From 6565ded4d532eb012220bb6941c499a97ad7be66 Mon Sep 17 00:00:00 2001 From: olcxja Date: Sun, 4 Oct 2026 20:50:10 +0200 Subject: [PATCH 03/29] improve shortcut 'open with' handling --- development.md | 10 +++++- src/argv.js | 97 +++++++++++++++++++++++++++++++++----------------- 2 files changed, 73 insertions(+), 34 deletions(-) diff --git a/development.md b/development.md index 51bf94b..358dc02 100644 --- a/development.md +++ b/development.md @@ -188,7 +188,15 @@ handlers in src/ipc/. Keep new APIs in that pattern. when it sits inside the prefix's drive_c, `Z:\...` otherwise. Giving a file to an app that is ALREADY running is a handoff (runGame `handoff: true`): an extra untracked process, no log/playtime writes, because the app's own - single-instance logic is what actually opens the file. + single-instance logic is what actually opens the file. The second-instance + handoff must never read a value from the token next to its option: Electron + RE-SERIALIZES that argv (all switches first, their values after, the dev app + path among them), so `--launch --file ` arrives as + `--launch --file --allow-file-access-from-files `. argv.js pairs + options with positionals IN ORDER (inline `--name=value` wins, the dev app + path is skipped); reading the next token used to take the launch id as the + opened file, so an app started without its image. + `electron test/argv-handoff.test.js` proves the real serialization. - A sender hashing a big unit is minutes of network silence: it pings `/plwl/ping` (+token) every HEARTBEAT_MS for the whole sync, the receiver's handler re-arms its popup watchdog (stage 'wait' changes nothing on screen), diff --git a/src/argv.js b/src/argv.js index 7ad29d3..699f54c 100644 --- a/src/argv.js +++ b/src/argv.js @@ -10,49 +10,79 @@ const fs = require('fs') // // Supported forms: // --launch | --launch= auto-start that library app -// --file hand this file to the --launch app +// --file | --file= hand this file to the --launch app // --open | --open= open an .exe with the wine picker // a bare existing-file path same as --open (plumbing for Exec %f) +// +// Electron hands the second instance's argv over RE-SERIALIZED: every switch +// comes first and its value follows as a positional argument, e.g. a shortcut +// `--launch --file ` arrives as +// `--launch --file --allow-file-access-from-files `. Taking the +// token next to an option therefore picks the wrong value (it used to take +// the launch id as the opened file, so an app started without its image). +// Options and positionals are matched IN ORDER here instead. -function firstNonFlagAfter(args, i) { - // Chromium injects its own flags into the second instance's argv and they - // can land BETWEEN the option and its value (e.g. `--launch - // --allow-file-access-from-files 1790414564627`), so skip anything that - // looks like a flag and take the first non-flag token. - for (let j = i + 1; j < args.length; j++) { - if (!args[j].startsWith('--')) return args[j] +const VALUE_OPTIONS = ['--launch', '--file', '--open'] + +// The options that carry a value, the positional arguments that follow the +// switches, and the values written inline (`--name=value`, which keeps its +// value no matter how the rest is ordered). +// +// Tokens before the first switch are the executable - never values. In dev +// mode (`electron ...`) Electron re-serializes the app path ALONG WITH +// the values, so it is matched against the running app's own path and skipped: +// it would otherwise be paired with the first option and shift every value. +function splitArgv(argv) { + const options = [] + const positional = [] + const inline = new Map() + const appPath = process.defaultApp && process.argv[1] ? path.resolve(process.argv[1]) : null + let switched = false + for (const arg of Array.isArray(argv) ? argv : []) { + if (typeof arg !== 'string' || !arg) continue + if (arg.startsWith('-')) { + switched = true + const eq = arg.indexOf('=') + const name = eq === -1 ? arg : arg.slice(0, eq) + if (!VALUE_OPTIONS.includes(name)) continue + if (eq === -1) options.push(name) + else inline.set(name, arg.slice(eq + 1)) + continue + } + // absolute in the handed-over argv, possibly relative here + if (appPath && path.resolve(arg) === appPath) continue + if (switched) positional.push(arg) } - return null + return { options, positional, inline } } -function findValue(args, names) { - for (const name of names) { - const i = args.indexOf(name) - if (i !== -1) return firstNonFlagAfter(args, i) - const eq = args.find(a => a.startsWith(name + '=')) - if (eq) return eq.slice(name.length + 1) - } - return null +// 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). +function optionValue(parsed, name) { + if (parsed.inline.has(name)) return parsed.inline.get(name) + const index = parsed.options.indexOf(name) + if (index === -1) return null + const value = parsed.positional[index] + return value === undefined ? null : value } -// PLWL's own options must not swallow Chromium's flags (--flag=... style), -// so anything after a flag that itself carries a value is skipped here by -// looking only at the KNOWN option names above. // The file an app shortcut was asked to open: `--file ` when the // generated shortcut passes it explicitly, or the path a file manager appends // on its own to an Exec line without a file field (KDE does that). Only an -// existing absolute FILE counts in the fallback, so Chromium's injected flags -// and the launcher's own path can never be mistaken for the opened document. +// absolute path counts in either place: the launch id, Chromium's injected +// flags and the launcher's own path can never be mistaken for the document. const FIELD_CODE_RE = /^%[fFuUdDnNickvm]?$/ -function launchFileOf(args, launchId) { - const explicit = findValue(args, ['--file']) +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 - if (explicit && !FIELD_CODE_RE.test(explicit)) return explicit + if (explicit && !FIELD_CODE_RE.test(explicit) && path.isAbsolute(explicit)) return explicit if (!launchId) return null - for (let i = args.length - 1; i > 1; i--) { - const arg = args[i] - if (typeof arg !== 'string' || arg.startsWith('-') || !path.isAbsolute(arg)) continue + for (let i = parsed.positional.length - 1; i >= 0; i--) { + const arg = parsed.positional[i] + if (!path.isAbsolute(arg)) continue try { if (fs.statSync(arg).isFile()) return arg } catch {} @@ -62,9 +92,10 @@ function launchFileOf(args, launchId) { function parseArgv(argv) { const args = Array.isArray(argv) ? argv : process.argv - const launchId = findValue(args, ['--launch']) - const launchFile = launchFileOf(args, launchId) - let openPath = findValue(args, ['--open']) + const parsed = splitArgv(args) + const launchId = optionValue(parsed, '--launch') + 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 @@ -74,11 +105,11 @@ function parseArgv(argv) { a => !a.startsWith('-') && /\.(exe|msi|bat|cmd|lnk)$/i.test(a) && - !/(electron|olcxja\.plwl)(\.exe)?$/i.test(a) + !/\b(electron|olcxja\.plwl)(\.exe)?$/i.test(a) ) if (bare) openPath = bare } return { launchId, openPath, launchFile } } -module.exports = { parseArgv, findValue } +module.exports = { parseArgv } From 2bcb47076a9fc61f2e64993dac9cfd68f84cb648 Mon Sep 17 00:00:00 2001 From: olcxja Date: Sun, 4 Oct 2026 20:50:29 +0200 Subject: [PATCH 04/29] bump to 0.4.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 77ac71b..0152daa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "PLWL", "productName": "PLWL", - "version": "0.3.1", + "version": "0.4.0", "description": "Portable Linux Wine Launcher", "main": "src/main.js", "scripts": { From 9edcc9841b3ec9a427899cc11ac509bf5e671393 Mon Sep 17 00:00:00 2001 From: olcxja Date: Mon, 5 Oct 2026 13:43:49 +0200 Subject: [PATCH 05/29] code refactor & sync bugfixes --- README.md | 56 ++-- development.md | 228 ++++---------- files/translations/en-us.json | 5 + files/translations/pl.json | 5 + renderer/app.js | 6 + renderer/index.html | 7 + src/fsutil.js | 28 +- src/hashcache.js | 161 ++++++++++ src/ipc/index.js | 11 +- src/ipc/lansync.js | 4 + src/lansync-discovery.js | 135 ++++++++ src/lansync-identity.js | 133 ++++++++ src/lansync-manifest.js | 152 +++++++++ src/lansync.js | 574 +++------------------------------- src/lansyncpush.js | 15 +- src/library.js | 8 +- src/prefixes.js | 8 +- src/preload.js | 62 ++-- src/symlinks.js | 10 +- src/wines.js | 24 +- 20 files changed, 820 insertions(+), 812 deletions(-) create mode 100644 src/hashcache.js create mode 100644 src/lansync-discovery.js create mode 100644 src/lansync-identity.js create mode 100644 src/lansync-manifest.js diff --git a/README.md b/README.md index 0c00d17..2428f74 100644 --- a/README.md +++ b/README.md @@ -24,63 +24,47 @@ npm start ## Building & installing -`./build.sh` produces `build/PLWL-.tar.gz`: the Electron runtime renamed to `olcxja.plwl/` (binary `olcxja.plwl/olcxja.plwl`) with the whole app (code, renderer and bundled `files/`) packed into `olcxja.plwl/resources/app.asar`, plus the `olcxja.plwl.png` icon, `install.sh` and `createshortcut.sh`: +`./build.sh` produces `build/PLWL-.tar.gz`: the Electron runtime renamed to `olcxja.plwl/` (binary `olcxja.plwl/olcxja.plwl`, whole app in `olcxja.plwl/resources/app.asar`), plus the icon, `install.sh` and `createshortcut.sh`. ```bash -tar -xzf PLWL-.tar.gz -cd PLWL -./install.sh # copies everything to ~/.local/bin/olcxja.plwl/, - # installs the icon (olcxja.plwl.png) + shortcut - # removes the extraction folder, launches - -./createshortcut.sh # portable mode: only installs the icon + shortcut - -# or run without any shortcuts: -./olcxja.plwl/olcxja.plwl +./install.sh # install to ~/.local/bin/olcxja.plwl/ (icon + shortcut) +./createshortcut.sh # portable mode: icon + shortcut only +./olcxja.plwl/olcxja.plwl # or run without installing ``` ## Importing from a wine prefix -Besides copy/move/leave, an app can be imported **from a wine prefix**: pick the prefix, then the app folder inside its `drive_c`. Nothing is copied — paths are stored relative to the prefix, so wine sees the app as if it were normally installed on `C:\` (registry entries pointing to `C:\...` keep working). Both layouts work: plain wine prefixes (`drive_c` at the top) and Proton-managed ones (`drive_c` under `pfx/`). The app stays tied to that prefix; removing the launcher entry never deletes files from the prefix. A prefix can be renamed later (Wine prefixes): the folder moves on disk and everything running in it follows the new name, because those paths are stored relative to the prefix. Edits wait for running apps: while anything from a prefix is playing, renaming or deleting it (and switching the default prefix away from an app that has none of its own) is refused with the name of the app to close first — wine keeps its registry and user files open in that folder. Deleting is also refused while a prefix still has apps registered in it, so an app is never silently orphaned into the default prefix. Imports keep the folder as it was: symlinks are recreated (rewritten to relative whenever the target is part of the launcher) and file permissions are carried over, so an imported game or build runs straight away. +Besides copy/move/leave, an app can be imported **from a prefix**: nothing is copied — paths are stored relative to the prefix, so wine sees the app as installed on `C:\` (registry entries keep working). Plain prefixes (`drive_c` on top) and Proton ones (`pfx/`) both work. The app stays tied to that prefix; removing the entry never deletes prefix files. A prefix can be renamed later — the folder moves and everything stored relative follows. Edits wait for running apps: renaming/deleting a prefix in use (or moving the default away from an app without its own) is refused with the app to close first; deleting a prefix that still has apps is refused too. Imports recreate symlinks (relative when the target belongs to the launcher) and keep permissions, so an imported game runs straight away. -## "Open with PLWL" in the file manager +## "Open with PLWL" -The desktop entry (`MimeType` + `Exec %f`) makes PLWL show up in Dolphin/Nautilus under **Open With** for Windows executables (`.exe`, `.msi`, `.bat`, `.cmd`, `.lnk`). Opening a file that way shows a small popup in the launcher where you pick the **Wine/Proton version** and the **prefix** for this run — both default to the launcher's defaults, nothing is imported into the library. A second launcher instance hands the path to the running one and exits (same single-instance mechanism the shortcuts use). - -The app shortcuts created in an app's configuration work as **Open With** targets too: pick one in the file manager (right-click a photo → Open With → your Photoshop shortcut) and that app starts with the file, receiving it as the Windows path it would get on Windows — `C:\...` when the file sits inside its prefix, `Z:\...` for the rest of the machine. Giving a file to an app that is already running hands it to that instance instead of starting a second tracked run. +The desktop entry registers `.exe/.msi/.bat/.cmd/.lnk` in file managers. Opening one shows a popup to pick the **Wine/Proton build** and **prefix** for that run (defaults preselected, nothing imported). A second instance hands the path to the running one. App shortcuts work as **Open With** targets too: the app receives the file as the Windows path it would get on Windows — `C:\...` inside its prefix, `Z:\...` otherwise. A file handed to an already running app goes to that instance instead of a second tracked run. ## LAN syncing -Two PLWL installs on the same LAN can mirror each other's content - no cloud, no accounts. Open **LAN syncing** in the sidebar (under Settings): +Two PLWL installs on one LAN can mirror each other (sidebar → **LAN syncing**). No cloud, no accounts. -- **Device ID** - 24 characters, generated once per install (current timestamp + random characters) and re-rollable at any time. -- **Sync code** - 8 characters, letters and digits, case-insensitive. A device only accepts a connection when the caller proves it knows *its own* code; each verification costs two seconds and a device may only have one in flight, so guessing over the LAN is pointless. Both the ID and the code can be re-rolled. A correct code is still not enough: the receiving device shows an **Accept / Decline popup** before anything moves, and without a click within a minute the sender is turned down. Declining (or ignoring) the popup never touches this machine and does not unpair the two devices. -- **Devices on your network** - other PLWL installs, found over UDP; a device shows up only while PLWL is running there, and disappears a few seconds after it is closed. +- **Device ID** (24 chars) and **Sync code** (8 chars, letters+digits, case-insensitive), both re-rollable. A caller must prove it knows its own code; each verification costs 2 s and one may run at a time, so guessing is pointless. A correct code still opens an **Accept/Decline popup** on the receiver (60 s timeout); declining or ignoring never touches this machine and keeps the pairing. +- **Devices on your network** are found over UDP and visible only while PLWL runs there (a few seconds after it closes they are gone). +- Pick what to push: **portable apps**, **prefix-imported apps**, **Wine/Proton builds**, **prefixes**, **Sync settings**. Only what is ticked travels — without **Sync settings** the other machine's defaults and app configs are untouched. An accepted code is stored with the device ID, so later syncs need no typing; a re-rolled code is forgotten and asked for again. -Pick a device and choose what to push: **portable apps**, **apps imported from a prefix**, **Wine/Proton builds**, **prefixes** and **Sync settings**. Only what is ticked travels - a sync without **Sync settings** never touches the other machine's defaults, names or app configurations. The code has to be typed once - an accepted code is stored next to that device ID, so every later sync needs no typing. If the other side re-rolled its code, the stored one is forgotten and the launcher asks for the new one instead of failing silently. +**Both machines show a progress popup** (phase, unit, files, per-category counters like `Apps: 2/5`, transferred vs. planned bytes; the bar follows the current phase). The popups cannot be dismissed, and a sender hashing a big unit shows *Waiting for the other device…* instead of a dead popup — the sender pings while it works. They close on completion, or after 15 minutes of silence (and reopen if the sync turns out to be alive). -**Both machines show what is happening.** The sender gets a progress popup and the receiving device gets the same popup titled *Receiving from X…*, driven by the transfer as it arrives. Both show the current phase and unit, the files being moved, a sent/total counter per category (`Apps: 2/5 · Prefixes: 1/2`) and the transferred vs. planned size. The bar follows **bytes, not file counts**, and it belongs to the phase being worked on: hashing fills its own bar and the transfer starts again from empty, so nothing ever looks finished while it has just begun (the byte line next to it keeps the overall transferred vs. planned total). Both popups warn that the launcher must stay open, and a running sync is not cancellable from the outside: clicking the backdrop or pressing Escape does nothing. If the other device goes quiet for a few seconds (it hashes before it sends) the popup says *Waiting for the other device…* rather than closing, and the sending side keeps signalling while it works, so even a long hashing phase never looks like a dead peer. The popup closes itself when the transfer ends, or after fifteen minutes of complete silence when the other side has really vanished — and comes back if the sync turns out to be alive after all. +The **sender is the source of truth**: differing files are overwritten, missing ones written, extras inside the synced trees (`files/apps/`, `files/wines/`, `files/wineprefixes/`, `files/data/icons/`) deleted. Files are compared with BLAKE2b-512 (SHA-512/SHA-256 fallback); **Force sync all files** skips the comparison. Hashes are cached per file (size + mtime), so an unchanged library costs nothing to re-sync. Permissions travel with the files, and symlinks are sent as **relative** paths whenever the target belongs to the launcher (a Proton prefix's links into its build keep working on the other machine); `dosdevices` and targets outside `files/` stay absolute, and links wine itself rewrote to absolute are relativized again on the wire, on import and before every run. -The **sending device is the source of truth**: files that differ are overwritten, missing ones are written, and anything living only on the receiving side inside the synced trees (`files/apps/`, `files/wines/`, `files/wineprefixes/`, `files/data/icons/`) is deleted. Files are compared with a fast, collision-resistant hash (BLAKE2b-512, SHA-512/SHA-256 fallback); **Force sync all files** skips the comparison and overwrites everything instead. Hashes are remembered per file (size + modification time), so a repeat sync of a library that did not change reads nothing at all. - -**Wine builds and prefixes survive the trip.** Permissions travel with a sync, so the `proton`, `wine` and `wineserver` binaries are still executable on the other machine (a build whose executable bit was lost is additionally repaired at startup, without re-importing it), and symlinks are sent as RELATIVE paths whenever the file they point at belongs to the launcher: a Proton prefix is full of links into its build, and the receiving side gets links that resolve to its own copy instead of the sender's `/home/...` folders. Links that leave the launcher on purpose (`dosdevices` drive mappings, a mounted drive, the user's home folder) stay absolute, and links into a folder that does not exist yet on the other machine are still written relative, so they start working the moment that folder appears. Wine and Proton rewrite a prefix's links with local absolute targets while they update it; PLWL relativizes them again on the wire, on import and before every run. - -A sync also carries **how the apps are set up**: the Wine/Proton build and prefix each app runs in, its arguments, environment variables and DLL overrides, plus playtime and last played. The **Sync settings** category carries the registry-level part of that even when no files move: the launcher's **default Wine build and default prefix**, the custom **names** you gave Wine/Proton builds (and their disabled flags), the **prefix registry**, every **app's configuration**, and the **order of the library**. It only patches what already exists on the other machine - an unknown Wine id or prefix is ignored instead of leaving a dangling default or a broken app behind, apps and prefixes the sender does not know stay where they are, and a settings-only sync deletes nothing (that is what the file categories are for). Playtime only ever grows - the higher counter wins - so pushing from a freshly installed machine cannot wipe a library's play history. Everything else stays local: language and window settings, logs, translations, and external (leave) apps - prefixes are always kept usable, so app entries pointing inside a prefix keep their prefix-relative paths and the receiving side never loses its own `Main` prefix. +A sync also carries **how apps are set up**: wine build and prefix, args, env, DLL overrides, playtime and last played. **Sync settings** carries the registry-level part even when no files move: default wine/prefix, custom build names, prefix registry, every app's configuration and the library order. It only patches what already exists (unknown ids are ignored, unknown apps/prefixes stay), deletes nothing, and playtime only grows — the higher counter wins. Language, window settings, logs and external (leave) apps stay local. ## Wine & Proton support -- **System Wine** — uses the `wine` binary from PATH. -- **Imported builds** — drop any wine or Proton build into `files/wines/`. The folder may contain the build directly, or one level of nesting (zip-extracted layouts are detected automatically). After the copy, PLWL rewires the build's absolute symlinks to relative ones and restores the executable bit on the binaries it needs to run (`proton`, `wine`, `wineserver`, `winetricks`), so a build that arrived from a zip or another machine works without further fiddling. A build can also be given a **displayed name** of its own (Wine versions): it is used everywhere the build is offered — library config, settings and the *Open with PLWL* popup — while the folder in `files/wines/` keeps its name and the symlinks inside prefixes stay valid. -- **Proton without Steam** — fully supported. Proton is invoked like Steam does it: `STEAM_COMPAT_DATA_PATH` is a per-build compat folder linked to the launcher's own prefix, and `STEAM_COMPAT_CLIENT_INSTALL_PATH` points at an installed Steam client when there is one (Proton reads its legacy DLLs and `steamclient.so` from there, read-only) and at the empty `files/data/fake-steam` otherwise. GE builds work out of the box: PLWL launches through Proton's built-in UMU path (`UMU_ID`), which needs no Steam client at all. An app that breaks under umu.exe has a per-app **Disable UMU** switch in its config — PLWL then runs the build's own wine directly (Proton's `runinprefix` verb): no umu.exe/steam.exe launcher and no Proton prefix setup take part, so the run stays as close to plain wine as possible. (Proton's session init still reads `STEAM_COMPAT_CLIENT_INSTALL_PATH` on every verb; a disableUmu run gets the empty `fake-steam` folder and nothing is copied out of it.) -- **One C: drive per prefix** — Proton normally keeps its prefix one level deeper (`/pfx/drive_c`) than wine (`/drive_c`), which left a prefix used with both kinds with two separate C: drives (two registries, two sets of saves). Whenever a prefix has no `pfx` yet (a fresh prefix, or a plain-wine prefix first used through Proton), PLWL makes `pfx` a relative symlink back to the prefix itself, so Proton and wine share one `drive_c`, one registry and one `dosdevices`. Existing layouts are left exactly as they are — prefixes from older versions keep their real `pfx/` folder, and a prefix with both `drive_c` trees keeps both, because two live Windows installs cannot be merged safely (both stay visible to the prefix import picker). +- **System Wine** — `wine` from PATH. +- **Imported builds** — drop any wine/Proton build into `files/wines/` (one nesting level auto-detected). Symlinks are rewired to relative and executable bits restored, so builds from a zip or another machine run as they are. A build can get a **display name** of its own; the folder keeps its name. +- **Proton without Steam** — invoked like Steam does it: `STEAM_COMPAT_DATA_PATH` linked to the launcher's prefix, `STEAM_COMPAT_CLIENT_INSTALL_PATH` pointing at a real Steam client when there is one (read-only) and at the empty `files/data/fake-steam` otherwise. GE builds run through Proton's UMU path (`UMU_ID`) with no Steam client at all. A per-app **Disable UMU** switch runs the build's own wine directly (`runinprefix`), as close to plain wine as it gets. +- **One C: drive per prefix** — when a prefix has no `pfx` yet, PLWL makes `pfx` a relative symlink back to the prefix itself, so Proton and wine share one `drive_c`, registry and `dosdevices`. Existing layouts (a real `pfx/` folder, a prefix with both trees) are left exactly as they are. ## Translations -All UI text comes from JSON files in `files/translations/`. The `data` object carries the language names; `texts` maps keys to strings, with `{0}`, `{1}` ... placeholders filled at runtime. - -The translations bundled with a release are launcher-owned: on every start they are refreshed into `files/translations/` whenever their content changed, so launcher updates deliver new/changed keys to existing installs. Extra language files the bundle does not ship (user-added) are never touched. +All UI text comes from JSON files in `files/translations/`: `data` holds the language names, `texts` maps keys to strings with `{0}`, `{1}` ... placeholders. The bundled files are refreshed into that folder on every start when their content changed (so updates deliver new keys); user-added languages are never touched. ## Development -Codebase overview, conventions and rules for contributors: [development.md](development.md). - +Codebase overview and contributor rules: [development.md](development.md). diff --git a/development.md b/development.md index 358dc02..a21a42d 100644 --- a/development.md +++ b/development.md @@ -9,7 +9,7 @@ src/ main process (Node/Electron backend) main.js entry point: tray, single-instance handoff, --launch / --open argv.js command-line parsing shared by boot and second-instance handoff window.js main window creation - ipc/ all ipcMain handlers + ipc/ all ipcMain handlers index.js handler registration, icon protocol, dialogs games.js games service: run/stop state, wine resolution, open-with handoff lansync.js LAN sync handlers (identity, devices, re-roll, sync + progress) @@ -30,9 +30,14 @@ src/ main process (Node/Electron backend) paths.js portable layout, FILES-relative path helpers, translation sync i18n.js translation loading + {0} placeholder filling preload.js contextBridge API exposed to the renderer - lansync.js LAN sync: identity + pairing, hashing, HTTP receiver (with its - receive-progress hook), UDP discovery + lansync.js LAN sync receiver: identity + pairing, hashing, HTTP server + (with its receive-progress hook); uses hashcache.js, + lansync-identity.js, lansync-manifest.js, lansync-discovery.js lansyncpush.js the pushing side (createPusher factory, keeps helper state out) + hashcache.js file hash cache (path -> size/mtime/hash) for LAN sync + lansync-identity.js identity + sync code (createIdentity factory) + lansync-manifest.js sender-side manifest building (createManifestBuilder) + lansync-discovery.js UDP beacon discovery (createDiscovery factory) appicon.js window/taskbar icon renderer/ UI (plain HTML/CSS/JS, no framework) app.js views, modals, custom-select, all renderer logic @@ -45,183 +50,60 @@ test/ plain node scripts, run them with `node test/.test.js`; tools/ install.sh / createshortcut.sh / build helpers ``` -Main process vs renderer: the renderer never touches Node/Electron directly - -everything goes through the `window.plwl` bridge (preload.js) and ipcMain -handlers in src/ipc/. Keep new APIs in that pattern. +Renderer never touches Node/Electron directly — everything goes through the `window.plwl` bridge (preload.js) and ipcMain handlers in src/ipc/. Keep new APIs in that pattern. ## Rules -- Keep it simple. Plain functions over classes, no abstractions "for later". -- Comments explain the WHY (quirks, workarounds, races), not the WHAT. If the - code says it, don't repeat it in words. No comments at all when nothing - needs explaining. -- No duplicate logic: the second time you copy something, move it into a - helper (a shared module in src/ or a function next to its users) 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/. -- Registries (apps.json, wines.json, prefixes.json) are rewritten whole via - writeJson(); playtime lives in its own tiny file so the registry is not - rewritten while a game runs. -- Deleting user data needs a confirm modal; best-effort cleanup is - try/catch-and-ignore with a comment saying why it is safe to ignore. -- Windows apps run through runGame()/buildEnv() - never spawn wine directly - somewhere else (prefix handling, Proton env and playtime live there). -- A prefix or wine build that a running app uses cannot be renamed/deleted - (wine holds its registry and user files open in that folder): the guards sit - next to the handlers in src/ipc/, use the games service's running list plus - the helpers in library.js (prefixNameForGame/gamesUsingPrefix/is*InUse) and - answer { ok:false, error:'inUse'|'hasApps', apps:[names] } for the toast. -- Long operations stream progress through the single import:progress slot as - { phase: 'count'|'copy'|'remove', files, bytes, totalFiles, totalBytes } and - the renderer drives a bar from it (runWithProgress for deletes/imports). - Emit the 'count' phase first (a treeSize walk) and copy/remove through the - fsutil helpers; never ship a progress bar no data can move. -- CSS: reuse the existing variables/classes (.button, .field-label, - .custom-select, form-card); spacing rhythm comes from --button-margin and - the flex gap of the container, not per-element magic numbers. -- LAN sync has two sides: the receiver lives in lansync.js, the sender in - lansyncpush.js and is built by createPusher(helpers) - several launcher - states share the module, so nothing may be kept in its module scope. Both - sides report their own progress (the receiver through the onReceive hook), - so each machine can show the transfer. -- An incoming sync needs CONSENT: a correct code opens the gate, but the - receiver's onIncoming hook (registered in main.js) shows an Accept/Decline - popup and only a click - answered through lansync:respondIncoming - starts - the session. Unanswered requests expire after INCOMING_TIMEOUT_MS; a decline - returns 'rejected' to the sender and KEEPS the pairing (the code was proven). - Without an onIncoming hook (tests, headless) the gate stays open. -- Sync progress is bytes plus per-category counters (items = announced units, - itemsDone = finished ones, totals = everything the sender picked), never raw - file counts: the popup says "Apps: 2/5". The bar follows the CURRENT PHASE's - bytes (barBytes/barTotalBytes, not the overall bytes/totalBytes) so a phase - starts from an empty bar instead of inheriting a full one - otherwise hashing - looks finished the moment it starts. Hashing and copying walk tens of - thousands of files, so every per-file update goes through progressGate - (~10/s, phase changes forced) - never send one IPC message per file. -- Registry-level settings are opt-in (the "settings" sync category, shown as - "Sync settings"; the old name "defaults" is still accepted as an alias on - BOTH sides - the receiver normalizes payload.categories, so a sender from an - older version keeps working). payload carries defaults (default wine and - prefix), wineSettings (displayName + disabled per build id), prefixSettings - (the registry names; a settings sync never renames or creates folders - - that is the prefixes category), appSettings (name, wine, prefix, args, env, - dllOverrides, disableUmu per app id) and appOrder. The receiver only patches - things that already exist there: an unknown wine id / prefix / app is - skipped, nothing is created or deleted, and the order merge keeps local - entries the sender does not know at the end. Values are written through the - normal helpers (setWineDisplayName clears a custom label when the sender has - none, library.updateGame pins the prefix of prefix imports, reorderGames - merges by id). -- LAN sync remembers file hashes in files/data/lansync-hashes.json, keyed by - path with size + mtime (machine-local, never synced): equal size and mtime - means equal bytes, so a repeat sync only stat's the tree. Hashing and the - receiving side's comparison both run HASH_CONCURRENCY files at a time. -- A modal showing a running operation (a sync) opens locked: - showModal(html, { locked: true }) ignores the backdrop and Escape, because - the user cannot cancel the operation by dismissing it. -- Symlinks inside everything PLWL owns (a wine/proton build, a prefix, an - imported app) are stored RELATIVE whenever the target can be expressed - locally: a Proton prefix is full of links into its build, and an absolute one - points at the machine that wrote it. symlinks.js owns this (rewireSymlinks, - plus restoreExecutableBits for the executables a content copy drops) and - every writer calls it: importWineFolder/healBuild, importer after copy/move, - healPrefix, and the sync both on the wire (portableLinkTarget sends the - relative target) and after a unit lands (healUnitRoots). dosdevices drive - mappings and targets that leave files/ (a mount, a system path, the user's - home) stay absolute - they are machine-specific by design. -- A prefix that has NO `pfx` yet gets ONE C: drive: `pfx` is a relative - symlink to the prefix itself, so Proton's `/pfx` - (runner.linkPrefix) resolves to `` and shares `drive_c`, the - registry and `dosdevices` with plain wine. `prefixes.ensureUnifiedPrefix` - only ever CREATES that link; an existing layout (a real `pfx/` folder from - an older PLWL, a both-drives prefix) is left exactly as it is. Hoisting a - prefix's files one level up was tried and removed: it breaks every relative - link written against the old depth (the `umu.exe` link into the build is the - one that kills a Proton launch), and moving a live Windows install is not - the launcher's call. `healPrefix`/`rewireSymlinks` repairs such a prefix by - re-anchoring broken relative links through files/. Never assume a single - drive_c path: use `prefixDriveRoots()` (deduplicated) and compare realpaths - when deciding whether a folder is inside a prefix's C: drive. -- App launch env is built in runner.buildEnv only. WINEDLLOVERRIDES is an - ordered semicolon list: inherited value first, the app's overrides last, - because Wine lets a later entry replace an earlier one for the same module. - An empty setting (`dll=`) is Wine's "disabled" and must survive the filter. - Proton appends its own dxvk/nvapi/steam.exe defaults AFTER everything, so an - app override only wins where Proton has no default. `disableUmu` (per app) - drops UMU_ID and switches Proton to its `runinprefix` verb, so no - umu.exe/steam.exe launcher and no Proton prefix setup take part - the - build's own wine runs as close to plain wine as it gets. - STEAM_COMPAT_CLIENT_INSTALL_PATH must be set in BOTH modes: Proton's session - init reads it on every verb (sync_appcache runs even for runinprefix, and - with SteamGameId set a missing variable aborts the launch with a KeyError - before wine starts). On the default path it prefers a real Steam client - found on the machine; a disableUmu run always gets the empty - files/data/fake-steam, whose contents are never read. -- Wine and Proton create prefix links with ABSOLUTE local targets whenever they - update a prefix (font/dll stubs, Steam appcache), so on-disk links are not - always relative after a run: the sync payload relativizes them anyway, and - refreshWines()/healPrefix() repair what arrived from an older PLWL. A build - whose binaries lost their executable bit is detected by a cheap audit in - refreshWines() and healed once, because spawn() on a non-executable `proton` - fails with EACCES and the game would look like it started while nothing ran. -- File permissions travel with a sync (manifest entry `mode`): the receiving - side applies them after writing the bytes and repairs a file that has the - same hash but the wrong mode without re-uploading it. -- A sync must never walk through a symlinked ROOT. pruneTree/healUnitRoots - (sync), removeTree/treeSize (fsutil) and healBuild (wines) lstat their root - first and treat anything that is not a real directory as the link (or file) - it is: a build, prefix or app folder kept on another disk must not have its - target deleted, pruned or rewritten through the link. `listDirs()` in the - sync counts symlinked folders as units, so such a folder still travels - as - the link it is (`wineUnits` maps nested builds to their TOP folder under - wines/, otherwise a zip-extracted build becomes a phantom unit whose root - does not exist and the "Wine versions" totals grow). -- Generated app shortcuts are file handlers too: `Exec= - --launch --file %f` (the field code is appended after escaping - a - literal % is `%%`). argv.js takes the file from `--file` or from a bare - trailing path (KDE appends it to Exec lines without a file field), and - runner.windowsPathFor passes it to the app the way Windows would: `C:\...` - when it sits inside the prefix's drive_c, `Z:\...` otherwise. Giving a file - to an app that is ALREADY running is a handoff (runGame `handoff: true`): - an extra untracked process, no log/playtime writes, because the app's own - single-instance logic is what actually opens the file. The second-instance - handoff must never read a value from the token next to its option: Electron - RE-SERIALIZES that argv (all switches first, their values after, the dev app - path among them), so `--launch --file ` arrives as - `--launch --file --allow-file-access-from-files `. argv.js pairs - options with positionals IN ORDER (inline `--name=value` wins, the dev app - path is skipped); reading the next token used to take the launch id as the - opened file, so an app started without its image. - `electron test/argv-handoff.test.js` proves the real serialization. -- A sender hashing a big unit is minutes of network silence: it pings - `/plwl/ping` (+token) every HEARTBEAT_MS for the whole sync, the receiver's - handler re-arms its popup watchdog (stage 'wait' changes nothing on screen), - and a receiving popup its watchdog closed reopens on the next real event - - only the 'done' stage ends a sync for good, and reopening NEVER asks for - consent again: the Accept/Decline gate is the onIncoming popup at verify - time (a fresh session), while the progress popup is driven by the same - token's events. A 'wait' event that reopens a closed popup fills the phase - line (`recvWaiting`) instead of leaving every line blank. -- Tests are plain assert-by-print scripts (see test/): set PLWL_BUILD_DIR to - a temp dir, check(), exit 1 on failure. Add one when fixing a bug. +- 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. +- 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/. +- Registries (apps.json, wines.json, prefixes.json) are rewritten whole via writeJson(); playtime lives in its own tiny file so the registry is not rewritten while a game runs. +- Deleting user data needs a confirm modal; best-effort cleanup is try/catch-and-ignore with a comment saying why it is safe to ignore. +- Windows apps run through runGame()/buildEnv() — never spawn wine directly elsewhere (prefix handling, Proton env and playtime live there). +- A prefix or wine build a running app uses cannot be renamed/deleted (wine holds files open there): the guards sit next to the handlers in src/ipc/, use the games service's running list plus library.js helpers (prefixNameForGame/gamesUsingPrefix/is*InUse) and answer `{ ok:false, error:'inUse'|'hasApps', apps:[names] }` for the toast. +- Long operations stream progress through the single import:progress slot as `{ phase: 'count'|'copy'|'remove', files, bytes, totalFiles, totalBytes }` (runWithProgress for deletes/imports). Emit 'count' (a treeSize walk) first, copy/remove through the fsutil helpers. +- CSS: reuse the existing variables/classes (.button, .field-label, .custom-select, form-card); spacing comes from --button-margin and flex gaps, not per-element magic numbers. + +### LAN sync + +- Two sides: the receiver lives in lansync.js, the sender in lansyncpush.js, built by createPusher(helpers) — several launcher states share the module, so nothing may sit in its module scope. Both sides report their own progress (receiver through the onReceive hook). +- An incoming sync needs CONSENT: a correct code opens the gate, but the receiver's onIncoming hook (registered in main.js) shows Accept/Decline, answered through lansync:respondIncoming. Unanswered requests expire (INCOMING_TIMEOUT_MS); a decline returns 'rejected' and KEEPS the pairing (the code was proven). Without an onIncoming hook (tests, headless) the gate stays open. +- Progress is bytes plus per-category counters (items = announced units, itemsDone = finished, totals = everything picked) — "Apps: 2/5". The bar follows the CURRENT PHASE's bytes (barBytes/barTotalBytes) so a phase starts from an empty bar. Hashing and copying walk tens of thousands of files: every per-file update goes through progressGate (~10/s, phase changes forced) — never one IPC message per file. +- Registry-level settings are opt-in (the "settings" category, old name "defaults" still accepted as an alias on both sides). The payload carries defaults, wineSettings (displayName + disabled per build), prefixSettings, appSettings (name, wine, prefix, args, env, dllOverrides, disableUmu per app) and appOrder. The receiver only patches what already exists (unknown ids skipped, nothing created or deleted, unknown local entries kept at the end of the order) through the normal helpers (setWineDisplayName, library.updateGame, reorderGames). +- File hashes are remembered in files/data/lansync-hashes.json (path -> size + mtime + hash; machine-local, never synced): equal size and mtime means equal bytes, so a repeat sync only stats the tree. Hashing and the receiver's comparison both run HASH_CONCURRENCY files at a time. +- File permissions travel with the manifest (`mode`) and are applied after the bytes; a file with the right hash but wrong mode is repaired without re-upload. +- A sender hashing a big unit is minutes of silence: it pings `/plwl/ping` every HEARTBEAT_MS, the receiver re-arms its popup watchdog (stage 'wait' changes nothing on screen). A receiving popup its watchdog closed reopens on the next real event — only 'done' ends a sync, and reopening never asks for consent again (consent is the onIncoming popup at verify time; the progress popup is driven by the same token's events). +- A sync must never walk through a symlinked ROOT: pruneTree/healUnitRoots (sync), removeTree/treeSize (fsutil) and healBuild (wines) lstat the root first. `listDirs()` counts symlinked folders as units, so a build on another disk still travels — as the link it is (`wineUnits` maps nested builds to their TOP folder under wines/). +- Wine/Proton rewrite prefix links to absolute targets whenever they update a prefix, so on-disk links are not always relative after a run: the sync relativizes them on the wire (portableLinkTarget) and refreshWines()/healPrefix() repair what arrived from an older PLWL. A build whose binaries lost the executable bit is detected in refreshWines() and healed once (spawn() on a non-executable `proton` fails with EACCES while the game looks started). + +### Paths and prefixes + +- Symlinks inside everything PLWL owns are stored RELATIVE whenever the target can be expressed locally (symlinks.js owns this: rewireSymlinks + restoreExecutableBits; every writer calls it — imports, healPrefix, sync on the wire and after a unit lands). dosdevices drive mappings and targets leaving files/ stay absolute — machine-specific by design. +- A prefix with NO `pfx` yet gets ONE C: drive: `pfx` is a relative symlink to the prefix itself (`prefixes.ensureUnifiedPrefix` only CREATES it), so Proton's `/pfx` shares drive_c, registry and dosdevices with plain wine. Existing layouts are left as they are — hoisting files one level up was tried and removed (it breaks relative links, the umu.exe link kills a Proton launch). Never assume a single drive_c: use `prefixDriveRoots()` and compare realpaths. +- App launch env is built in runner.buildEnv only. WINEDLLOVERRIDES is an ordered semicolon list (inherited first, app's last; an empty `dll=` is "disabled" and must survive the filter). Proton appends its dxvk/nvapi defaults AFTER everything. `disableUmu` drops UMU_ID and runs Proton's `runinprefix` verb. STEAM_COMPAT_CLIENT_INSTALL_PATH is required in BOTH modes (Proton's session init reads it on every verb; missing it aborts the launch before wine starts): a real Steam client when found, else the empty files/data/fake-steam. + +### Launch and shortcuts + +- Generated shortcuts are file handlers too: `Exec= --launch --file %f` (a literal % is `%%`). argv.js takes the file from `--file` or a bare trailing path, and runner.windowsPathFor passes it to the app as Windows would (`C:\...` inside the prefix, `Z:\...` otherwise). A file given to an already running app is a handoff (untracked process, no log/playtime writes). +- The second-instance handoff must never read the token next to its option: Electron RE-SERIALIZES argv (`--launch --file ` arrives as `--launch --file --allow-file-access-from-files `), so argv.js pairs options with positionals IN ORDER (inline `--name=value` wins, the dev app path is skipped). Reading the next token used to take the launch id as the opened file. `electron test/argv-handoff.test.js` proves the real serialization. +- A modal showing a running sync opens locked: showModal(html, { locked: true }) ignores backdrop and Escape. +- Tests are plain assert-by-print scripts (see test/): set PLWL_BUILD_DIR to a temp dir, check(), exit 1 on failure. Add one when fixing a bug. ## Verify before you commit ``` -node --check # syntax -node test/import.test.js # ...and the other test files -node test/lansync.test.js # LAN sync contract (identity, pairing, mirror) -node test/lansync-receiving.test.js # receive-side progress events (the popup) -node test/lansync-settings.test.js # playtime, app settings, wine/prefix + defaults -node test/lansync-links.test.js # wire: exec bits + relative links in a sync -node test/lansync-pending-links.test.js # links to files created later, startup heal -node test/lansync-consent.test.js # incoming sync: accept / decline / timeout gate -node test/bench-sync.js # timing: phases of a cold and a cached sync -npm start # click through the flow you touched +node --check # syntax +node test/smoke.js # quick sanity +node test/lansync.test.js # LAN sync contract (identity, pairing, mirror) +node test/lansync-receiving.test.js # receive-side progress events (the popup) +node test/lansync-settings.test.js # playtime, app settings, wine/prefix + defaults +node test/lansync-links.test.js # wire: exec bits + relative links +node test/lansync-pending-links.test.js # links to files created later, startup heal +node test/lansync-consent.test.js # accept / decline / timeout gate +node test/lansync-token.test.js # long-sync token expiry (sliding TTL) +node test/bench-sync.js # timing: cold vs cached sync +npm start # click through the flow you touched # Repairing / testing a real instance (test/ scripts are gitignored helpers): PLWL_BUILD_DIR=/path/to/instance node test/heal-instance.js # links + exec bits diff --git a/files/translations/en-us.json b/files/translations/en-us.json index c5b1806..058b1a7 100644 --- a/files/translations/en-us.json +++ b/files/translations/en-us.json @@ -175,6 +175,11 @@ "settings.saved": "Settings saved", "settings.openFiles": "Open files folder", "settings.closeToTray": "Close window to tray (keep tracking playtime in background)", + "settings.hashCache": "Sync file cache", + "settings.hashCacheHint": "LAN syncing remembers the hash of every file (with its size and modification time), so an unchanged library is not read again on the next sync. Clear it if a transfer compares wrong - everything gets hashed fresh on the next sync.", + "settings.clearHashCache": "Clear cache", + "settings.hashCacheClearConfirm": "Forget all remembered file hashes? The next sync will hash the whole library again.", + "settings.hashCacheCleared": "Hash cache cleared", "lansync.title": "LAN syncing", "lansync.subtitle": "Push your apps, prefixes and Wine builds to another PLWL on the same network.", "lansync.thisDevice": "This device", diff --git a/files/translations/pl.json b/files/translations/pl.json index 4329d0e..ad1c0ca 100644 --- a/files/translations/pl.json +++ b/files/translations/pl.json @@ -175,6 +175,11 @@ "settings.saved": "Ustawienia zapisane", "settings.openFiles": "Otwórz folder files", "settings.closeToTray": "Zamykaj okno do zasobnika systemowego (licz czas gry w tle)", + "settings.hashCache": "Pamięć podręczna haszy", + "settings.hashCacheHint": "Synchronizacja LAN zapamiętuje hash każdego pliku (razem z rozmiarem i datą modyfikacji), dzięki czemu niezmieniona biblioteka nie jest czytana przy każdym syncu. Wyczyść, jeśli transfer porównuje pliki błędnie — przy następnej synchronizacji wszystko zostanie zahaszowane od nowa.", + "settings.clearHashCache": "Wyczyść", + "settings.hashCacheClearConfirm": "Zapomnieć wszystkie zapamiętane hasze plików? Następna synchronizacja zahashuje całą bibliotekę od nowa.", + "settings.hashCacheCleared": "Pamięć podręczna wyczyszczona", "lansync.title": "Synchronizacja LAN", "lansync.subtitle": "Wypchnij swoje aplikacje, prefiksy i buildy Wine na inny PLWL w tej samej sieci.", "lansync.thisDevice": "To urządzenie", diff --git a/renderer/app.js b/renderer/app.js index 0f8aca2..dc53847 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1970,6 +1970,12 @@ function wireEvents() { }) qs('#btn-settings-save').addEventListener('click', saveSettings) qs('#btn-open-files').addEventListener('click', () => window.plwl.shellOpenPath(window.plwl.FILES_DIR)) + qs('#btn-clear-hash-cache').addEventListener('click', async () => { + const ok = await confirmModal(t('settings.hashCacheClearConfirm'), t('settings.clearHashCache')) + if (!ok) return + await window.plwl.lansyncClearHashCache() + toast(t('settings.hashCacheCleared'), 'success') + }) qs('#btn-gc-back').addEventListener('click', () => showView('library')) qs('#btn-gc-save').addEventListener('click', saveGameConfig) qs('#btn-gc-shortcut').addEventListener('click', createAppShortcut) diff --git a/renderer/index.html b/renderer/index.html index d654566..a5aa95c 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -103,6 +103,13 @@ +
+

+

+
+ +
+
diff --git a/src/fsutil.js b/src/fsutil.js index 6966007..69b8e76 100644 --- a/src/fsutil.js +++ b/src/fsutil.js @@ -4,6 +4,16 @@ const fs = require('fs') const fsp = require('fs').promises const path = require('path') +// accessSync instead of existsSync: same answer, one syscall fewer. +function pathExists(p) { + try { + fs.accessSync(p) + return true + } catch { + return false + } +} + // Returns a non-existing path in `base` named `name` (or `name (2)`, `name (3)`...). function dedupeDir(base, name) { const candidate = path.join(base, name) @@ -50,6 +60,22 @@ 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. +async function mapLimit(items, limit, fn) { + const out = new Array(items.length) + let next = 0 + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (next < items.length) { + const i = next++ + out[i] = await fn(items[i], i) + } + }) + await Promise.all(workers) + 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 @@ -218,5 +244,5 @@ async function removeTree(root, onProgress) { return { files, dirs, bytes } } -module.exports = { dedupeDir, copyTree, treeSize, removeTree, progressGate } +module.exports = { pathExists, dedupeDir, copyTree, treeSize, removeTree, progressGate, mapLimit } diff --git a/src/hashcache.js b/src/hashcache.js new file mode 100644 index 0000000..6adbf72 --- /dev/null +++ b/src/hashcache.js @@ -0,0 +1,161 @@ +'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 +// 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. +const HASH_ALGO = (() => { + for (const algo of ['blake2b512', 'sha512', 'sha256']) { + try { + crypto.createHash(algo) + return algo + } catch {} + } + 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. +function hashFile(file, onBytes) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash(HASH_ALGO) + const stream = fs.createReadStream(file, { highWaterMark: 64 * 1024 }) + stream.on('error', reject) + if (onBytes) stream.on('data', chunk => onBytes(chunk.length)) + stream.on('data', chunk => hash.update(chunk)) + stream.on('end', () => resolve(hash.digest('hex'))) + stream.resume() + }) +} + +// A quarter million entries is a whole game library; beyond that, entries the +// longest time untouched are dropped first. +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. +const SAVE_MIN_MS = 60000 + +function createHashCache(dataDir) { + const FILE = path.join(dataDir, 'lansync-hashes.json') + let cache = null + let dirty = false + let savedAt = 0 + const stats = { hits: 0, misses: 0 } + + function load() { + if (cache) return cache + try { + const stored = JSON.parse(fs.readFileSync(FILE, 'utf8')) + cache = stored && typeof stored === 'object' && stored.files && typeof stored.files === 'object' ? stored.files : {} + } catch { + cache = {} + } + return cache + } + + // Compact JSON (the entries are arrays) keeps a full cache at tens of + // milliseconds instead of the seconds pretty-printed JSON needs. + function save(force) { + if (!dirty || !cache) return + if (!force && Date.now() - savedAt < SAVE_MIN_MS) return + const names = Object.keys(cache) + if (names.length > HASH_CACHE_MAX) { + names.sort((a, b) => (cache[b][3] || 0) - (cache[a][3] || 0)) + const trimmed = {} + for (const name of names.slice(0, HASH_CACHE_MAX)) trimmed[name] = cache[name] + cache = trimmed + } + dirty = false + savedAt = Date.now() + try { + fs.mkdirSync(path.dirname(FILE), { recursive: true }) + const tmp = `${FILE}.tmp` + fs.writeFileSync(tmp, JSON.stringify({ v: 1, algo: HASH_ALGO, files: cache })) + fs.renameSync(tmp, FILE) + } 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. + function hit(rel, size, mtimeMs) { + const known = load()[rel] + if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') { + stats.misses++ + return null + } + stats.hits++ + if (Date.now() - (known[3] || 0) > 86400000) { + known[3] = Date.now() + dirty = true + } + return known[2] + } + + function remember(rel, size, mtimeMs, hash) { + load()[rel] = [size, mtimeMs, hash, Date.now()] + 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. + async function cachedHash(rel, abs, size, mtimeMs, onBytes) { + const known = hit(rel, size, mtimeMs) + if (known) return known + const hash = await hashFile(abs, onBytes) + if (hash) remember(rel, size, mtimeMs, hash) + return hash + } + + // The user can drop the cache from the settings view; a corrupted or stale + // file is also replaced wholesale this way. + function clear() { + cache = null + dirty = false + savedAt = 0 + stats.hits = 0 + stats.misses = 0 + try { + fs.rmSync(FILE, { force: true }) + fs.rmSync(`${FILE}.tmp`, { force: true }) + } 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. + function forget() { + cache = null + dirty = false + } + + return { + HASH_ALGO, + hashFile, + hit, + remember, + cachedHash, + save, + clear, + forget, + stats: () => ({ ...stats, size: cache ? Object.keys(cache).length : 0, algo: HASH_ALGO }), + resetStats: () => { + stats.hits = 0 + stats.misses = 0 + } + } +} + +module.exports = { createHashCache, hashFile, HASH_ALGO } diff --git a/src/ipc/index.js b/src/ipc/index.js index 207d49f..e91b38e 100644 --- a/src/ipc/index.js +++ b/src/ipc/index.js @@ -68,18 +68,9 @@ function registerIpc({ getWindow, onGamesChanged }) { ipcMain.handle('settings:set', (e, patch) => library.saveSettings(patch)) // wines ---------------------------------------------------------------------- - function systemWine() { - let version = '' - try { - const { execFileSync } = require('child_process') - version = String(execFileSync('wine', ['--version'], { timeout: 15000 })).trim().replace(/^wine-?/i, '') - } catch {} - return { id: 'system', type: 'system', label: 'System Wine', version } - } - ipcMain.handle('wines:list', async () => { await wines.refreshWines() - return [systemWine(), ...wines.getWines()] + return [wines.systemWine(), ...wines.getWines()] }) ipcMain.handle('wines:refresh', async () => (await wines.refreshWines()).filter(w => !w.disabled)) ipcMain.handle('wines:setDisabled', (e, id, disabled) => wines.setWineDisabled(id, disabled)) diff --git a/src/ipc/lansync.js b/src/ipc/lansync.js index 027e487..b53c31b 100644 --- a/src/ipc/lansync.js +++ b/src/ipc/lansync.js @@ -19,6 +19,10 @@ function registerLanSyncIpc({ getWindow }) { lansync.forgetDevice(id) return { ok: true } }) + ipcMain.handle('lansync:clearHashCache', () => { + lansync.clearHashCache() + return { ok: true } + }) ipcMain.handle('lansync:respondIncoming', (e, requestId, accept) => lansync.respondIncoming(requestId, accept)) ipcMain.handle('lansync:sync', async (e, payload) => { try { diff --git a/src/lansync-discovery.js b/src/lansync-discovery.js new file mode 100644 index 0000000..3aa6f65 --- /dev/null +++ b/src/lansync-discovery.js @@ -0,0 +1,135 @@ +'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. + +const dgram = require('dgram') +const os = require('os') + +// Broadcast to the global address AND to every interface's own subnet +// broadcast - some networks drop one or the other. +function broadcastAddresses() { + const out = ['255.255.255.255'] + const interfaces = os.networkInterfaces() + for (const list of Object.values(interfaces)) { + for (const iface of list || []) { + if (iface.family !== 'IPv4' || iface.internal || !iface.netmask) continue + const addr = ipToInt(iface.address) + const mask = ipToInt(iface.netmask) + if (addr == null || mask == null) continue + out.push(intToIp((addr | (~mask >>> 0)) >>> 0)) + } + } + return out +} + +function ipToInt(ip) { + const parts = String(ip).split('.') + if (parts.length !== 4) return null + let out = 0 + for (const part of parts) { + const n = Number(part) + if (!Number.isInteger(n) || n < 0 || n > 255) return null + out = (out << 8) | n + } + return out >>> 0 +} + +function intToIp(int) { + return [24, 16, 8, 0].map(shift => (int >>> shift) & 255).join('.') +} + +function createDiscovery({ identity, tag, protocol, port, beaconMs, deviceTtlMs, getPort }) { + let socket = null + let timer = null + let devicesCb = null + // id -> { id, name, address, port, at } + const seen = new Map() + + function listDevices() { + const known = identity().known + const now = Date.now() + return [...seen.values()] + .filter(d => now - d.at <= deviceTtlMs) + .map(d => { + const pair = known[d.id] + return { id: d.id, name: d.name, address: d.address, port: d.port, paired: !!pair, code: pair ? pair.code : '' } + }) + .sort((a, b) => String(a.name).localeCompare(String(b.name))) + } + + function emitDevices() { + if (devicesCb) devicesCb(listDevices()) + } + + function pruneDevices() { + const now = Date.now() + let changed = false + for (const [id, device] of seen) { + if (now - device.at > deviceTtlMs) { + seen.delete(id) + changed = true + } + } + if (changed) emitDevices() + } + + function beacon() { + if (!socket || !getPort()) return + const me = identity() + const payload = Buffer.from(JSON.stringify({ t: tag, v: protocol, id: me.deviceId, name: os.hostname(), port: getPort() })) + for (const address of broadcastAddresses()) { + try { + socket.send(payload, 0, payload.length, port, address) + } catch {} + } + pruneDevices() + } + + function start(onDevices) { + stop() + devicesCb = onDevices + socket = dgram.createSocket({ type: 'udp4', reuseAddr: true }) + socket.on('error', () => stop()) + socket.on('message', (buf, rinfo) => { + let msg = null + try { + msg = JSON.parse(buf.toString('utf8')) + } catch {} + if (!msg || msg.t !== tag || msg.v !== protocol || !msg.id) return + if (msg.id === identity().deviceId) return // our own broadcast + const previous = seen.get(msg.id) + const next = { id: msg.id, name: String(msg.name || rinfo.address), address: rinfo.address, port: Number(msg.port) || 0, at: Date.now() } + const changed = !previous || previous.address !== next.address || previous.port !== next.port || previous.name !== next.name + seen.set(msg.id, next) + if (changed) emitDevices() + }) + socket.bind(port, () => { + try { + socket.setBroadcast(true) + } catch {} + beacon() + timer = setInterval(beacon, beaconMs) + }) + } + + function stop() { + if (timer) { + clearInterval(timer) + timer = null + } + if (socket) { + const closing = socket + socket = null + try { + closing.close() + } catch {} + } + } + + return { start, stop, listDevices } +} + +module.exports = { createDiscovery } diff --git a/src/lansync-identity.js b/src/lansync-identity.js new file mode 100644 index 0000000..f9e252c --- /dev/null +++ b/src/lansync-identity.js @@ -0,0 +1,133 @@ +'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. + +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). +const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' +const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789' + +function randomChars(alphabet, count) { + let out = '' + while (out.length < count) { + for (const byte of crypto.randomBytes(count)) { + if (out.length >= count) break + out += alphabet[byte % alphabet.length] + } + } + 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. +function makeDeviceId() { + const stamp = Date.now().toString(36) + return (stamp + randomChars(ID_ALPHABET, Math.max(0, 24 - stamp.length))).slice(0, 24) +} + +function makeSyncCode() { + return randomChars(CODE_ALPHABET, 8) +} + +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. + let cached = null + + function readConfig() { + if (cached) return cached + const cfg = readJson(configFile, {}) + cached = cfg && typeof cfg === 'object' ? cfg : {} + 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). + function identity() { + const cfg = readConfig() + let changed = false + if (typeof cfg.deviceId !== 'string' || cfg.deviceId.length !== 24) { + cfg.deviceId = makeDeviceId() + changed = true + } + if (typeof cfg.syncCode !== 'string' || cfg.syncCode.length !== 8) { + cfg.syncCode = makeSyncCode() + changed = true + } + if (!cfg.known || typeof cfg.known !== 'object') { + cfg.known = {} + changed = true + } + if (changed) writeJson(configFile, cfg) + return cfg + } + + // Both rotations return the whole identity ({ deviceId, syncCode, known }), + // so callers get a consistent view of this device after the change. + function rotateDeviceId() { + const cfg = identity() + const previous = cfg.deviceId + cfg.deviceId = makeDeviceId() + writeJson(configFile, cfg) + if (cfg.deviceId === previous) return rotateDeviceId() + return cfg + } + + function rotateSyncCode() { + const cfg = identity() + const previous = cfg.syncCode + cfg.syncCode = makeSyncCode() + writeJson(configFile, cfg) + if (cfg.syncCode === previous) return rotateSyncCode() // never a no-op rotation + 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. + function knownDevice(id) { + return identity().known[id] || null + } + + function knownPeers() { + return identity().known || {} + } + + function rememberDevice(id, code, name) { + const cfg = identity() + const stored = String(code || '').trim().toUpperCase() + cfg.known = { ...cfg.known, [id]: { code: stored, syncCode: stored, name: name || '', at: Date.now() } } + writeJson(configFile, cfg) + } + + function forgetDevice(id) { + const cfg = identity() + const known = { ...cfg.known } + delete known[id] + cfg.known = known + writeJson(configFile, cfg) + } + + 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. +function sameCode(a, b) { + const x = Buffer.from(String(a == null ? '' : a).trim().toUpperCase()) + const y = Buffer.from(String(b == null ? '' : b).trim().toUpperCase()) + return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y) +} + +module.exports = { createIdentity, sameCode, makeDeviceId, makeSyncCode } diff --git a/src/lansync-manifest.js b/src/lansync-manifest.js new file mode 100644 index 0000000..b3864c5 --- /dev/null +++ b/src/lansync-manifest.js @@ -0,0 +1,152 @@ +'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. + +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. + function portableLinkTarget(linkAbs, target) { + if (typeof target !== 'string' || !target || !path.isAbsolute(target)) return target + const rel = path.relative(path.dirname(linkAbs), target) + if (!rel || path.isAbsolute(rel)) return target + const root = path.resolve(filesRoot) + const resolved = path.resolve(path.dirname(linkAbs), rel) + if (resolved !== root && !resolved.startsWith(root + path.sep)) return target + return rel + } + + // Walk without following symlinks: builds and prefixes contain links that + // are part of what is being synced (dosdevices, build internals), and + // following them would both loop and copy the wrong thing. + // + // Iterative (a queue instead of an await-per-directory recursion) and with + // the stat/readlink calls of one directory in flight together: walking a + // prefix is dominated by those two syscalls, and serializing them costs more + // than the readdir itself. Regular files are re-verified with one stat + // (size, mode, mtime feed both the manifest and the hash cache), everything + // else is skipped. + async function scanTree(abs, rel, out, mtimes) { + const queue = [[abs, rel]] + while (queue.length) { + const [dirAbs, dirRel] = queue.pop() + let entries + try { + entries = await fsp.readdir(dirAbs, { withFileTypes: true }) + } catch { + continue + } + const dirs = [] + const leaves = [] + for (const entry of entries) { + const relChild = `${dirRel}/${entry.name}` + if (entry.isDirectory()) { + out.push({ path: relChild, type: 'dir' }) + dirs.push([path.join(dirAbs, entry.name), relChild]) + } else { + leaves.push(entry) + } + } + await mapLimit(leaves, concurrency, async entry => { + const absChild = path.join(dirAbs, entry.name) + const relChild = `${dirRel}/${entry.name}` + if (entry.isSymbolicLink()) { + let target = '' + try { + target = await fsp.readlink(absChild) + } catch {} + out.push({ path: relChild, type: 'link', target: portableLinkTarget(absChild, target) }) + return + } + let st + try { + st = await fsp.stat(absChild) + } catch { + 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 + out.push({ path: relChild, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null }) + // kept out of the manifest: only this machine can use its own mtimes + if (mtimes) mtimes.set(relChild, st.mtimeMs || 0) + }) + queue.push(...dirs) + } + } + + // Manifest of one sync unit. `force` skips hashing entirely - the receiver + // then treats every file as different ("force sync all files"). + async function buildManifest(roots, force, onProgress) { + const entries = [] + const mtimes = new Map() + for (const rel of roots) { + const abs = filesPath(rel) + if (!abs) continue + let st + try { + st = await fsp.lstat(abs) + } catch { + continue + } + if (st.isSymbolicLink()) { + let target = '' + try { + target = await fsp.readlink(abs) + } catch {} + entries.push({ path: rel, type: 'link', target: portableLinkTarget(abs, target) }) + } else if (st.isDirectory()) { + entries.push({ path: rel, type: 'dir' }) + await scanTree(abs, rel, entries, mtimes) + } else if (st.isFile()) { + entries.push({ path: rel, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null }) + mtimes.set(rel, st.mtimeMs || 0) + } + } + const files = entries.filter(e => e.type === 'file') + // the unit's total size is known before a byte is hashed, so the bar can + // start empty and fill with the bytes actually read + const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0) + if (!force && files.length) { + let done = 0 + let hashed = 0 + 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 + const mtimeMs = mtimes.get(entry.path) || 0 + entry.hash = abs + ? await hash.cachedHash(entry.path, abs, entry.size, mtimeMs, bytes => { + hashed += bytes + if (onProgress) onProgress(done, files.length, hashed, totalBytes) + }) + : null + } catch { + entry.hash = null + } + done++ + if (!entry.hash) hashed += entry.size || 0 // nothing read: still report the file as passed over + if (onProgress) onProgress(done, files.length, hashed, totalBytes) + }) + } + hash.save() + return entries + } + + return { buildManifest, scanTree, portableLinkTarget } +} + +module.exports = { createManifestBuilder } diff --git a/src/lansync.js b/src/lansync.js index 55b1c90..b36535f 100644 --- a/src/lansync.js +++ b/src/lansync.js @@ -7,7 +7,6 @@ // behind, and paths stored in the registries stay relative as they are. const http = require('http') -const dgram = require('dgram') const crypto = require('crypto') const os = require('os') const path = require('path') @@ -17,10 +16,15 @@ const readline = require('readline') const { DIRS, readJson, writeJson, toPortablePath } = require('./paths') const { rewireSymlinks, restoreExecutableBits } = require('./symlinks') -const { progressGate } = require('./fsutil') +const { progressGate, mapLimit } = require('./fsutil') +const { createHashCache } = require('./hashcache') +const { createIdentity, sameCode } = require('./lansync-identity') +const { createManifestBuilder } = require('./lansync-manifest') +const { createDiscovery } = require('./lansync-discovery') const library = require('./library') const wines = require('./wines') const prefixes = require('./prefixes') +const { isPrefixRel } = prefixes const CONFIG = path.join(DIRS.DATA, 'lansync.json') @@ -39,7 +43,9 @@ const DEVICE_TTL_MS = 6000 // flight - together they make guessing an 8 character code over the LAN // pointless (and a failed guess cannot be retried in a tight loop). const VERIFY_DELAY_MS = 2000 -const TOKEN_TTL_MS = 30 * 60 * 1000 +// How long a session token stays valid without any request. Env override for +// tests, which cannot wait half an hour to prove expiry. +const TOKEN_TTL_MS = Number(process.env.PLWL_LANSYNC_TOKEN_TTL) || 30 * 60 * 1000 const 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 @@ -100,7 +106,6 @@ function appCategory(game) { // 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. -const PREFIX_REL_RE = /^(pfx[/\\])?drive_c([/\\]|$)/i 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 @@ -109,7 +114,7 @@ function classifyApp(app) { 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) - if (PREFIX_REL_RE.test(value)) return 'prefix' + if (isPrefixRel(value)) return 'prefix' if (/^apps\//.test(value)) return 'portable' return outside ? 'external' : null } @@ -125,248 +130,21 @@ function normalizeCategories(list) { // --- identity --------------------------------------------------------------- -// 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). -const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' -const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789' - -function randomChars(alphabet, count) { - let out = '' - while (out.length < count) { - for (const byte of crypto.randomBytes(count)) { - if (out.length >= count) break - out += alphabet[byte % alphabet.length] - } - } - 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. -function makeDeviceId() { - const stamp = Date.now().toString(36) - return (stamp + randomChars(ID_ALPHABET, Math.max(0, 24 - stamp.length))).slice(0, 24) -} - -function makeSyncCode() { - return randomChars(CODE_ALPHABET, 8) -} - -// 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 the same functions), so the cache cannot -// go stale. -let identityCache = null - -function readConfig() { - if (identityCache) return identityCache - const cfg = readJson(CONFIG, {}) - identityCache = cfg && typeof cfg === 'object' ? cfg : {} - return identityCache -} - -// 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). -function identity() { - const cfg = readConfig() - let changed = false - if (typeof cfg.deviceId !== 'string' || cfg.deviceId.length !== 24) { - cfg.deviceId = makeDeviceId() - changed = true - } - if (typeof cfg.syncCode !== 'string' || cfg.syncCode.length !== 8) { - cfg.syncCode = makeSyncCode() - changed = true - } - if (!cfg.known || typeof cfg.known !== 'object') { - cfg.known = {} - changed = true - } - if (changed) writeJson(CONFIG, cfg) - return cfg -} - -// Both rotations return the whole identity ({ deviceId, syncCode, known }), -// so callers get a consistent view of this device after the change. -function rotateDeviceId() { - const cfg = identity() - const previous = cfg.deviceId - cfg.deviceId = makeDeviceId() - writeJson(CONFIG, cfg) - if (cfg.deviceId === previous) return rotateDeviceId() - return cfg -} - -function rotateSyncCode() { - const cfg = identity() - const previous = cfg.syncCode - cfg.syncCode = makeSyncCode() - writeJson(CONFIG, cfg) - if (cfg.syncCode === previous) return rotateSyncCode() // never a no-op rotation - 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. -function knownDevice(id) { - return identity().known[id] || null -} - -// Devices this launcher paired with: id -> { code, name, at }. The code is the -// one that was accepted, so later syncs need no typing. -function knownPeers() { - return identity().known || {} -} - -function rememberDevice(id, code, name) { - const cfg = identity() - const stored = String(code || '').trim().toUpperCase() - cfg.known = { ...cfg.known, [id]: { code: stored, syncCode: stored, name: name || '', at: Date.now() } } - writeJson(CONFIG, cfg) -} - -function forgetDevice(id) { - const cfg = identity() - const known = { ...cfg.known } - delete known[id] - cfg.known = known - writeJson(CONFIG, cfg) -} - -function sameCode(a, b) { - const x = Buffer.from(String(a == null ? '' : a).trim().toUpperCase()) - const y = Buffer.from(String(b == null ? '' : b).trim().toUpperCase()) - return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y) -} +const identityApi = createIdentity(CONFIG) +const { identity, rotateDeviceId, rotateSyncCode, knownDevice, knownPeers, rememberDevice, forgetDevice, makeDeviceId, makeSyncCode } = identityApi // --- files: hashing, paths, manifests --------------------------------------- -// BLAKE2b resists collisions like SHA-2 but is noticeably faster - syncing a -// wine prefix means hashing tens of thousands of files, so this is the hot -// path. Older runtimes fall back to SHA-2. -const HASH_ALGO = (() => { - for (const algo of ['blake2b512', 'sha512', 'sha256']) { - try { - crypto.createHash(algo) - return algo - } catch {} - } - return 'sha256' -})() - // Hashing a tree is dominated by opening the (often tiny) files, not by the // digest itself: a few in flight at once keeps the disk and the cpu busy // instead of waiting on one file at a time. const HASH_CONCURRENCY = Math.max(4, Math.min(16, (os.cpus() || []).length || 4)) -// Hash results are remembered per file (size + mtime), so a library that did -// not change since the last sync is never read again - only stat'ed. -const HASH_CACHE_FILE = path.join(DIRS.DATA, 'lansync-hashes.json') -const HASH_CACHE_MAX = 250000 -let hashCache = null -let hashCacheDirty = false -let hashCacheSavedAt = 0 -const hashStats = { hits: 0, misses: 0 } - -function hashCacheStats() { - return { ...hashStats, size: hashCache ? Object.keys(hashCache).length : 0, algo: HASH_ALGO } -} - -function resetHashCacheStats() { - hashStats.hits = 0 - hashStats.misses = 0 -} - -// Dropping the in-memory copy makes the next lookup re-read the file from -// disk - what tests use to prove a file was hashed instead of remembered. -function forgetHashCache() { - hashCache = null - hashCacheDirty = false -} - -function loadHashCache() { - if (!hashCache) { - const stored = readJson(HASH_CACHE_FILE, null) - hashCache = stored && typeof stored === 'object' && stored.files && typeof stored.files === 'object' ? stored.files : {} - } - return hashCache -} - -// Written once per operation at most: this file can hold a quarter million -// entries, and rewriting it per hashed file would cost more than the hashing. -// Compact JSON (the entries are already arrays) keeps a full cache at tens of -// milliseconds instead of the seconds pretty-printed JSON needs. -function saveHashCache() { - if (!hashCacheDirty || !hashCache) return - const now = Date.now() - if (now - hashCacheSavedAt < 60000) return - const names = Object.keys(hashCache) - if (names.length > HASH_CACHE_MAX) { - names.sort((a, b) => (hashCache[b][3] || 0) - (hashCache[a][3] || 0)) - const trimmed = {} - for (const name of names.slice(0, HASH_CACHE_MAX)) trimmed[name] = hashCache[name] - hashCache = trimmed - } - hashCacheDirty = false - hashCacheSavedAt = now - try { - fs.mkdirSync(path.dirname(HASH_CACHE_FILE), { recursive: true }) - fs.writeFileSync(HASH_CACHE_FILE, JSON.stringify({ v: 1, algo: HASH_ALGO, files: hashCache })) - } 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. -function hashCacheHit(rel, size, mtimeMs) { - const cache = loadHashCache() - const known = cache[rel] - if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') { - hashStats.misses++ - return null - } - hashStats.hits++ - if (Date.now() - (known[3] || 0) > 86400000) { - known[3] = Date.now() - hashCacheDirty = true - } - return known[2] -} - -// Hash of a file we already stat'ed: size and mtime unchanged means the bytes -// are the same, and the digest from an earlier sync still describes them. -function cachedHash(rel, abs, size, mtimeMs) { - const known = hashCacheHit(rel, size, mtimeMs) - if (known) return Promise.resolve(known) - return hashFile(abs).then(hash => { - if (hash) { - loadHashCache()[rel] = [size, mtimeMs, hash, Date.now()] - hashCacheDirty = true - } - return hash - }) -} - -// 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, so the -// old 1 MB reads only ever paid for themselves in memory, not in time. -function hashFile(file, onBytes) { - return new Promise((resolve, reject) => { - const hash = crypto.createHash(HASH_ALGO) - const stream = fs.createReadStream(file, { highWaterMark: 64 * 1024 }) - stream.on('error', reject) - if (onBytes) stream.on('data', chunk => onBytes(chunk.length)) - stream.on('data', chunk => hash.update(chunk)) - stream.on('end', () => resolve(hash.digest('hex'))) - stream.resume() - }) -} +// 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). +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 @@ -386,163 +164,10 @@ function ownedRoot(rel) { return SYNC_ROOTS.some(prefix => clean.startsWith(prefix)) ? clean : null } -async function mapLimit(items, limit, fn) { - const out = new Array(items.length) - let next = 0 - const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { - while (next < items.length) { - const i = next++ - out[i] = await fn(items[i], i) - } - }) - await Promise.all(workers) - return out -} +// --- manifests (the sender's hashing) --------------------------------------- -// Symlink targets travel RELATIVE whenever they point inside the launcher data -// (a prefix linking into its wine build, an app linking to its own assets): the -// mirrored trees have the same internal layout on both machines, so a relative -// target is correct over there while an absolute one would point at the -// sender's own folders - which is exactly how a synced prefix ends up with -// hundreds of dangling links. Targets outside files/ (a system font, a mounted -// drive, /) are machine-specific and stay as they are. -function portableLinkTarget(linkAbs, target) { - if (typeof target !== 'string' || !target || !path.isAbsolute(target)) return target - const rel = path.relative(path.dirname(linkAbs), target) - if (!rel || path.isAbsolute(rel)) return target - const root = path.resolve(DIRS.FILES) - const resolved = path.resolve(path.dirname(linkAbs), rel) - if (resolved !== root && !resolved.startsWith(root + path.sep)) return target - return rel -} - -// Walk without following symlinks: builds and prefixes contain links that are -// part of what is being synced (dosdevices, build internals), and following -// them would both loop and copy the wrong thing. -// -// 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. -const SCAN_CONCURRENCY = Math.max(4, HASH_CONCURRENCY) - -async function scanTree(abs, rel, out, mtimes) { - const queue = [[abs, rel]] - while (queue.length) { - const [dirAbs, dirRel] = queue.pop() - let entries - try { - entries = await fsp.readdir(dirAbs, { withFileTypes: true }) - } catch { - continue - } - const dirs = [] - const leaves = [] - for (const entry of entries) { - const relChild = `${dirRel}/${entry.name}` - if (entry.isDirectory()) { - out.push({ path: relChild, type: 'dir' }) - dirs.push([path.join(dirAbs, entry.name), relChild]) - } else { - leaves.push(entry) - } - } - await mapLimit(leaves, SCAN_CONCURRENCY, async entry => { - const absChild = path.join(dirAbs, entry.name) - const relChild = `${dirRel}/${entry.name}` - if (entry.isSymbolicLink()) { - let target = '' - try { - target = await fsp.readlink(absChild) - } catch {} - out.push({ path: relChild, type: 'link', target: portableLinkTarget(absChild, target) }) - return - } - let st - try { - st = await fsp.stat(absChild) - } catch { - 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 - out.push({ path: relChild, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null }) - // kept out of the manifest: only this machine can use its own mtimes - if (mtimes) mtimes.set(relChild, st.mtimeMs || 0) - }) - queue.push(...dirs) - } -} - -// Manifest of one sync unit. `force` skips hashing entirely - the receiver -// then treats every file as different ("force sync all files"). -async function buildManifest(roots, force, onProgress) { - const entries = [] - const mtimes = new Map() - for (const rel of roots) { - const abs = filesPath(rel) - if (!abs) continue - let st - try { - st = await fsp.lstat(abs) - } catch { - continue - } - if (st.isSymbolicLink()) { - let target = '' - try { - target = await fsp.readlink(abs) - } catch {} - entries.push({ path: rel, type: 'link', target: portableLinkTarget(abs, target) }) - } else if (st.isDirectory()) { - entries.push({ path: rel, type: 'dir' }) - await scanTree(abs, rel, entries, mtimes) - } else if (st.isFile()) { - entries.push({ path: rel, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null }) - mtimes.set(rel, st.mtimeMs || 0) - } - } - const files = entries.filter(e => e.type === 'file') - // the unit's total size is known before a byte is hashed, so the bar can - // start empty and fill with the bytes actually read - const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0) - if (!force && files.length) { - let done = 0 - let hashed = 0 - await mapLimit(files, HASH_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 - const mtimeMs = mtimes.get(entry.path) || 0 - const known = hashCacheHit(entry.path, entry.size, mtimeMs) - if (known && abs) { - entry.hash = known - } else { - entry.hash = abs - ? await hashFile(abs, bytes => { - hashed += bytes - if (onProgress) onProgress(done, files.length, hashed, totalBytes) - }) - : null - if (entry.hash) { - loadHashCache()[entry.path] = [entry.size, mtimeMs, entry.hash, Date.now()] - hashCacheDirty = true - } - } - } catch { - entry.hash = null - } - done++ - if (!entry.hash) hashed += entry.size || 0 // nothing read: still report the file as passed over - if (onProgress) onProgress(done, files.length, hashed, totalBytes) - }) - } - saveHashCache() - return entries -} +const manifest = createManifestBuilder({ filesPath, filesRoot: DIRS.FILES, hash: hashStore, mapLimit, concurrency: HASH_CONCURRENCY }) +const buildManifest = manifest.buildManifest // --- HTTP helpers (shared with the pusher) ---------------------------------- @@ -699,7 +324,7 @@ async function entrySame(entry) { if (!st.isFile() || st.size !== entry.size) return false let same = false try { - same = (await cachedHash(entry.path, abs, st.size, st.mtimeMs || 0)) === entry.hash + same = (await hashStore.cachedHash(entry.path, abs, st.size, st.mtimeMs || 0)) === entry.hash } catch { return false } @@ -1194,6 +819,13 @@ 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. + session.expires = Date.now() + TOKEN_TTL_MS if (state.session && state.session.token === token) state.session.lastAt = session.lastAt return session } @@ -1371,7 +1003,7 @@ async function handlePlan(state, session, url, req, res, hooks) { announce({ unit: label }) return same === true }) - saveHashCache() + hashStore.save() const paths = new Set() const upload = [] const same = [] @@ -1533,128 +1165,22 @@ function handlePing(session, res, hooks) { // --- discovery -------------------------------------------------------------- -let beaconSocket = null -let beaconTimer = null -let httpPort = 0 -let seen = new Map() -let devicesCb = null - -function ipToInt(ip) { - const parts = String(ip).split('.') - if (parts.length !== 4) return null - let out = 0 - for (const part of parts) { - const n = Number(part) - if (!Number.isInteger(n) || n < 0 || n > 255) return null - out = (out << 8) | n - } - return out >>> 0 -} - -function intToIp(int) { - return [24, 16, 8, 0].map(shift => (int >>> shift) & 255).join('.') -} - -// Broadcast to the global address AND to every interface's own subnet -// broadcast - some networks drop one or the other. -function broadcastAddresses() { - const out = ['255.255.255.255'] - const interfaces = os.networkInterfaces() - for (const list of Object.values(interfaces)) { - for (const iface of list || []) { - if (iface.family !== 'IPv4' || iface.internal || !iface.netmask) continue - const addr = ipToInt(iface.address) - const mask = ipToInt(iface.netmask) - if (addr == null || mask == null) continue - out.push(intToIp((addr | (~mask >>> 0)) >>> 0)) - } - } - return out -} - -function listDevices() { - const known = identity().known - const now = Date.now() - return [...seen.values()] - .filter(d => now - d.at <= DEVICE_TTL_MS) - .map(d => { - const pair = known[d.id] - return { id: d.id, name: d.name, address: d.address, port: d.port, paired: !!pair, code: pair ? pair.code : '' } - }) - .sort((a, b) => String(a.name).localeCompare(String(b.name))) -} - -function emitDevices() { - if (devicesCb) devicesCb(listDevices()) -} - -function pruneDevices() { - const now = Date.now() - let changed = false - for (const [id, device] of seen) { - if (now - device.at > DEVICE_TTL_MS) { - seen.delete(id) - changed = true - } - } - if (changed) emitDevices() -} - -function beacon() { - if (!beaconSocket || !httpPort) return - const me = identity() - const payload = Buffer.from(JSON.stringify({ t: BEACON_TAG, v: PROTOCOL, id: me.deviceId, name: os.hostname(), port: httpPort })) - for (const address of broadcastAddresses()) { - try { - beaconSocket.send(payload, 0, payload.length, DISCOVERY_PORT, address) - } catch {} - } - pruneDevices() -} - -function startDiscovery(onDevices) { - stopDiscovery() - devicesCb = onDevices - beaconSocket = dgram.createSocket({ type: 'udp4', reuseAddr: true }) - beaconSocket.on('error', () => stopDiscovery()) - beaconSocket.on('message', (buf, rinfo) => { - let msg = null - try { - msg = JSON.parse(buf.toString('utf8')) - } catch {} - if (!msg || msg.t !== BEACON_TAG || msg.v !== PROTOCOL || !msg.id) return - if (msg.id === identity().deviceId) return // our own broadcast - const previous = seen.get(msg.id) - const next = { id: msg.id, name: String(msg.name || rinfo.address), address: rinfo.address, port: Number(msg.port) || 0, at: Date.now() } - const changed = !previous || previous.address !== next.address || previous.port !== next.port || previous.name !== next.name - seen.set(msg.id, next) - if (changed) emitDevices() - }) - beaconSocket.bind(DISCOVERY_PORT, () => { - try { - beaconSocket.setBroadcast(true) - } catch {} - beacon() - beaconTimer = setInterval(beacon, BEACON_MS) - }) -} - -function stopDiscovery() { - if (beaconTimer) { - clearInterval(beaconTimer) - beaconTimer = null - } - if (beaconSocket) { - const socket = beaconSocket - beaconSocket = null - try { - socket.close() - } catch {} - } -} +const discovery = createDiscovery({ + identity, + tag: BEACON_TAG, + protocol: PROTOCOL, + port: DISCOVERY_PORT, + beaconMs: BEACON_MS, + deviceTtlMs: DEVICE_TTL_MS, + getPort: () => httpPort +}) +const startDiscovery = discovery.start +const stopDiscovery = discovery.stop +const listDevices = discovery.listDevices // --- service ---------------------------------------------------------------- +let httpPort = 0 let httpServer = null let serverHooks = {} let listening = null // promise resolved once the receiving server is up @@ -1686,6 +1212,9 @@ 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 + hashStore.save(true) if (httpServer) { try { httpServer.close() @@ -1705,7 +1234,7 @@ function stopServer() { function start(hooks = {}) { serverHooks = { ...serverHooks, ...hooks } return startServer().then(info => { - if (!beaconSocket) startDiscovery(serverHooks.onDevices) + startDiscovery(serverHooks.onDevices) return info }) } @@ -1721,7 +1250,7 @@ function serverPort() { function getState() { const me = identity() - return { deviceId: me.deviceId, syncCode: me.syncCode, hash: HASH_ALGO, port: httpPort, devices: listDevices() } + return { deviceId: me.deviceId, syncCode: me.syncCode, hash: hashStore.HASH_ALGO, port: httpPort, devices: listDevices() } } // The pusher lives in its own module and is built with THIS module's helpers @@ -1810,11 +1339,12 @@ const exported = { forgetDevice, respondIncoming, sameCode, - HASH_ALGO, + HASH_ALGO: hashStore.HASH_ALGO, hashFile, - hashCacheStats, - resetHashCacheStats, - forgetHashCache, + hashCacheStats: hashStore.stats, + resetHashCacheStats: hashStore.resetStats, + forgetHashCache: hashStore.forget, + clearHashCache: hashStore.clear, HASH_CONCURRENCY, progressGate, filesPath, diff --git a/src/lansyncpush.js b/src/lansyncpush.js index da34f4a..4a272c5 100644 --- a/src/lansyncpush.js +++ b/src/lansyncpush.js @@ -414,11 +414,16 @@ 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 every manifest entry counts: created/overwritten vs - // already identical (the plan reports both) - bucket.copied += entries.length - same.size - bucket.skipped += same.size - summary.skipped += same.size + // 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) + const fileEntries = entries.filter(e => e.type === 'file') + const filePaths = new Set(fileEntries.map(e => e.path)) + let sameFiles = 0 + for (const p of same) if (filePaths.has(p)) sameFiles++ + bucket.copied += fileEntries.length - sameFiles + bucket.skipped += sameFiles + summary.skipped += sameFiles const pending = [] for (const entry of entries) { if (entry.type !== 'file') continue diff --git a/src/library.js b/src/library.js index 7aa98af..ccb8f85 100644 --- a/src/library.js +++ b/src/library.js @@ -3,7 +3,7 @@ const path = require('path') const fs = require('fs') const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths') -const { prefixPath } = require('./prefixes') +const { prefixPath, isPrefixRel } = require('./prefixes') const REGISTRY = path.join(DIRS.DATA, 'apps.json') const SETTINGS = path.join(DIRS.DATA, 'settings.json') @@ -29,12 +29,6 @@ function writePlaySeconds(id, seconds) { fs.writeFileSync(file, String(Math.max(0, Math.round(seconds)))) } -// Prefix imports keep exe/workdir paths relative to the PREFIX ROOT -// ("drive_c/..." for plain wine, "pfx/drive_c/..." for Proton-managed -// prefixes) so wine sees them under C:\... - everything else is -// FILES-relative/absolute. -const isPrefixRel = v => typeof v === 'string' && /^(pfx[/\\])?drive_c([/\\]|$)/i.test(v) - function loadGames() { const games = readJson(REGISTRY, []) for (const g of games) { diff --git a/src/prefixes.js b/src/prefixes.js index 854b5e5..69c6da7 100644 --- a/src/prefixes.js +++ b/src/prefixes.js @@ -7,6 +7,12 @@ 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. +const PREFIX_REL_RE = /^(pfx[/\\])?drive_c([/\\]|$)/i +const isPrefixRel = v => typeof v === 'string' && PREFIX_REL_RE.test(v) + const REGISTRY = path.join(DIRS.DATA, 'prefixes.json') const MAIN = 'Main' @@ -198,4 +204,4 @@ function setDefaultPrefix(name) { return { ok: true } } -module.exports = { listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoots, isPrefixRoot, healPrefix, ensureUnifiedPrefix, MAIN } +module.exports = { isPrefixRel, PREFIX_REL_RE, listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoots, isPrefixRoot, healPrefix, ensureUnifiedPrefix, MAIN } diff --git a/src/preload.js b/src/preload.js index ba8a43c..cc608ad 100644 --- a/src/preload.js +++ b/src/preload.js @@ -3,33 +3,26 @@ const { contextBridge, ipcRenderer } = require('electron') const { DIRS } = require('./paths') -// single-slot progress: every import modal used to register ANOTHER listener -// and old ones never unregistered, so the file counter sped up per import -let importProgressCb = null -ipcRenderer.on('import:progress', (e, data) => { - if (importProgressCb) importProgressCb(data) -}) +// 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. +function singleSlot(channel) { + let slot = null + ipcRenderer.on(channel, (e, data) => { + if (slot) slot(data) + }) + return fn => { + slot = fn + } +} -// "Open with PLWL": single-slot callback for files handed over via --open -// (Exec %f) - the renderer shows the wine/prefix picker popup for it. -let openWithCb = null -ipcRenderer.on('openwith:file', (e, openPath) => { - if (openWithCb) openWithCb(openPath) -}) - -// LAN sync progress: same single-slot rule as the import counter, only one -// sync can run at a time anyway. -let lanSyncProgressCb = null -ipcRenderer.on('lansync:progress', (e, data) => { - if (lanSyncProgressCb) lanSyncProgressCb(data) -}) - -// Incoming sync consent: the receiver's Accept/Decline popup. Single slot for -// the same reason - one pending request at a time (the server enforces it). -let lanSyncIncomingCb = null -ipcRenderer.on('lansync:incoming', (e, data) => { - if (lanSyncIncomingCb) lanSyncIncomingCb(data) -}) +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. +const onOpenWith = singleSlot('openwith:file') +// one sync (and one pending consent request, enforced server-side) at a time +const onLanSyncProgress = singleSlot('lansync:progress') +const onLanSyncIncoming = singleSlot('lansync:incoming') const fs = require('fs') const path = require('path') @@ -54,9 +47,7 @@ contextBridge.exposeInMainWorld('plwl', { openwithTake: () => ipcRenderer.invoke('openwith:take'), openwithRun: payload => ipcRenderer.invoke('openwith:run', payload), openwithTexts: lang => ipcRenderer.invoke('openwith:texts', lang), - onOpenWith: cb => { - openWithCb = cb - }, + onOpenWith: onOpenWith, basename: p => (p ? path.basename(p) : ''), fileExists: p => { try { @@ -91,24 +82,19 @@ contextBridge.exposeInMainWorld('plwl', { dialogPickFile: opts => ipcRenderer.invoke('dialog:pickFile', opts), shellOpenPath: p => ipcRenderer.invoke('shell:openPath', p), shellShowItemInFolder: p => ipcRenderer.invoke('shell:showItemInFolder', p), - onImportProgress: cb => { - importProgressCb = cb - }, + onImportProgress: onImportProgress, onGamesChanged: cb => ipcRenderer.on('games:changed', () => cb()), lansyncGet: () => ipcRenderer.invoke('lansync:get'), lansyncDevices: () => ipcRenderer.invoke('lansync:devices'), lansyncRerollDeviceId: () => ipcRenderer.invoke('lansync:rerollDeviceId'), lansyncRerollSyncCode: () => ipcRenderer.invoke('lansync:rerollSyncCode'), lansyncForget: id => ipcRenderer.invoke('lansync:forget', id), + lansyncClearHashCache: () => ipcRenderer.invoke('lansync:clearHashCache'), lansyncSync: payload => ipcRenderer.invoke('lansync:sync', payload), lansyncRespondIncoming: (requestId, accept) => ipcRenderer.invoke('lansync:respondIncoming', requestId, accept), onLanSyncDevices: cb => ipcRenderer.on('lansync:devices', (e, data) => cb(data)), - onLanSyncProgress: cb => { - lanSyncProgressCb = cb - }, - onLanSyncIncoming: cb => { - lanSyncIncomingCb = cb - }, + onLanSyncProgress: onLanSyncProgress, + onLanSyncIncoming: onLanSyncIncoming, onLanSyncApplied: cb => ipcRenderer.on('lansync:applied', (e, data) => cb(data)), onLanSyncReceiving: cb => ipcRenderer.on('lansync:receiving', (e, data) => cb(data)) }) diff --git a/src/symlinks.js b/src/symlinks.js index e1f774c..f11b724 100644 --- a/src/symlinks.js +++ b/src/symlinks.js @@ -25,15 +25,7 @@ const fsp = require('fs').promises const fs = require('fs') const path = require('path') - -function exists(p) { - try { - fs.accessSync(p) - return true - } catch { - return false - } -} +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 diff --git a/src/wines.js b/src/wines.js index 671b37a..69ab83e 100644 --- a/src/wines.js +++ b/src/wines.js @@ -4,7 +4,7 @@ const fs = require('fs') const fsp = fs.promises const path = require('path') const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths') -const { dedupeDir, copyTree, treeSize, removeTree } = require('./fsutil') +const { dedupeDir, copyTree, treeSize, removeTree, pathExists } = require('./fsutil') const { normalizeLabel } = require('./names') const { rewireSymlinks, restoreExecutableBits, EXEC_NAMES } = require('./symlinks') @@ -12,14 +12,7 @@ const WINES_DIR = DIRS.WINES const REGISTRY = path.join(DIRS.DATA, 'wines.json') const DEFAULT_KEY = 'defaultWine' -function exists(p) { - try { - fs.accessSync(p) - return true - } catch { - return false - } -} +const exists = pathExists function detectType(dir) { if (exists(path.join(dir, 'proton'))) return 'proton' @@ -343,4 +336,15 @@ async function importWineFolder(srcDir, onProgress) { return { ok: true, path: toPortablePath(dst), symlinksFixed: healed.links, executablesFixed: healed.executables } } -module.exports = { refreshWines, getWines, getWine, resolveWine, setWineDisabled, setWineDisplayName, clearWineDisplayName, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild } +// 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). +function systemWine() { + let version = '' + try { + const { execFileSync } = require('child_process') + version = String(execFileSync('wine', ['--version'], { timeout: 15000 })).trim().replace(/^wine-?/i, '') + } catch {} + return { id: 'system', type: 'system', label: 'System Wine', version } +} + +module.exports = { refreshWines, getWines, getWine, resolveWine, systemWine, setWineDisabled, setWineDisplayName, clearWineDisplayName, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild } From 7d8ab87eee4b7194ee0ea6877a532d9e9407e593 Mon Sep 17 00:00:00 2001 From: olcxja Date: Mon, 5 Oct 2026 15:42:44 +0200 Subject: [PATCH 06/29] cleanup comments, update translations, bump to 0.4.1 --- development.md | 4 +- files/translations/en-us.json | 4 +- files/translations/pl.json | 4 +- package.json | 2 +- renderer/app.js | 232 ++++++++-------------------- src/argv.js | 52 ++----- src/fsutil.js | 36 ++--- src/hashcache.js | 40 ++--- src/icons.js | 10 +- src/importer.js | 42 ++---- src/ipc/games.js | 48 ++---- src/ipc/index.js | 47 ++---- src/lansync-discovery.js | 6 +- src/lansync-identity.js | 33 ++-- src/lansync-manifest.js | 42 ++---- src/lansync.js | 277 +++++++++------------------------- src/lansyncpush.js | 116 ++++---------- src/library.js | 69 +++------ src/main.js | 35 ++--- src/names.js | 6 +- src/openwith.js | 20 +-- src/paths.js | 38 ++--- src/prefixes.js | 57 ++----- src/preload.js | 7 +- src/runner.js | 139 +++++------------ src/shortcuts.js | 25 +-- src/symlinks.js | 74 ++------- src/wines.js | 61 +++----- 28 files changed, 412 insertions(+), 1114 deletions(-) diff --git a/development.md b/development.md index a21a42d..c4cea56 100644 --- a/development.md +++ b/development.md @@ -54,7 +54,9 @@ Renderer never touches Node/Electron directly — everything goes through the `w ## Rules -- Keep it simple. Plain functions over classes, no abstractions "for later". Comments explain the WHY (quirks, workarounds, races), not the WHAT — none at all when nothing needs explaining. +- Keep it simple. Plain functions over classes, no abstractions "for later". +- Structure: one topic per file. Extract a module once a file grows or logic repeats (see lansync-identity/manifest/discovery, hashcache). Factories (createX(...)) carry per-state helpers so module scope stays clean when several states share a module. +- Comments are RARE and SHORT, always in English. One line, two at most. Explain only the WHY (a quirk, a workaround, a race) — never the WHAT. No comment at all when nothing needs explaining; no banners, no restating the next line, no changelog notes. - No duplicate logic: the second time you copy something, move it into a helper and call it from both places. - Everything user-visible goes through the translation files (en-us.json, pl.json) with `{0}`, `{1}` placeholders. No hardcoded strings in app.js. - Paths stored on disk are FILES-relative (portable layout): write with toPortablePath(), read with resolvePortablePath() — never store absolute paths for anything inside files/. diff --git a/files/translations/en-us.json b/files/translations/en-us.json index 058b1a7..d16e330 100644 --- a/files/translations/en-us.json +++ b/files/translations/en-us.json @@ -81,13 +81,13 @@ "config.prefixLocked": "This app was imported from a prefix, its prefix cannot be changed", "config.prefixLockedHint": "Pinned to the prefix it was imported from (its paths are stored relative to it)", "config.disableUmu": "Disable UMU (launch with wine directly)", - "config.disableUmuHint": "Runs the game with the build's own wine binary instead of its umu.exe/steam.exe launcher - no UMU and no Steam client is started, and Proton's prefix setup is skipped too. This is the closest to a plain wine run.", + "config.disableUmuHint": "Runs the game with the build's own wine binary instead of its umu.exe/steam.exe launcher. This is the closest to a plain wine run.", "config.dllOverrides": "DLL overrides", "config.overrides.add": "Add override", "config.overrides.dll": "DLL name", "config.overrides.setting": "Setting", "config.overrides.remove": "Remove", - "config.overrides.hint": "Wine module names, e.g. dinput8. native,builtin (the default) loads a DLL shipped with the game and falls back to Wine's builtin; disabled blocks the module. 'name=setting' pasted into the name box is split automatically.", + "config.overrides.hint": "Wine module names, e.g. dinput8. native,builtin (the default) loads a DLL shipped with the game and falls back to Wine's builtin; disabled blocks the module.", "config.save": "Save changes", "config.shortcut": "Create shortcut", "config.shortcutCreated": "Shortcut for {0} created (menu + desktop)", diff --git a/files/translations/pl.json b/files/translations/pl.json index ad1c0ca..58806a7 100644 --- a/files/translations/pl.json +++ b/files/translations/pl.json @@ -81,13 +81,13 @@ "config.prefixLocked": "Ta aplikacja została zaimportowana z prefiksu, nie można zmienić jej prefiksu", "config.prefixLockedHint": "Przypięta do prefiksu, z którego została zaimportowana (ścieżki trzymane są względem niego)", "config.disableUmu": "Wyłącz UMU (uruchamiaj bezpośrednio wine)", - "config.disableUmuHint": "Uruchamia grę bezpośrednio binarką wine z builda, z pominięciem launchera umu.exe/steam.exe — bez UMU i bez uruchamiania klienta Steam, z pominięciem przygotowania prefiksu przez Protona. To najbliżej zwykłego uruchomienia wine.", + "config.disableUmuHint": "Uruchamia grę bezpośrednio binarką wine z builda, z pominięciem launchera umu.exe/steam.exe. To najbliżej zwykłego uruchomienia wine.", "config.dllOverrides": "Nadpisania DLL", "config.overrides.add": "Dodaj nadpisanie", "config.overrides.dll": "Nazwa DLL", "config.overrides.setting": "Ustawienie", "config.overrides.remove": "Usuń", - "config.overrides.hint": "Nazwy modułów Wine, np. dinput8. native,builtin (domyślne) wczytuje DLL dołączony do gry, a gdy go nie ma — wbudowany Wine; disabled blokuje moduł. Wklejone 'nazwa=ustawienie' zostanie rozdzielone automatycznie.", + "config.overrides.hint": "Nazwy modułów Wine, np. dinput8. native,builtin (domyślne) wczytuje DLL dołączony do gry, a gdy go nie ma — wbudowany Wine; disabled blokuje moduł.", "config.save": "Zapisz zmiany", "config.shortcut": "Utwórz skrót", "config.shortcutCreated": "Skrót dla {0} utworzony (menu + pulpit)", diff --git a/package.json b/package.json index 0152daa..bbdc0ce 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "PLWL", "productName": "PLWL", - "version": "0.4.0", + "version": "0.4.1", "description": "Portable Linux Wine Launcher", "main": "src/main.js", "scripts": { diff --git a/renderer/app.js b/renderer/app.js index dc53847..857cdf5 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -15,9 +15,7 @@ const state = { games: [], currentGame: null, lansync: null, - // true while this device pushes a sync (and while it shows the progress - // popup): only one such popup exists, the pushing side wins over an - // incoming sync + // true while this device pushes a sync: it owns the single popup lanPushing: false, currentView: 'library' } @@ -78,8 +76,7 @@ function setButtonIcons() { } } -// Smoothly animate elements of `container` between their previous and new -// positions after `mutate()` changes the layout (FLIP technique). +// FLIP: animate elements between their previous and new layout positions. function flipShift(container, mutate) { const entries = [...container.children].map(el => ({ el, top: el.getBoundingClientRect().top })) mutate() @@ -111,20 +108,13 @@ function toast(message, kind) { }) } -// The teardown of a closing modal is delayed (for the fade-out) - an -// eagerly armed timer must never kill a modal opened in the meantime (e.g. -// import mode -> prefix picker happens within milliseconds), so showModal -// cancels any pending teardown and clears the closing state. +// A delayed teardown must never kill a modal opened in the meantime. let modalTeardownTimer = null let modalSwapTimer = null -// A running sync must not be thrown away by a stray click: a locked modal -// ignores the backdrop and Escape, only its own buttons (or the end of the -// operation) close it. +// A locked modal ignores backdrop/Escape; only its own buttons close it. let modalLocked = false -// Resolver of the current promise-based modal (confirm/prompt). Escape, the -// backdrop or another modal opening must settle it, or the awaiting caller -// would never continue. +// Settles the promise-based modal (confirm/prompt) so callers never hang. let modalSettle = null function settleModal(value) { @@ -143,9 +133,7 @@ function showModal(html, opts) { const overlay = qs('#modal-overlay') const modal = qs('#modal') modal.classList.remove('closing') - // Opening over an already-visible modal (import mode -> prefix picker): - // morph instead of close-then-open - the box glides to the new height - // while old content dissolves into the new one. + // morph instead of close-then-open when a modal opens over a visible one const swapping = overlay.style.display !== 'none' && !!modal.innerHTML.trim() if (swapping) swapModalContent(modal, html) else modal.innerHTML = html @@ -154,10 +142,8 @@ function showModal(html, opts) { return qs('#modal') } -// Animated content swap while the modal stays open. The new HTML is put in -// SYNCHRONOUSLY (callers wire their buttons right after showModal returns); -// an opaque snapshot of the old content is laid on top and fades out while -// the box animates to the new content's height. Same feel as switching tabs. +// Animated content swap: new HTML goes in synchronously, the old one fades +// out on top while the box glides to the new height. function swapModalContent(modal, html) { if (modalSwapTimer) { clearTimeout(modalSwapTimer) @@ -234,8 +220,7 @@ function confirmModal(message, okLabel) { }) } -// Shared "type a name" modal (prefix rename, wine display name): Enter -// submits, Cancel/Escape/backdrop resolve null. +// Shared "type a name" modal; Cancel/Escape/backdrop resolve null. function promptModal({ title, label, value, confirm, hint }) { return new Promise(resolve => { const modal = showModal(` @@ -419,10 +404,7 @@ function iconUrl(id) { return `plwl-icon://${encodeURIComponent(id)}/icon` } -// Paths of imported (copy/move) apps live inside FILES/ and are stored -// relative to it in the registry (portable mode) - show them prefixed with -// files/ instead of the absolute install location. External (leave) apps -// keep their absolute path. +// Portable apps show files/-relative paths; external apps keep absolute ones. function displayPath(p) { if (!p) return '' const files = window.plwl.FILES_DIR @@ -430,8 +412,7 @@ function displayPath(p) { return p } -// Inverse of displayPath for input fields: accept a files/-prefixed (or bare, -// FILES-relative) path and resolve it to an absolute one. +// Inverse of displayPath: files/-prefixed or bare path -> absolute. function absPathInput(v) { if (!v) return v const files = window.plwl.FILES_DIR @@ -441,9 +422,7 @@ function absPathInput(v) { return v } -// Prefix-imported apps: show paths the way wine sees them - relative to the -// prefix ("pfx/drive_c/..." or "drive_c/...") instead of the long -// files/wineprefixes//... form. +// Prefix-imported apps: show paths as wine sees them (prefix-relative). function displayGamePath(p, game) { if (game && game.importSource === 'prefix' && game.prefix) { const files = window.plwl.FILES_DIR @@ -453,9 +432,7 @@ function displayGamePath(p, game) { return displayPath(p) } -// Inverse of displayGamePath: accept a prefix-relative (drive_c/... or -// pfx/drive_c/...) path and resolve it under the game's prefix; anything -// else falls through to the normal FILES/absolute handling. +// Inverse of displayGamePath: prefix-relative path -> absolute under the prefix. function absGamePathInput(v, game) { if (game && game.importSource === 'prefix' && game.prefix && /^(pfx\/)?drive_c([\/]|$)/i.test(v || '')) { const files = window.plwl.FILES_DIR @@ -474,11 +451,8 @@ function fmtPlaytime(seconds) { } // --- reordering the library (drag & drop) ---------------------------------- -// The registry array IS the display order, so a drop reorders state.games and -// persists the whole id list; the main process appends any id the renderer did -// not know about, so a stale card list can never drop an entry. Cards are -// plain flex items, so the insertion point is the half of the hovered card the -// pointer is over. +// The registry array is the display order; a drop reorders state.games and +// persists the whole id list. let dragId = null function clearDropMarks(except) { @@ -550,8 +524,7 @@ function wireCardDrag(card, game) { } async function attachCardIcon(card, game) { - // placeholder first so every card has a stable icon slot; swapped for the - // real icon once it loads - games without one just keep the placeholder + // placeholder first, swapped for the real icon once it loads const ph = document.createElement('div') ph.className = 'game-icon placeholder' card.prepend(ph) @@ -708,9 +681,7 @@ async function startImportFlow() { }) } -// Import from a wine prefix, step 1: pick WHICH prefix - the app folder must -// live inside one so its paths can stay prefix-relative (wine then sees it as -// a regular C:\ install). +// Import step 1: pick the prefix the app folder lives in. async function startPrefixImportFlow() { try { state.prefixes = await window.plwl.prefixesList() @@ -763,8 +734,7 @@ async function doImport(srcDir, mode) { const bar = modal.querySelector('#import-progress') const pct = modal.querySelector('.progress-pct') const status = modal.querySelector('#import-status') - // byte totals drive the bar when the copy knows them; a rename or a - // symlink-only run sends plain path events and only bumps the count + // byte totals drive the bar when the copy knows them if (bar && data && data.totalBytes) { renderProgressBar(bar, data.bytes, data.totalBytes) if (pct) pct.textContent = Math.round(((data.bytes || 0) / data.totalBytes) * 100) + '%' @@ -815,12 +785,9 @@ function isWindowsExecutable(p) { return /\.(exe|msi|bat|cmd|lnk)$/i.test(p || '') } -// The popup: wine version + prefix selects defaulting to the LAUNCHER's -// defaults (settings.defaultWine / prefixes.default), like the per-app -// config's "Default (...)" options - just without importing anything. +// "Open with" popup: wine + prefix selects defaulting to the launcher defaults. async function showOpenWithModal(openPath) { - // always fresh: wines/prefixes/default can change while the launcher sits - // in the tray + // always fresh: defaults can change while the launcher sits in the tray try { const [wines, prefixes, settings] = await Promise.all([ window.plwl.winesList(), @@ -892,21 +859,16 @@ async function handleOpenWith(openPath) { } function wireOpenWith() { - // Both delivery paths (live handoff and the boot queue) converge on - // openwithTake(): the main-process slot is drained exactly when a popup - // shows, so a reloaded window can never resurrect an old popup and two - // quick handoffs collapse into one (the newest file wins). + // both delivery paths converge on openwithTake(); newest file wins window.plwl.onOpenWith(() => window.plwl.openwithTake().then(handleOpenWith)) // file queued before the window existed (boot handoff): pick it up once window.plwl.openwithTake().then(handleOpenWith) } -// Import from a prefix, step 2: register the app in place - nothing is -// copied, paths stay prefix-relative. +// Import step 2: register the app in place, nothing is copied. async function doPrefixImport(prefix, srcDir) { const name = srcDir.split('/').filter(Boolean).pop() - // nothing is copied here - the app is just registered in place, so there is - // no progress to show (no bar that would sit at zero) + // nothing copied: no progress to show showModal(`

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

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

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

${title}

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

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

@@ -1724,7 +1732,9 @@ function armReceiveWatchdog() { receiveStall = setTimeout(() => { receiveStall = null const parts = receiveModalParts() - if (parts) parts.phase.textContent = t('lansync.recvStalled') + if (!parts) return + // silence also means no bytes: freeze the bar and ask for patience + parts.phase.textContent = t('lansync.waiting') }, RECEIVE_STALL_MS) } @@ -1759,16 +1769,18 @@ function onLanReceiving(info) { function renderReceiving(info) { const parts = receiveModalParts() if (!parts) return - // a ping keeps the screen as it is; only a reopened popup needs a phase line + // a ping keeps the bar and counters as they are; only the phase line moves if (info.stage === 'wait') { - if (!parts.phase.textContent) parts.phase.textContent = t('lansync.recvWaiting') + parts.phase.textContent = t('lansync.waiting') return } // 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() + parts.phase.textContent = (info.stage === 'start' ? t('lansync.waiting') : t(phaseKey, info.label || '')).trim() renderProgressBar(parts.bar, ...barValues(info)) - if (info.total > 0) parts.count.textContent = t('lansync.progressFiles', info.done, info.total) + // the file counter belongs to the copy/done phases; receive events for other + // phases carry cumulative numbers that would look stale mid-phase + if ((info.stage === 'copy' || info.stage === 'done') && info.total > 0) parts.count.textContent = t('lansync.progressFiles', info.done, info.total) else parts.count.textContent = '' parts.stats.textContent = lanStatsLine(info.items, info.itemsDone, info.totals) parts.bytes.textContent = lanBytesLine(info.bytes, info.totalBytes) diff --git a/renderer/plwl.png b/renderer/plwl.png index ee0c558dbb65ef4e1acac8fbd50ec8f3a2b4171e..cfab4e6a14d524a387219b57ed55b480d3ffc7b1 100644 GIT binary patch literal 9536 zcmb7qc|4Te`~N*=H1;)njK{t<$P!|-C{faaY$0nTvae$r;mMjxvL&g6kUi^IN<_9o z_7M?^i7>+$zccFj{QmyFUc82T&N6SQ|v6lED!{-pEf#i0fJ!QPZ-3s z4}94AzH|j2%sxg|eh|dbM*oGq_kQUN9t!yzUiQD}?dl)o=<5On1qB_vcEi)p$=i=3^v48s^v49(c1_@uP>y|2j{c5k zwPV}9yGGBp9(>(^jHK>`J%tP;2Fv82yG8*@ERfU%(Rc>v&ZYPNx5umXdQiQ(j?U=E zkeP2;Yh5r~4&w2IU(!$pY?AG@3XUIwSt5;nZB9eX&`kQS6$3E}!o08bO;X*^bn^2K z3J6GibHvo<(jl0GzrVly{H*|JTM-w@!t{#al8QxPVCS(55rvM^_NQF$qxT2u?zdDV(hcXYlj_^qzoB3xjmDViCap=X7 z(UFnW7h_Rng@vq8x@1I~D{hK`n0F&N_5=e-r&(-$`_p#YH0jA$lxkifGj#lT8K+#_ zszy60#MyL&pKsnkM~~C|tYoUOWVR{zQT{PwX3m!dPrs;trMxj9)%*I#8ehq^rgjm* zHO<{9$u0kg@P-yDYLpUAO5`wSgP6e?+ZhqMi^-L+cqJl%K?+IXq3{Bz-&uJ<(( zN7ow6A^33M^QUr~(3jgRe*6WOSk~4~OWi;H?7|u`hUUwRy}7a&J@t~|);@Jj&D^pw zZg%#>R0cc?QK+3%TvAd!#S(D6;)n=zwd}G~>eVU6FPchfo@r-fRCRUZDHJPtaW1NB zqbCa6sO4m!B6v)0|1l>gpE6ekTz03_c6+%?ab9cG>!VGIC2v}Ps_y^Zky4bpyzE!w z*OLVO(0PEvWScsMh6?{8OU=!>`sQ!Uogl@B4Wp=51PUeO%(-)tDUa}lFSwZy{0Ajj z-a@uBYmPP&DF?!qYwernG$xM(>{C(SU*9^vrpG`!_h2-~__@lVLy67kK$7r>9pS*bSCL6om z+wN}vf{7YL)SVe8r%#GXLE=YC7;*jmWDmpX2&m-qL%*IBqce^k9t;bW3I%^?*F5#t zi7qRXYi~c9TWE+yoge(&m6!J`nJLKmdQ?*DZrYfS*M;My5jP?wQ?4-Bi!^wI)B zc%#*VUewjoi%v<2H+nv|9+u|Y6Um!sT6}*fmswj3I(brNSF!mqlB@rr?EQ*Ij|7h^ zq)L#Z!+}eA*OENFML9Ur8l=8MpWO~!Kdg8P4r1%q&2|z3hwn@t84-FrbJM4X8T*et z(9oC|x#EI(Qhz`9+nM`Yi~qEfW;=|+8!KUgcb#Ak&dyian;*maUMc51HWg3#7cNeI zw?`#xdf4OY)dRe|su4Hus?sj0>r8(+ej^Eio1X48PbhPP`FnKbzxetn{WQeIc!};0 znM8rj;vWLZ=N!Vr&)AQ1yw1<-FGwBOSNZRCPKa)fr^IVentNA%KJTNahnO}qA~Bl<^cwr)qz4N=;XlTzrLkeG;D!}GmX*3T)Q2O;R?IQTFhCYe&R z0(}YevI3$}!|xFVR_W|FGDlhz79KMqT}>7^miyx31>GoiH>m8gQmf#$ zte2IVc5JP7zZ}yHr!{~9b;ZlC*O%C>t{tvH9h%dmzLC*Mgz>adSyG;*4 z_!kXg%1)Hz_Wp>z{)@<9xvy)|y9JjNxlgl0PEHpsVXqv9Feygj*`_9ow}5hF$nPS4 zMx#287z!pY4I^Lco;)>E=ayz z8wj@ApzEPXl$M}&+K07=A-u4VcFs!jE)R6_)Ty+C4P~;j+WE9U0kXWrLA81}!@E^o z(|)x~TJ7XN($Gr}2UhjcC1YT@%uGIi`eAeh1x+K>kQXOdTH5_HyzZ&;$OEF|uO=R+ zr*r#D{av#;-J1}Np6%{1F&|M@J1KbpiivfyJhu68()q&d_j4dO@>0JxP2w;`CnV1I zJyZ=>@*A@|(+V><|6r_gtkapN&_g4WuH>_%`0Y?uEMzw5e=zc7)0qj0!*cRzkVutC zMTJH3|CrY!SA5VvS=#S*LNA)ZK#4yLrCz%FHOmP*X$*lV+v9tsmD8DL;rpce@1y+! z@l1@kOU4w|pBq$a!+tI%Y{j1DYk+KNu&pYMLY-X!mqL+y-=z(X&c8$Jy_EVJb?f@n z5!Id^{vNnbPXjL$SCOtH^It%UkzxCbOC8xF<-cCnh7yvJ0^ye*qo-vUpudt>2vD=! zu4EKtA}?#JaIGkKxSBnLL-UDgo;4Q>)Dt3d{~E(4g5*X+>-TN#knawPAs8Su6SUy} z!KjEw-{O?n=s6H^X)`9IE>c&-0A+E0G`#5;|3@Qd4gL3-z}z*CDcC^-%&t1T5W z^BFA*{!bB8i?Jx^Ugy(V&3QQPDM{>SAO!*mV$UK~FbwqUlOgO${v|z0o=5PGd1~+V zG-1`h{10(Z({~K{tW5?FNu-$ISYWun&g}_IBrR4wbA=W9PlPZMyf2>>*=)-UDUT+G z|Bw3UZIoQVK4Oow!SQJsWDB}&OqVpaaGGQjWj_^oG|}SJKffC3ht$*%(Lc}b$yo06 zM#gz;sfGEEmC!BT7S#v{KlKV^_GJ^2P0DW|K;}9<%9yc5_k^Lp=JeKUMSJ@U0QGwh z(KDtr+0bQ7>d8$IXH5u#)O;Nt<)a62{r6uKs;}YWa0va|o@T<0iO}n#l|n_9r7$Q< z+fNwD@#B4eUc|oeM$X;3fTgmKwU3OLDcVkQZt03)e9|D$+-r6b&C4EwiKhGs9?s4k zCp>{;3=5H0>XvSd2aXNh6RQux;B$7V`SIjX6z%8OO8Zc;y0od|`kZmPYiEE!OatvzSXfFZimO%aiuP3pSB zi+(X!G<->#nkBMJNg#jIH|M6}oc635fUPFv<~sK8fU9BO^csbYlAba_+78#heXJ#} zN<>640y)TvAsYQT40zN9dC7uF7-7o0qxWUjn?V641Y6Z*s*BbjJH{bwZ`H&ty5Bv^ z4P|F~Lbq_Lj}$sP4fRjHx82fx$xeoi z?M2bjL8?I~bxm4&hOK#l4?Bd0c_6XP5C3fD#1O}Ya3Wn{#dZvz`KdU%TY&Td$2OCf zCJ)`m4b2XXV)NXTDHTsfqfoE4WgTvCws`jq>yqH@varN)Q$=Yl)Q@W(EJD5?!jV+E zFRz@6Ree)eer+wubmd~VNH6{rcSO7>0T zW)suX+^l++kW`!}aFMhbFBA}1UESGT!{OpZ-nx28>F`Eizu&+8^9l+It~P?omyMl0 z!@m^dMay!AFeGiM#3nZL#^~+AB1<^blcgOMh2$hYPcwF_*H~0k)1R5#XypWnP8be{ zU#{{nH!S_uWjUMl@Jb8{vUqj*+RMqXmBx5~9k15fIwJDO16~!&wtvVg4QmI-BX>7_ zK57h$3$jTFvVG0)ENR)OYo zgb7=3<$RMx=X;u&@Ixa~dcvn|o>UXLU1jL{rfb9Fy3aT3Fa&TM=yKy$+Z^)J`HhB> zuJ0?mtS^ql6w)UsuW@unD%Gx~p;L>88Qk__H!;f2$ zo%f!9|M5fZ^VdznZiFELe@`I_Bq*AA)xG13 z=TySCJ}rsuE1$3VCqzXF2w1V$iICt2XX!G6Wru@FxRIWlK*%=Brt&XY20vSJ21hE~ z*ai=UdNjWI5n-~Dq0!FJyznVsaqC*dyIBq@u1y=}r(t^=1zia(;N0F^Ii$-p9_&M_w&RC` z&rISL*cpTBXgE0bZ%N&vg1Wl8^^bR0vWT$o%|UZZA&-cy-zy)s79xtf?8vZKU!baB z1no>j5)u{Oe1YrB8&zFZ_quD-?u8H*3SKI%Ff*^Z+W6vyO!)6SOPlX4STc<0gxY?T zE8U#s*RZo;BfVoxvA(|z_&JDJ<)Kmn)$q6~IWuJvJbfGoTn~9XptN!0zRW~6$Zqui%D`(yY>u5RiOwA)+C9?WZBW*-eS@mIofQ6!!IPSIi>zE3Yd9&JP%` zYWRv<$G5k4oD8w?l#Kg9MC5^B!HzD{e1zIjsKa(9v%ftt2L=HzZ;TldxFku1-rwPU z|JtSNVch!EE{&6zYI!Afh#;a9_(O0JhHdtx$j*M}S(>e&P-;kqopWIfL)Te#4-TZ+Vv2mV6<==>d(-@mkcphP@sQb~?-L!625;ShHT-H{ z8rWLPxDvS&z{YU}VTK~XOI#T@4pOZ!{o+2odRtw$7NapmZaF)Ifvw*eSV~tU(hNg_ zi-LM20#0|u&^w|09IWxEV8he= z$6H!QV;7@Pq{|ojY|kdQo11Z!Wp9dGTH=GJqwOtAE?W$OOCT$;2=SMOuVyE-PDW*`GC*nSUL$BF3uh34HV& zokw$)NSdD}(*gCLd7KP`2n@u=!9lzu`}pqi(gO}ro!c!(orIFt3Ps7Vw0l5B7TQ#b z8Xtx_%}FB94B?xTMD0s-$YpoCoi>;4_|N4|41WH+Uo6Bqt<$245kou%^z~8r7G|ka z`#qfVGAn~k^>{+S(vSeT^DdAHRPAk(jme{Zv5lh@Zdx)j-AdE~JrKimYYYieDLO+@ z%`eVo##2wsBB)lPhhK9Fpe=gqaPb02A0jTjl0?6-TQ3l;qcQfw)?9bjE+qaCgz|wi z0?;CSdu-QCBbjA)21E1i?(Pt!cM}=_30b)SF-JnP=8rHX@>*&+=2PB6hboot&ND#?ZdG z`>V&HsKl^+p2PZ7T>b!l7)^!FT)EHR4 zD^nztez+ip_AB{JP1gq#l3SJMu<7shM!BHF42w%)-1UJ!)y*oJh)COWtfr)?ps66G zb5JNZR@|>{r`UT(uO|nC_BP@Vpix7@*D@pBh~>{{VDVY7cvyno3EsRug_Wg|-v_<@ zs8mQGpn|3%(rzu$wr-<*l$8&+*mr>*RbM|&RJXdm+{oR>hiR;4>W+KmfZd&~Xq1mM zeF-P%j}NP>->zTw-8e(c4x-_7m%q4*lRj%vaWmIim}#MSR7V1(adYxrwO7sbZ0CJ7 zUHO9hvi6`XuDR+!Y-G`3#y01H3q+ZTM(rrv4-f`f*AIE&mnO zZTdI($c^BHclr-QOKsbtK!9{JKk__Enm=XnjpUYU{M=_MV)JW~Zgnn!U~gS2faY-6 z-lzu;rwkQR)sD13)eG2%A(nv>fFBWwb=F`YSb(H1Tz$QkIc(zyeqrHSm!1sIr!S3R z0yi3dRU8gkb-C!IGHqHB;*La`oPEPZMeKg{@aA;-^x`b}huf~M zj*bp{m5^xS*G}1-w=GfIs;a7vp$Udzg@vSFbYxRM~U%ZI`Lcn)A9OrCqpL^JkP2A4cz zOAbn?1$`bt;I6&Lt1^ry_9iC~)8s%gYa6T=d=f!$bw&_Qa8q4p=?$`mq$F87j;N)W z>_V?HUvBL+OT`_>t!^=6wdu&n637vU{`X%I3>!jlCaC8d&T4>4>U|R02EW^v8x>5v zwZcnfz^(#V>wIk%-xpExaIsCYvds<62^8qnWP3s5`j0>bLp>aGH-a-q8}wRxi>-X+ z6<>#eTWWF8^UQh>ndFGK?>krGoj%Eg{p9)}EoQQ#hss1c5V z4(N@>PSm61>m#R{8vp;sR$m8ETnz|3H_W#DWcb=ydI#t^{A9?{hNR4tB~Xa>YV%TCCCDE?41zXfK5Or6i`lYDB7(q1 z7?;Ik?4yWidCupIvSb&~;&D?c!W!U4U+I$sF^CnScZEzCT-Rca^NzR5xaUHC3M3EbTk}x3|#-CP$Z0PY$8SL`s}Pn zy1u-j+`{?hA%7MzZ8~jxmZY)!P`Re#d*8)6%3QDItBaoC7Jv3vj9QiH8rP4hYC7u= z6n4&3F=2?o);<7awqL$)Qn6!k@p*_jHi7rOO5pwHs6Zrfn{Se=a)LJ zfB(q!54nP#q9Y>E4<^oag(Ys)X%#GXK6?VStOSs|LgGSrsis|ir;oxwK1iE3A&I2q z-@`K?FaRs5sh&=wY|f?A7oP(%Ns~Wz0G|OS1BeM77kSJC(HHKaB6Q~fKp`CySv8}# zhc9xD(9u^*P3kI*$&kUI+~xx`G{KE1 zu(pR@YOg99b64Li+W8j%-=;!`_`#RptQzbz63RK3tQ;f_?azX#Sd7H&(OeS2KKU#J z`AI_i1FgSSJ~Q~s6o&bpX=j+^5wH+2?X6*e*I$nl| zGVHXBbO}+pdjJv^si3#U3zlZaq|DzXpi+A9p^3V5VLicE4tlBZc=(2vy1MWcp6e$N zgpvnDtpz(9l42abz1`tx`c!G102CTJN<$FC12a`)98}&IvCc@41D+!Yr&mTnyPuJ1 z^BeiaZ`y}E;k%w_bQlFWku84)s(exMI89nI*<%Pa;FZH|TAIya}evq;d+Nk(z0+1-5radE^VVfz~#^bR?m zr#uw@kFF-5J2FzRIo{Zu_4=6$qlNi6t9a@J2ZF&J2vW)Z-#UHj$B)HzeAPyL`3gO%D^4d>uoXYf#j?1avQLm zb0K^NH~3XjGv4?08T!ck{{cYXlpZ1T8Megn75(Ooytq(VS6!n;efS{Bzx?OV=Elb9 zDZifOp@)62uzn>jO~DK^|TB z7B0XZ%yKVa1vXQeF~GU(z6uG`d@L=Oj>?OsrWAuW2nH5L(6Hc(HbVmgahnvyZU{oO z(BB29u72v4$^P$N{qZj1TkF)wNVLyo!nihiF1x9_z$}$?tku!HfFJ8H-&n4=_Ve-{4^AK4K)hc&@UymzVwF9eFKPqrV#W zolZUx%5~>^GW8Mc!MUJ-s-}OQAA=+mQzF<`tvI2@Y zkjnv&Q2>qQRLv}J`1=9Y0&r8GC(ju8PZ$mX~(;NtGd%G%)d$6m$% zsy=CK_!x*-2$xTWpQ=rJb-w@V?vjFeyEJ`7nnu);Y!|e)9xZe3yl}fs^xKuB zHJ)agjnu#f{#%k%*Psd1SXVzbwY(HY)9D%c**75#smZt3)zyIw(qPQ4xF$^>Qh|$+ zvV#Q&T&`VJQ?j~1Rq4f_gs$s`{-&&_7%gd=h>l2&i6Qw1`})r0_oi)eG<%;)H+ZN0 zetcYs=Z-w+HF0Z|t~m+kxd8kN<|Kyaxmc>678moiIILauyQJVmcI&yntH^UFA@e|? z(!y`C&btkTM8Ab;0D7dv~t6*~8{`jHp>gJ}ZrNvs-00&s^%3^GeYD`Sb$=k<1 zGVMDKeo|##m_z3j;#QSMw5iVEg$eN@cmG8-TqHZOhCwdm@pHcU0|lRt%+1fI7=M&Z zKO>Q93^&UyJW!YsQ|SAE28Pk!f-T`xwzyW{4!Lq{aZj7)Jq?_k;0(9A2{AGI@`9eu zK}{m03E4vdHQ9w3&|PySew%xy6=$;)bCBdg`l}*e59IHUURkG*VOHKhZW@~lK$XH` zNp=NtKlpK82-4A-_s0trv*A9E{AibDM6m&dg>alj+2!k}6`sFBezCvVv&-rfoHOPm zF?jEbAr8ezj(n*QR-K>!^W2SOqZkhv=(`3On|XMAO)FA3h$Q7kN1EKVa{tUA-av}% z929<5cb=ztKTc3B-k%5Bm!jR8bJ2XmLLu6(>tR(=-QkmCvs=an^_3)6-D*c+(DX#f IF$etr1LCoDGynhq literal 9458 zcmb7Kc{r47*nfv%ETQZ>Wi4b+BBMo=5M{|WgGwbkk!1`e$6m>jJ+fzOvBu2gWT{Zc zzRoz7DeE8%2H!Kz>3siu|9sbl!TUbX{oLE{-k#_yriL7B!fX%(aa=SyZvjCt@GA^r zVFn-eKE=1d2dkISbzcbLY@vU{+B}PIfPV`48QAz;^}OX5;N)`?3J3@|e#gVj*Tu=} z=5bFS*NjD7VF;3hE}lPgEiiK-E7(i4FKY=`1(@`l4QuM9=EBRlRQL%&!g6_+Oi*0o^Z9>RsAK z(iZ-gmV+6jxjT^N&=Vs|MK*}LB*p-yf5fs{9x{4$jGqaLzV;~(l6+cf!wX4U@WnD< z&zCy?-!EJBF0s}XR8&+v$Q*EN*Tx;hM?!k-f=C{HM!X2re*n#iO{?UQ*(~S9N@HKn z?(<3AP_PA{(NIvisEG7#R_(9rd_Za=I_0orAS$Xlo7EQ&Q) zBw>`S819kpJ*%U37&D{C$4)^F4HZ=5uFW;50`3(~Ts>@=yo(pCQ7_aT?s+?@iIbbW z#wP#`1#G1!LpV-+)J`^6G@QhTJuMtxnh~%xe)`A8jrn3{26V@>MWi+FbXnX)5TrT1 z<;z)oS2(@!NRs)>(qq>&)ptar3h%V<{s`GZP3(4A2D!R6PY1kNatlScjjxAQ4TWay zFh8jryLRWK%S zG{y&k<&*dQu9nmxnPqx7`O@K}i!H<57bvm8*2ig9e6fAGH8q^?-@h*|DNzz&5yrX- zwrB+h2kcngMyK)(LO&K4J-TlUIzV=exl?6XmkLz0wWIxlEZA7!IEN)li|6{S1Any5 znWIpN!^6~jjdvjNO9g`i8;fHPECRWY%X`7_gISkcU6paybYlmV(UYp*z2-bI ztFhjOGtZ{nAVr4;n!N#`2Rg2X)Y8<9TwXRWF75FRdCP+F_KYA_0Nu@WxnIzAM9u%S z=9Aef$<8KovU41fBr@cd&Ba7!x^*+m-*eF+1Bw?fnd z>-l1w^@P!eLM?JYTg}JZRYX=~0Hew{UbGqGZLONJ(wORMryFWQ5vcZ5lE6kvTVyjy zkw5ljxx0AY_Xsz}bJ&%i=ba66-knkUybAsJVY3?$H-UsriSZOzBngU$Fp)D!0xL4q zPfgP;mB!|Rwzk8uS=@2H3EdlO3tI*62ukfeP#T6zWXdHB>S=3--|#sA5tL_)fmj<6 zICEceO>T8{$EYqrd;2_!*!Ae$#ph35Vc))cZyss2QN~)iY*Pcdd3Y{Dd01hCeO>(B z{B~ROMAxU$`p%gdC#byV0! zM97~g6e)}wEAgt(V_>9Z`o<-2^3Xogj}$;5As~~S<5?-IcdrSm?cdDN-F~}c&pyqK-cA7=M$GHrRMOj)#5TEW# z`8YLv-dz7w)`=5+rN`_JyU1EFU{&qiMzhn>4*E$0Cjw_g?M-xdMM}N+y7%TuVfEaN zuNO`NZWfwZ3LAeFS{zy01?trTbVUwO{biCmK7x!zo|%t0mHK2iFZ~`w})a^E|Gpvz`WLVNW{b$66QsPAM_L0c7oy zy9c{l!O{VMhYLH&ha2}b50H2u4o5QWN_oVu~=4|QGlV!;2pepBcl zkgoHb#&hMOLpfaP?Ef6{hWz~fI~l9cg^?pH(7&cpu1MAH9@iM~6C-6BD>vnpqqSmv zL?ICFZ;y7}xMmZ4hVE$K<=Pce_b!ThK_vd~ho*)HS)gnIs5HI$jrAdkB6ayiIRHXm zmH|vtr^R1{bPfz3c^bmMU^FF+Jqq=zjs4!_%>;st5$j9kh8{$Ty+r`<+E=-i9u0bO ziO=3`RS)8gBwHVq7y3_7oIu`|>mwJh4g-bE zn=2cZ^pKP~Atp*4`z1}yB6yAm*uFP)J-pmZKTbduiGJKKlrN`!TpCVHeeOI1iy$`B z)nrT=NmhXAQ%2Tgsh@yz$UPGWD;(&8OC<#yEBWtH*&)n>q$K|TeJGIZCIBms1zsvc zmC?55qhXi8%dy+sv}c)RoEW^97HSDV^>z?rn4zJZ)oYvRNOl1j#3?$MsAP8{zf#$W zphcww`1v_ce|B5NGm&y8n$Sk*Pvj68B#9Wgk9ixsS#DXc6asq@|V-4 zl2lkX!~Q0_cf(9fI$w7)>g$RdN^@6Fxf*)3NJO8%f|5`kX{@Okb-PkeVa$fp?P=~b zu%!;0qidY!^UaMhR@0vhL#u7R{w4mFI9~|t&z_5JA_MfGlzi#>to2nF4q1hjSznBT zp%(sLZxuAfk6)Zb1VT&?(Ln;yl38U z(i$6*Tm(&oOL)o9?G4}#;sdZLmj{(^uVTFjTt@p;wFD`Q{hk}yO)NhyW6Bq5gL>6` zd3p7yuv9mOne4+1HXhz zK&Cl*?j<z|OB&)w|KOk)R{^5*T_2JJ4xD zZ7_uqU+vPSSkC;qtgPk3g9U>Sx*WddAi4bg{cmoApe(9$;&xAy4_H#dCMrrzJ-&d~ z9C(fY7|9NU5S*g;{&Kr-MAV1ja!b_H&FdTY1u)PROFqK{Q9?aA+pgb@5b@S`Ddo&# z38+hNThw@GJaj{>xb$vI{TmX3;Tn(1K#LUBvwqrYG+Y+_{{Wu&4eHEl&G1oeL!J^m9VDGO-$(Q z3E%?Hbu=qjSA0b7PFgEQ>Zbu$1M8}%>d}NDJ{S_c!$Ml9Esx9@pnM5Y3aQ**r@aUG zl5gy`v<`iFkL8gVFsnoOO)?7I(cL^ntR{b0?W*nV8SZujj-xjK|GGw@M&w|+P^Q)n zYfQ9NWSD=7!l;bl`xEsR!3a389!SxidU%`Xm_mTZz|FOdz4ei>H&dwt7H7cve){@} zySS{z!=W?k*8Gq3-{ZNUC{D3Ay_MzVq5W&n`ZOoaHTwa-t=)*?!tyq8ulY5~(KwR8 zMco%#QQ{V-xbAJY-tLf@&!a!}HGz@KnPmfQ_IqsKDu3L}S09jXTIMS%s(b2-#Sbq$ z7xfBV*m6`DdahsTKB5iq3EQJ^y>;MWk)Snz&Cn$|edp$erWYN81eD(JL)#lm%5$#2 z_>zzmM)7@y4`HAmOJ?ZR8I${R;boP_{hd@kP#E75o>WG%EBokPuv#SMZJ(0>YZ08h z>`lOn18wL;U8(-K?WMg#;$a~fV;!W~vQ7c!77pZ-CWjh6CC3kfnwsx94ZS6H)6;Dg z9X|~IHNL&ESMe%mXrP`JDM2ZgMOPT=le*W*R07wcFE4|&V+l&|U6b4Hy<-pt3fx(H z_)Vky9tY#I&U0Qyc0nUYFLfqc7`TSY1b))Z#uF7LK*1=byuH==aU%SpjA z3-k-{TsPbpo7eg!6xSTyOsQoojsq>EI@J96tmWmoD3w#EY)RIi2bv>bNRl}OKeYQ| z$KWjKZNk~yXQY8pRG?F3L9@h8aTY}3#6UK0Zj{KR3OEYNQ!+XSvLhG)0HuaU9N3F9 zI|0YeYd4EIJeYQcK^x=2wFPTT$t+0x@<(2TCq4M^ zl@0(b$222T9k2s)bi+#6=8?39N)bbbWKLX#fa9Q)dtC0_ z?rd6)>Q@S5cro3_U2CQz^=(K9Y<4zkXiMCnAlf$pwR5-D$!^d)rFv>hJGt93{2^lR zvQ8TphI0DlK`17R^BPEmy{6g^eb|~+_^XvY-SNRR-vLf>rYm9qz^s@!kJB1*ZnKPf zM{WA$`b-S0jrw+95>dasHLbiSqQ8dcg4fC4t|&62bpJ}?V@T#gLi2;m$P`Zz$BUnD zs13e%GqePz&kZFr?p+QWGGtiig9A$r3=|CBXqI%R<=*bjh8^Iz-pFTr$C96>55-2z z1Tn*b!p@%~xjjqa*hb(gigGZQhD9Utin6yNbI(l^xZtk=O;c(Vek4@{OT)X*^3tmirQGX(5dHFazf(2ME3eRJvd)!?Q{FdEj+9>b#j{+AP0}&L_*R@7j;*^_E>zN^>m*k=pMn)XHM*T0a540T)iBYjs{xyDn27$u zuXA&v1%&Mu3sTibWY55LmiGFR$9f370?@RxGXrs}KZ|eGaw2TIcJE7Hy=~vn>(x>)gopPd08^Lw{u$OZ555(Vr~x zPk(&cTI0I@V3|>QX&i&Y#N$uO)M+iKkHsy)V10ovYukPxYta6y?Ug$3dKHEY_)LsHI* z>ya&$3P~NL?BK=LPOVA;;H4lw=+}=!uN{IF7VF8!K?CWFuVP@~XF$s8H>oFh&)zS} zxH!wQ3W8LkZrt*+cbk-K(&S*3(`lf8-@#hX%5rUf-K&ulM#y(7YG^<2ClH(k>{&1o zVfQ<;M&f{}l6-Tvb!}~Jl@@$grdVk-GP13|JVRAi_g-)UQ};DV8aZb#{KLX|=hXZR z(>g&2jwE=AKZ&I}%=z4@CB^$1iR3r1n3Ko;%66%lsp794l}5Y1}3Wr6Va2$e%!iYgOBs+B^4 z^jhm{94S>ERKW~Zd}*ZA{Lc^fOQ}3)KQa9Slol17zz_Bl&HcaN)ajjP`F~~JGXDm! z-ha*`0KNxw;Y|!B%21hTQ=oU1w^Dzs3b|h_^_9B0CmMpfs$&%tt?g~4_+32LkBy19 za-cXc%LWYwV?9fzbuJJH>%5+pFV}byO)2bK9cxOe!URt{y=zSC0=uuH)^_~}W}{{m zp3&b~For92$Ex-~4oY~4P+S|SaaSNH|BxG~{qr^)=kmaY?y*a9(HLFNY4L(oN`Bi=olqQUlX_UEUu-mV-j>c#ig6HkSRI$Kd-~+R# zu;>=$FUrJJQYu;DDm?5cQ=u$+d>oEjyi8$_CFPie7qh0-Y7r`RKVaNGghs8mE)A%cLq20I3Pn_f#Fp}Gb=;>0|tbke6gvKq}N@z_C( z?YLW?HAukZ49@r;qG^+_;E6*+k-+?0@(cuCHtQXCK^t!^c%lxi3ra||?e1(Z54t4g z)z%{VSdb(qPy^AI>=h`Nx1?)obqIbF=o=WAd}e%{Ad_kz5hcDQgrQAJ*p6!{f^0C0 z;h)z51j(6Zy1;XS3Pb%q zaY!Ah_V#xJHD-?IpLZ@hJ(8e&_`R;EY{%5Nfa!X=>=vs52-AVK{F$K1kQzKSX&8#& zoKIB^%=M$7mvfGSlqEYEz!1HOvI)YnQo8Vo)9ZuzOzuvgB6{_ELsw9`?nCO{VV5J} z-_CLE+zV2|$Cbh``hTB-B9LU!yHx+Nu)W0P*|JJgWw`C5t*szd-&ND0NPV2TB0Sw3 zWao7a2mjbV1+OG%eQ`G#Ne5lhGy4);zO@x#uKC0Ov^J1GD5)#FtFqK4D^g4Z1Hc01 zmQ+%0o<1KF695&|W6!#Y)Xfe4w7gu1UOG&4-6 zf`LvzRjo`|x&|v{yG;4=D~SwRj@zFfqsAp(8bPmCv|f~Fv0^qR0szX^&dxE@m|mM` zi=C5(T36zD=YTB*Gvd4{ErxA@ns7YL|G|?sPHx-vc6InumfUpv=iMZyoOFul)mc(|+yT zpkXOWZ`=%Ilg9O_gQDmr>}+(tus$dsofh&yV@24|L278Fx3}~`j(+58PNAi+Yjll=j;EwBsN<0 zae6upYQI6+Ii&CDx#SHkrO92`=L@)MU*D%rH;?i0#sKogBHu$h$MGJM7fejbonlF; zKT`)Tfk6c)f(9K-6yQaTO$Vwy5ry8r4+1h*+gY(?#bk_w-h_Vj?3c#awYSG!Lf-HVhAyM2-mB_LrDa(YIM zj+&a$Nk}8!Q^jMTudlN~u`csB(M*crk?#@g*z`hopBuNHctO9`PU9rd$|55%_9gG6 zBZ$5Gyvfm^Z-ax+1E}5FjdV4bm3gzuT zE2S5}^)*U8B3>$*SGOb&ou_2{Ba29IFyZvCWqXB34mBIJ``KDBpuqs`A++LAmED9k z-<3M^o3IujJUo1NeN?40v+9*4Q@_S2hh)BIn==@I0ZzCGo_A%a?mIfdofI^n_wYEfVdO#AAa#pd{;cWLThBkw1c z-JM6hI)Hxi3lbCzT<0avyS3H?eh^<<^VO$_?8~Z;aSsfKZr@Hz8t4*Sq`&q2`I)$8 z`{SptUv0H%@T8wYT)X^9yVwBS6zmsY{z|@B4FU7t98A>e;Ir ze0;BTX?NdvHwTOR;U1?7y8KC0f2aSJ;PT{8&b>yew;&5khfO>HP6Y;#!o$aKK z_hK)&04AfxM56g)fM0`2SttNhl}zu}>oaA$=(sUmm8xz3ql|n{%W4rzb0hVvBrc(m zzn6nFv}#%E!0?JbjHtX!4S%pY!?(%F5mr?jK(;!{nEa#+TnQ*WwRsb&6dIZNdguJr ztAv0IC3<49Pvj$Uc=++mtyLe?hrH4x)Kj%@iaw_yW@cttX!@$kwS)I<(gGm}?n}Q5 z!2G5RE+RZ1;`Zc!SC5Xy)zmzgapf}1A1-<`U#DQL$0T1^QK7)^)5Qa=xTa7&TU$2HZ8>OqGWyR1y`vAWKPdv-iHUvLOHifBVLwE`cz7lSg-^ju z)Vrd7pApj=09tW;3hCe$fgW+7b}rR|`K^36Bh)U&s>>m{RA&0(vM^}LfPo9cejNBu z!KdcB+4N=^Rp9bD>nmBPRAm_SV*vin6?VU-I?Fd>4-iLK{w)Ue*}h${^C5?E_U{o8nNzsZ$j;p9J@gVk3$#7hM$;7ytO@ zqpU;&`uGU6_XUN8XL6fD#ht6uoy9J|MxPnYKeOn)dM%t40wdfy+F1i9F5i|~miS9W zJ%U2o@zK%CYKHLrNatx7r>dc$@$&uq+?Yi39mp=pXg;K8S^pVdF$BFq$v8InZ3?$i z9aKUu<${s`eeWI|44*+eF(_qUE#=3AIAT z6ShLeSZ_!;`bIMcCF6>7hZeVD8*)|nbRalp0i?7CA?}vd{}0a$!{9|L48iuIXR@%Ne5Pnx;)!CY3|58 z75SJ5qgTxD-)sKy>CN8VyLVsnzj?txWtQ|w{QB3JN2xP|zI8yr#*5O=R$dkMTol|& zu%0wGIq;%6wbkg6=TrYS@ycu7Fm&(!g$%YvPP8=x6}x|xWE@_V<-2QBBZ|3Mn#YJQ z1{V(0)rAUp(AHjE$S%RI)nL!>_*-hG%D~ zk?D`wVIf{y#24+yHPRV#A8hUAm1!DT=JR4zp`xb_uae*@f6VL9GSXWeyKhit#+!7^ zwoV!j@D77(H%8ObK^IlHqj~NB?>jgDuM0Z;dx(bAy`MF&xe4ID5_HkP^nArxbj1Gv Dt@3 e.type === 'file') - // total size is known before hashing, so the bar starts empty + // total size is known before hashing: the receiver sizes its compare bar + // from this, the sender's popup from onProgress const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0) - if (!force && files.length) { - let done = 0 - let hashed = 0 - await mapLimit(files, concurrency, async entry => { - const abs = filesPath(entry.path) - try { - // 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 => { - hashed += bytes - if (onProgress) onProgress(done, files.length, hashed, totalBytes) - }) - : null - } catch { - entry.hash = null - } + if (onTotal) await onTotal(totalBytes) + let done = 0 + let hashed = 0 + // one batch hashes with the whole disk queue behind it; its entries go + // on the wire in order before the next batch starts + for (let i = 0; i < files.length; i += concurrency) { + const batch = files.slice(i, i + concurrency) + if (!force) { + await Promise.all(batch.map(async entry => { + const abs = filesPath(entry.path) + try { + // 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 => { + hashed += bytes + if (onProgress) onProgress(done, files.length, hashed, totalBytes) + }) + : null + } catch { + entry.hash = null + } + })) + } + for (const entry of batch) { done++ if (!entry.hash) hashed += entry.size || 0 // nothing read: still report the file as passed over if (onProgress) onProgress(done, files.length, hashed, totalBytes) - }) + if (onEntry) await onEntry(entry) + } } hash.save() return entries } - return { buildManifest, scanTree, portableLinkTarget } + // Buffering variant for callers that want the whole manifest first. + async function buildManifest(roots, force, onProgress) { + const entries = [] + await buildManifestStream(roots, force, { + onProgress, + onEntry: entry => { entries.push(entry) } + }) + return entries + } + + return { buildManifest, buildManifestStream, scanTree, portableLinkTarget } } module.exports = { createManifestBuilder } diff --git a/src/lansync.js b/src/lansync.js index fecc632..8621905 100644 --- a/src/lansync.js +++ b/src/lansync.js @@ -240,14 +240,23 @@ function readJsonBody(req, limit = 16 * 1024 * 1024) { function readNdjson(req, onEntry) { return new Promise(resolve => { const rl = readline.createInterface({ input: req, crlfDelay: Infinity }) + let chain = Promise.resolve() rl.on('line', line => { if (!line.trim()) return + // entries are handled strictly in order: a handler may be async (the + // streamed plan compares/hashes while the body keeps arriving), but + // the response must never reorder the manifest + let parsed = null try { - onEntry(JSON.parse(line)) - } catch {} + parsed = JSON.parse(line) + } catch { + return + } + chain = chain.then(() => onEntry(parsed)).catch(() => {}) }) - rl.on('close', resolve) - rl.on('error', resolve) + const done = () => chain.then(resolve, () => resolve()) + rl.on('close', done) + rl.on('error', done) }) } @@ -801,7 +810,7 @@ async function handleVerify(state, req, res, hooks) { requestId, deviceId: from, name: String(payload.name || ''), - totals: { apps: Number(announced.apps) || 0, wines: Number(announced.wines) || 0, prefixes: Number(announced.prefixes) || 0 } + totals: { apps: Number(announced.apps) || 0, wines: Number(announced.wines) || 0, prefixes: Number(announced.prefixes) || 0, settings: Number(announced.settings) || 0 } }) } catch { resolve('reject') // no window to answer with -> never auto-accept silently @@ -857,13 +866,14 @@ async function handlePlan(state, session, url, req, res, hooks) { } catch {} const owned = (Array.isArray(roots) ? roots : []).map(ownedRoot).filter(Boolean) if (!key || !owned.length) return sendJson(res, 400, { ok: false, error: 'bad-unit' }) - const entries = [] - await readNdjson(req, entry => { - if (inlineSame(entry)) entries.push(entry) - }) - // the mode each file has to end up with on this side - const modes = new Map() - for (const entry of entries) if (entry.type === 'file' && typeof entry.mode === 'number') modes.set(entry.path, entry.mode) + // comparing is its own phase: the bar restarts for the copy phase + const announce = progressGate((data, force) => emitReceive(hooks, session, 'compare', data)) + // the unit counts from the start of its comparison, not from its commit + const kind = UNIT_KINDS[String(key).split(':')[0]] + if (kind && !session.counted.has(key)) { + session.counted.add(key) + session.items[kind] = (session.items[kind] || 0) + 1 + } // a force sync compares nothing: every entry counts as different const forced = url.searchParams.get('force') === '1' // the sender's totals, so the popup can say "2/5" from the start @@ -877,24 +887,64 @@ async function handlePlan(state, session, url, req, res, hooks) { } } } catch {} - const allowed = entries.filter(entry => withinRoots(entry.path, owned)) - // 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)) + // The manifest is compared as it streams in (both sides hash at the same + // time). The sender sends its unit total first, so the bar starts with its + // full width; files missing from the sender's total compare as the usual + // entries - the bar just grows when that happens. + const entries = [] + const results = new Map() + let unitTotalBytes = 0 let compared = 0 let comparedBytes = 0 - session.barTotal = unitTotalBytes - session.barBytes = 0 - const identical = await mapLimit(allowed, HASH_CONCURRENCY, async entry => { + const compareOne = async entry => { const same = forced ? false : await entrySame(entry) + results.set(entry.path, same === true) compared++ comparedBytes += entry.size || 0 session.barBytes = comparedBytes + // the sender's announced total (or a provisional full bar until it + // arrives) - clamped at the top by the renderer, never over 100% + session.barTotal = unitTotalBytes || 1 // no file counting yet: dirs/links are compared too announce({ unit: label }) - return same === true + } + // files compared concurrently: the sender streams its manifest, this side + // keeps at most HASH_CONCURRENCY digests in flight (the sender's hashing + // queue is the wider throttle) + const jobs = [] + let inFlight = 0 + const settleOldest = () => Promise.race(jobs.splice(0, 1)) + await readNdjson(req, async entry => { + if (!inlineSame(entry)) return + if (entry.unitBytes != null) { + // the sender announces its unit total before hashing starts; it can + // arrive after early compares (dirs/links), so it just overwrites + unitTotalBytes = Number(entry.unitBytes) || 0 + session.barTotal = unitTotalBytes || 1 + return + } + entries.push(entry) + if (!withinRoots(entry.path, owned)) return + if (forced) { + await compareOne(entry) + return + } + // the sender's hashing queue is the throttle on its side; this side's + // own concurrency limit keeps a saturated disk from stacking up here + if (inFlight >= HASH_CONCURRENCY) await settleOldest() + inFlight++ + const job = compareOne(entry).finally(() => { + inFlight-- + }) + jobs.push(job) }) + await Promise.all(jobs) hashStore.save() + const allowed = entries.filter(entry => withinRoots(entry.path, owned)) + const identical = allowed.map(entry => results.get(entry.path) === true) + // the mode each file has to end up with on this side + const modes = new Map() + for (const entry of allowed) if (entry.type === 'file' && typeof entry.mode === 'number') modes.set(entry.path, entry.mode) const paths = new Set() const upload = [] const same = [] @@ -922,17 +972,19 @@ async function handlePlan(state, session, url, req, res, hooks) { session.unitWritten = 0 session.barTotal = uploadBytes session.barBytes = 0 - const kind = UNIT_KINDS[String(key).split(':')[0]] - if (kind && !session.counted.has(key)) { - session.counted.add(key) - session.items[kind] = (session.items[kind] || 0) + 1 - } // 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) } +// One PUT, one file. The sender declares a content-length up front; if the +// body stops short of it (sender crashed, lost link, file changed size after +// the manifest), the request would otherwise stay open forever and wedge +// this file, the session and the sync. Any arriving byte re-arms the timer, +// so a slow honest transfer never trips it. Env override for tests. +const PUT_IDLE_MS = Number(process.env.PLWL_LANSYNC_PUT_IDLE) || 60000 + async function handlePut(session, url, req, res, hooks) { // unit and path travel as headers: constant per request const key = req.headers['x-plwl-unit'] || url.searchParams.get('unit') || '' @@ -945,8 +997,22 @@ async function handlePut(session, url, req, res, hooks) { } // report bytes while the file is still arriving const announce = progressGate((data, force) => emitReceive(hooks, session, 'copy', data)) + // a ping must not flash "waiting" while transfers are running + session.writing = (session.writing || 0) + 1 + let idleTimer = null + const armIdle = () => { + if (idleTimer) clearTimeout(idleTimer) + idleTimer = setTimeout(() => { + // the write's stream 'error' handler removes the tmp file; the sender's + // request errors out and its own timer/heartbeat handles the rest + req.destroy(new Error('body-stalled')) + }, PUT_IDLE_MS) + if (idleTimer.unref) idleTimer.unref() + } + armIdle() try { await writeStream(abs, req, delta => { + armIdle() session.bytes = (session.bytes || 0) + delta session.unitWritten = (session.unitWritten || 0) + delta session.barBytes = session.unitWritten @@ -959,6 +1025,9 @@ async function handlePut(session, url, req, res, hooks) { announce({ unit: session.unitLabel || '' }, true) } catch (err) { sendJson(res, 500, { ok: false, error: String((err && err.message) || err) }) + } finally { + if (idleTimer) clearTimeout(idleTimer) + session.writing-- } } @@ -1030,10 +1099,13 @@ function handleEnd(state, session, res, hooks) { emitReceive(hooks, session, 'done', { deleted: session.deleted || 0 }) } -// Liveness ping: hashing can take minutes; re-arms the popup watchdog. +// Liveness ping: hashing can take minutes; re-arms the popup watchdog. The +// event must repeat on every ping (it is what keeps the popup's watchdog +// armed), but never while a file is being written: a copy in flight reports +// itself and must not flash the waiting text over the receiving phase. function handlePing(session, res, hooks) { session.lastAt = Date.now() - emitReceive(hooks, session, 'wait') + if (!session.writing) emitReceive(hooks, session, 'wait') sendJson(res, 200, { ok: true }) } @@ -1140,6 +1212,7 @@ function getPusher() { filesPath, ownedRoot, buildManifest, + buildManifestStream: manifest.buildManifestStream, listDirs, listDevices, mapLimit, @@ -1219,6 +1292,7 @@ const exported = { filesPath, ownedRoot, buildManifest, + buildManifestStream: manifest.buildManifestStream, mapLimit, listDirs, syncTo diff --git a/src/lansyncpush.js b/src/lansyncpush.js index 8b6ed16..01b412f 100644 --- a/src/lansyncpush.js +++ b/src/lansyncpush.js @@ -1,8 +1,9 @@ 'use strict' -// 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. +// The pushing side of a LAN sync: per unit, stream a manifest while it is +// still being hashed, 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') @@ -12,10 +13,22 @@ const path = require('path') // Files sent at once: one at a time wastes LAN round trips on small files. const UPLOAD_CONCURRENCY = 4 +// Two lost heartbeats in a row: the receiver is gone (40 s of silence). +const MAX_MISSED_PINGS = 2 + +// One file, one PUT. A file that has not finished on its own after this long +// (stalled receiver, lost socket while the OS still reports it open, a file +// that changed size after the manifest) fails the whole sync instead of +// hanging it forever. Env override for tests. +const UPLOAD_TIMEOUT_MS = Number(process.env.PLWL_LANSYNC_UPLOAD_TIMEOUT) || 10 * 60 * 1000 + // 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 +// One ping round trip; env override for tests. +const PING_TIMEOUT_MS = Number(process.env.PLWL_LANSYNC_PING_TIMEOUT) || 10000 + function createPusher(api) { const { CATEGORIES, @@ -26,7 +39,7 @@ function createPusher(api) { httpJson, filesPath, ownedRoot, - buildManifest, + buildManifestStream, listDirs, DIRS, toPortablePath, @@ -35,8 +48,10 @@ function createPusher(api) { prefixes } = api - // Manifest in, upload list out; NDJSON avoids one giant string. - function planUnit(device, token, unit, entries, force, meta) { + // Manifest in, upload list out; NDJSON avoids one giant string. The body + // is written by `produce` WHILE the sender hashes, so the receiver can + // compare entries as they arrive (both sides hash at the same time). + function planUnit(device, token, unit, force, meta, produce) { // 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) => { @@ -62,23 +77,47 @@ function createPusher(api) { }) } ) - req.on('error', reject) - let index = 0 - const pump = () => { - while (index < entries.length) { - if (!req.write(`${JSON.stringify(entries[index++])}\n`)) { - req.once('drain', pump) - return - } + let dead = null + req.on('error', err => { + dead = err + reject(err) + }) + // ordered writes with backpressure: the receiver reads continuously, + // but a slow socket must not buffer the whole manifest in memory + const write = obj => new Promise((resolveWrite, rejectWrite) => { + if (dead) return rejectWrite(dead) + const onDrain = () => { + cleanup() + resolveWrite() } - req.end() - } - pump() + const onErr = () => { + cleanup() + rejectWrite(dead || new Error('plan-write-failed')) + } + const cleanup = () => { + req.off('drain', onDrain) + req.off('error', onErr) + } + if (req.write(`${JSON.stringify(obj)}\n`)) { + cleanup() + return resolveWrite() + } + req.once('drain', onDrain) + req.once('error', onErr) + }) + produce(write).then(() => req.end(), err => { + try { req.destroy() } catch {} + reject(err) + }) }) } function putFile(device, token, unitKey, rel, abs) { return new Promise((resolve, reject) => { + const settle = (fn, value) => { + clearTimeout(timer) + fn(value) + } // unit and path travel as headers: constant per request const req = http.request( { @@ -96,12 +135,26 @@ function createPusher(api) { }, res => { res.resume() - res.on('end', () => resolve({ status: res.statusCode })) + res.on('end', () => settle(resolve, { status: res.statusCode })) + res.on('aborted', () => settle(reject, new Error('response-aborted'))) } ) - req.on('error', reject) + // The timer spans the WHOLE transfer, response included: a stalled + // receiver or a silently lost socket must fail the sync instead of + // hanging the popup on the last file. Generous - a file would have to + // take over ten minutes at 20 MB/s to hit it. + const timer = setTimeout(() => { + req.destroy(new Error('upload-timeout')) + settle(reject, new Error('upload-timeout')) + }, UPLOAD_TIMEOUT_MS) + if (timer.unref) timer.unref() + req.on('error', err => settle(reject, err)) const stream = fs.createReadStream(abs) - stream.on('error', reject) + stream.on('error', err => settle(reject, err)) + // pipe ends the request when the file ends; a body shorter than the + // declared content-length (file changed mid-sync) wedges the receiver + // - its own idle watchdog breaks that, this side's timer breaks every + // other silent case stream.pipe(req) }) } @@ -265,8 +318,9 @@ function createPusher(api) { // 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 } + const totals = { apps: 0, wines: 0, prefixes: 0, settings: 0 } for (const unit of units) totals[bucketOf(unit.kind)] += 1 + if (cats.has('settings')) totals.settings = 1 // every progress event is kept, so callers can report without subscribing const events = [] const progress = data => { @@ -274,32 +328,6 @@ function createPusher(api) { if (onProgress) onProgress(data) } - let verified - try { - // 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 - }) - } catch (err) { - return fail(String((err && err.message) || err)) - } - if (!verified.data || !verified.data.ok) { - const error = (verified.data && verified.data.error) || `http-${verified.status}` - // 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 - // paired now: the next sync needs no typing - rememberDevice(device.id || deviceId, code, device.name) - - // 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) - if (heartbeat.unref) heartbeat.unref() - const summary = newSummary(units.length) const finished = { apps: 0, wines: 0, prefixes: 0 } // planned bytes so far, so the popup can show "1.2 GB / 3.4 GB" @@ -341,13 +369,58 @@ function createPusher(api) { // 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) + // consent may wait on a human: say so before anything else happens + emit('wait', null, null, null, true) + + let verified + try { + // 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 + }) + } catch (err) { + return fail(String((err && err.message) || err)) + } + if (!verified.data || !verified.data.ok) { + const error = (verified.data && verified.data.error) || `http-${verified.status}` + // 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 + // paired now: the next sync needs no typing + rememberDevice(device.id || deviceId, code, device.name) + + // The heartbeat keeps the receiver's popup alive while this side hashes + // (minutes of silence). Consecutive ping failures mean the peer dropped + // (crash, sleep, lost network): without this a dead receiver hangs the + // sync until the next request notices, which may be never. + let missedPings = 0 + const abort = { dead: false, reject: null } + const abortPromise = new Promise((resolve, reject) => { abort.reject = reject }) + abortPromise.catch(() => {}) // only awaited via Promise.race below + const heartbeat = setInterval(() => { + httpJson(device, 'POST', `/plwl/ping?token=${encodeURIComponent(token)}`, { json: {}, timeout: PING_TIMEOUT_MS }) + .then(() => { missedPings = 0 }) + .catch(() => { + missedPings++ + if (missedPings >= MAX_MISSED_PINGS && !abort.dead) { + abort.dead = true + abort.reject(new Error('receiver-lost')) + } + }) + }, HEARTBEAT_MS) + if (heartbeat.unref) heartbeat.unref() + const end = async () => { try { await httpJson(device, 'POST', `/plwl/end?token=${encodeURIComponent(token)}`, { json: {} }) } catch {} } - try { + const runUnits = async () => { for (const unit of units) { + if (abort.dead) throw new Error('receiver-lost') const roots = unit.roots.filter(ownedRoot) if (!roots.length) continue const kind = bucketOf(unit.kind) @@ -357,14 +430,36 @@ function createPusher(api) { barBytes = 0 barTotal = 0 emit('scan', unit, null, null, true) - const entries = await buildManifest(roots, !!force, (done, total, hashedBytes, totalBytes) => { - barBytes = hashedBytes - barTotal = totalBytes - // no file count until the plan returns - emit('hash', unit, null, null) + // The manifest streams WHILE this side hashes its tail: the receiver + // compares entries as they arrive, so both devices hash at the same + // time. Once this side is done hashing, the popup keeps a full bar + // and the waiting text until the plan answers (a cached sender waits + // for a receiver that still has to hash). + const entries = [] + let writes = Promise.resolve() + const plan = await planUnit(device, token, { ...unit, roots }, !!force, { totals }, async write => { + await buildManifestStream(roots, !!force, { + // the receiver sizes its compare bar from this, before any hashing + onTotal: totalBytes => { + writes = writes.then(() => write({ unitBytes: totalBytes })) + }, + onEntry: entry => { + entries.push(entry) + writes = writes.then(() => write(entry)) + }, + onProgress: (hashedDone, hashedTotal, hashedBytes, totalBytes) => { + barBytes = hashedBytes + barTotal = totalBytes + emit('hash', unit, null, null) + } + }) + await writes + // this side is done hashing; the receiver may still be comparing + barTotal = barTotal || 1 + barBytes = barTotal + emit('wait', unit, null, null, true) }) - emit('plan', unit, null, null, true) - const plan = await planUnit(device, token, { ...unit, roots }, entries, !!force, { totals }) + if (abort.dead) throw new Error('receiver-lost') 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 || []) @@ -383,32 +478,32 @@ function createPusher(api) { pending.push(entry) plannedBytes += entry.size || 0 } - // 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 + // 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) emit('copy', unit, allDone, allTotal + pending.length, true) - } - let failed = null - let done = 0 - const send = async entry => { - if (failed) return - const res = await putFile(device, token, unit.key, entry.path, filesPath(entry.path)) - if (res.status !== 200) { - failed = new Error(`upload-failed-${res.status}`) - return + let failed = null + let done = 0 + const send = async entry => { + if (failed || abort.dead) return + const res = await putFile(device, token, unit.key, entry.path, filesPath(entry.path)) + if (res.status !== 200) { + failed = new Error(`upload-failed-${res.status}`) + return + } + done++ + summary.uploaded++ + summary.bytes += entry.size || 0 + barBytes += entry.size || 0 + emit('copy', unit, allDone + done, allTotal + pending.length) } - done++ - summary.uploaded++ - summary.bytes += entry.size || 0 - barBytes += entry.size || 0 - emit('copy', unit, allDone + done, allTotal + pending.length) + if (typeof api.mapLimit === 'function') await api.mapLimit(pending, UPLOAD_CONCURRENCY, send) + else for (const entry of pending) await send(entry) + if (failed) throw failed } - if (typeof api.mapLimit === 'function') await api.mapLimit(pending, UPLOAD_CONCURRENCY, send) - else for (const entry of pending) await send(entry) - if (failed) throw failed - emit('copy', unit, allDone + done, allTotal + pending.length, true) + emit('copy', unit, allDone + pending.length, allTotal + pending.length, true) // the totals count a unit's files only once they are on the wire allDone += pending.length allTotal += pending.length @@ -431,9 +526,16 @@ function createPusher(api) { if (deleted) summary[key].deleted += deleted } if (applied.data.deleted) summary.deleted += applied.data.deleted + // a sync with nothing to send still ends with a full bar + barTotal = barTotal || 1 + barBytes = barTotal emit('done', null, allDone, allTotal, true) await end() + } + try { + await Promise.race([runUnits(), abortPromise]) } catch (err) { + abort.dead = true await end() return fail(String((err && err.message) || err), summary, events) } finally { @@ -442,7 +544,9 @@ function createPusher(api) { return syncResult(true, null, summary, events) } - return { syncToDevice, buildUnits, buildRegistryPayload, CATEGORIES } + // putFile is exposed for the stall tests (it is the piece that must never + // leave a request half-open) + return { syncToDevice, buildUnits, buildRegistryPayload, putFile, CATEGORIES } } module.exports = createPusher From d4996988d954598b1d0e031ed4dc30a98a7101ba Mon Sep 17 00:00:00 2001 From: olcxja Date: Tue, 6 Oct 2026 08:11:31 +0200 Subject: [PATCH 10/29] better sync progress reporting --- development.md | 4 +++- src/lansync.js | 26 ++++++++++++++++---------- src/lansyncpush.js | 24 +++++++++++++++++------- 3 files changed, 36 insertions(+), 18 deletions(-) diff --git a/development.md b/development.md index 8234e8f..fcbdcea 100644 --- a/development.md +++ b/development.md @@ -71,7 +71,8 @@ Renderer never touches Node/Electron directly — everything goes through the `w - Two sides: the receiver lives in lansync.js, the sender in lansyncpush.js, built by createPusher(helpers) — several launcher states share the module, so nothing may sit in its module scope. Both sides report their own progress (receiver through the onReceive hook). - An incoming sync needs CONSENT: a correct code opens the gate, but the receiver's onIncoming hook (registered in main.js) shows Accept/Decline, answered through lansync:respondIncoming. Unanswered requests expire (INCOMING_TIMEOUT_MS); a decline returns 'rejected' and KEEPS the pairing (the code was proven). Without an onIncoming hook (tests, headless) the gate stays open. -- Progress is bytes plus per-category counters (items = announced units, itemsDone = finished, totals = everything picked) — "Apps: 2/5". The bar follows the CURRENT PHASE's bytes (barBytes/barTotalBytes) so a phase starts from an empty bar. Hashing and copying walk tens of thousands of files: every per-file update goes through progressGate (~10/s, phase changes forced) — never one IPC message per file. +- Progress is bytes plus per-category counters (items = announced units, itemsDone = finished, totals = everything picked) — "Apps: 2/5". The bar follows the CURRENT PHASE's bytes (barBytes/barTotalBytes) so a phase starts from an empty bar. Hashing and copying walk tens of thousands of files: every per-file update goes through progressGate (~10/s, phase changes forced) — never one IPC message per file. The gate holds a snapshot FUNCTION on the sender side: the snapshot is built when it flushes, so a throttled update never shows a byte count one gate window old. +- The sender's transferred counter counts bytes AS THEY ARE READ (putFile's onBytes callback), not on upload completion: a multi-GB file has to move it while it flies or the sender shows hundreds of MB behind the receiver. A unit's byte total travels as the `{ unitBytes }` control record BEFORE its files; the receiver has to handle it before the entry filter (it has no path/type, so a filter-first order dropped it and the comparing bar measured against a placeholder total of 1 — read as 100% for the whole phase). The comparing phase ends on a full bar, then the copy bar restarts empty (its first PUT byte emits it). - While the receiver answers the consent popup (and whenever its side waits on the other one), both popups show "Waiting for the other device…" (lansync.waiting): the sender emits a 'wait' phase before verify, the receiver's ping handler emits stage 'wait'. A unit counts toward "Apps: 1/5" as soon as its comparison starts (not when it commits), and phases without a file count (hash/compare/commit) clear the counter so a stale "3 / 10 files" never lingers. On the wire the receiver's comparing phase is called 'compare' (sender-side 'plan' is unchanged). - Registry-level settings are opt-in (the "settings" category, old name "defaults" still accepted as an alias on both sides). The payload carries defaults, wineSettings (displayName + disabled per build), prefixSettings, appSettings (name, wine, prefix, args, env, dllOverrides, disableUmu per app) and appOrder. The receiver only patches what already exists (unknown ids skipped, nothing created or deleted, unknown local entries kept at the end of the order) through the normal helpers (setWineDisplayName, library.updateGame, reorderGames). - File hashes are remembered in files/data/lansync-hashes.json (path -> size + mtime + hash; machine-local, never synced): equal size and mtime means equal bytes, so a repeat sync only stats the tree. Hashing and the receiver's comparison both run HASH_CONCURRENCY files at a time. @@ -100,6 +101,7 @@ node --check # syntax node test/smoke.js # quick sanity node test/lansync.test.js # LAN sync contract (identity, pairing, mirror) node test/lansync-receiving.test.js # receive-side progress events (the popup) +node test/lansync-progress.test.js # live sender bytes, comparing bar widths node test/lansync-settings.test.js # playtime, app settings, wine/prefix + defaults node test/lansync-links.test.js # wire: exec bits + relative links node test/lansync-pending-links.test.js # links to files created later, startup heal diff --git a/src/lansync.js b/src/lansync.js index 8621905..98f1b7b 100644 --- a/src/lansync.js +++ b/src/lansync.js @@ -915,14 +915,15 @@ async function handlePlan(state, session, url, req, res, hooks) { let inFlight = 0 const settleOldest = () => Promise.race(jobs.splice(0, 1)) await readNdjson(req, async entry => { - if (!inlineSame(entry)) return + // the sender announces its unit total before hashing starts (a control + // record, no path): it carries the compare bar's width, so it must be + // read before the entry filter drops it if (entry.unitBytes != null) { - // the sender announces its unit total before hashing starts; it can - // arrive after early compares (dirs/links), so it just overwrites unitTotalBytes = Number(entry.unitBytes) || 0 session.barTotal = unitTotalBytes || 1 return } + if (!inlineSame(entry)) return entries.push(entry) if (!withinRoots(entry.path, owned)) return if (forced) { @@ -968,14 +969,16 @@ async function handlePlan(state, session, url, req, res, hooks) { // this unit's files/bytes, reported as they arrive session.planned += upload.length session.plannedBytes += uploadBytes - // the bar restarts for the copy phase - session.unitWritten = 0 - session.barTotal = uploadBytes - session.barBytes = 0 - // 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 }) + // the compare is finished: report it full, then reset for the copy phase + // (the first byte that arrives emits the new bar) + session.barTotal = unitTotalBytes || session.barTotal || 1 + session.barBytes = session.barTotal announce({ unit: label }, true) + session.unitWritten = 0 + session.unitLabel = label // named up front, so file events can label it + session.barBytes = 0 + session.barTotal = uploadBytes + sendJson(res, 200, { ok: true, upload, same, entries: paths.size }) } // One PUT, one file. The sender declares a content-length up front; if the @@ -1095,6 +1098,9 @@ async function handleApply(session, req, res, hooks) { function handleEnd(state, session, res, hooks) { state.tokens.delete(session.token) if (state.session && state.session.token === session.token) state.session = null + // a finished sync leaves a finished bar, even one that moved no files + session.barTotal = session.barTotal || 1 + session.barBytes = session.barTotal sendJson(res, 200, { ok: true }) emitReceive(hooks, session, 'done', { deleted: session.deleted || 0 }) } diff --git a/src/lansyncpush.js b/src/lansyncpush.js index 01b412f..5c31d88 100644 --- a/src/lansyncpush.js +++ b/src/lansyncpush.js @@ -112,7 +112,7 @@ function createPusher(api) { }) } - function putFile(device, token, unitKey, rel, abs) { + function putFile(device, token, unitKey, rel, abs, onBytes) { return new Promise((resolve, reject) => { const settle = (fn, value) => { clearTimeout(timer) @@ -151,6 +151,9 @@ function createPusher(api) { req.on('error', err => settle(reject, err)) const stream = fs.createReadStream(abs) stream.on('error', err => settle(reject, err)) + // live bytes: a multi-GB file has to move the counter while it flies, + // not only when its response lands (read = what this side hands over) + if (onBytes) stream.on('data', chunk => onBytes(chunk.length)) // pipe ends the request when the file ends; a body shorter than the // declared content-length (file changed mid-sync) wedges the receiver // - its own idle watchdog breaks that, this side's timer breaks every @@ -366,9 +369,12 @@ function createPusher(api) { } return data } - // 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) + // Snapshot per file, forward a few times per second (phase changes + // always). The snapshot is built when the gate flushes, not when it is + // pushed: a throttled byte count must not be one gate window old. + const emitNow = data => progress(typeof data === 'function' ? data() : data) + const gate = typeof api.progressGate === 'function' ? api.progressGate(emitNow) : emitNow + const emit = (phase, unit, done, total, force) => gate(() => snapshot(phase, unit, done, total), !!force) // consent may wait on a human: say so before anything else happens emit('wait', null, null, null, true) @@ -488,15 +494,19 @@ function createPusher(api) { let done = 0 const send = async entry => { if (failed || abort.dead) return - const res = await putFile(device, token, unit.key, entry.path, filesPath(entry.path)) + const res = await putFile(device, token, unit.key, entry.path, filesPath(entry.path), delta => { + summary.bytes += delta + barBytes += delta + emit('copy', unit, allDone + done, allTotal + pending.length) + }) if (res.status !== 200) { failed = new Error(`upload-failed-${res.status}`) return } done++ summary.uploaded++ - summary.bytes += entry.size || 0 - barBytes += entry.size || 0 + // the byte counters already moved while the file was read; the + // completion still has to push the file count next emit('copy', unit, allDone + done, allTotal + pending.length) } if (typeof api.mapLimit === 'function') await api.mapLimit(pending, UPLOAD_CONCURRENCY, send) From e7636afe3d5c29a6ccf2ed32c71f987dc6104ef1 Mon Sep 17 00:00:00 2001 From: olcxja Date: Tue, 6 Oct 2026 20:54:16 +0200 Subject: [PATCH 11/29] [EXPERIMENTAL] linux apps support --- README.md | 24 ++- development.md | 28 ++- files/translations/en-us.json | 45 +++++ files/translations/pl.json | 45 +++++ renderer/app.js | 150 ++++++++++++++- renderer/index.html | 59 ++++-- src/fsutil.js | 40 +++- src/icons.js | 34 +++- src/importer.js | 36 ++-- src/ipc/games.js | 35 ++-- src/ipc/index.js | 53 ++++-- src/lansync-manifest.js | 3 + src/lansync.js | 4 +- src/library.js | 32 +++- src/linuxapp.js | 265 ++++++++++++++++++++++++++ src/linuxrunner.js | 349 ++++++++++++++++++++++++++++++++++ src/prefixes.js | 17 +- src/preload.js | 6 +- src/runner.js | 93 +++++---- src/sandbox.js | 160 ++++++++++++++++ 20 files changed, 1377 insertions(+), 101 deletions(-) create mode 100644 src/linuxapp.js create mode 100644 src/linuxrunner.js create mode 100644 src/sandbox.js diff --git a/README.md b/README.md index 2428f74..21084ec 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PLWL — Portable Linux Wine Launcher -A portable launcher for Windows games and applications on Linux. +A portable launcher for Windows games and applications on Linux — and for native Linux apps, run with their own isolated home (bubblewrap when available). ## Portable layout @@ -9,7 +9,7 @@ Everything the launcher needs lives next to the executable: ``` ./files/ wines/ imported Wine / Proton builds - wineprefixes/ wine prefixes + wineprefixes/ wine prefixes (a native Linux app's data lives in /linux_pfx) apps/ imported app folders (optional) data/ launcher config (apps.json, settings.json) translations/ translation files @@ -36,6 +36,26 @@ npm start Besides copy/move/leave, an app can be imported **from a prefix**: nothing is copied — paths are stored relative to the prefix, so wine sees the app as installed on `C:\` (registry entries keep working). Plain prefixes (`drive_c` on top) and Proton ones (`pfx/`) both work. The app stays tied to that prefix; removing the entry never deletes prefix files. A prefix can be renamed later — the folder moves and everything stored relative follows. Edits wait for running apps: renaming/deleting a prefix in use (or moving the default away from an app without its own) is refused with the app to close first; deleting a prefix that still has apps is refused too. Imports recreate symlinks (relative when the target belongs to the launcher) and keep permissions, so an imported game runs straight away. +## Native Linux apps + +The import dialog starts with a choice: **Windows app (Wine / Proton)** or **Linux app (native)** — after that both go through the same copy/move/leave steps. + +The entry point is found automatically in the picked folder: an ELF binary or `.AppImage` (with the executable bit), or a `.desktop` entry whose `Exec=` points at one. Libraries (`lib/`, `share/`, …) are skipped, and the icon comes from the `.desktop` `Icon=`, an image next to the program, or the system theme. + +A native app runs with its **own home folder**: `HOME`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_CACHE_HOME` and `XDG_STATE_HOME` point into `files/wineprefixes//linux_pfx/`, so config, cache and state land there instead of in your `~`. The prefix select in the app's config picks which one; **Open data folder** takes you to it. `linux_pfx` is machine-local — a LAN sync never pushes it and never deletes it. + +When **bubblewrap** is installed *and* usable (PLWL probes it with a real run, not `which bwrap` — Ubuntu's AppArmor profile kills it even when it is installed), the app also gets its own namespaces: `/` is mounted read-only, so only **its home folder and its own app folder stay writable** — anything else fails with `EROFS`. The app folder is included on purpose: portable apps keep their logs and data next to themselves (Prism Launcher refuses to start otherwise). System trees are never handed out writable, not even when the app itself lives in one. Screens, audio (X11/Wayland, PulseAudio/PipeWire), gamepads and networking keep working. `XDG_RUNTIME_DIR` stays the host's, so the session bus is shared — the sandbox is not airtight, and the UI says so rather than pretending. + +The redirected home starts with a **copy of your desktop preferences** — `kdeglobals`, the GTK 3/4 settings and `user-dirs.dirs` — so the app keeps your theme instead of falling back to its built-in light look. The copy keeps following your host as long as the app has not changed it: a theme change arrives on the next launch, and the moment the app writes its own version, PLWL never touches it again. Your own **fonts, icons, themes and colour schemes are not copied but mounted read-only** inside the sandbox and added to `XDG_DATA_DIRS`, so they follow the host live and nothing can be written back to your `~`. + +Every prefix in the **Wine prefixes** tab has a **Reset Linux config** button: it deletes the desktop preferences PLWL copied in (and the hashes that track them), so the next app start seeds a clean copy from your system — the way back when an app has hijacked a seeded file with its own theme. App data in `linux_pfx` is never touched, and an app running from that prefix blocks the reset. + +Imported trees get **normalised permissions**: every file gets the owner's `rwx` and an `x` mirrored from its `r`, because sources come from archives, FAT sticks or read-only backups where those bits are simply not there. + +Some apps draw their window icon themselves, which Wayland does not allow — Minecraft (LWJGL 3.4+) is the known case: its taskbar icon turns into the generic Wayland one. The per-app **Force X11 (XWayland)** switch moves the app *and the programs it launches* back to X11, where the icon works again. + +Per app you choose the **Isolation** policy: *Automatic* (bwrap when it works, plain run when it does not), *Off*, or *Required* (refuse to start instead of running unsandboxed). The backend actually used is always visible: a badge on the library card, a `[plwl] isolation: …` line in the app log, and a status card in Settings with **Check again**. When bwrap is missing, PLWL says so once instead of quietly launching without it. + ## "Open with PLWL" The desktop entry registers `.exe/.msi/.bat/.cmd/.lnk` in file managers. Opening one shows a popup to pick the **Wine/Proton build** and **prefix** for that run (defaults preselected, nothing imported). A second instance hands the path to the running one. App shortcuts work as **Open With** targets too: the app receives the file as the Windows path it would get on Windows — `C:\...` inside its prefix, `Z:\...` otherwise. A file handed to an already running app goes to that instance instead of a second tracked run. diff --git a/development.md b/development.md index fcbdcea..f464a91 100644 --- a/development.md +++ b/development.md @@ -13,7 +13,11 @@ src/ main process (Node/Electron backend) index.js handler registration, icon protocol, dialogs games.js games service: run/stop state, wine resolution, open-with handoff lansync.js LAN sync handlers (identity, devices, re-roll, sync + progress) - runner.js spawns wine/proton (env, prefix, playtime, logs) + runner.js spawns wine/proton (env, prefix, playtime, logs) and owns + spawnTracked() - the log/playtime/exit tail every run shares + linuxrunner.js native Linux apps: redirected HOME/XDG, bwrap or plain run + linuxapp.js native app detection (ELF/AppImage/.desktop) + icon lookup + sandbox.js bubblewrap probe (functional, cached) + buildBwrapArgs library.js apps registry (apps.json) + settings + playtime files, plus the "which prefix does this app use" / in-use guards wines.js wine/proton build scan, registry, portable symlink healing @@ -63,10 +67,26 @@ Renderer never touches Node/Electron directly — everything goes through the `w - Registries (apps.json, wines.json, prefixes.json) are rewritten whole via writeJson(); playtime lives in its own tiny file so the registry is not rewritten while a game runs. - Deleting user data needs a confirm modal; best-effort cleanup is try/catch-and-ignore with a comment saying why it is safe to ignore. - Windows apps run through runGame()/buildEnv() — never spawn wine directly elsewhere (prefix handling, Proton env and playtime live there). +- Native Linux apps run through linuxrunner.runLinuxGame()/buildLinuxEnv() the same way: HOME and the XDG folders are redirected into `{prefix}/linux_pfx/`, and bwrap (when the probe says it works) forces the same layout with mounts. Never spawn a native app elsewhere. `game.kind` is absent on old entries — that means Windows; `game.isolation` is `auto` (bwrap when usable, plain run otherwise), `off` or `require` (refuse to start instead of quietly weakening). The chosen backend always lands in the log and the UI (a silent downgrade is the failure mode to avoid). +- Under bwrap ONLY two things stay writable: the redirected home and the app's own folder (`writableAppDir`: inside files/apps, or the folder the app was imported from — never `/usr`, `/etc`, `/bin`… and never files/wines). Portable apps write logs and data next to themselves and a read-only app folder makes them refuse to start (Prism Launcher: "couldn't create a log file - Read-only file system"). Everything else is `EROFS` by design. +- Two pipes feed one log in spawnTracked: neither may end the stream (pipe(ws, { end: false }), closed when both sources drained, at the latest LOG_DRAIN_MS after the exit) and the write stream carries an error listener. Ending it at `exit` while a pipe still had data killed the whole launcher with ERR_STREAM_WRITE_AFTER_END. - A prefix or wine build a running app uses cannot be renamed/deleted (wine holds files open there): the guards sit next to the handlers in src/ipc/, use the games service's running list plus library.js helpers (prefixNameForGame/gamesUsingPrefix/is*InUse) and answer `{ ok:false, error:'inUse'|'hasApps', apps:[names] }` for the toast. - Long operations stream progress through the single import:progress slot as `{ phase: 'count'|'copy'|'remove', files, bytes, totalFiles, totalBytes }` (runWithProgress for deletes/imports). Emit 'count' (a treeSize walk) first, copy/remove through the fsutil helpers. - CSS: reuse the existing variables/classes (.button, .field-label, .custom-select, form-card); spacing comes from --button-margin and flex gaps, not per-element magic numbers. +### Native Linux apps + +- Entry point detection (linuxapp.js): ELF, `.AppImage`, or a `.desktop` whose `Exec=` points at one; `lib/`, `share/` … are skipped. The icon comes from the `.desktop` `Icon=`, an image next to the program, or the system theme — `extractIconFromExe` is PE-only. +- The redirected home starts as a COPY of the desktop preferences (kdeglobals, kdedefaults/kdeglobals, gtk-3.0|gtk-4.0/settings.ini, qt5ct/qt6ct, Trolltech.conf, user-dirs.dirs). Without the seed Qt falls back to its built-in light palette (measured: `#eff0f1` against the host's `#202326`). The copy keeps following the host while the app has not touched it: the hashes of what PLWL wrote live in `{prefix}/linux_pfx/.seeded.json`, OUTSIDE the app's home, and a prefix from an older launcher is adopted on evidence (byte-identical with the host, or the copy is older than the host's file). The PulseAudio cookie is seeded once, because with a redirected home Pulse looks in `$HOME/.config/pulse/cookie` and the app otherwise comes up silent; `~/.Xauthority` is refreshed instead, since nothing exports XAUTHORITY to a sandboxed app. +- Host fonts/icons/themes/colour schemes are not copied but `--ro-bind` under `/tmp/.plwl-host/…` and prepended to XDG_DATA_DIRS: live, and unable to write back into the user's `~`. Symlinks were measured and rejected — the prefix is mounted ON `~`, so a link to `~/.config/kdeglobals` points at itself (`Too many levels of symbolic links`); without bwrap the same link writes through into the real home. +- `forceX11` (per app) sets XDG_SESSION_TYPE=x11 + QT_QPA_PLATFORM=xcb + GDK_BACKEND=x11 so the app and everything it launches falls back to XWayland, where window icons still exist at all (Wayland forbids them; a compiled GLFW probe showed XDG_SESSION_TYPE picks X11 vs Wayland, `GLFW_PLATFORM` does not exist). `game.env` still wins. +- SESSION_MANAGER is dropped in the sandbox: the session socket sits in the private `/tmp`, so the pointer led nowhere (Qt: "Could not open network socket"). +- Imports normalise permissions (fsutil.chmodTree): owner `rwx`, `x` mirrored from `r` (0644 → 0755). Sources are archives, FAT sticks and read-only backups. +- **Reset Linux config** on every prefix row removes exactly what PLWL seeded (DESKTOP_SEEDS, the pulse cookie, `home/.Xauthority`, `.seeded.json`) plus the folders that existed only for the seed, so the next start seeds clean. syncDesktopConfig can only follow a copy it can still prove is its own; the reset does not argue. App data (`data/`, `cache/`, `state/`, `config//`, the rest of `home/`) is never touched, and a running app from that prefix blocks it (the usual `inUse` guard). +- The user's real `~` is deliberately invisible in the sandbox (HOME is bound straight to `{prefix}/linux_pfx/home`), so absolute paths an app wrote into its config before the import (Prism's instance `JavaPath`) do not resolve — that is what `isolation: 'off'` is for. +- Not built: a private or overlay `/etc` (`buildBwrapArgs` accepts `etcRoot`, nothing sets it; `--ro-bind / /` leaves `/etc` read-only, which is all we promise today), `--tmpfs /home` against OTHER users' homes (`hideHome` is plumbed, no UI) and `dataMode` — app data always lands in `{prefix}/linux_pfx`. +- Measured, not assumed (Debian 13, kernel 6.12, `bwrap` 0.12.0): the functional probe passes here, no firejail/proot/fuse-overlayfs on the box, `/etc` is 16 MB and its `machine-id` is read-only even on the host. + ### LAN sync - Two sides: the receiver lives in lansync.js, the sender in lansyncpush.js, built by createPusher(helpers) — several launcher states share the module, so nothing may sit in its module scope. Both sides report their own progress (receiver through the onReceive hook). @@ -78,6 +98,7 @@ Renderer never touches Node/Electron directly — everything goes through the `w - File hashes are remembered in files/data/lansync-hashes.json (path -> size + mtime + hash; machine-local, never synced): equal size and mtime means equal bytes, so a repeat sync only stats the tree. Hashing and the receiver's comparison both run HASH_CONCURRENCY files at a time. - File permissions travel with the manifest (`mode`) and are applied after the bytes; a file with the right hash but wrong mode is repaired without re-upload. - A sender hashing a big unit is minutes of silence: it pings `/plwl/ping` every HEARTBEAT_MS, the receiver re-arms its popup watchdog (stage 'wait' shows "Waiting for the other device…" but never while a file is being written). A receiving popup its watchdog closed reopens on the next real event — only 'done' ends a sync, and reopening never asks for consent again (consent is the onIncoming popup at verify time; the progress popup is driven by the same token's events). +- Native Linux app data (`{prefix}/linux_pfx/`: the redirected home, caches, PulseAudio cookie, machine-local state) is MACHINE-LOCAL: the sender's manifest skips it and the receiver's pruneTree refuses to delete it (prefixes.isLocalOnlyRel, shared by lansync-manifest.js and lansync.js). Otherwise a mirrored prefix sync would either push a huge cache or delete the other machine's app data. - A sync must never walk through a symlinked ROOT: pruneTree/healUnitRoots (sync), removeTree/treeSize (fsutil) and healBuild (wines) lstat the root first. `listDirs()` counts symlinked folders as units, so a build on another disk still travels — as the link it is (`wineUnits` maps nested builds to their TOP folder under wines/). - Wine/Proton rewrite prefix links to absolute targets whenever they update a prefix, so on-disk links are not always relative after a run: the sync relativizes them on the wire (portableLinkTarget) and refreshWines()/healPrefix() repair what arrived from an older PLWL. A build whose binaries lost the executable bit is detected in refreshWines() and healed once (spawn() on a non-executable `proton` fails with EACCES while the game looks started). @@ -108,6 +129,11 @@ node test/lansync-pending-links.test.js # links to files created later, startup node test/lansync-consent.test.js # accept / decline / timeout gate node test/lansync-token.test.js # long-sync token expiry (sliding TTL) node test/bench-sync.js # timing: cold vs cached sync +node test/linux-sandbox.test.js # bwrap argv profile + auto/off/require choice +node test/linux-env.test.js # redirected HOME/XDG_* for both backends +node test/linux-import.test.js # ELF/AppImage/.desktop detection + icons +node test/linux-sync.test.js # linux_pfx stays out of a sync +node test/linux-e2e.js # REAL run: plain + bwrap (/etc read-only, home in prefix) npm start # click through the flow you touched # Repairing / testing a real instance (test/ scripts are gitignored helpers): diff --git a/files/translations/en-us.json b/files/translations/en-us.json index 3ade575..416a397 100644 --- a/files/translations/en-us.json +++ b/files/translations/en-us.json @@ -34,8 +34,14 @@ "library.removeConfirm": "Remove {0} from the launcher?{1}", "library.removeFilesNote": "\nIts imported files in files/apps will also be deleted.", "library.removePrefixNote": "\nFiles stay in the prefix, only the launcher entry is removed.", + "library.removeLinuxDataNote": "\nIts files in files/apps are deleted; its data in the prefix is kept.", "library.tools": "Wine tools", "library.reorderHint": "Drag to change the order", + "import.kind.title": "What are you importing?", + "import.kind.windows": "Windows app (Wine / Proton)", + "import.kind.windowsHint": "Runs through wine or proton, as before. Needs an .exe.", + "import.kind.linux": "Linux app (native)", + "import.kind.linuxHint": "Runs directly on this system, with its own isolated home folder. Needs an ELF binary, an .AppImage or a .desktop file.", "import.question": "Should the app files be brought into the launcher so it stays portable, or left where they are?", "import.mode.copy": "Copy into launcher (portable)", "import.mode.move": "Move into launcher (portable)", @@ -58,9 +64,11 @@ "import.done": "Import finished", "import.failed": "Import failed: {0}", "import.noExe": "no .exe file found in the selected folder", + "import.noLinuxExe": "no runnable Linux program found in the selected folder (ELF binary, .AppImage or .desktop)", "import.prefixRoot": "this folder is a wine prefix (or its C: drive) - import an app from inside its drive_c instead", "config.title": "App configuration", "config.icon.auto": "Extract from exe", + "config.icon.autoLinux": "Extract icon", "config.icon.browse": "Choose image", "config.icon.remove": "Remove icon", "config.icon.pick": "Choose an icon image", @@ -80,6 +88,19 @@ "config.prefixDefault": "Default prefix ({0})", "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.linuxDataRoot": "Data folder (prefix)", + "config.linuxDataRootHint": "A native app keeps its home, config and cache in linux_pfx inside this prefix. Nothing else is protected unless the sandbox is active.", + "config.openData": "Open data folder", + "config.linuxKindValue": "Linux (native)", + "config.isolation": "Isolation", + "config.isolation.auto": "Automatic (strongest available)", + "config.isolation.off": "Off (plain run)", + "config.isolation.require": "Required (refuse to run without bwrap)", + "config.isolationHint": "Automatic uses bubblewrap when it works on this system and a plain run when it does not. 'Required' refuses to start instead of quietly weakening.", + "config.isolation.bwrap": "bwrap {0} (strong)", + "config.isolation.env": "no sandbox: {0}", + "config.forceX11": "Force X11 (XWayland)", + "config.forceX11Hint": "Runs the app through XWayland instead of Wayland. Useful when a window cannot carry its own icon (Minecraft) or the app misbehaves on Wayland. It also covers the programs this app launches. The app's own environment variables still win.", "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. This is the closest to a plain wine run.", "config.dllOverrides": "DLL overrides", @@ -88,6 +109,23 @@ "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.", + "sandbox.title": "Linux app isolation", + "sandbox.hint": "Native apps always run with a redirected home folder. With bubblewrap they get their own namespaces too: only their home folder and their own app folder stay writable, the rest of the system is read-only.", + "sandbox.status": "Status", + "sandbox.check": "Check again", + "sandbox.rechecked": "Isolation checked again", + "sandbox.reason.missing": "not installed", + "sandbox.reason.not-permitted": "blocked by the kernel (user namespaces are not permitted here)", + "sandbox.reason.timeout": "not responding", + "sandbox.reason.failed": "not working", + "sandbox.warnTitle": "No sandbox for Linux apps", + "sandbox.warnBody": "bubblewrap is {0}. Linux apps will run with only a redirected home folder - they can still read the rest of your system, and write to anything your user can.", + "sandbox.dismiss": "Got it", + "sandbox.badgeStrong": "bwrap", + "sandbox.badgeWeak": "no sandbox", + "sandbox.badgeOff": "isolation off", + "sandbox.badgeRequired": "bwrap required", + "sandbox.required": "This app needs a working bubblewrap sandbox: {0}", "config.save": "Save changes", "config.shortcut": "Create shortcut", "config.shortcutCreated": "Shortcut for {0} created (menu + desktop)", @@ -136,6 +174,13 @@ "prefixes.inUse": "Cannot change prefix {0} while an app from it is running: {1}. Close it first.", "prefixes.inUseHint": "An app is running from this prefix right now - close it before renaming.", "prefixes.hasApps": "Cannot delete prefix {0} - these apps still use it: {1}. Point them at another prefix or remove them first.", + "prefixes.resetConfig": "Reset Linux config", + "prefixes.resetConfigHint": "Deletes the desktop preferences (theme, GTK/Qt, PulseAudio and X11 cookies) PLWL copied from your system into this prefix. They are copied again on the next app start; app data stays.", + "prefixes.resetConfigConfirm": "Delete the desktop preferences copied into prefix {0}? The next app start copies them again from your system. App data is left untouched.", + "prefixes.resetConfigDone": "Linux config of prefix {0} reset - it is copied again from your system on the next app start.", + "prefixes.resetConfigNone": "Nothing to reset in prefix {0} - the desktop preferences are not copied in.", + "prefixes.resetConfigBusy": "Cannot reset the Linux config of prefix {0} while an app from it is running: {1}. Close it first.", + "prefixes.resetConfigFailed": "Could not reset the Linux config of prefix {0}.", "wines.title": "Wine versions", "wines.subtitle": "You can import your wine/proton builds here. Proton runs fully without Steam.", "wines.system": "System Wine", diff --git a/files/translations/pl.json b/files/translations/pl.json index 71122ba..6a731cb 100644 --- a/files/translations/pl.json +++ b/files/translations/pl.json @@ -34,8 +34,14 @@ "library.removeConfirm": "Usunąć {0} z launchera?{1}", "library.removeFilesNote": "\nJej zaimportowane pliki w files/apps również zostaną usunięte.", "library.removePrefixNote": "\nPliki zostają w prefiksie, usuwany jest tylko wpis w launcherze.", + "library.removeLinuxDataNote": "\nJej pliki w files/apps zostaną usunięte; dane w prefiksie zostają.", "library.tools": "Narzędzia Wine", "library.reorderHint": "Przeciągnij, aby zmienić kolejność", + "import.kind.title": "Co importujesz?", + "import.kind.windows": "Aplikacja Windows (Wine / Proton)", + "import.kind.windowsHint": "Uruchamiana przez wine albo proton, jak dotychczas. Wymaga pliku .exe.", + "import.kind.linux": "Aplikacja Linux (natywna)", + "import.kind.linuxHint": "Uruchamiana bezpośrednio w systemie, z własnym odizolowanym folderem domowym. Wymaga binarki ELF, pliku .AppImage lub .desktop.", "import.question": "Czy pliki aplikacji mają trafić do launchera, aby był przenośny, czy mają zostać tam, gdzie są?", "import.mode.copy": "Skopiuj do launchera (przenośne)", "import.mode.move": "Przenieś do launchera (przenośne)", @@ -58,9 +64,11 @@ "import.done": "Import zakończony", "import.failed": "Import nie powiódł się: {0}", "import.noExe": "nie znaleziono pliku .exe w wybranym folderze", + "import.noLinuxExe": "nie znaleziono uruchamialnego programu linuksowego w wybranym folderze (binarka ELF, .AppImage lub .desktop)", "import.prefixRoot": "ten folder to prefiks wine (lub jego dysk C:) - zaimportuj aplikację z wnętrza jego drive_c", "config.title": "Konfiguracja aplikacji", "config.icon.auto": "Wyciągnij z exe", + "config.icon.autoLinux": "Wyciągnij ikonę", "config.icon.browse": "Wybierz obrazek", "config.icon.remove": "Usuń ikonę", "config.icon.pick": "Wybierz obrazek ikony", @@ -80,6 +88,19 @@ "config.prefixDefault": "Prefiks domyślny ({0})", "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.linuxDataRoot": "Folder danych (prefiks)", + "config.linuxDataRootHint": "Natywna aplikacja trzyma swój home, config i cache w linux_pfx w tym prefiksie. Bez aktywnego sandboxa nic więcej nie jest chronione.", + "config.openData": "Otwórz folder danych", + "config.linuxKindValue": "Linux (natywna)", + "config.isolation": "Izolacja", + "config.isolation.auto": "Automatyczna (najmocniejsza dostępna)", + "config.isolation.off": "Wyłączona (zwykłe uruchomienie)", + "config.isolation.require": "Wymagana (odmów bez bwrap)", + "config.isolationHint": "Automatyczna używa bubblewrap, jeśli działa w tym systemie, a jeśli nie - uruchamia zwyczajnie. „Wymagana” odmawia startu, zamiast po cichu osłabić izolację.", + "config.isolation.bwrap": "bwrap {0} (mocna)", + "config.isolation.env": "brak sandboxa: {0}", + "config.forceX11": "Wymuś X11 (XWayland)", + "config.forceX11Hint": "Uruchamia aplikację przez XWayland zamiast Waylanda. Przydatne, gdy okno nie potrafi mieć własnej ikony (Minecraft) albo aplikacja źle działa na Waylandzie. Obejmuje też programy, które ta aplikacja uruchamia. Zmienne środowiskowe samej aplikacji mają pierwszeństwo.", "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. To najbliżej zwykłego uruchomienia wine.", "config.dllOverrides": "Nadpisania DLL", @@ -88,6 +109,23 @@ "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ł.", + "sandbox.title": "Izolacja aplikacji Linux", + "sandbox.hint": "Natywne aplikacje zawsze działają z przekierowanym folderem domowym. Z bubblewrap dostają dodatkowo własne przestrzenie nazw: zapisywać można tylko w swoim folderze domowym i w swoim folderze aplikacji, reszta systemu jest tylko do odczytu.", + "sandbox.status": "Status", + "sandbox.check": "Sprawdź ponownie", + "sandbox.rechecked": "Sprawdzono izolację ponownie", + "sandbox.reason.missing": "nie jest zainstalowany", + "sandbox.reason.not-permitted": "blokowany przez jądro (brak zgody na user namespaces)", + "sandbox.reason.timeout": "nie odpowiada", + "sandbox.reason.failed": "nie działa", + "sandbox.warnTitle": "Brak sandboxa dla aplikacji Linux", + "sandbox.warnBody": "bubblewrap {0}. Aplikacje linuksowe będą działać tylko z przekierowanym folderem domowym - nadal mogą czytać resztę systemu i pisać wszędzie, gdzie pozwala Twój użytkownik.", + "sandbox.dismiss": "Rozumiem", + "sandbox.badgeStrong": "bwrap", + "sandbox.badgeWeak": "bez sandboxa", + "sandbox.badgeOff": "izolacja wyłączona", + "sandbox.badgeRequired": "wymaga bwrap", + "sandbox.required": "Ta aplikacja wymaga działającego sandboxa bubblewrap: {0}", "config.save": "Zapisz zmiany", "config.shortcut": "Utwórz skrót", "config.shortcutCreated": "Skrót dla {0} utworzony (menu + pulpit)", @@ -136,6 +174,13 @@ "prefixes.inUse": "Nie można zmienić prefiksu {0}, gdy działa z niego aplikacja: {1}. Najpierw ją zamknij.", "prefixes.inUseHint": "Właśnie działa aplikacja z tego prefiksu - zamknij ją przed zmianą nazwy.", "prefixes.hasApps": "Nie można usunąć prefiksu {0} - używają go jeszcze aplikacje: {1}. Wskaż im inny prefiks albo usuń je najpierw.", + "prefixes.resetConfig": "Resetuj config Linuksa", + "prefixes.resetConfigHint": "Usuwa preferencje pulpitu (motyw, GTK/Qt, ciasteczka PulseAudio i X11) skopiowane z systemu do tego prefiksu. Przy następnym starcie apki zostaną skopiowane od nowa; dane aplikacji zostają.", + "prefixes.resetConfigConfirm": "Usunąć preferencje pulpitu skopiowane do prefiksu {0}? Przy następnym starcie apki zostaną skopiowane od nowa z systemu. Dane aplikacji pozostaną nietknięte.", + "prefixes.resetConfigDone": "Zresetowano config Linuksa prefiksu {0} - przy następnym starcie apki zostanie skopiowany od nowa z systemu.", + "prefixes.resetConfigNone": "W prefiksie {0} nie ma nic do zresetowania - preferencje pulpitu nie są tam skopiowane.", + "prefixes.resetConfigBusy": "Nie można zresetować configu Linuksa prefiksu {0}, gdy działa z niego aplikacja: {1}. Najpierw ją zamknij.", + "prefixes.resetConfigFailed": "Nie udało się zresetować configu Linuksa prefiksu {0}.", "wines.title": "Wersje Wine", "wines.subtitle": "Tutaj możesz zaimportować swoje buildy wine/proton. Proton działa w pełni bez Steama.", "wines.system": "Wine systemowe", diff --git a/renderer/app.js b/renderer/app.js index 4bf1313..108108c 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -26,6 +26,7 @@ const state = { prefixes: { default: '', prefixes: [] }, games: [], currentGame: null, + sandbox: null, lansync: null, // true while this device pushes a sync: it owns the single popup lanPushing: false, @@ -383,18 +384,20 @@ function showView(name) { } async function loadAll() { - const [langs, settings, wines, prefixes, games] = await Promise.all([ + const [langs, settings, wines, prefixes, games, sandbox] = await Promise.all([ window.plwl.i18nList(), window.plwl.settingsGet(), window.plwl.winesList(), window.plwl.prefixesList(), - window.plwl.gamesList() + window.plwl.gamesList(), + window.plwl.sandboxStatus() ]) state.langs = langs state.settings = settings state.wines = wines state.prefixes = prefixes state.games = games + state.sandbox = sandbox state.lang = settings.language || 'en-us' await loadLangTexts(state.lang) } @@ -416,6 +419,29 @@ function iconUrl(id) { return `plwl-icon://${encodeURIComponent(id)}/icon` } +// --- native Linux apps ------------------------------------------------------ + +const isLinuxGame = game => !!game && game.kind === 'linux' + +function sandboxStrong() { + return !!(state.sandbox && state.sandbox.backend === 'bwrap') +} + +// "bwrap 0.12.0 (strong)" / "no sandbox: not installed" +function isolationStatusText(status) { + if (!status) return '' + if (status.backend === 'bwrap') return t('config.isolation.bwrap', status.version || '') + return t('config.isolation.env', t('sandbox.reason.' + (status.reason || 'failed'))) +} + +function isolationBadge(game) { + const policy = (game && game.isolation) || 'auto' + if (policy === 'off') return `${t('sandbox.badgeOff')}` + if (sandboxStrong()) return `${t('sandbox.badgeStrong')}` + if (policy === 'require') return `${t('sandbox.badgeRequired')}` + return `${t('sandbox.badgeWeak')}` +} + // Portable apps show files/-relative paths; external apps keep absolute ones. function displayPath(p) { if (!p) return '' @@ -565,6 +591,7 @@ function renderLibrary() { card.style.animationDelay = `${idx * 0.04}s` const portable = game.mode === 'copy' || game.mode === 'move' const inPrefix = game.importSource === 'prefix' + const isLinux = isLinuxGame(game) const sourceBadge = inPrefix ? `${t('library.inPrefix')}${game.prefix ? ' · ' + game.prefix : ''}` : `${portable ? t('library.portable') : t('library.external')}` @@ -573,7 +600,8 @@ function renderLibrary() {

${sourceBadge} - ${wineLabel(game.wine)} + ${isLinux ? `${t('config.linuxKindValue')}` : `${wineLabel(game.wine)}`} + ${isLinux ? isolationBadge(game) : ''}
${t('library.lastPlayed', last)}
${t('library.playtime', fmtPlaytime(game.playSeconds || 0))}
@@ -605,7 +633,7 @@ function renderLibrary() { card.querySelector('[data-act=remove]').onclick = async () => { const note = game.mode === 'copy' || game.mode === 'move' ? t('library.removeFilesNote') - : (inPrefix ? t('library.removePrefixNote') : '') + : (inPrefix ? t('library.removePrefixNote') : (isLinux ? t('library.removeLinuxDataNote') : '')) const ok = await confirmModal(t('library.removeConfirm', game.name, note), t('library.remove')) if (!ok) return // portable apps delete their folder too - show how far that got @@ -633,6 +661,9 @@ async function playGame(game) { const entry = runningCards.get(game.id) if (entry) entry.applyRunning(true) toast(t('library.running'), 'error') + } else if (res.error === 'sandbox-required') { + const reason = t('sandbox.reason.' + ((state.sandbox && state.sandbox.reason) || 'failed')) + toast(t('sandbox.required', reason), 'error') } else { toast(t('toast.startFailed', game.name, res.error || ''), 'error') } @@ -666,7 +697,34 @@ function wireRealtime() { }, 15000) } +// Step 0: Windows or native Linux? Everything after this is the same flow. async function startImportFlow() { + const modal = showModal(` +

${t('library.import')}

+

${t('import.kind.title')}

+
+ +

${t('import.kind.windowsHint')}

+ +

${t('import.kind.linuxHint')}

+
+ + `) + modal.querySelector('#modal-cancel').onclick = closeModal + modal.querySelectorAll('[data-kind]').forEach(btn => { + btn.onclick = () => { + const kind = btn.dataset.kind + closeModal() + startModeFlow(kind) + } + }) +} + +// Step 1: same question as before; only Windows apps can come from a prefix. +async function startModeFlow(kind) { + const fromPrefix = kind !== 'linux' const modal = showModal(`

${t('library.import')}

${t('import.question')}

@@ -674,7 +732,7 @@ async function startImportFlow() { - + ${fromPrefix ? `` : ''} +
+

+

+ +

+
+ +
+
@@ -180,26 +189,48 @@ - - +
+ + +
- + + - + + + - -

+ - -
- -

+
+ +

+ + +
+ +

+
@@ -208,7 +239,7 @@
-
+

diff --git a/src/fsutil.js b/src/fsutil.js index 12db874..7ebd1b3 100644 --- a/src/fsutil.js +++ b/src/fsutil.js @@ -161,6 +161,44 @@ async function copyTree(src, dst, onProgress) { return { done, bytes } } +// Imported trees come from media that lose permissions (archives, FAT, a +// read-only backup drive), and copyFile keeps whatever the source had: every +// node gets owner rwx and `x` mirrored from `r`, so the app can run its own +// binaries and write its own files. +async function chmodTree(root) { + const desired = mode => mode | 0o700 | ((mode & 0o444) >> 2) + let files = 0 + let dirs = 0 + const walk = async dir => { + let entries + try { + entries = await fsp.readdir(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const full = path.join(dir, entry.name) + if (entry.isSymbolicLink()) continue + if (entry.isDirectory()) { + await walk(full) + } else if (!entry.isFile()) { + continue + } + try { + const before = (await fsp.stat(full)).mode & 0o7777 + const after = desired(before) + if (after !== before) { + await fsp.chmod(full, after) + if (entry.isDirectory()) dirs++ + else files++ + } + } catch {} + } + } + await walk(root) + return { files, dirs } +} + // Delete deepest-first with progress through onProgress; symlinks are // unlinked, never followed. async function removeTree(root, onProgress) { @@ -228,5 +266,5 @@ async function removeTree(root, onProgress) { return { files, dirs, bytes } } -module.exports = { pathExists, dedupeDir, copyTree, treeSize, removeTree, progressGate, mapLimit } +module.exports = { pathExists, dedupeDir, copyTree, treeSize, removeTree, chmodTree, progressGate, mapLimit } diff --git a/src/icons.js b/src/icons.js index b89b761..912b686 100644 --- a/src/icons.js +++ b/src/icons.js @@ -2,6 +2,7 @@ const fs = require('fs') const path = require('path') +const { desktopFileFor, readDesktopEntry, findIconFile, pngOrSvg } = require('./linuxapp') function rvaToOffset(sections, rva) { for (const s of sections) { @@ -208,4 +209,35 @@ function extractIconFromExe(exePath, outPath) { } } -module.exports = { extractIconFromExe, parseIcons, buildIco } +// Native Linux apps carry no icon inside the binary: it comes from the +// .desktop entry (Icon=), an image next to the program, or the theme. +// `outBase` is a path without extension; the sniffed one is appended. +function iconFromLinuxApp(exePath, outBase) { + try { + const desktop = desktopFileFor(exePath) + const entry = desktop ? readDesktopEntry(desktop) : null + const baseDir = desktop ? path.dirname(desktop) : path.dirname(exePath) + let source = findIconFile(entry && entry.Icon, baseDir) + if (!source) { + // no .desktop: a same-named image (or the AppImage .DirIcon) next to it + const dir = path.dirname(exePath) + const stem = path.basename(exePath).replace(/\.(appimage|sh|bin)$/i, '') + for (const name of [stem + '.png', stem + '.svg', '.DirIcon', 'icon.png', 'icon.svg']) { + const candidate = path.join(dir, name) + if (pngOrSvg(candidate)) { + source = candidate + break + } + } + } + if (!source) return { ok: false, error: 'no-icon' } + const outPath = `${outBase}.${pngOrSvg(source)}` + fs.mkdirSync(path.dirname(outPath), { recursive: true }) + fs.copyFileSync(source, outPath) + return { ok: true, path: outPath } + } catch (err) { + return { ok: false, error: String((err && err.message) || err) } + } +} + +module.exports = { extractIconFromExe, iconFromLinuxApp, parseIcons, buildIco } diff --git a/src/importer.js b/src/importer.js index 22a4db7..b85cdc6 100644 --- a/src/importer.js +++ b/src/importer.js @@ -5,10 +5,11 @@ const fsp = fs.promises const path = require('path') const { DIRS, resolvePortablePath } = require('./paths') const { newGame, newGameId, addGame } = require('./library') -const { extractIconFromExe } = require('./icons') +const { extractIconFromExe, iconFromLinuxApp } = require('./icons') +const { findLinuxExe } = require('./linuxapp') const { listPrefixes, prefixDriveRoots, isPrefixRoot } = require('./prefixes') const { rewireSymlinks } = require('./symlinks') -const { copyTree, treeSize } = require('./fsutil') +const { copyTree, treeSize, chmodTree } = require('./fsutil') // Directories that typically hold installers/redistributables, not the game. const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc']) @@ -21,13 +22,16 @@ function moveFolder(src, dst, onProgress) { return fsp.rename(src, dst).catch(fallback) } -async function importGame(srcDir, mode, onProgress) { +async function importGame(srcDir, mode, onProgress, kind = 'windows') { + const isLinux = kind === 'linux' // 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 anything: no .exe -> refuse - const preExe = await findDefaultExe(srcDir) - if (!preExe) return { ok: false, error: 'no-exe-found' } + const finder = isLinux ? findLinuxExe : findDefaultExe + const missing = isLinux ? 'no-linux-exe' : 'no-exe-found' + // validate before touching anything: no entry point -> refuse + const preExe = await finder(srcDir) + if (!preExe) return { ok: false, error: missing } // the id doubles as the imported folder name const id = newGameId() let workdir = srcDir @@ -43,21 +47,27 @@ 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 + // the source's permissions are not the source's truth: an app out of a + // read-only or mode-less medium has to become runnable and writable + await chmodTree(dst) // in-data link targets become relative, so the import survives a move try { await rewireSymlinks(dst, [DIRS.FILES]) } catch {} } - const exePath = (await findDefaultExe(workdir)) || preExe + const exePath = (await finder(workdir)) || preExe if (!exePath) { - return { ok: false, error: 'no-exe-found' } + return { ok: false, error: missing } } - const game = newGame({ id, name, exePath, workdir, mode }) + const game = newGame({ id, name, exePath, workdir, mode, kind }) game.imported = mode === 'copy' || mode === 'move' // 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 + const iconDir = path.join(DIRS.DATA, 'icons', id) + const icon = isLinux + ? iconFromLinuxApp(resolvePortablePath(exePath), path.join(iconDir, 'icon')) + : extractIconFromExe(resolvePortablePath(exePath), path.join(iconDir, 'icon.ico')) + if (icon.ok) { + game.iconSource = icon.path } return addGame(game) } @@ -140,4 +150,4 @@ async function findDefaultExe(dir) { return best } -module.exports = { importGame, importGameFromPrefix, findDefaultExe, moveFolder } +module.exports = { importGame, importGameFromPrefix, findDefaultExe, findLinuxExe, moveFolder } diff --git a/src/ipc/games.js b/src/ipc/games.js index 1aba314..a7cfe37 100644 --- a/src/ipc/games.js +++ b/src/ipc/games.js @@ -1,6 +1,7 @@ 'use strict' const { runGame, runTool } = require('../runner') +const { runLinuxGame } = require('../linuxrunner') const { importGame, importGameFromPrefix } = require('../importer') const library = require('../library') @@ -22,7 +23,7 @@ function sendToWindow(win, channel, data) { // onGamesChanged fires in the main process when the running set changes. const STARTUP_GRACE_MS = 60000 -function createGamesService({ getWindow, onGamesChanged }) { +function createGamesService({ getWindow, onGamesChanged, sandbox }) { const running = new Map() const startedAt = new Map() const killRequests = new Set() @@ -91,30 +92,40 @@ function createGamesService({ getWindow, onGamesChanged }) { if (onGameExit) onGameExit(id) } + // Native Linux apps skip wine entirely; everything else stays the same. + function runnerFor(game) { + if (!library.isLinuxGame(game)) return null + return payload => runLinuxGame({ game, sandbox, ...payload }) + } + async function start(win, id, openFile) { const game = library.getGame(id) if (!game) return { ok: false, error: 'game-not-found' } prune() - let wine - try { - wine = resolveWine(game) - } catch { - return { ok: false, error: 'wine-not-found' } + const isLinux = library.isLinuxGame(game) + let wine = null + if (!isLinux) { + try { + wine = resolveWine(game) + } catch { + return { ok: false, error: 'wine-not-found' } + } } + const run = isLinux ? runnerFor(game) : payload => runGame({ game, wine, systemWine: wine.type === 'system', ...payload }) if (running.has(id)) { // 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 }) + await run({ openFile, handoff: true }) return { ok: true, pid: null, logFile: null } } catch (err) { return { ok: false, error: String((err && err.message) || err) } } } - running.set(id, { pid: null, prefix: library.prefixNameForGame(game), wine: wine.id }) + running.set(id, { pid: null, prefix: library.prefixNameForGame(game), wine: wine ? wine.id : '' }) startedAt.set(id, Date.now()) try { - const res = await runGame({ game, wine, systemWine: wine.type === 'system', onExit: () => handleExit(id), openFile }) + const res = await run({ onExit: () => handleExit(id), openFile }) // stop requested while still starting: kill and clear if (killRequests.has(id)) { killRequests.delete(id) @@ -158,6 +169,8 @@ function createGamesService({ getWindow, onGamesChanged }) { async function runToolFor(id, tool, args) { const game = library.getGame(id) if (!game) return { ok: false, error: 'game-not-found' } + // Wine tools make no sense for a native Linux app + if (library.isLinuxGame(game)) return { ok: false, error: 'not-windows' } let wine try { wine = resolveWine(game) @@ -177,8 +190,8 @@ function createGamesService({ getWindow, onGamesChanged }) { onGameExit = typeof fn === 'function' ? fn : null } - async function importTo(win, srcDir, mode) { - return importGame(srcDir, mode, data => sendToWindow(win, 'import:progress', data)) + async function importTo(win, srcDir, mode, kind) { + return importGame(srcDir, mode, data => sendToWindow(win, 'import:progress', data), kind) } // prefix import: register in place, nothing copied diff --git a/src/ipc/index.js b/src/ipc/index.js index c0ab20e..817e01d 100644 --- a/src/ipc/index.js +++ b/src/ipc/index.js @@ -9,7 +9,9 @@ const library = require('../library') const wines = require('../wines') const prefixes = require('../prefixes') const runner = require('../runner') -const { extractIconFromExe } = require('../icons') +const linuxrunner = require('../linuxrunner') +const { createSandboxProbe } = require('../sandbox') +const { extractIconFromExe, iconFromLinuxApp } = require('../icons') const { createGamesService, sendToWindow } = require('./games') const shortcuts = require('../shortcuts') const openwith = require('../openwith') @@ -34,13 +36,15 @@ function clearIconDir(id, keep) { } catch {} } -function extractGameIcon(id, exePath) { - const out = path.join(iconDir(id), 'icon.ico') - const res = extractIconFromExe(exePath, out) +function extractGameIcon(id, exePath, kind) { + const isLinux = kind === 'linux' + // the Linux helper picks the extension itself (png/svg), the PE one is ico + const out = path.join(iconDir(id), isLinux ? 'icon' : 'icon.ico') + const res = isLinux ? iconFromLinuxApp(exePath, out) : extractIconFromExe(exePath, out) // on failure nothing is cleared, so the previous icon survives a wrong exe if (res.ok) { - clearIconDir(id, 'icon.ico') - library.updateGame(id, { iconSource: out }) + clearIconDir(id, path.basename(res.path)) + library.updateGame(id, { iconSource: res.path }) } return res } @@ -48,7 +52,16 @@ function extractGameIcon(id, exePath) { // --- registration ----------------------------------------------------------- function registerIpc({ getWindow, onGamesChanged }) { - const games = createGamesService({ getWindow, onGamesChanged }) + // one probe per launcher, cached: it decides bwrap vs plain run + const sandbox = createSandboxProbe() + const games = createGamesService({ getWindow, onGamesChanged, sandbox }) + + // sandbox -------------------------------------------------------------------- + ipcMain.handle('sandbox:status', async () => sandbox.status() || (await sandbox.probe())) + ipcMain.handle('sandbox:probe', async () => { + sandbox.invalidate() + return sandbox.probe() + }) // i18n --------------------------------------------------------------------- ipcMain.handle('i18n:list', async () => { @@ -107,6 +120,12 @@ function registerIpc({ getWindow, onGamesChanged }) { return prefixes.deletePrefix(name, data => sendToWindow(getWindow(), 'import:progress', data)) }) ipcMain.handle('prefixes:rename', (e, oldName, newName) => prefixInUse(oldName) || prefixes.renamePrefix(oldName, newName)) + // The seeded desktop config is ours to drop (app data is not a seed), but a + // running app would lose the files under its feet. + ipcMain.handle('prefixes:resetLinuxConfig', (e, name) => { + if (!prefixes.listPrefixes().prefixes.some(p => p.name === name)) return { ok: false, error: 'notFound' } + return prefixInUse(name) || linuxrunner.resetSeededConfig(name) + }) ipcMain.handle('prefixes:setDefault', (e, name) => { // switching the default would silently move apps that follow it const defs = prefixes.listPrefixes() @@ -175,7 +194,7 @@ function registerIpc({ getWindow, onGamesChanged }) { ipcMain.handle('games:extractIcon', (e, id) => { const game = library.getGame(id) if (!game || !game.exePath) return { ok: false, error: 'bad-request' } - return extractGameIcon(id, game.exePath) + return extractGameIcon(id, game.exePath, game.kind) }) // use the exe the field holds (maybe picked but not saved yet) ipcMain.handle('games:autoIcon', (e, id, exePath) => { @@ -183,7 +202,19 @@ function registerIpc({ getWindow, onGamesChanged }) { if (!game) return { ok: false, error: 'bad-request' } const exe = exePath || game.exePath if (!exe) return { ok: false, error: 'bad-request' } - return extractGameIcon(id, exe) + return extractGameIcon(id, exe, game.kind) + }) + // Linux apps keep their data next to the prefix's Windows side + ipcMain.handle('games:openDataFolder', async (e, id) => { + const game = library.getGame(id) + if (!library.isLinuxGame(game)) return { ok: false, error: 'not-linux' } + try { + const root = linuxrunner.ensureLinuxPfx(library.prefixNameForGame(game)) + const err = await shell.openPath(root) + return err ? { ok: false, error: err } : { ok: true, path: root } + } catch (err) { + return errorResult(err) + } }) // "Open with PLWL" ------------------------------------------------------------------ @@ -226,9 +257,9 @@ function registerIpc({ getWindow, onGamesChanged }) { }) return res.canceled || !res.filePaths[0] ? null : res.filePaths[0] }) - ipcMain.handle('games:import', async (e, srcDir, mode) => { + ipcMain.handle('games:import', async (e, srcDir, mode, kind) => { try { - return await games.import(getWindow(), srcDir, mode) + return await games.import(getWindow(), srcDir, mode, kind) } catch (err) { return errorResult(err) } diff --git a/src/lansync-manifest.js b/src/lansync-manifest.js index 77d17be..6536f8c 100644 --- a/src/lansync-manifest.js +++ b/src/lansync-manifest.js @@ -6,6 +6,7 @@ const path = require('path') const fs = require('fs') const fsp = fs.promises +const { isLocalOnlyRel } = require('./prefixes') function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurrency }) { // Targets inside the launcher data travel relative (the mirrored layout is @@ -38,6 +39,8 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren const leaves = [] for (const entry of entries) { const relChild = `${dirRel}/${entry.name}` + // native Linux app data never leaves this machine + if (isLocalOnlyRel(relChild)) continue if (entry.isDirectory()) { const record = { path: relChild, type: 'dir' } out.push(record) diff --git a/src/lansync.js b/src/lansync.js index 98f1b7b..3617ac8 100644 --- a/src/lansync.js +++ b/src/lansync.js @@ -21,7 +21,7 @@ const { createDiscovery } = require('./lansync-discovery') const library = require('./library') const wines = require('./wines') const prefixes = require('./prefixes') -const { isPrefixRel } = prefixes +const { isPrefixRel, isLocalOnlyRel } = prefixes const CONFIG = path.join(DIRS.DATA, 'lansync.json') @@ -432,6 +432,8 @@ async function pruneTree(abs, rel, keep) { for (const entry of entries) { const childAbs = path.join(abs, entry.name) const childRel = `${rel}/${entry.name}` + // a mirror must not delete this machine's native Linux app data + if (isLocalOnlyRel(childRel)) continue if (!keep.has(childRel)) { if (removeAbs(childAbs)) removed++ continue diff --git a/src/library.js b/src/library.js index 3e53933..efa894f 100644 --- a/src/library.js +++ b/src/library.js @@ -108,6 +108,27 @@ function gamesFollowingDefault(running) { }) } +// Windows is the default: entries written before Linux apps existed have no +// kind at all. +function isLinuxGame(game) { + return !!game && game.kind === 'linux' +} + +const ISOLATION_MODES = ['auto', 'off', 'require'] + +// Only known values reach the registry; anything else falls back to the safe +// default (a typo must not silently disable the sandbox). +function sanitizeLinuxPatch(patch) { + if (!patch) return patch + const out = { ...patch } + if (out.kind !== undefined) out.kind = out.kind === 'linux' ? 'linux' : 'windows' + if (out.isolation !== undefined) { + out.isolation = ISOLATION_MODES.includes(out.isolation) ? out.isolation : 'auto' + } + if (out.forceX11 !== undefined) out.forceX11 = !!out.forceX11 + return out +} + function addGame(game) { const games = loadGames() games.push(game) @@ -127,7 +148,7 @@ function updateGame(id, patch) { } // playSeconds lives in its own file; the registry mirrors it if (patch.playSeconds !== undefined) writePlaySeconds(id, patch.playSeconds) - Object.assign(g, patch) + Object.assign(g, sanitizeLinuxPatch(patch)) saveGames(games) return g } @@ -175,11 +196,12 @@ function newGameId() { return id } -function newGame({ id, name, exePath, workdir, mode }) { +function newGame({ id, name, exePath, workdir, mode, kind }) { return { id: id || newGameId(), name, mode, + kind: kind === 'linux' ? 'linux' : 'windows', path: workdir, exePath, workdir, @@ -189,6 +211,10 @@ function newGame({ id, name, exePath, workdir, mode }) { dllOverrides: [], // Proton-only: UMU by default, or the build's steam.exe launcher disableUmu: false, + // Linux-only: how hard to try the bwrap sandbox before falling back + isolation: 'auto', + // Linux-only: force XWayland, where a window can still carry its icon + forceX11: false, env: {}, iconSource: '', playSeconds: 0, @@ -232,4 +258,4 @@ function resolvePrefixDir(game) { return prefixPath(prefixNameForGame(game)) } -module.exports = { loadGames, saveGames, portableGame, getGame, addGame, updateGame, removeGame, reorderGames, renamePrefixRefs, prefixNameForGame, gamesUsingPrefix, appsUsingPrefix, isPrefixInUse, isWineInUse, gamesFollowingDefault, loadSettings, saveSettings, newGame, newGameId, resolvePrefixDir, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile } +module.exports = { loadGames, saveGames, portableGame, getGame, isLinuxGame, addGame, updateGame, removeGame, reorderGames, renamePrefixRefs, prefixNameForGame, gamesUsingPrefix, appsUsingPrefix, isPrefixInUse, isWineInUse, gamesFollowingDefault, loadSettings, saveSettings, newGame, newGameId, resolvePrefixDir, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile } diff --git a/src/linuxapp.js b/src/linuxapp.js new file mode 100644 index 0000000..64f724c --- /dev/null +++ b/src/linuxapp.js @@ -0,0 +1,265 @@ +'use strict' + +// What makes a file a runnable native Linux app: an ELF binary or an AppImage +// (both with the executable bit), or a .desktop entry pointing at one. + +const fs = require('fs') +const fsp = fs.promises +const os = require('os') +const path = require('path') + +const ELF_MAGIC = Buffer.from([0x7f, 0x45, 0x4c, 0x46]) + +// Folders that ship libraries/data, never the entry point. Skipping them is +// what keeps `share/` and `lib/` .desktop files out of the candidates. +const SKIPPED_DIRS = new Set([ + 'lib', 'lib32', 'lib64', 'libexec', 'share', 'include', 'man', 'doc', 'docs', + 'locale', 'node_modules', 'redist', 'python', 'site-packages', '__pycache__', + 'translations', 'resources', 'licenses', 'locale' +]) + +const LIBRARY_RE = /\.(so(\.\d+(\.\d+)*)?|a|o|py|pyc|jar|dll|dylib)$/i +const SCRIPT_RE = /^(run|start|launch|play|app)\.sh$/i + +function isElfBuffer(buf) { + return !!buf && buf.length >= 4 && buf.subarray(0, 4).equals(ELF_MAGIC) +} + +function isElfSync(file) { + try { + const fd = fs.openSync(file, 'r') + try { + const buf = Buffer.alloc(4) + fs.readSync(fd, buf, 0, 4, 0) + return isElfBuffer(buf) + } finally { + fs.closeSync(fd) + } + } catch { + return false + } +} + +// [Desktop Entry] fields we care about; the first group wins, later groups +// (Desktop Action ...) are ignored. +function parseDesktopEntry(text) { + const entry = {} + let inEntry = false + for (const raw of String(text || '').split('\n')) { + const line = raw.trim() + if (line.startsWith('[')) { + inEntry = line === '[Desktop Entry]' + continue + } + if (!inEntry || !line || line.startsWith('#')) continue + const eq = line.indexOf('=') + if (eq <= 0) continue + const key = line.slice(0, eq).trim() + const value = line.slice(eq + 1).trim() + if (key === 'Exec' || key === 'Icon' || key === 'Path' || key === 'Name' || key === 'Type') { + if (!(key in entry)) entry[key] = value + } + } + return entry +} + +function readDesktopEntry(file) { + try { + return parseDesktopEntry(fs.readFileSync(file, 'utf8')) + } catch { + return null + } +} + +// Exec is a command line with field codes: the first token is the program. +function execToken(exec) { + const m = String(exec || '').match(/"([^"]*)"|'([^']*)'|(\S+)/) + const token = m ? (m[1] ?? m[2] ?? m[3]) : '' + return token && !/^%[fFuUdDnNickvm]?$/.test(token) ? token : '' +} + +// A .desktop's program, resolved against the entry's own folder. Relative +// Exec= paths are common in app dirs; the spec says that is not allowed, so +// the fallback is to the folder itself. +function desktopExecPath(entry, desktopFile) { + const token = execToken(entry && entry.Exec) + if (!token) return '' + const dir = path.dirname(desktopFile) + const resolved = path.isAbsolute(token) ? token : path.resolve(dir, token) + try { + fs.accessSync(resolved) + return resolved + } catch { + const base = path.join(dir, path.basename(token)) + try { + fs.accessSync(base) + return base + } catch { + return '' + } + } +} + +function isExecutable(file) { + try { + return (fs.statSync(file).mode & 0o111) !== 0 + } catch { + return false + } +} + +async function sizeOf(file) { + try { + return (await fsp.stat(file)).size + } catch { + return 0 + } +} + +// Best entry point in an app folder: the same question findDefaultExe answers +// for Windows, with ELF/AppImage/.desktop instead of .exe. +async function findLinuxExe(dir) { + const candidates = [] + const desktops = [] + const walk = async (d, depth) => { + let entries + try { + entries = await fsp.readdir(d, { withFileTypes: true }) + } catch { + return + } + const subdirs = [] + for (const entry of entries) { + const full = path.join(d, entry.name) + if (entry.isDirectory()) { + if (!SKIPPED_DIRS.has(entry.name.toLowerCase())) subdirs.push([full, depth + 1]) + continue + } + const lower = entry.name.toLowerCase() + if (lower.endsWith('.desktop')) { + desktops.push([full, depth]) + continue + } + if (lower.endsWith('.appimage')) { + candidates.push({ file: full, depth, score: 100 }) + continue + } + if (LIBRARY_RE.test(entry.name)) continue + if (SCRIPT_RE.test(entry.name)) { + candidates.push({ file: full, depth, score: 60 }) + continue + } + if (isElfSync(full)) candidates.push({ file: full, depth, score: isExecutable(full) ? 55 : 40 }) + } + await Promise.all(subdirs.map(([full, next]) => walk(full, next))) + } + await walk(dir, 0) + + // A .desktop is the intended way in when it names a program in the tree. + for (const [full, depth] of desktops) { + const exec = desktopExecPath(readDesktopEntry(full), full) + if (exec) candidates.push({ file: exec, depth, score: 90 }) + } + if (!candidates.length) return '' + + let best = '' + let bestScore = -Infinity + for (const candidate of candidates) { + const size = await sizeOf(candidate.file) + // shallow wins, and between equals the bigger binary is the app + const score = candidate.score - candidate.depth * 5 + Math.min(size / 1000000, 20) + if (score > bestScore) { + bestScore = score + best = candidate.file + } + } + return best +} + +// The .desktop that belongs to this program: one in the same folder, whose +// Exec points at it (or the only one there). +function desktopFileFor(exePath) { + const dir = path.dirname(exePath) + let names = [] + try { + names = fs.readdirSync(dir).filter(n => n.toLowerCase().endsWith('.desktop')) + } catch {} + if (!names.length) { + try { + names = fs.readdirSync(path.join(dir, '..')).filter(n => n.toLowerCase().endsWith('.desktop')) + } catch {} + if (names.length) return path.join(dir, '..', names[0]) + return '' + } + for (const name of names) { + const file = path.join(dir, name) + if (desktopExecPath(readDesktopEntry(file), file) === exePath) return file + } + return path.join(dir, names[0]) +} + +function pngOrSvg(file) { + try { + const fd = fs.openSync(file, 'r') + try { + const buf = Buffer.alloc(64) + const read = fs.readSync(fd, buf, 0, 64, 0) + const head = buf.subarray(0, read) + if (head.length >= 8 && head.readUInt32BE(0) === 0x89504e47) return 'png' + const text = head.toString('utf8').trim().toLowerCase() + if (text.startsWith('= 3 && head[0] === 0xff && head[1] === 0xd8) return 'jpg' + } finally { + fs.closeSync(fd) + } + } catch {} + return '' +} + +const THEME_SIZES = ['512x512', '256x256', '128x128', 'scalable', '64x64', '48x48', '32x32'] + +// An `Icon=` value: an absolute path, a path relative to the .desktop's +// folder (AppImage app dirs put .DirIcon next to it), or a theme icon name. +function findIconFile(spec, baseDir) { + const candidates = [] + const name = String(spec || '').trim() || '.DirIcon' + if (path.isAbsolute(name)) { + candidates.push(name) + } else { + if (baseDir) candidates.push(path.join(baseDir, name)) + if (!path.extname(name)) { + const dataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share') + for (const dir of [path.join(dataHome, 'icons'), '/usr/share/icons']) { + for (const size of THEME_SIZES) { + for (const ext of ['png', 'svg']) candidates.push(path.join(dir, 'hicolor', size, 'apps', `${name}.${ext}`)) + } + } + for (const dir of [dataHome, '/usr/share', '/usr/local/share']) { + candidates.push(path.join(dir, 'pixmaps', name + '.png'), path.join(dir, 'pixmaps', name + '.svg')) + } + } else { + for (const dir of ['/usr/share/pixmaps', process.env.XDG_DATA_HOME ? path.join(process.env.XDG_DATA_HOME, 'pixmaps') : '']) { + if (dir) candidates.push(path.join(dir, name)) + } + } + } + for (const candidate of candidates) { + if (!candidate) continue + const kind = pngOrSvg(candidate) + if (kind) return candidate + } + return '' +} + +module.exports = { + ELF_MAGIC, + isElfBuffer, + isElfSync, + findLinuxExe, + parseDesktopEntry, + readDesktopEntry, + desktopExecPath, + desktopFileFor, + findIconFile, + pngOrSvg +} diff --git a/src/linuxrunner.js b/src/linuxrunner.js new file mode 100644 index 0000000..cc401fe --- /dev/null +++ b/src/linuxrunner.js @@ -0,0 +1,349 @@ +'use strict' + +// Native Linux apps: the twin of runner.js. Same tracked spawn tail, but the +// program runs directly (no wine) with a redirected home and, when bubblewrap +// works, inside a namespace. + +const crypto = require('crypto') +const fs = require('fs') +const os = require('os') +const path = require('path') +const { DIRS, readJson, writeJson } = require('./paths') +const { prefixPath, prefixLinuxRoot } = require('./prefixes') +const { prefixNameForGame } = require('./library') +const { spawnTracked, parseArgs } = require('./runner') +const { buildBwrapArgs, SHARE_ROOT } = require('./sandbox') + +const DATA_DIRS = ['home', 'config', 'data', 'cache', 'state'] + +// host folder <-> in-sandbox XDG variable, so both backends set the same env +const XDG_LAYOUT = { + XDG_CONFIG_HOME: ['config', '.config'], + XDG_DATA_HOME: ['data', path.join('.local', 'share')], + XDG_CACHE_HOME: ['cache', '.cache'], + XDG_STATE_HOME: ['state', path.join('.local', 'state')] +} + +// Write access is handed out to the folder the app was imported from and to +// nothing else outside its home: a portable app writes its logs and data next +// to itself (Prism Launcher refuses to start otherwise), while system trees +// must never come back writable. +const SYSTEM_DIRS = ['/bin', '/sbin', '/lib', '/lib32', '/lib64', '/libx32', '/usr', '/etc', '/boot', '/proc', '/sys', '/dev', '/run'] + +function writableAppDir(game) { + const dir = (game && (game.path || (game.exePath && path.dirname(game.exePath)))) || '' + // realpathSync('') would answer with the cwd - never hand that out + if (!dir) return '' + let real + try { + real = fs.realpathSync(dir) + if (!fs.statSync(real).isDirectory()) return '' + } catch { + return '' + } + // launcher-owned folders: only an imported app may be written to + if (real.startsWith(DIRS.FILES + path.sep)) { + return real.startsWith(DIRS.APPS + path.sep) ? real : '' + } + if (real === '/') return '' + for (const system of SYSTEM_DIRS) { + if (real === system || real.startsWith(system + '/')) return '' + } + return real +} + +function linuxUser() { + try { + return os.userInfo().username + } catch { + return process.env.USER || 'user' + } +} + +function copyFromHost(src, dst, { refresh = false } = {}) { + try { + if (!refresh && fs.existsSync(dst)) return false + if (!fs.statSync(src).isFile()) return false + fs.mkdirSync(path.dirname(dst), { recursive: true }) + fs.copyFileSync(src, dst) + return true + } catch { + return false + } +} + +// PulseAudio looks for its cookie under $HOME/.config/pulse; with HOME +// redirected there is none, and sound dies for everything going through +// Pulse. Copied once - the app owns it afterwards. +function seedPulseCookie(root) { + const dst = path.join(root, 'config', 'pulse', 'cookie') + const seeded = copyFromHost(path.join(os.homedir(), '.config', 'pulse', 'cookie'), dst) + if (seeded) { + try { + fs.chmodSync(dst, 0o600) + } catch {} + } +} + +// The host's desktop preferences, not its data: a redirected home has no +// theme at all, and Qt/GTK then fall back to their built-in light look (KDE's +// dark palette lives in kdeglobals). +const DESKTOP_SEEDS = [ + 'kdeglobals', + 'kdedefaults/kdeglobals', + 'gtk-3.0/settings.ini', + 'gtk-4.0/settings.ini', + 'qt5ct/qt5ct.conf', + 'qt6ct/qt6ct.conf', + 'Trolltech.conf', + 'user-dirs.dirs' +] + +// The host's desktop *data* - fonts, icons, themes, colour schemes - is not +// copied but handed over read-only (see buildBwrapArgs), so it follows the +// host without an import. +const DATA_SHARES = ['fonts', 'icons', 'themes', 'color-schemes'] + +function hostShareDir() { + return path.join(os.homedir(), '.local', 'share') +} + +function dataShares() { + return DATA_SHARES.map(name => path.join(hostShareDir(), name)).filter(dir => fs.existsSync(dir)) +} + +function hashOf(file) { + try { + return crypto.createHash('sha1').update(fs.readFileSync(file)).digest('hex') + } catch { + return '' + } +} + +function mtimeOf(file) { + try { + return fs.statSync(file).mtimeMs + } catch { + return 0 + } +} + +// Hashes of what we put in the prefix, so a host change can be followed while +// the app has not touched our copy. Kept in the prefix root: the app only +// ever sees home/config/data/cache/state. +const SEED_MARKER = '.seeded.json' + +// Copies a preference in, then keeps following it for as long as we can prove +// the copy is still ours: an unusable theme is worse than a stale one, and an +// app's own edit is never overwritten. +function syncDesktopConfig(root) { + const hostConfig = path.join(os.homedir(), '.config') + const markerFile = path.join(root, SEED_MARKER) + const marker = readJson(markerFile, {}) + let changed = false + for (const rel of DESKTOP_SEEDS) { + const src = path.join(hostConfig, rel) + const dst = path.join(root, 'config', rel) + const hostHash = hashOf(src) + if (!hostHash) continue + const known = marker[rel] + if (fs.existsSync(dst)) { + const current = hashOf(dst) + if (known) { + // the app owns it now, or there is nothing to do + if (current !== known || current === hostHash) continue + } else if (current !== hostHash && mtimeOf(dst) >= mtimeOf(src)) { + // a prefix from an older launcher: an identical copy is ours, and so is + // one the host's file has since outgrown. A copy that is not older than + // the host's is the app's own - ties stay with the app + continue + } + } + // refresh: this is provably still our own copy + if (copyFromHost(src, dst, { refresh: true })) { + marker[rel] = hostHash + changed = true + } + } + if (changed) { + try { + writeJson(markerFile, marker) + } catch {} + } +} + +// X11 access: XAUTHORITY usually lives in /run/user (bound), but a launcher +// started without it would leave X11 dead inside. Refreshed: a cookie is +// session state, not data. +function seedXauthority(root) { + copyFromHost(path.join(os.homedir(), '.Xauthority'), path.join(root, 'home', '.Xauthority'), { refresh: true }) +} + +// Everything this module copies in from the host, prefix-relative: the one +// list a reset has to clear. +function seededRelPaths() { + return [ + ...DESKTOP_SEEDS.map(rel => path.join('config', rel)), + path.join('config', 'pulse', 'cookie'), + path.join('home', '.Xauthority'), + SEED_MARKER + ] +} + +// syncDesktopConfig only follows the host while the copy is provably ours, so +// an app that edited its own theme keeps it forever. The reset drops our +// copies (and the hashes that track them) and lets the next start seed clean +// ones - the app's own files in the same folders are never touched. +function resetSeededConfig(name) { + const root = prefixLinuxRoot(name) + const removed = [] + for (const rel of seededRelPaths()) { + const file = path.join(root, rel) + try { + // lstat: a link pointing out of the prefix is none of our business + if (!fs.lstatSync(file).isFile()) continue + fs.rmSync(file, { force: true }) + removed.push(rel) + } catch {} + } + // folders that exist only for a seed, and only while nothing else lives there + const dirs = ['kdedefaults', 'gtk-3.0', 'gtk-4.0', 'qt5ct', 'qt6ct', 'pulse'] + for (const dir of dirs) { + try { + fs.rmdirSync(path.join(root, 'config', dir)) + } catch {} + } + return { ok: true, removed } +} + +function ensureLinuxPfx(name) { + const root = prefixLinuxRoot(name) + fs.mkdirSync(path.join(root, 'home', '.local', 'share'), { recursive: true }) + for (const dir of DATA_DIRS) fs.mkdirSync(path.join(root, dir), { recursive: true }) + seedPulseCookie(root) + seedXauthority(root) + return root +} + +// Wayland cannot carry a window icon and the compositor only guesses one from +// the app id; XWayland can, and GLFW/Qt/GTK all read these, so one flag moves +// the whole process tree (Prism and the Minecraft it launches) back to X11. +const X11_ENV = { XDG_SESSION_TYPE: 'x11', QT_QPA_PLATFORM: 'xcb', GDK_BACKEND: 'x11' } + +// The theme data sits in front of the inherited list: on the host the user's +// own icons and fonts also win over the system ones. +function withHostShare(env, entries) { + const own = entries.filter(Boolean) + if (!own.length) return + env.XDG_DATA_DIRS = [...own, env.XDG_DATA_DIRS].filter(Boolean).join(':') +} + +// HOME and the XDG folders point at the prefix (plain run) or at the bind +// targets inside the sandbox - the two must agree or the app writes outside. +function buildLinuxEnv({ game, backend = 'env' }) { + const prefixName = prefixNameForGame(game) + const root = ensureLinuxPfx(prefixName) + syncDesktopConfig(root) + const sandboxed = backend === 'bwrap' + const home = sandboxed ? `/home/${linuxUser()}` : path.join(root, 'home') + const env = { ...process.env } + env.HOME = home + for (const [key, [hostDir, sandboxSub]] of Object.entries(XDG_LAYOUT)) { + env[key] = sandboxed ? path.join(home, sandboxSub) : path.join(root, hostDir) + } + // XDG_RUNTIME_DIR stays the host's: Wayland, PipeWire and the session bus + // live there. It weakens the isolation, and the UI says so. + // The X session manager sits in the private /tmp, so its pointer would only + // lead to a dead socket; clients skip it when it is absent. + if (sandboxed) delete env.SESSION_MANAGER + // the host's fonts/icons/themes: mounted read-only by buildBwrapArgs, or - in + // a plain run, where nothing is hidden - just put on the search path + if (dataShares().length) withHostShare(env, [sandboxed ? SHARE_ROOT : hostShareDir()]) + if (game.forceX11) Object.assign(env, X11_ENV) + // 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, root, home, prefixName, prefixDir: prefixPath(prefixName) } +} + +// bwrap when it works, plain run otherwise - unless the app demands a sandbox. +function pickBackend({ game, sandbox }) { + const policy = (game && game.isolation) || 'auto' + const status = sandbox && typeof sandbox.status === 'function' ? sandbox.status() : null + const available = !!status && status.backend === 'bwrap' + if (policy === 'off') return { ok: true, backend: 'env', policy, status } + if (available) return { ok: true, backend: 'bwrap', policy, status } + if (policy === 'require') return { ok: false, error: 'sandbox-required', policy, status } + return { ok: true, backend: 'env', policy, status } +} + +function isolationLine({ backend, status }) { + if (backend === 'bwrap') return `[plwl] isolation: bwrap ${(status && status.version) || ''}`.trim() + const reason = (status && status.reason) || 'missing' + return `[plwl] isolation: env-only (reason: ${reason})` +} + +// "Open with": a native app takes the file where it is (the sandbox sees /). +function linuxPathFor(file) { + let abs = String(file || '') + try { + abs = fs.realpathSync(abs) + } catch {} + return abs +} + +function bwrapSource() { + const devices = ['/dev/shm', '/dev/dri', '/dev/snd', '/dev/input'].filter(d => fs.existsSync(d)) + const x11 = fs.existsSync('/tmp/.X11-unix') ? '/tmp/.X11-unix' : '' + const runUser = `/run/user/${typeof process.getuid === 'function' ? process.getuid() : 0}` + return { + devices, + x11, + runUser: fs.existsSync(runUser) ? runUser : '', + shares: dataShares(), + user: linuxUser() + } +} + +async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false }) { + const chosen = pickBackend({ game, sandbox }) + if (!chosen.ok) throw new Error(chosen.error) + + const exe = game.exePath + const workdir = game.workdir || path.dirname(exe) + const argList = parseArgs(game.args) + if (openFile) argList.push(linuxPathFor(openFile)) + + const { env, root } = buildLinuxEnv({ game, backend: chosen.backend }) + const header = [isolationLine(chosen)] + if (game.forceX11) header.push('[plwl] x11 forced (XWayland)') + + let cmd = exe + let args = argList + if (chosen.backend === 'bwrap') { + const source = bwrapSource() + cmd = 'bwrap' + args = [...buildBwrapArgs({ dataRoot: root, user: source.user, devices: source.devices, x11: source.x11, runUser: source.runUser, shares: source.shares, appDir: writableAppDir(game) }), '--', exe, ...argList] + header.push(`[plwl] HOME=${env.HOME}`) + } + + return spawnTracked({ game, cmd, args, cwd: workdir, env, handoff, header, onExit }) +} + +module.exports = { + DATA_DIRS, + XDG_LAYOUT, + SYSTEM_DIRS, + DESKTOP_SEEDS, + DATA_SHARES, + X11_ENV, + writableAppDir, + ensureLinuxPfx, + resetSeededConfig, + dataShares, + syncDesktopConfig, + buildLinuxEnv, + pickBackend, + isolationLine, + linuxPathFor, + runLinuxGame +} diff --git a/src/prefixes.js b/src/prefixes.js index 6c907f5..560d151 100644 --- a/src/prefixes.js +++ b/src/prefixes.js @@ -38,6 +38,20 @@ function prefixPath(name) { return path.join(DIRS.PREFIXES, name) } +// Native Linux apps keep their redirected home/config/cache in here, next to +// the Windows side of the same prefix. +const LINUX_PFX = 'linux_pfx' + +function prefixLinuxRoot(name) { + return path.join(prefixPath(name), LINUX_PFX) +} + +// Native Linux app data is machine-local (sockets, caches, machine-id, the +// sandbox's home): a sync neither sends it nor deletes it as an extra. +function isLocalOnlyRel(rel) { + return String(rel || '').split('/').includes(LINUX_PFX) +} + function prefixExists(name) { try { fs.accessSync(prefixPath(name)) @@ -100,6 +114,7 @@ function isPrefixRoot(p) { const root = prefixPath(entry.name) if (abs === root || abs === path.join(root, 'pfx')) return true if (abs === path.join(root, 'drive_c') || abs === path.join(root, 'pfx', 'drive_c')) return true + if (abs === path.join(root, LINUX_PFX) || abs === path.join(root, LINUX_PFX, 'home')) return true } return false } @@ -173,4 +188,4 @@ function setDefaultPrefix(name) { return { ok: true } } -module.exports = { isPrefixRel, PREFIX_REL_RE, listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoots, isPrefixRoot, healPrefix, ensureUnifiedPrefix, MAIN } +module.exports = { isPrefixRel, PREFIX_REL_RE, LINUX_PFX, isLocalOnlyRel, listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixLinuxRoot, prefixExists, prefixDriveRoots, isPrefixRoot, healPrefix, ensureUnifiedPrefix, MAIN } diff --git a/src/preload.js b/src/preload.js index d6364a7..28fe56f 100644 --- a/src/preload.js +++ b/src/preload.js @@ -41,6 +41,7 @@ contextBridge.exposeInMainWorld('plwl', { prefixesDelete: name => ipcRenderer.invoke('prefixes:delete', name), prefixesRename: (oldName, newName) => ipcRenderer.invoke('prefixes:rename', oldName, newName), prefixesSetDefault: name => ipcRenderer.invoke('prefixes:setDefault', name), + prefixesResetLinuxConfig: name => ipcRenderer.invoke('prefixes:resetLinuxConfig', name), openwithTake: () => ipcRenderer.invoke('openwith:take'), openwithRun: payload => ipcRenderer.invoke('openwith:run', payload), openwithTexts: lang => ipcRenderer.invoke('openwith:texts', lang), @@ -68,7 +69,10 @@ contextBridge.exposeInMainWorld('plwl', { gamesShortcutPaths: id => ipcRenderer.invoke('games:shortcutPaths', id), gamesPickFolder: () => ipcRenderer.invoke('games:pickFolder'), gamesPickPrefixFolder: prefix => ipcRenderer.invoke('games:pickPrefixFolder', prefix), - gamesImport: (src, mode) => ipcRenderer.invoke('games:import', src, mode), + gamesImport: (src, mode, kind) => ipcRenderer.invoke('games:import', src, mode, kind), + gamesOpenDataFolder: id => ipcRenderer.invoke('games:openDataFolder', id), + sandboxStatus: () => ipcRenderer.invoke('sandbox:status'), + sandboxProbe: () => ipcRenderer.invoke('sandbox:probe'), gamesImportFromPrefix: (prefix, src) => ipcRenderer.invoke('games:importFromPrefix', prefix, src), gamesRun: id => ipcRenderer.invoke('games:run', id), gamesRunTool: (id, tool, args) => ipcRenderer.invoke('games:runTool', id, tool, args), diff --git a/src/runner.js b/src/runner.js index a527a93..5ff9f95 100644 --- a/src/runner.js +++ b/src/runner.js @@ -198,6 +198,10 @@ function windowsPathFor(file, prefixDir) { } const GRACE_MS = 60000 +// A finished run may leave output in the pipes (or a grandchild holding them +// open): the log closes when its writers are done, at the latest this long +// after the exit. +const LOG_DRAIN_MS = 5000 // 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 @@ -222,32 +226,17 @@ function openLatestLog(gameId) { return fs.createWriteStream(logFile, { flags: 'w' }) } -async function runGame({ game, wine, systemWine, onExit, openFile, handoff = false }) { - await healPrefixOnce(game) - const isProton = wine && wine.type === 'proton' - const { env, prefixDir } = buildEnv({ game, wine }) - const exe = game.exePath - const workdir = game.workdir || path.dirname(exe) - const argList = parseArgs(game.args) - // an opened file is an argument to the exe, exactly like Windows' "Open with" - if (openFile) argList.push(windowsPathFor(openFile, prefixDir)) - - let cmd - let base - if (isProton) { - cmd = wine.wineBin || path.join(wine.path, 'proton') - base = [protonVerb(game), exe, ...argList] - } else { - cmd = systemWine ? 'wine' : wine.wineBin - base = [exe, ...argList] - } - +// The tail every tracked run shares (wine and native Linux alike): spawn +// detached, tee output into the app's latest log, tick playtime, stamp +// lastPlayed, report the exit. `header` lines land right after the launch line. +function spawnTracked({ game, cmd, args, cwd, env, handoff = false, header = [], onExit }) { + const library = require('./library') fs.mkdirSync(DIRS.DATA, { recursive: true }) return new Promise((resolve, reject) => { let child try { - child = spawn(cmd, base, { - cwd: workdir, + child = spawn(cmd, args, { + cwd, env, // a handoff is untracked: its pipes drain so the app never blocks stdio: handoff ? 'ignore' : ['ignore', 'pipe', 'pipe'], @@ -272,24 +261,36 @@ async function runGame({ game, wine, systemWine, onExit, openFile, handoff = fal const logFile = latestLogPath(game.id) const ws = openLatestLog(game.id) // 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`) - if (isProton) ws.write(`[plwl] proton launcher: ${game.disableUmu ? 'direct wine (no UMU, no Steam)' : `umu.exe (UMU_ID=${env.UMU_ID})`}\n`) - child.stdout.pipe(ws) - child.stderr.pipe(ws) + ws.write(`[plwl] ${cmd} ${args.join(' ')}\n`) + for (const line of header) ws.write(line + '\n') + // two pipes feed one log, so neither may end it: pipe() would close the + // stream on the first EOF, and the second writer then kills the process + // with ERR_STREAM_WRITE_AFTER_END (an unhandled WriteStream 'error') + child.stdout.pipe(ws, { end: false }) + child.stderr.pipe(ws, { end: false }) + let openPipes = 2 + const endLog = () => { + if (--openPipes <= 0) ws.end() + } + child.stdout.once('end', endLog) + child.stderr.once('end', endLog) + // an unwritable log (full disk, file removed) must not take us down + ws.on('error', () => {}) child.unref() const startedAt = Date.now() // read the LIVE playtime file, not the registry (shared ids) - const basePlaySeconds = require('./library').readPlaySeconds(game.id) ?? (game.playSeconds || 0) + const basePlaySeconds = library.readPlaySeconds(game.id) ?? (game.playSeconds || 0) const stopTicker = startPlaytimeTicker({ game, startedAt, base: basePlaySeconds }) // stamp "last played" once at start, even if we crash later - require('./library').updateGame(game.id, { lastPlayed: startedAt }) + library.updateGame(game.id, { lastPlayed: startedAt }) child.on('exit', (code, signal) => { stopTicker() - ws.end() + // nothing may outlive the run: end the log once the buffered output had + // its chance, even if something else still holds the pipes + const drain = setTimeout(() => ws.end(), LOG_DRAIN_MS) + if (drain.unref) drain.unref() const counted = Math.max(0, Math.round((Date.now() - startedAt - GRACE_MS) / 1000)) - require('./library').updateGame(game.id, { + library.updateGame(game.id, { lastPlayed: startedAt, playSeconds: basePlaySeconds + counted }) @@ -300,6 +301,32 @@ async function runGame({ game, wine, systemWine, onExit, openFile, handoff = fal }) } +async function runGame({ game, wine, systemWine, onExit, openFile, handoff = false }) { + await healPrefixOnce(game) + const isProton = wine && wine.type === 'proton' + const { env, prefixDir } = buildEnv({ game, wine }) + const exe = game.exePath + const workdir = game.workdir || path.dirname(exe) + const argList = parseArgs(game.args) + // an opened file is an argument to the exe, exactly like Windows' "Open with" + if (openFile) argList.push(windowsPathFor(openFile, prefixDir)) + + let cmd + let base + if (isProton) { + cmd = wine.wineBin || path.join(wine.path, 'proton') + base = [protonVerb(game), exe, ...argList] + } else { + cmd = systemWine ? 'wine' : wine.wineBin + base = [exe, ...argList] + } + + const header = [`[plwl] WINEPREFIX=${env.WINEPREFIX}`] + if (env.WINEDLLOVERRIDES) header.push(`[plwl] WINEDLLOVERRIDES=${env.WINEDLLOVERRIDES}`) + if (isProton) header.push(`[plwl] proton launcher: ${game.disableUmu ? 'direct wine (no UMU, no Steam)' : `umu.exe (UMU_ID=${env.UMU_ID})`}`) + return spawnTracked({ game, cmd, args: base, cwd: workdir, env, handoff, header, onExit }) +} + async function runTool({ game, wine, tool, args, systemWine }) { await healPrefixOnce(game) const isProton = wine && wine.type === 'proton' @@ -336,4 +363,4 @@ function runAdHoc({ openPath, wine, systemWine, prefix, onExit }) { return runGame({ game, wine, systemWine, onExit }) } -module.exports = { buildEnv, runGame, runTool, runAdHoc, parseArgs, latestLogPath, windowsPathFor } +module.exports = { buildEnv, runGame, runTool, runAdHoc, parseArgs, latestLogPath, windowsPathFor, spawnTracked } diff --git a/src/sandbox.js b/src/sandbox.js new file mode 100644 index 0000000..44644ca --- /dev/null +++ b/src/sandbox.js @@ -0,0 +1,160 @@ +'use strict' + +const { execFile } = require('child_process') +const fs = require('fs') +const path = require('path') + +// bubblewrap is only usable when unprivileged user namespaces actually work: +// Ubuntu 24.04 ships bwrap but AppArmor kills it ("Failed to unshare user +// namespace"), so a functional probe decides - not `which bwrap`. +const PROBE_TIMEOUT_MS = 3000 +// /bin/true is a symlink on most distros but missing on a few; env is the +// last resort that still exits 0. +const TRUE_BINARIES = ['/bin/true', '/usr/bin/true', '/usr/bin/env'] + +function firstExisting(candidates) { + for (const candidate of candidates) { + try { + fs.accessSync(candidate) + return candidate + } catch {} + } + return null +} + +function firstLine(text) { + return String(text || '').trim().split('\n')[0].trim() +} + +// A failed probe maps to a reason the UI and the log can name. +function reasonFor(err, stderr) { + if (err && err.code === 'ENOENT') return 'missing' + if (err && (err.killed || err.signal === 'SIGKILL')) return 'timeout' + const text = String(stderr || '') + ' ' + String((err && err.message) || '') + if (/Operation not permitted|Permission denied|Failed to unshare|uid map|setting up uid|No permissions to create/i.test(text)) return 'not-permitted' + return 'failed' +} + +function run(cmd, args, timeoutMs) { + return new Promise(resolve => { + execFile(cmd, args, { timeout: timeoutMs, killSignal: 'SIGKILL' }, (err, stdout, stderr) => { + resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || '') }) + }) + }) +} + +// Status = { backend: 'bwrap' | 'env', version, reason, detail, checkedAt } +// reason = null | 'missing' | 'not-permitted' | 'timeout' | 'failed' +function createSandboxProbe({ timeoutMs = PROBE_TIMEOUT_MS } = {}) { + let cached = null + let inflight = null + + const makeStatus = (backend, version, reason, detail) => ({ + backend, + version: version || null, + reason: reason || null, + detail: detail || '', + checkedAt: Date.now() + }) + + async function measure() { + const trueBin = firstExisting(TRUE_BINARIES) + if (!trueBin) { + cached = makeStatus('env', null, 'missing', 'no /bin/true to probe with') + return cached + } + const versionRun = await run('bwrap', ['--version'], timeoutMs) + if (versionRun.err && versionRun.err.code === 'ENOENT') { + cached = makeStatus('env', null, 'missing', 'bubblewrap is not installed') + return cached + } + const version = firstLine(versionRun.stdout).split(/\s+/).pop() || null + const probeRun = await run('bwrap', ['--ro-bind', '/', '/', '--proc', '/proc', '--dev', '/dev', '--', trueBin], timeoutMs) + if (probeRun.err) { + cached = makeStatus('env', version, reasonFor(probeRun.err, probeRun.stderr), firstLine(probeRun.stderr) || firstLine(probeRun.err.message)) + return cached + } + cached = makeStatus('bwrap', version, null, '') + return cached + } + + // Runs the probe once and answers from the cache afterwards (3s on every + // launch click would be unacceptable). + function probe() { + if (cached) return Promise.resolve(cached) + if (!inflight) inflight = measure().finally(() => { inflight = null }) + return inflight + } + + return { + probe, + status: () => cached, + invalidate: () => { cached = null } + } +} + +// Host theme data (fonts, icons, colour schemes) is mounted read-only under +// this path - inside the private /tmp the app cannot mistake it for its own +// data, and the caller lists it in XDG_DATA_DIRS. A bind, not a symlink: the +// prefix is mounted over ~, so a link back into ~ would point at itself. +const SHARE_ROOT = '/tmp/.plwl-host' + +// The bwrap profile for a native Linux app. Pure: the caller decides which +// devices and host folders exist on this machine, so the argv is testable +// as-is. +function buildBwrapArgs({ + dataRoot, + user, + home = `/home/${user}`, + uid = typeof process.getuid === 'function' ? process.getuid() : 0, + devices = [], + x11 = '', + runUser = '', + shares = [], + hideHome = false, + etcRoot = '', + unshareNet = false, + appDir = '' +}) { + const xdgDir = sub => path.join(home, sub) + return [ + '--die-with-parent', + '--new-session', + // network stays: multiplayer and DNS keep working + '--unshare-all', + unshareNet ? '--unshare-net' : '--share-net', + '--hostname', 'plwl', + // --- filesystem --- + '--ro-bind', '/', '/', + '--proc', '/proc', + '--dev', '/dev', + ...devices.flatMap(device => ['--dev-bind', device, device]), + '--tmpfs', '/tmp', + // X11 sockets live in /tmp, which the tmpfs above just emptied + ...(x11 ? ['--ro-bind', x11, x11] : []), + // the host's theme data, read-only: fonts, icons, colour schemes + ...shares.flatMap(dir => ['--ro-bind', dir, path.join(SHARE_ROOT, path.basename(dir))]), + // hard profile: nothing of anybody's /home is even readable + ...(hideHome ? ['--tmpfs', '/home', '--tmpfs', '/root', '--tmpfs', '/mnt', '--tmpfs', '/media'] : []), + // the app's own folder stays writable (portable apps keep logs/data next + // to themselves); the rest of the read-only root does not + ...(appDir ? ['--bind', appDir, appDir] : []), + // --- app data (host paths bound to a fresh HOME) --- + '--bind', path.join(dataRoot, 'home'), home, + '--bind', path.join(dataRoot, 'config'), xdgDir('.config'), + '--bind', path.join(dataRoot, 'data'), xdgDir(path.join('.local', 'share')), + '--bind', path.join(dataRoot, 'cache'), xdgDir('.cache'), + '--bind', path.join(dataRoot, 'state'), xdgDir(path.join('.local', 'state')), + // Wayland, PipeWire, PulseAudio and the session bus all live here + ...(runUser ? ['--bind', runUser, runUser] : []), + ...(etcRoot ? ['--bind', etcRoot, '/etc'] : []), + // --- environment --- + '--setenv', 'HOME', home, + '--setenv', 'XDG_CONFIG_HOME', xdgDir('.config'), + '--setenv', 'XDG_DATA_HOME', xdgDir(path.join('.local', 'share')), + '--setenv', 'XDG_CACHE_HOME', xdgDir('.cache'), + '--setenv', 'XDG_STATE_HOME', xdgDir(path.join('.local', 'state')) + ] +} + +module.exports = { createSandboxProbe, buildBwrapArgs, SHARE_ROOT, PROBE_TIMEOUT_MS } From c3fc5e7dd1f112a9776445c854427798d12e1efb Mon Sep 17 00:00:00 2001 From: olcxja Date: Tue, 6 Oct 2026 23:38:18 +0200 Subject: [PATCH 12/29] update translations --- files/translations/en-us.json | 7 +++---- files/translations/pl.json | 7 +++---- renderer/index.html | 1 - 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/files/translations/en-us.json b/files/translations/en-us.json index 416a397..9ea9037 100644 --- a/files/translations/en-us.json +++ b/files/translations/en-us.json @@ -39,7 +39,7 @@ "library.reorderHint": "Drag to change the order", "import.kind.title": "What are you importing?", "import.kind.windows": "Windows app (Wine / Proton)", - "import.kind.windowsHint": "Runs through wine or proton, as before. Needs an .exe.", + "import.kind.windowsHint": "Runs through wine or proton.", "import.kind.linux": "Linux app (native)", "import.kind.linuxHint": "Runs directly on this system, with its own isolated home folder. Needs an ELF binary, an .AppImage or a .desktop file.", "import.question": "Should the app files be brought into the launcher so it stays portable, or left where they are?", @@ -89,18 +89,17 @@ "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.linuxDataRoot": "Data folder (prefix)", - "config.linuxDataRootHint": "A native app keeps its home, config and cache in linux_pfx inside this prefix. Nothing else is protected unless the sandbox is active.", + "config.linuxDataRootHint": "A native app keeps its home, config and cache in linux_pfx inside this prefix.", "config.openData": "Open data folder", "config.linuxKindValue": "Linux (native)", "config.isolation": "Isolation", "config.isolation.auto": "Automatic (strongest available)", "config.isolation.off": "Off (plain run)", "config.isolation.require": "Required (refuse to run without bwrap)", - "config.isolationHint": "Automatic uses bubblewrap when it works on this system and a plain run when it does not. 'Required' refuses to start instead of quietly weakening.", "config.isolation.bwrap": "bwrap {0} (strong)", "config.isolation.env": "no sandbox: {0}", "config.forceX11": "Force X11 (XWayland)", - "config.forceX11Hint": "Runs the app through XWayland instead of Wayland. Useful when a window cannot carry its own icon (Minecraft) or the app misbehaves on Wayland. It also covers the programs this app launches. The app's own environment variables still win.", + "config.forceX11Hint": "Runs the app through XWayland instead of Wayland. Useful when a window cannot carry its own icon or misbehaves on Wayland.", "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. This is the closest to a plain wine run.", "config.dllOverrides": "DLL overrides", diff --git a/files/translations/pl.json b/files/translations/pl.json index 6a731cb..9532baf 100644 --- a/files/translations/pl.json +++ b/files/translations/pl.json @@ -39,7 +39,7 @@ "library.reorderHint": "Przeciągnij, aby zmienić kolejność", "import.kind.title": "Co importujesz?", "import.kind.windows": "Aplikacja Windows (Wine / Proton)", - "import.kind.windowsHint": "Uruchamiana przez wine albo proton, jak dotychczas. Wymaga pliku .exe.", + "import.kind.windowsHint": "Uruchamiana przez wine albo proton.", "import.kind.linux": "Aplikacja Linux (natywna)", "import.kind.linuxHint": "Uruchamiana bezpośrednio w systemie, z własnym odizolowanym folderem domowym. Wymaga binarki ELF, pliku .AppImage lub .desktop.", "import.question": "Czy pliki aplikacji mają trafić do launchera, aby był przenośny, czy mają zostać tam, gdzie są?", @@ -89,18 +89,17 @@ "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.linuxDataRoot": "Folder danych (prefiks)", - "config.linuxDataRootHint": "Natywna aplikacja trzyma swój home, config i cache w linux_pfx w tym prefiksie. Bez aktywnego sandboxa nic więcej nie jest chronione.", + "config.linuxDataRootHint": "Natywna aplikacja trzyma swój home, config i cache w linux_pfx w tym prefiksie.", "config.openData": "Otwórz folder danych", "config.linuxKindValue": "Linux (natywna)", "config.isolation": "Izolacja", "config.isolation.auto": "Automatyczna (najmocniejsza dostępna)", "config.isolation.off": "Wyłączona (zwykłe uruchomienie)", "config.isolation.require": "Wymagana (odmów bez bwrap)", - "config.isolationHint": "Automatyczna używa bubblewrap, jeśli działa w tym systemie, a jeśli nie - uruchamia zwyczajnie. „Wymagana” odmawia startu, zamiast po cichu osłabić izolację.", "config.isolation.bwrap": "bwrap {0} (mocna)", "config.isolation.env": "brak sandboxa: {0}", "config.forceX11": "Wymuś X11 (XWayland)", - "config.forceX11Hint": "Uruchamia aplikację przez XWayland zamiast Waylanda. Przydatne, gdy okno nie potrafi mieć własnej ikony (Minecraft) albo aplikacja źle działa na Waylandzie. Obejmuje też programy, które ta aplikacja uruchamia. Zmienne środowiskowe samej aplikacji mają pierwszeństwo.", + "config.forceX11Hint": "Uruchamia aplikację przez XWayland zamiast Waylanda. Przydatne, gdy okno nie potrafi mieć własnej ikony albo źle działa na Waylandzie.", "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. To najbliżej zwykłego uruchomienia wine.", "config.dllOverrides": "Nadpisania DLL", diff --git a/renderer/index.html b/renderer/index.html index f703b0b..cb4bea1 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -207,7 +207,6 @@

-