From cb0b071a38f9feaec1045233f9a30633e1ccf22a Mon Sep 17 00:00:00 2001 From: olcxja Date: Mon, 5 Oct 2026 17:37:23 +0200 Subject: [PATCH 01/23] 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 02/23] 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 03/23] [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 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 IF$etr1LCoDGynhq literal 9458 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 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 04/23] 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 05/23] [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 06/23] 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 @@

-