diff --git a/development.md b/development.md index e8f0c86..cbe421f 100644 --- a/development.md +++ b/development.md @@ -73,7 +73,7 @@ Renderer never touches Node/Electron directly — everything goes through the `w - 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 one of four levels, resolved by linuxrunner.resolveLevel: `none` (plain run), `relaxed` (bwrap + the whole host filesystem mounted writable at sandbox.FS_ROOT) and `isolated` (bwrap with the stock read-only profile), with `auto` (default) meaning relaxed where bwrap works and none where it does not. Only `auto` may weaken itself — an explicit relaxed/isolated answers `sandbox-required` instead ("no silent downgrade"). library.js keeps exactly these four values, both when reading the registry and when a patch is saved (anything else falls back to `auto`), and the level always lands in the log and the UI. resolveLevel awaits the probe when nothing has measured yet: a shortcut boots HEADLESS, so no renderer ran `sandbox:status` — without that wait `auto` would read an empty cache and quietly pick `none` (and an explicit level would refuse a machine that can sandbox perfectly well). -- 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 — at the isolated level. Relaxed is the deliberate exception and says so in the UI: `FS_ROOT` is the same filesystem, mounted writable, so "everything else" is only unreachable through the real paths. +- 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"). That bind is written LAST in buildBwrapArgs on purpose: an app lives under the host's home and the redirected home is mounted over that very directory, so the bind used to be swallowed by it and bwrap answered `execvp : No such file or directory` — a click on Play and the window closed again (measured on 0.12.0, and again as a real exec in test/sandbox-appdir.test.js). Everything else is `EROFS` by design — at the isolated level. Relaxed is the deliberate exception and says so in the UI: `FS_ROOT` is the same filesystem, mounted writable, so "everything else" is only unreachable through the real paths. - 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. An isolated hand-off has its own slot, handoff:progress (`{ phase: 'copy'|'done'|'error', name, bytes, totalBytes, notice }`), because it can start before the window exists: main.js gets the event first and, when `notice` is set (a copy above linuxrunner.STAGE_NOTICE_BYTES), puts the launcher window up so the renderer's popup can be seen; below that the copy is over before a window could paint. Copies of one file go through fsutil.copyFileProgress (streamed, throttled by progressGate, `.part` + rename). @@ -83,7 +83,7 @@ Renderer never touches Node/Electron directly — everything goes through the `w ### 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 copy with no marker entry (what an older launcher left) counts as ours and is refreshed. 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. +- 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 copy with no marker entry (or one the app has since changed) is the app's own and is never overwritten. 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"). @@ -106,14 +106,14 @@ Renderer never touches Node/Electron directly — everything goes through the `w - 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, isolation and forceX11 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). +- Registry-level settings are opt-in (the "settings" category). The payload carries defaults, wineSettings (displayName + disabled per build), prefixSettings, appSettings (name, wine, prefix, args, env, dllOverrides, disableUmu, isolation and forceX11 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. -- A file's unit and path travel in the PUT's query, percent-encoded, and the receiver still reads the old `x-plwl-unit`/`x-plwl-path` headers first: Node rejects any header value holding a code above U+00FF (`ERR_INVALID_CHAR`), and a real library has those names (Minecraft pack names, Polish file names) — one such file killed the whole sync, leaving the receiver with half an app and no registry entry. +- A file's unit and path travel in the PUT's query, not in headers: Node rejects any header value holding a code above U+00FF (`ERR_INVALID_CHAR`), and a real library has those names (Minecraft pack names, Polish file names) — one such file killed the whole sync, leaving the receiver with half an app and no registry entry. Categories are matched by exact name; anything this build does not know is ignored. - 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, configs, caches, PulseAudio cookie, state) travels INSIDE its prefix unit like the Windows side of the same prefix: the manifest walks it and the mirror prunes it, so no file of the launcher's own trees is special-cased on the wire. Native apps sit in the same categories as Windows ones (`apps-portable`/`apps-prefix`), and only the per-app fields that are Linux-only (isolation, forceX11) are named as such in the settings payload. - 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). +- 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 absolute links that reached disk anyway. A build whose binaries lost the executable bit is detected in refreshWines() and healed once (spawn() on a non-executable `proton` fails with EACCES while the game looks started). ### Paths and prefixes @@ -143,6 +143,7 @@ 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 + the four levels, handed paths, mode gate +node test/sandbox-appdir.test.js # the app folder stays reachable inside its own redirected home 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-links.test.js # link opening: shim binds + mime seed diff --git a/package.json b/package.json index 05f5aac..c49b18f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "PLWL", "productName": "PLWL", - "version": "0.5.1", + "version": "0.5.2", "description": "Portable Linux Wine Launcher", "main": "src/main.js", "scripts": { diff --git a/renderer/app.js b/renderer/app.js index a49e8eb..4101766 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1597,10 +1597,10 @@ function renderProgressBar(bar, bytes, totalBytes) { bar.value = Math.min(sent, bar.max) } -// Older peers only send the overall byte totals - then those drive the bar. +// The bar follows the current phase's bytes (a phase starts empty). function barValues(info) { - 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) + let bytes = Math.max(0, Number(info.barBytes) || 0) + let total = Math.max(0, Number(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 diff --git a/src/hashcache.js b/src/hashcache.js index 9e8524f..593e24d 100644 --- a/src/hashcache.js +++ b/src/hashcache.js @@ -42,7 +42,6 @@ function createHashCache(dataDir) { let cache = null let dirty = false let savedAt = 0 - const stats = { hits: 0, misses: 0 } function load() { if (cache) return cache @@ -80,11 +79,7 @@ function createHashCache(dataDir) { // the cache dirty only when the timestamp is stale. function hit(rel, size, mtimeMs) { const known = load()[rel] - if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') { - stats.misses++ - return null - } - stats.hits++ + if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') return null if (Date.now() - (known[3] || 0) > 86400000) { known[3] = Date.now() dirty = true @@ -111,35 +106,13 @@ function createHashCache(dataDir) { cache = null dirty = false savedAt = 0 - stats.hits = 0 - stats.misses = 0 try { fs.rmSync(FILE, { force: true }) fs.rmSync(`${FILE}.tmp`, { force: true }) } catch {} } - // Dropping the memory copy makes the next lookup re-read from disk (tests). - function forget() { - cache = null - dirty = false - } - - return { - HASH_ALGO, - hashFile, - hit, - remember, - cachedHash, - save, - clear, - forget, - stats: () => ({ ...stats, size: cache ? Object.keys(cache).length : 0, algo: HASH_ALGO }), - resetStats: () => { - stats.hits = 0 - stats.misses = 0 - } - } + return { HASH_ALGO, hashFile, hit, remember, cachedHash, save, clear } } module.exports = { createHashCache, hashFile, HASH_ALGO } diff --git a/src/icons.js b/src/icons.js index 912b686..83ea609 100644 --- a/src/icons.js +++ b/src/icons.js @@ -170,7 +170,7 @@ function buildIco(images) { planes = dims.planes bpp = dims.bpp } else { - // legacy fallback: ICONDIRENTRY-style first bytes (PNG-era heuristic) + // nothing measurable: the first bytes carry the size w = img.readUInt8(0) || 256 h = img.readUInt8(1) || 256 colors = img.readUInt8(2) diff --git a/src/ipc/lansync.js b/src/ipc/lansync.js index b53c31b..70fc936 100644 --- a/src/ipc/lansync.js +++ b/src/ipc/lansync.js @@ -8,11 +8,11 @@ function registerLanSyncIpc({ getWindow }) { ipcMain.handle('lansync:get', () => lansync.getState()) ipcMain.handle('lansync:devices', () => lansync.listDevices()) ipcMain.handle('lansync:rerollDeviceId', () => { - const identity = lansync.rerollDeviceId() + const identity = lansync.rotateDeviceId() return { deviceId: identity.deviceId } }) ipcMain.handle('lansync:rerollSyncCode', () => { - const identity = lansync.rerollSyncCode() + const identity = lansync.rotateSyncCode() return { syncCode: identity.syncCode } }) ipcMain.handle('lansync:forget', (e, id) => { diff --git a/src/lansync-identity.js b/src/lansync-identity.js index 5bedc3f..8c3c5f8 100644 --- a/src/lansync-identity.js +++ b/src/lansync-identity.js @@ -93,7 +93,7 @@ function createIdentity(configFile) { function rememberDevice(id, code, name) { const cfg = identity() const stored = String(code || '').trim().toUpperCase() - cfg.known = { ...cfg.known, [id]: { code: stored, syncCode: stored, name: name || '', at: Date.now() } } + cfg.known = { ...cfg.known, [id]: { code: stored, name: name || '', at: Date.now() } } writeJson(configFile, cfg) } diff --git a/src/lansync.js b/src/lansync.js index 430f725..e6f5161 100644 --- a/src/lansync.js +++ b/src/lansync.js @@ -44,60 +44,23 @@ const INCOMING_TIMEOUT_MS = Number(process.env.PLWL_LANSYNC_INCOMING_TIMEOUT) || // The only trees a sync may touch; the rest of files/ is local state. const SYNC_ROOTS = ['apps/', 'wines/', 'wineprefixes/', 'data/icons/'] -// Sync categories plus lenient aliases for older senders. +// What a sync may carry, one name per category. const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'settings'] -const CATEGORY_ALIASES = { - // the whole app library: portable + prefix-import launcher entries - apps: ['apps-portable', 'apps-prefix'], - app: ['apps-portable', 'apps-prefix'], - appsportable: ['apps-portable'], - portableapps: ['apps-portable'], - portable: ['apps-portable'], - appsprefix: ['apps-prefix'], - prefixapps: ['apps-prefix'], - appsimportedfromprefix: ['apps-prefix'], - wine: ['wines'], - wines: ['wines'], - wineversions: ['wines'], - wineprefix: ['prefixes'], - wineprefixes: ['prefixes'], - prefixes: ['prefixes'], - prefix: ['prefixes'], - // launcher settings: defaults, wine labels, prefix registry, app configs - settings: ['settings'], - syncsettings: ['settings'], - defaults: ['settings'], - default: ['settings'], - defaultsettings: ['settings'] -} - // An app entry's category: portable imports own their apps/ folder. function appCategory(game) { if (!game || typeof game !== 'object') return null - if (game.importSource === 'prefix' || game.mode === 'prefix') return 'apps-prefix' - if (game.imported === true || game.mode === 'copy' || game.mode === 'move') return 'apps-portable' + if (game.importSource === 'prefix') return 'apps-prefix' + if (game.imported === true) return 'apps-portable' return null } -// An app entry's files, read off its stored path; outside the data = not synced. -function classifyApp(app) { - if (!app || (typeof app !== 'string' && typeof app !== 'object')) return null - const raw = typeof app === 'string' ? app : app.path || app.exePath || app.workdir - // external entry: nothing to sync - if (!raw || typeof raw !== 'string') return 'external' - const value = raw.replace(/\\/g, '/') - const outside = value.startsWith('..') || path.isAbsolute(raw) || /^[a-z]:\//i.test(value) || /^[a-z][a-z0-9+.-]*:\/\//i.test(value) - if (isPrefixRel(value)) return 'prefix' - if (/^apps\//.test(value)) return 'portable' - return outside ? 'external' : null -} - +// The list comes from the wire: anything this build does not know is ignored. function normalizeCategories(list) { const out = new Set() for (const raw of Array.isArray(list) ? list : []) { - const aliases = CATEGORY_ALIASES[String(raw || '').toLowerCase().replace(/[^a-z]+/g, '')] - for (const alias of aliases || []) out.add(alias) + const name = String(raw || '') + if (CATEGORIES.includes(name)) out.add(name) } return [...out] } @@ -183,28 +146,6 @@ function targetOf(target) { return null } -// Trade the sync code for a token; a rejected code is never stored. -async function verifyPeer(target, code) { - // { host, port, code } in one object, or a target plus a separate code - if (target && typeof target === 'object' && code === undefined && target.code !== undefined) code = target.code - const device = targetOf(target) - if (!device || !device.port) return { ok: false, error: 'bad-target' } - let res - try { - res = await httpJson(device, 'POST', '/plwl/verify', { - json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase() }, - timeout: 20000 - }) - } catch (err) { - return { ok: false, error: String((err && err.message) || err) } - } - if (!res.data || !res.data.ok) return { ok: false, error: (res.data && res.data.error) || `http-${res.status}` } - const peer = res.data.device || { id: res.data.deviceId, name: res.data.name } - const peerId = (target && target.deviceId) || (target && target.id) || (peer && peer.id) - if (peerId) rememberDevice(peerId, code, (peer && peer.name) || (target && target.name)) - return { ok: true, token: res.data.token, deviceId: peer && peer.id, device: peer, peer: peer } -} - // --- receiving side --------------------------------------------------------- function sendJson(res, status, data) { @@ -992,10 +933,8 @@ async function handlePlan(state, session, url, req, res, hooks) { const PUT_IDLE_MS = Number(process.env.PLWL_LANSYNC_PUT_IDLE) || 60000 async function handlePut(session, url, req, res, hooks) { - // current senders put the pair in the query (percent-encoded); 0.5.x used - // headers, which cannot carry a code above U+00FF at all - const key = req.headers['x-plwl-unit'] || url.searchParams.get('unit') || '' - const rel = req.headers['x-plwl-path'] || url.searchParams.get('path') || '' + const key = url.searchParams.get('unit') || '' + const rel = url.searchParams.get('path') || '' const unit = session.units.get(key) const abs = filesPath(rel) if (!unit || !abs || !unit.paths.has(rel)) { @@ -1066,7 +1005,7 @@ async function handleCommit(session, url, res, hooks) { async function handleApply(session, req, res, hooks) { const payload = await readJsonBody(req) if (!payload) return sendJson(res, 400, { ok: false, error: 'bad-request' }) - // sender categories go through the aliases (older PLWL says "defaults") + // only the categories this build knows, whatever the sender sent const cats = new Set(normalizeCategories(payload.categories)) const appsDeleted = applyApps(payload, cats) const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0 @@ -1091,9 +1030,6 @@ async function handleApply(session, req, res, hooks) { sendJson(res, 200, { ok: true, deleted: removed, - apps: { deleted: appsDeleted }, - wines: { deleted: winesDeleted }, - prefixes: { deleted: prefixesDeleted }, settings, defaults: settings ? settings.defaults : null }) @@ -1264,27 +1200,19 @@ const exported = { startServer, stop, stopServer, - closeServer: stopServer, - close: stopServer, httpJson, - verifyPeer, runSync, appCategory, - classifyApp, CATEGORIES, normalizeCategories, serverPort, - getServerPort: serverPort, getState, listDevices, identity, - loadIdentity: identity, makeDeviceId, makeSyncCode, rotateDeviceId, rotateSyncCode, - rerollDeviceId: rotateDeviceId, - rerollSyncCode: rotateSyncCode, knownDevice, knownPeers, rememberDevice, @@ -1293,9 +1221,6 @@ const exported = { sameCode, HASH_ALGO: hashStore.HASH_ALGO, hashFile, - hashCacheStats: hashStore.stats, - resetHashCacheStats: hashStore.resetStats, - forgetHashCache: hashStore.forget, clearHashCache: hashStore.clear, HASH_CONCURRENCY, progressGate, diff --git a/src/lansyncpush.js b/src/lansyncpush.js index e330a48..578c040 100644 --- a/src/lansyncpush.js +++ b/src/lansyncpush.js @@ -283,42 +283,18 @@ function createPusher(api) { deleted: 0, bytes: 0, // the popup reports "3 games, 1 prefix" instead of file counts - items: { apps: 0, wines: 0, prefixes: 0 }, - apps: { copied: 0, skipped: 0, deleted: 0 }, - wines: { copied: 0, skipped: 0, deleted: 0 }, - prefixes: { copied: 0, skipped: 0, deleted: 0 } + items: { apps: 0, wines: 0, prefixes: 0 } } } // Every sync resolves to the same shape, success or failure. - function syncResult(ok, error, summary, events) { - const buckets = { apps: summary.apps, prefixes: summary.prefixes, wines: summary.wines } - summary.progress = events.length - summary.events = events.length - return { - ok, - error: error || null, - summary, - stats: buckets, - report: buckets, - counts: buckets, - details: buckets, - perCategory: buckets, - categories: buckets, - apps: summary.apps, - prefixes: summary.prefixes, - wines: summary.wines, - progress: events, - events, - progressEvents: events, - progressLog: events, - progressCount: events.length - } + function syncResult(ok, error, summary) { + return { ok, error: error || null, summary } } // Push to the peer; a rejected code comes back as 'bad-code'. async function syncToDevice({ deviceId, device: target, code, categories, force, onProgress }) { - const fail = (error, summary, events) => syncResult(false, error, summary || newSummary(0), events || []) + const fail = (error, summary) => syncResult(false, error, summary || newSummary(0)) const device = target || api.listDevices().find(d => d.id === deviceId) if (!device || !device.address || !device.port) return fail('offline') const cats = new Set(normalizeCategories(categories)) @@ -329,10 +305,7 @@ function createPusher(api) { 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 => { - events.push(data) if (onProgress) onProgress(data) } @@ -435,7 +408,6 @@ function createPusher(api) { const roots = unit.roots.filter(ownedRoot) if (!roots.length) continue const kind = bucketOf(unit.kind) - const bucket = summary[kind] summary.items[kind] += 1 itemsSeen += 1 barBytes = 0 @@ -474,13 +446,9 @@ function createPusher(api) { if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`) const upload = new Set(plan.data.upload || []) const same = new Set(plan.data.same || []) - // per category: files created/overwritten vs already identical - const fileEntries = entries.filter(e => e.type === 'file') - const filePaths = new Set(fileEntries.map(e => e.path)) + const filePaths = new Set(entries.filter(e => e.type === 'file').map(e => e.path)) let sameFiles = 0 for (const p of same) if (filePaths.has(p)) sameFiles++ - bucket.copied += fileEntries.length - sameFiles - bucket.skipped += sameFiles summary.skipped += sameFiles const pending = [] for (const entry of entries) { @@ -523,10 +491,7 @@ function createPusher(api) { allDone += pending.length allTotal += pending.length const commit = await httpJson(device, 'POST', `/plwl/unit/commit?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}`, { json: {} }) - if (commit.data && commit.data.deleted) { - summary.deleted += commit.data.deleted - bucket.deleted += commit.data.deleted - } + if (commit.data && commit.data.deleted) summary.deleted += commit.data.deleted finished[kind] += 1 // a finished unit leaves a finished bar behind, even one that sent none barTotal = barTotal || 1 @@ -536,10 +501,6 @@ function createPusher(api) { emit('registry', null, null, null, true) const applied = await httpJson(device, 'POST', `/plwl/apply?token=${encodeURIComponent(token)}`, { json: buildRegistryPayload([...cats]), timeout: 60000 }) if (!applied.data || !applied.data.ok) throw new Error((applied.data && applied.data.error) || `http-${applied.status}`) - for (const key of ['apps', 'wines', 'prefixes']) { - const deleted = applied.data[key] && applied.data[key].deleted - 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 @@ -552,11 +513,11 @@ function createPusher(api) { } catch (err) { abort.dead = true await end() - return fail(String((err && err.message) || err), summary, events) + return fail(String((err && err.message) || err), summary) } finally { clearInterval(heartbeat) } - return syncResult(true, null, summary, events) + return syncResult(true, null, summary) } // putFile is exposed for the stall tests (it is the piece that must never diff --git a/src/library.js b/src/library.js index 457e7b4..32b9504 100644 --- a/src/library.js +++ b/src/library.js @@ -109,8 +109,7 @@ function gamesFollowingDefault(running) { }) } -// Windows is the default: entries written before Linux apps existed have no -// kind at all. +// `kind` is only ever written as 'linux'; a missing one is a Windows app. function isLinuxGame(game) { return !!game && game.kind === 'linux' } diff --git a/src/linuxrunner.js b/src/linuxrunner.js index 88bb5a5..6905bdb 100644 --- a/src/linuxrunner.js +++ b/src/linuxrunner.js @@ -265,13 +265,11 @@ function syncDesktopConfig(root) { const dst = path.join(root, 'config', rel) const hostHash = hashOf(src) if (!hostHash) continue - const known = marker[rel] - if (fs.existsSync(dst) && known) { + if (fs.existsSync(dst)) { + // only a marked copy is provably ours, and only while it is untouched const current = hashOf(dst) - // the app owns it now, or there is nothing to do - if (current !== known || current === hostHash) continue + if (current !== marker[rel] || current === hostHash) continue } - // no marker entry: the copy is ours (seeded before the marker existed) if (copyFromHost(src, dst, { refresh: true })) { marker[rel] = hostHash changed = true diff --git a/src/openwith.js b/src/openwith.js index 93231c2..af62c74 100644 --- a/src/openwith.js +++ b/src/openwith.js @@ -24,25 +24,8 @@ function clearPending() { pending = null } -// Fall back to English keys on older installs. function textsFor(code) { - let texts = {} - try { - texts = require('./i18n').getLang(code).texts || {} - } catch {} - const fallback = { - 'openwith.title': 'Open with Wine', - 'openwith.file': 'File', - 'openwith.fileMissing': 'This file no longer exists.', - 'openwith.wine': 'Wine / Proton version', - 'openwith.wineDefault': 'Default ({0})', - 'openwith.prefix': 'Wine prefix', - 'openwith.prefixDefault': 'Default prefix ({0})', - 'openwith.run': 'Run', - 'openwith.startFailed': 'Could not start {0}: {1}', - 'openwith.notWindows': 'This is not a Windows executable (exe/msi/bat/cmd/lnk).' - } - return { ...fallback, ...texts } + return require('./i18n').getLang(code).texts || {} } function resolveWine(id) { diff --git a/src/runner.js b/src/runner.js index 5ff9f95..e00e383 100644 --- a/src/runner.js +++ b/src/runner.js @@ -88,28 +88,16 @@ async function healPrefixOnce(game) { } } -// Proton's STEAM_COMPAT_DATA_PATH must be a symlink to the launcher prefix. -// A leftover EMPTY compat dir (an older PLWL bug) is replaced by the link; a -// populated one is left alone. +// Proton's STEAM_COMPAT_DATA_PATH must be a symlink to the launcher prefix; +// a real compat dir already sitting there is not ours to replace. function linkPrefix(compatData, prefixDir) { try { fs.mkdirSync(path.dirname(compatData), { recursive: true }) } catch {} try { - const st = fs.lstatSync(compatData) - if (st.isSymbolicLink()) { - if (fs.readlinkSync(compatData) === prefixDir) return - fs.unlinkSync(compatData) - } else if (st.isDirectory()) { - let names = [] - try { - names = fs.readdirSync(compatData) - } catch {} - if (names.length) return - fs.rmdirSync(compatData) - } else { - return - } + if (!fs.lstatSync(compatData).isSymbolicLink()) return + if (fs.readlinkSync(compatData) === prefixDir) return + fs.unlinkSync(compatData) } catch {} try { fs.symlinkSync(prefixDir, compatData, 'dir') diff --git a/src/sandbox.js b/src/sandbox.js index e6acd69..49890c4 100644 --- a/src/sandbox.js +++ b/src/sandbox.js @@ -146,9 +146,6 @@ function buildBwrapArgs({ ...(urlOpener && urlOpener.shim ? ['--ro-bind', urlOpener.shim, urlOpener.target] : []), // 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'), @@ -158,6 +155,11 @@ function buildBwrapArgs({ // Wayland, PipeWire, PulseAudio and the session bus all live here ...(runUser ? ['--bind', runUser, runUser] : []), ...(etcRoot ? ['--bind', etcRoot, '/etc'] : []), + // the app's own folder stays writable (portable apps keep logs/data next + // to themselves). Last, because an imported app lives under the host's home + // and the home bind above would otherwise mount over and hide it - bwrap + // then fails with "execvp : No such file or directory". + ...(appDir ? ['--bind', appDir, appDir] : []), // --- environment --- '--setenv', 'HOME', home, '--setenv', 'XDG_CONFIG_HOME', xdgDir('.config'),