From 4680d7f01495b0b9115086293fbee5d0da837d01 Mon Sep 17 00:00:00 2001 From: olcxja Date: Wed, 7 Oct 2026 14:56:12 +0200 Subject: [PATCH] [UNTESTED] fix linux apps/prefix sync --- README.md | 4 ++-- development.md | 7 ++++--- src/lansync-manifest.js | 3 --- src/lansync.js | 10 ++++++---- src/lansyncpush.js | 17 +++++++++++------ src/prefixes.js | 8 +------- 6 files changed, 24 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 039f34c..ec8ffc8 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ The import dialog starts with a choice: **Windows app (Wine / Proton)** or **Lin 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. +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 part of its prefix like everything else: a sync carries it with the prefix — Linux apps and their data travel in the same categories as the Windows ones. 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 two sandboxed levels also give the app its own namespaces: `/` is mounted read-only, so only **its home folder and its own app folder stay writable** — anything else fails with `EROFS`. Relaxed adds exactly one mount on top of that profile (the writable host filesystem described below); everything else on this page is the same at both levels. 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. @@ -81,7 +81,7 @@ Two PLWL installs on one LAN can mirror each other (sidebar → **LAN syncing**) The **sender is the source of truth**: differing files are overwritten, missing ones written, extras inside the synced trees (`files/apps/`, `files/wines/`, `files/wineprefixes/`, `files/data/icons/`) deleted. Files are compared with BLAKE2b-512 (SHA-512/SHA-256 fallback); **Force sync all files** skips the comparison. Hashes are cached per file (size + mtime), so an unchanged library costs nothing to re-sync. Permissions travel with the files, and symlinks are sent as **relative** paths whenever the target belongs to the launcher (a Proton prefix's links into its build keep working on the other machine); `dosdevices` and targets outside `files/` stay absolute, and links wine itself rewrote to absolute are relativized again on the wire, on import and before every run. -A sync also carries **how apps are set up**: wine build and prefix, args, env, DLL overrides, playtime and last played. **Sync settings** carries the registry-level part even when no files move: default wine/prefix, custom build names, prefix registry, every app's configuration and the library order. It only patches what already exists (unknown ids are ignored, unknown apps/prefixes stay), deletes nothing, and playtime only grows — the higher counter wins. Language, window settings, logs and external (leave) apps stay local. +A sync also carries **how apps are set up**: wine build and prefix, args, env, DLL overrides, a native Linux app's isolation level and Force X11, playtime and last played. **Sync settings** carries the registry-level part even when no files move: default wine/prefix, custom build names, prefix registry, every app's configuration and the library order. It only patches what already exists (unknown ids are ignored, unknown apps/prefixes stay), deletes nothing, and playtime only grows — the higher counter wins. Language, window settings, logs and external (leave) apps stay local. ## Wine & Proton support diff --git a/development.md b/development.md index 8c5d7d3..e8f0c86 100644 --- a/development.md +++ b/development.md @@ -106,11 +106,12 @@ 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 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, 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). - 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. - 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. +- 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). @@ -147,7 +148,7 @@ node test/linux-import.test.js # ELF/AppImage/.desktop detection + ico node test/linux-links.test.js # link opening: shim binds + mime seed node test/linux-open.test.js # what an app opens: the script + the launcher relay node test/games-service.test.js # run bookkeeping: the request watcher, Stop while starting -node test/linux-sync.test.js # linux_pfx stays out of a sync +node test/lansync-linux-apps.test.js # Linux apps + linux_pfx travel with their categories node test/linux-e2e.js # REAL runs: plain, relaxed (+ its mount), isolated, hand-off into a running app npm start # click through the flow you touched diff --git a/src/lansync-manifest.js b/src/lansync-manifest.js index 6536f8c..77d17be 100644 --- a/src/lansync-manifest.js +++ b/src/lansync-manifest.js @@ -6,7 +6,6 @@ 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 @@ -39,8 +38,6 @@ 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 3617ac8..430f725 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, isLocalOnlyRel } = prefixes +const { isPrefixRel } = prefixes const CONFIG = path.join(DIRS.DATA, 'lansync.json') @@ -432,8 +432,6 @@ 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 @@ -623,6 +621,9 @@ function applyAppSettings(payload) { if (from.env && typeof from.env === 'object' && JSON.stringify(from.env) !== JSON.stringify(game.env || {})) patch.env = from.env if (Array.isArray(from.dllOverrides) && JSON.stringify(from.dllOverrides) !== JSON.stringify(game.dllOverrides || [])) patch.dllOverrides = from.dllOverrides if (from.disableUmu !== undefined && !!from.disableUmu !== !!game.disableUmu) patch.disableUmu = !!from.disableUmu + // Linux-only app config travels with the app's configuration too + if (from.isolation !== undefined && from.isolation !== game.isolation) patch.isolation = from.isolation + if (from.forceX11 !== undefined && !!from.forceX11 !== !!game.forceX11) patch.forceX11 = !!from.forceX11 if (Object.keys(patch).length) { library.updateGame(game.id, patch) patched++ @@ -991,7 +992,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) { - // unit and path travel as headers: constant per request + // 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 unit = session.units.get(key) diff --git a/src/lansyncpush.js b/src/lansyncpush.js index 5c31d88..e330a48 100644 --- a/src/lansyncpush.js +++ b/src/lansyncpush.js @@ -118,18 +118,20 @@ function createPusher(api) { clearTimeout(timer) fn(value) } - // unit and path travel as headers: constant per request + // Unit and path travel in the query, percent-encoded: Node refuses any + // header value holding a code above U+00FF (ERR_INVALID_CHAR), which a + // real library has (Minecraft pack names, Polish file names), and that + // refusal used to kill the sync mid-unit. + const query = `token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unitKey)}&path=${encodeURIComponent(rel)}` const req = http.request( { host: device.address, port: device.port, - path: `/plwl/unit/file?token=${encodeURIComponent(token)}`, + path: `/plwl/unit/file?${query}`, method: 'PUT', headers: { 'content-type': 'application/octet-stream', - 'content-length': fs.statSync(abs).size, - 'x-plwl-unit': unitKey, - 'x-plwl-path': rel + 'content-length': fs.statSync(abs).size }, timeout: 0 }, @@ -254,7 +256,10 @@ function createPusher(api) { args: g.args || '', env: g.env || {}, dllOverrides: g.dllOverrides || [], - disableUmu: !!g.disableUmu + disableUmu: !!g.disableUmu, + // Linux-only app config, part of the app's configuration as well + isolation: g.isolation, + forceX11: !!g.forceX11 })) payload.appOrder = games.map(g => g.id) } diff --git a/src/prefixes.js b/src/prefixes.js index 560d151..477cc8e 100644 --- a/src/prefixes.js +++ b/src/prefixes.js @@ -46,12 +46,6 @@ 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)) @@ -188,4 +182,4 @@ function setDefaultPrefix(name) { return { ok: true } } -module.exports = { isPrefixRel, PREFIX_REL_RE, LINUX_PFX, isLocalOnlyRel, listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixLinuxRoot, prefixExists, prefixDriveRoots, isPrefixRoot, healPrefix, ensureUnifiedPrefix, MAIN } +module.exports = { isPrefixRel, PREFIX_REL_RE, LINUX_PFX, listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixLinuxRoot, prefixExists, prefixDriveRoots, isPrefixRoot, healPrefix, ensureUnifiedPrefix, MAIN }