Compare commits

...

3 commits

Author SHA1 Message Date
6776dfb5ce bump to 0.5.2 2026-10-07 16:40:48 +02:00
d58967237c [linux apps] fix hostname bind 2026-10-07 16:40:26 +02:00
d21b873327 code cleanup 2026-10-07 16:29:40 +02:00
14 changed files with 48 additions and 218 deletions

View file

@ -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. - 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). - 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). - 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 <app>: 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. - 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. - 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). - 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 ### 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. - 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. - 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. - `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"). - 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. - 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). - 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). - 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. - 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. - 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). - 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. - 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/). - 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 ### 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/lansync-token.test.js # long-sync token expiry (sliding TTL)
node test/bench-sync.js # timing: cold vs cached sync 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/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-env.test.js # redirected HOME/XDG_* for both backends
node test/linux-import.test.js # ELF/AppImage/.desktop detection + icons node test/linux-import.test.js # ELF/AppImage/.desktop detection + icons
node test/linux-links.test.js # link opening: shim binds + mime seed node test/linux-links.test.js # link opening: shim binds + mime seed

View file

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

View file

@ -1597,10 +1597,10 @@ function renderProgressBar(bar, bytes, totalBytes) {
bar.value = Math.min(sent, bar.max) 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) { function barValues(info) {
let bytes = Math.max(0, Number(info.barBytes == null ? info.bytes : info.barBytes) || 0) let bytes = Math.max(0, Number(info.barBytes) || 0)
let total = Math.max(0, Number(info.barTotalBytes == null ? info.totalBytes : info.barTotalBytes) || 0) let total = Math.max(0, Number(info.barTotalBytes) || 0)
// a provisional total can be smaller than what already moved (it grows as // a provisional total can be smaller than what already moved (it grows as
// entries arrive): the bar reads full, never over 100% // entries arrive): the bar reads full, never over 100%
if (total > 0 && bytes > total) bytes = total if (total > 0 && bytes > total) bytes = total

View file

@ -42,7 +42,6 @@ function createHashCache(dataDir) {
let cache = null let cache = null
let dirty = false let dirty = false
let savedAt = 0 let savedAt = 0
const stats = { hits: 0, misses: 0 }
function load() { function load() {
if (cache) return cache if (cache) return cache
@ -80,11 +79,7 @@ function createHashCache(dataDir) {
// the cache dirty only when the timestamp is stale. // the cache dirty only when the timestamp is stale.
function hit(rel, size, mtimeMs) { function hit(rel, size, mtimeMs) {
const known = load()[rel] const known = load()[rel]
if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') { if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') return null
stats.misses++
return null
}
stats.hits++
if (Date.now() - (known[3] || 0) > 86400000) { if (Date.now() - (known[3] || 0) > 86400000) {
known[3] = Date.now() known[3] = Date.now()
dirty = true dirty = true
@ -111,35 +106,13 @@ function createHashCache(dataDir) {
cache = null cache = null
dirty = false dirty = false
savedAt = 0 savedAt = 0
stats.hits = 0
stats.misses = 0
try { try {
fs.rmSync(FILE, { force: true }) fs.rmSync(FILE, { force: true })
fs.rmSync(`${FILE}.tmp`, { force: true }) fs.rmSync(`${FILE}.tmp`, { force: true })
} catch {} } catch {}
} }
// Dropping the memory copy makes the next lookup re-read from disk (tests). return { HASH_ALGO, hashFile, hit, remember, cachedHash, save, clear }
function forget() {
cache = null
dirty = false
}
return {
HASH_ALGO,
hashFile,
hit,
remember,
cachedHash,
save,
clear,
forget,
stats: () => ({ ...stats, size: cache ? Object.keys(cache).length : 0, algo: HASH_ALGO }),
resetStats: () => {
stats.hits = 0
stats.misses = 0
}
}
} }
module.exports = { createHashCache, hashFile, HASH_ALGO } module.exports = { createHashCache, hashFile, HASH_ALGO }

View file

@ -170,7 +170,7 @@ function buildIco(images) {
planes = dims.planes planes = dims.planes
bpp = dims.bpp bpp = dims.bpp
} else { } else {
// legacy fallback: ICONDIRENTRY-style first bytes (PNG-era heuristic) // nothing measurable: the first bytes carry the size
w = img.readUInt8(0) || 256 w = img.readUInt8(0) || 256
h = img.readUInt8(1) || 256 h = img.readUInt8(1) || 256
colors = img.readUInt8(2) colors = img.readUInt8(2)

View file

@ -8,11 +8,11 @@ function registerLanSyncIpc({ getWindow }) {
ipcMain.handle('lansync:get', () => lansync.getState()) ipcMain.handle('lansync:get', () => lansync.getState())
ipcMain.handle('lansync:devices', () => lansync.listDevices()) ipcMain.handle('lansync:devices', () => lansync.listDevices())
ipcMain.handle('lansync:rerollDeviceId', () => { ipcMain.handle('lansync:rerollDeviceId', () => {
const identity = lansync.rerollDeviceId() const identity = lansync.rotateDeviceId()
return { deviceId: identity.deviceId } return { deviceId: identity.deviceId }
}) })
ipcMain.handle('lansync:rerollSyncCode', () => { ipcMain.handle('lansync:rerollSyncCode', () => {
const identity = lansync.rerollSyncCode() const identity = lansync.rotateSyncCode()
return { syncCode: identity.syncCode } return { syncCode: identity.syncCode }
}) })
ipcMain.handle('lansync:forget', (e, id) => { ipcMain.handle('lansync:forget', (e, id) => {

View file

@ -93,7 +93,7 @@ function createIdentity(configFile) {
function rememberDevice(id, code, name) { function rememberDevice(id, code, name) {
const cfg = identity() const cfg = identity()
const stored = String(code || '').trim().toUpperCase() 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) writeJson(configFile, cfg)
} }

View file

@ -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. // The only trees a sync may touch; the rest of files/ is local state.
const SYNC_ROOTS = ['apps/', 'wines/', 'wineprefixes/', 'data/icons/'] 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 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. // An app entry's category: portable imports own their apps/ folder.
function appCategory(game) { function appCategory(game) {
if (!game || typeof game !== 'object') return null if (!game || typeof game !== 'object') return null
if (game.importSource === 'prefix' || game.mode === 'prefix') return 'apps-prefix' if (game.importSource === 'prefix') return 'apps-prefix'
if (game.imported === true || game.mode === 'copy' || game.mode === 'move') return 'apps-portable' if (game.imported === true) return 'apps-portable'
return null return null
} }
// An app entry's files, read off its stored path; outside the data = not synced. // The list comes from the wire: anything this build does not know is ignored.
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
}
function normalizeCategories(list) { function normalizeCategories(list) {
const out = new Set() const out = new Set()
for (const raw of Array.isArray(list) ? list : []) { for (const raw of Array.isArray(list) ? list : []) {
const aliases = CATEGORY_ALIASES[String(raw || '').toLowerCase().replace(/[^a-z]+/g, '')] const name = String(raw || '')
for (const alias of aliases || []) out.add(alias) if (CATEGORIES.includes(name)) out.add(name)
} }
return [...out] return [...out]
} }
@ -183,28 +146,6 @@ function targetOf(target) {
return null 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 --------------------------------------------------------- // --- receiving side ---------------------------------------------------------
function sendJson(res, status, data) { 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 const PUT_IDLE_MS = Number(process.env.PLWL_LANSYNC_PUT_IDLE) || 60000
async function handlePut(session, url, req, res, hooks) { async function handlePut(session, url, req, res, hooks) {
// current senders put the pair in the query (percent-encoded); 0.5.x used const key = url.searchParams.get('unit') || ''
// headers, which cannot carry a code above U+00FF at all const rel = url.searchParams.get('path') || ''
const key = req.headers['x-plwl-unit'] || url.searchParams.get('unit') || ''
const rel = req.headers['x-plwl-path'] || url.searchParams.get('path') || ''
const unit = session.units.get(key) const unit = session.units.get(key)
const abs = filesPath(rel) const abs = filesPath(rel)
if (!unit || !abs || !unit.paths.has(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) { async function handleApply(session, req, res, hooks) {
const payload = await readJsonBody(req) const payload = await readJsonBody(req)
if (!payload) return sendJson(res, 400, { ok: false, error: 'bad-request' }) if (!payload) return sendJson(res, 400, { ok: false, error: 'bad-request' })
// 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 cats = new Set(normalizeCategories(payload.categories))
const appsDeleted = applyApps(payload, cats) const appsDeleted = applyApps(payload, cats)
const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0 const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0
@ -1091,9 +1030,6 @@ async function handleApply(session, req, res, hooks) {
sendJson(res, 200, { sendJson(res, 200, {
ok: true, ok: true,
deleted: removed, deleted: removed,
apps: { deleted: appsDeleted },
wines: { deleted: winesDeleted },
prefixes: { deleted: prefixesDeleted },
settings, settings,
defaults: settings ? settings.defaults : null defaults: settings ? settings.defaults : null
}) })
@ -1264,27 +1200,19 @@ const exported = {
startServer, startServer,
stop, stop,
stopServer, stopServer,
closeServer: stopServer,
close: stopServer,
httpJson, httpJson,
verifyPeer,
runSync, runSync,
appCategory, appCategory,
classifyApp,
CATEGORIES, CATEGORIES,
normalizeCategories, normalizeCategories,
serverPort, serverPort,
getServerPort: serverPort,
getState, getState,
listDevices, listDevices,
identity, identity,
loadIdentity: identity,
makeDeviceId, makeDeviceId,
makeSyncCode, makeSyncCode,
rotateDeviceId, rotateDeviceId,
rotateSyncCode, rotateSyncCode,
rerollDeviceId: rotateDeviceId,
rerollSyncCode: rotateSyncCode,
knownDevice, knownDevice,
knownPeers, knownPeers,
rememberDevice, rememberDevice,
@ -1293,9 +1221,6 @@ const exported = {
sameCode, sameCode,
HASH_ALGO: hashStore.HASH_ALGO, HASH_ALGO: hashStore.HASH_ALGO,
hashFile, hashFile,
hashCacheStats: hashStore.stats,
resetHashCacheStats: hashStore.resetStats,
forgetHashCache: hashStore.forget,
clearHashCache: hashStore.clear, clearHashCache: hashStore.clear,
HASH_CONCURRENCY, HASH_CONCURRENCY,
progressGate, progressGate,

View file

@ -283,42 +283,18 @@ function createPusher(api) {
deleted: 0, deleted: 0,
bytes: 0, bytes: 0,
// the popup reports "3 games, 1 prefix" instead of file counts // the popup reports "3 games, 1 prefix" instead of file counts
items: { apps: 0, wines: 0, prefixes: 0 }, 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 }
} }
} }
// Every sync resolves to the same shape, success or failure. // Every sync resolves to the same shape, success or failure.
function syncResult(ok, error, summary, events) { function syncResult(ok, error, summary) {
const buckets = { apps: summary.apps, prefixes: summary.prefixes, wines: summary.wines } return { ok, error: error || null, summary }
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
}
} }
// Push to the peer; a rejected code comes back as 'bad-code'. // Push to the peer; a rejected code comes back as 'bad-code'.
async function syncToDevice({ deviceId, device: target, code, categories, force, onProgress }) { async function syncToDevice({ deviceId, device: target, code, categories, force, onProgress }) {
const fail = (error, summary, events) => syncResult(false, error, summary || newSummary(0), events || []) const fail = (error, summary) => syncResult(false, error, summary || newSummary(0))
const device = target || api.listDevices().find(d => d.id === deviceId) const device = target || api.listDevices().find(d => d.id === deviceId)
if (!device || !device.address || !device.port) return fail('offline') if (!device || !device.address || !device.port) return fail('offline')
const cats = new Set(normalizeCategories(categories)) const cats = new Set(normalizeCategories(categories))
@ -329,10 +305,7 @@ function createPusher(api) {
const totals = { apps: 0, wines: 0, prefixes: 0, settings: 0 } const totals = { apps: 0, wines: 0, prefixes: 0, settings: 0 }
for (const unit of units) totals[bucketOf(unit.kind)] += 1 for (const unit of units) totals[bucketOf(unit.kind)] += 1
if (cats.has('settings')) totals.settings = 1 if (cats.has('settings')) totals.settings = 1
// every progress event is kept, so callers can report without subscribing
const events = []
const progress = data => { const progress = data => {
events.push(data)
if (onProgress) onProgress(data) if (onProgress) onProgress(data)
} }
@ -435,7 +408,6 @@ function createPusher(api) {
const roots = unit.roots.filter(ownedRoot) const roots = unit.roots.filter(ownedRoot)
if (!roots.length) continue if (!roots.length) continue
const kind = bucketOf(unit.kind) const kind = bucketOf(unit.kind)
const bucket = summary[kind]
summary.items[kind] += 1 summary.items[kind] += 1
itemsSeen += 1 itemsSeen += 1
barBytes = 0 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}`) if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`)
const upload = new Set(plan.data.upload || []) const upload = new Set(plan.data.upload || [])
const same = new Set(plan.data.same || []) const same = new Set(plan.data.same || [])
// per category: files created/overwritten vs already identical const filePaths = new Set(entries.filter(e => e.type === 'file').map(e => e.path))
const fileEntries = entries.filter(e => e.type === 'file')
const filePaths = new Set(fileEntries.map(e => e.path))
let sameFiles = 0 let sameFiles = 0
for (const p of same) if (filePaths.has(p)) sameFiles++ for (const p of same) if (filePaths.has(p)) sameFiles++
bucket.copied += fileEntries.length - sameFiles
bucket.skipped += sameFiles
summary.skipped += sameFiles summary.skipped += sameFiles
const pending = [] const pending = []
for (const entry of entries) { for (const entry of entries) {
@ -523,10 +491,7 @@ function createPusher(api) {
allDone += pending.length allDone += pending.length
allTotal += pending.length allTotal += pending.length
const commit = await httpJson(device, 'POST', `/plwl/unit/commit?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}`, { json: {} }) const commit = await httpJson(device, 'POST', `/plwl/unit/commit?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}`, { json: {} })
if (commit.data && commit.data.deleted) { if (commit.data && commit.data.deleted) summary.deleted += commit.data.deleted
summary.deleted += commit.data.deleted
bucket.deleted += commit.data.deleted
}
finished[kind] += 1 finished[kind] += 1
// a finished unit leaves a finished bar behind, even one that sent none // a finished unit leaves a finished bar behind, even one that sent none
barTotal = barTotal || 1 barTotal = barTotal || 1
@ -536,10 +501,6 @@ function createPusher(api) {
emit('registry', null, null, null, true) emit('registry', null, null, null, true)
const applied = await httpJson(device, 'POST', `/plwl/apply?token=${encodeURIComponent(token)}`, { json: buildRegistryPayload([...cats]), timeout: 60000 }) 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}`) 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 if (applied.data.deleted) summary.deleted += applied.data.deleted
// a sync with nothing to send still ends with a full bar // a sync with nothing to send still ends with a full bar
barTotal = barTotal || 1 barTotal = barTotal || 1
@ -552,11 +513,11 @@ function createPusher(api) {
} catch (err) { } catch (err) {
abort.dead = true abort.dead = true
await end() await end()
return fail(String((err && err.message) || err), summary, events) return fail(String((err && err.message) || err), summary)
} finally { } finally {
clearInterval(heartbeat) 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 // putFile is exposed for the stall tests (it is the piece that must never

View file

@ -109,8 +109,7 @@ function gamesFollowingDefault(running) {
}) })
} }
// Windows is the default: entries written before Linux apps existed have no // `kind` is only ever written as 'linux'; a missing one is a Windows app.
// kind at all.
function isLinuxGame(game) { function isLinuxGame(game) {
return !!game && game.kind === 'linux' return !!game && game.kind === 'linux'
} }

View file

@ -265,13 +265,11 @@ function syncDesktopConfig(root) {
const dst = path.join(root, 'config', rel) const dst = path.join(root, 'config', rel)
const hostHash = hashOf(src) const hostHash = hashOf(src)
if (!hostHash) continue if (!hostHash) continue
const known = marker[rel] if (fs.existsSync(dst)) {
if (fs.existsSync(dst) && known) { // only a marked copy is provably ours, and only while it is untouched
const current = hashOf(dst) const current = hashOf(dst)
// the app owns it now, or there is nothing to do if (current !== marker[rel] || current === hostHash) continue
if (current !== known || current === hostHash) continue
} }
// no marker entry: the copy is ours (seeded before the marker existed)
if (copyFromHost(src, dst, { refresh: true })) { if (copyFromHost(src, dst, { refresh: true })) {
marker[rel] = hostHash marker[rel] = hostHash
changed = true changed = true

View file

@ -24,25 +24,8 @@ function clearPending() {
pending = null pending = null
} }
// Fall back to English keys on older installs.
function textsFor(code) { function textsFor(code) {
let texts = {} return require('./i18n').getLang(code).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 }
} }
function resolveWine(id) { function resolveWine(id) {

View file

@ -88,28 +88,16 @@ async function healPrefixOnce(game) {
} }
} }
// Proton's STEAM_COMPAT_DATA_PATH must be a symlink to the launcher prefix. // 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 // a real compat dir already sitting there is not ours to replace.
// populated one is left alone.
function linkPrefix(compatData, prefixDir) { function linkPrefix(compatData, prefixDir) {
try { try {
fs.mkdirSync(path.dirname(compatData), { recursive: true }) fs.mkdirSync(path.dirname(compatData), { recursive: true })
} catch {} } catch {}
try { try {
const st = fs.lstatSync(compatData) if (!fs.lstatSync(compatData).isSymbolicLink()) return
if (st.isSymbolicLink()) { if (fs.readlinkSync(compatData) === prefixDir) return
if (fs.readlinkSync(compatData) === prefixDir) return fs.unlinkSync(compatData)
fs.unlinkSync(compatData)
} else if (st.isDirectory()) {
let names = []
try {
names = fs.readdirSync(compatData)
} catch {}
if (names.length) return
fs.rmdirSync(compatData)
} else {
return
}
} catch {} } catch {}
try { try {
fs.symlinkSync(prefixDir, compatData, 'dir') fs.symlinkSync(prefixDir, compatData, 'dir')

View file

@ -146,9 +146,6 @@ function buildBwrapArgs({
...(urlOpener && urlOpener.shim ? ['--ro-bind', urlOpener.shim, urlOpener.target] : []), ...(urlOpener && urlOpener.shim ? ['--ro-bind', urlOpener.shim, urlOpener.target] : []),
// hard profile: nothing of anybody's /home is even readable // hard profile: nothing of anybody's /home is even readable
...(hideHome ? ['--tmpfs', '/home', '--tmpfs', '/root', '--tmpfs', '/mnt', '--tmpfs', '/media'] : []), ...(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) --- // --- app data (host paths bound to a fresh HOME) ---
'--bind', path.join(dataRoot, 'home'), home, '--bind', path.join(dataRoot, 'home'), home,
'--bind', path.join(dataRoot, 'config'), xdgDir('.config'), '--bind', path.join(dataRoot, 'config'), xdgDir('.config'),
@ -158,6 +155,11 @@ function buildBwrapArgs({
// Wayland, PipeWire, PulseAudio and the session bus all live here // Wayland, PipeWire, PulseAudio and the session bus all live here
...(runUser ? ['--bind', runUser, runUser] : []), ...(runUser ? ['--bind', runUser, runUser] : []),
...(etcRoot ? ['--bind', etcRoot, '/etc'] : []), ...(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 <app>: No such file or directory".
...(appDir ? ['--bind', appDir, appDir] : []),
// --- environment --- // --- environment ---
'--setenv', 'HOME', home, '--setenv', 'HOME', home,
'--setenv', 'XDG_CONFIG_HOME', xdgDir('.config'), '--setenv', 'XDG_CONFIG_HOME', xdgDir('.config'),