Compare commits

...

2 commits

Author SHA1 Message Date
3e063c1420 i hate those pfx links 2026-10-20 07:53:18 +02:00
02d296b7f1 fix syncing unselected apps from prefix 2026-10-20 07:44:11 +02:00
9 changed files with 163 additions and 25 deletions

View file

@ -77,7 +77,7 @@ Two PLWL installs on one LAN can mirror each other (sidebar → **LAN syncing**)
- **Device ID** (24 chars) and **Sync code** (8 chars, letters+digits, case-insensitive), both re-rollable. A caller must prove it knows its own code; each verification costs 2 s and one may run at a time, so guessing is pointless. A correct code still opens an **Accept/Decline popup** on the receiver (60 s timeout); declining or ignoring never touches this machine and keeps the pairing. - **Device ID** (24 chars) and **Sync code** (8 chars, letters+digits, case-insensitive), both re-rollable. A caller must prove it knows its own code; each verification costs 2 s and one may run at a time, so guessing is pointless. A correct code still opens an **Accept/Decline popup** on the receiver (60 s timeout); declining or ignoring never touches this machine and keeps the pairing.
- **Devices on your network** are found over UDP and visible only while PLWL runs there (a few seconds after it closes they are gone). - **Devices on your network** are found over UDP and visible only while PLWL runs there (a few seconds after it closes they are gone).
- Pick what to push: **portable apps**, **prefix-imported apps**, **Wine/Proton builds**, **prefixes**, **Sync settings**. Each category also has a **Select** list: everything starts ticked, and unchecking an app, a build or a prefix keeps that item on both machines untouched. Only what is ticked travels — without **Sync settings** the other machine's defaults and app configs are untouched. An accepted code is stored with the device ID, so later syncs need no typing; a re-rolled code is forgotten and asked for again. - Pick what to push: **portable apps**, **prefix-imported apps**, **Wine/Proton builds**, **prefixes**, **Sync settings**. Each category also has a **Select** list: everything starts ticked, and unchecking an app, a build or a prefix keeps that item on both machines untouched. Only what is ticked travels — without **Sync settings** the other machine's defaults and app configs are untouched, and an unticked app keeps its own configuration even when **Sync settings** is ticked. An accepted code is stored with the device ID, so later syncs need no typing; a re-rolled code is forgotten and asked for again.
A prefix-imported app's own folder lives inside its prefix, not in `files/apps/`. Unchecking such an app leaves that folder alone even when the prefix itself is synced: the sender takes it out of the prefix unit and marks it as off-limits for deletion. The picker says so when the app is ticked but its prefix is not — the folder then stays behind with the prefix. A prefix-imported app's own folder lives inside its prefix, not in `files/apps/`. Unchecking such an app leaves that folder alone even when the prefix itself is synced: the sender takes it out of the prefix unit and marks it as off-limits for deletion. The picker says so when the app is ticked but its prefix is not — the folder then stays behind with the prefix.
@ -85,6 +85,8 @@ Two PLWL installs on one LAN can mirror each other (sidebar → **LAN syncing**)
The **sender is the source of truth for what it sends**: differing files are overwritten, missing ones written, and extras inside the synced trees (`files/apps/`, `files/wines/`, `files/wineprefixes/`, `files/data/icons/`) are removed — but only after the receiver says so. Nothing is deleted while the bytes move: each unit only *plans* its leftovers, and the receiver gets one popup at the end with the top-most folders that would go (a fully redundant folder is a single collapsed row, a partly redundant one can be expanded). Every row is ticked to delete; unchecking a folder or a file keeps it. **Keep everything** is always one click away, an unanswered or expired list removes nothing, and a tree the sender could not fully read is never pruned at all, because a missing entry may only mean it was unreadable. The **sender is the source of truth for what it sends**: differing files are overwritten, missing ones written, and extras inside the synced trees (`files/apps/`, `files/wines/`, `files/wineprefixes/`, `files/data/icons/`) are removed — but only after the receiver says so. Nothing is deleted while the bytes move: each unit only *plans* its leftovers, and the receiver gets one popup at the end with the top-most folders that would go (a fully redundant folder is a single collapsed row, a partly redundant one can be expanded). Every row is ticked to delete; unchecking a folder or a file keeps it. **Keep everything** is always one click away, an unanswered or expired list removes nothing, and a tree the sender could not fully read is never pruned at all, because a missing entry may only mean it was unreadable.
A sync never changes what an existing path **is**. If the two machines lay the same prefix out differently — a real `pfx/` folder (Proton-style) on one, the unified `pfx` link on the other, or a synced tree that is a symlink on one side — that unit is **refused whole** with *layout-mismatch* naming the folder: nothing is written, replaced or deleted, because applying the sender's links would delete the receiver's real folder. Reconcile that folder on one machine and sync again. Writes also never travel through a link inside a synced tree, so nothing can land outside `files/`.
That review is the only deletion path, so no backup copy is ever made — the files simply stay on disk until the answer. Two things it cannot undo: files that exist on both sides with different content are **overwritten** with the sender's version (the popup says so; if a game is installed on both computers, leave its prefix out of the sync), and a file kept in one sync is proposed again the next time, since the mirror is recomputed from scratch every run. That review is the only deletion path, so no backup copy is ever made — the files simply stay on disk until the answer. Two things it cannot undo: files that exist on both sides with different content are **overwritten** with the sender's version (the popup says so; if a game is installed on both computers, leave its prefix out of the sync), and a file kept in one sync is proposed again the next time, since the mirror is recomputed from scratch every run.
Only one sync runs at a time (a second attempt on the same machine answers *busy*), and a transfer cut off mid-file leaves no half-written file behind. 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 peer that predates the review cannot sync units at all (it would prune without asking); update both machines. Only one sync runs at a time (a second attempt on the same machine answers *busy*), and a transfer cut off mid-file leaves no half-written file behind. 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 peer that predates the review cannot sync units at all (it would prune without asking); update both machines.

View file

@ -326,6 +326,7 @@
"lansync.codeRerolled": "New sync code generated", "lansync.codeRerolled": "New sync code generated",
"lansync.unknownError": "unknown error", "lansync.unknownError": "unknown error",
"lansync.oldPeer": "The other device runs an older PLWL. Update it — without the deletion review this sync is not safe.", "lansync.oldPeer": "The other device runs an older PLWL. Update it — without the deletion review this sync is not safe.",
"lansync.layoutMismatch": "The other device lays out {0} differently (e.g. a real pfx folder instead of the link). Nothing was written or deleted — make that folder match on both machines.",
"lansync.reviewExpired": "The deletion review expired and nothing was removed.", "lansync.reviewExpired": "The deletion review expired and nothing was removed.",
"openwith.title": "Open with Wine", "openwith.title": "Open with Wine",
"openwith.file": "File", "openwith.file": "File",

View file

@ -326,6 +326,7 @@
"lansync.codeRerolled": "Wygenerowano nowy kod synchronizacji", "lansync.codeRerolled": "Wygenerowano nowy kod synchronizacji",
"lansync.unknownError": "nieznany błąd", "lansync.unknownError": "nieznany błąd",
"lansync.oldPeer": "Drugie urządzenie ma starszą wersję PLWL. Zaktualizuj je — bez przeglądu usunięć ta synchronizacja nie jest bezpieczna.", "lansync.oldPeer": "Drugie urządzenie ma starszą wersję PLWL. Zaktualizuj je — bez przeglądu usunięć ta synchronizacja nie jest bezpieczna.",
"lansync.layoutMismatch": "Drugie urządzenie ma inny układ plików w {0} (np. prawdziwy folder pfx zamiast linku). Nic nie zostało zapisane ani usunięte — ujednolić ten folder na obu urządzeniach.",
"lansync.reviewExpired": "Przegląd usunięć wygasł i nic nie zostało usunięte.", "lansync.reviewExpired": "Przegląd usunięć wygasł i nic nie zostało usunięte.",
"openwith.title": "Otwórz przez Wine", "openwith.title": "Otwórz przez Wine",
"openwith.file": "Plik", "openwith.file": "Plik",

View file

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

View file

@ -2004,6 +2004,7 @@ async function runLanSync(device, code, categories, force, selection) {
: err === 'rejected' ? t('lansync.rejected') : err === 'rejected' ? t('lansync.rejected')
: err === 'incoming-timeout' ? t('lansync.incomingTimedOut') : err === 'incoming-timeout' ? t('lansync.incomingTimedOut')
: err === 'old-peer' ? t('lansync.oldPeer') : err === 'old-peer' ? t('lansync.oldPeer')
: err.startsWith('layout-mismatch') ? t('lansync.layoutMismatch', err.split(':').slice(1).join(':') || '—')
: (err || t('lansync.unknownError')) : (err || t('lansync.unknownError'))
toast(t('lansync.failed', message), 'error') toast(t('lansync.failed', message), 'error')
} }

View file

@ -27,7 +27,10 @@ function keptBy(rel, keep) {
// One entry and, for a folder, everything in it. `full` means nothing inside // One entry and, for a folder, everything in it. `full` means nothing inside
// survives, so the whole subtree can be shown (and deleted) as one row. // survives, so the whole subtree can be shown (and deleted) as one row.
async function planEntry(abs, rel, keep) { // `protect` is a subtree that is not ours to plan at all, even when its parent
// is redundant: without it a wholesale-planned parent would swallow an app a
// selection left out.
async function planEntry(abs, rel, keep, protect) {
let st let st
try { try {
st = await fsp.lstat(abs) st = await fsp.lstat(abs)
@ -51,11 +54,15 @@ async function planEntry(abs, rel, keep) {
let bytes = 0 let bytes = 0
for (const entry of entries) { for (const entry of entries) {
const childRel = `${rel}/${entry.name}` const childRel = `${rel}/${entry.name}`
if (protect && protect.has(childRel)) {
survived++
continue
}
if (keep.has(childRel)) { if (keep.has(childRel)) {
survived++ survived++
continue continue
} }
const child = await planEntry(`${abs}/${entry.name}`, childRel, keep) const child = await planEntry(`${abs}/${entry.name}`, childRel, keep, protect)
if (!child) { if (!child) {
survived++ survived++
continue continue
@ -64,6 +71,8 @@ async function planEntry(abs, rel, keep) {
files += child.files files += child.files
bytes += child.bytes bytes += child.bytes
} }
// nothing inside goes: the folder itself stays, so there is no row to show
if (survived > 0 && !children.length) return null
return { path: rel, name: baseName(rel), type: 'dir', files, bytes, full: survived === 0, children } return { path: rel, name: baseName(rel), type: 'dir', files, bytes, full: survived === 0, children }
} }
@ -88,7 +97,7 @@ async function walkDeletions(abs, rel, keep, protect) {
} }
continue continue
} }
const node = await planEntry(`${abs}/${entry.name}`, childRel, keep) const node = await planEntry(`${abs}/${entry.name}`, childRel, keep, protect)
if (node) out.push(node) if (node) out.push(node)
} }
return out return out

View file

@ -107,6 +107,31 @@ function filesPath(rel) {
return abs return abs
} }
// A write may not travel through a link the tree grew: a manifest entry's real
// parent has to stay inside the tree's own real root (a whole tree symlinked to
// another disk keeps working; a link inside it cannot redirect a write out).
function parentInsideTree(rel) {
const abs = filesPath(rel)
if (!abs) return false
const marker = SYNC_ROOTS.find(root => rel === root.slice(0, -1) || rel.startsWith(root))
if (!marker) return false
let realTree
try {
realTree = fs.realpathSync(path.join(DIRS.FILES, marker))
} catch {
return true // the tree is not there yet: nothing to travel through
}
let dir = path.dirname(abs)
while (dir.length > 1) {
try {
const real = fs.realpathSync(dir)
return real === realTree || real.startsWith(realTree + path.sep)
} catch {}
dir = path.dirname(dir)
}
return true
}
// A sync unit may only own one of the sync trees, nothing else. // A sync unit may only own one of the sync trees, nothing else.
function ownedRoot(rel) { function ownedRoot(rel) {
const clean = String(rel || '').replace(/\\/g, '/').replace(/\/+$/, '') const clean = String(rel || '').replace(/\\/g, '/').replace(/\/+$/, '')
@ -848,6 +873,15 @@ async function handlePlan(state, session, url, req, res, hooks) {
continue continue
} }
skip.push(rel) skip.push(rel)
// The same folder may be named differently on this side (a unified
// `pfx` link is not followed by the walk): protect every spelling the
// local tree really uses, or the folder would be planned for deletion.
const abs = filesPath(rel)
if (!abs) continue
try {
const real = toPortablePath(fs.realpathSync(abs))
if (real && real !== rel && ownedRoot(real) && owned.some(root => real === root || real.startsWith(root + '/'))) skip.push(real)
} catch {}
} }
} }
} }
@ -939,6 +973,56 @@ async function handlePlan(state, session, url, req, res, hooks) {
session.unitSizes.set(key, unitTotalBytes) session.unitSizes.set(key, unitTotalBytes)
session.sizeBytes = (session.sizeBytes || 0) - previousSize + unitTotalBytes session.sizeBytes = (session.sizeBytes || 0) - previousSize + unitTotalBytes
const allowed = entries.filter(entry => withinRoots(entry.path, owned)) const allowed = entries.filter(entry => withinRoots(entry.path, owned))
// A manifest may never change what an existing path IS. Replacing a real
// folder with the sender's link would delete its contents (the unified
// `pfx` link against a Proton-style prefix is the everyday case), a link at
// a unit root would send every later write outside the tree, and an entry
// below a link the manifest itself creates is not something a real walk can
// produce. Such a unit is refused whole, before anything is written or
// pruned, and the sender reports it.
const linkPaths = new Set(allowed.filter(entry => entry.type === 'link').map(entry => entry.path))
const underLink = rel => {
let cur = rel
for (;;) {
const cut = cur.lastIndexOf('/')
if (cut < 0) return false
cur = cur.slice(0, cut)
if (linkPaths.has(cur)) return true
}
}
const conflicts = []
for (const entry of allowed) {
if (underLink(entry.path)) {
conflicts.push(entry.path)
continue
}
if (entry.type === 'link' && owned.includes(entry.path)) {
conflicts.push(entry.path)
continue
}
if ((entry.type === 'dir' || entry.type === 'link') && !parentInsideTree(entry.path)) {
conflicts.push(entry.path)
continue
}
const abs = filesPath(entry.path)
if (!abs) continue
let st = null
try {
st = await fsp.lstat(abs)
} catch {}
if (!st) continue
const existingLink = st.isSymbolicLink()
const existingDir = st.isDirectory() && !existingLink
// a real folder must not become a link or a file, and a link must not
// become a folder - each would delete a tree before the review exists
if (entry.type === 'link' && existingDir) conflicts.push(entry.path)
else if (entry.type === 'dir' && existingLink) conflicts.push(entry.path)
else if (entry.type === 'file' && st.isDirectory()) conflicts.push(entry.path)
}
if (conflicts.length) {
sendJson(res, 409, { ok: false, error: 'layout-mismatch', paths: conflicts.slice(0, 8) })
return
}
const identical = allowed.map(entry => results.get(entry.path) === true) const identical = allowed.map(entry => results.get(entry.path) === true)
// the mode each file has to end up with on this side // the mode each file has to end up with on this side
const modes = new Map() const modes = new Map()
@ -995,7 +1079,9 @@ async function handlePut(session, url, req, res, hooks) {
const rel = url.searchParams.get('path') || '' const rel = 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)) { // the path is inside the unit and inside the tree the tree really is, not
// just lexically (an entry under a link would write outside the launcher)
if (!unit || !abs || !unit.paths.has(rel) || !parentInsideTree(rel)) {
req.resume() req.resume()
return sendJson(res, 400, { ok: false, error: 'bad-path' }) return sendJson(res, 400, { ok: false, error: 'bad-path' })
} }

View file

@ -208,15 +208,24 @@ function createPusher(api) {
// A prefix app a selection leaves out stays on this side: its folder must not // A prefix app a selection leaves out stays on this side: its folder must not
// travel with the prefix, and the receiver must not prune it as a leftover. // travel with the prefix, and the receiver must not prune it as a leftover.
// Every unselected prefix app inside the unit is found by where its folder
// really is, not by the prefix name it was registered with. The path is named
// the way the prefix walk names it: a folder imported through the unified
// `pfx` link is "drive_c/..." in the manifest, and the stored spelling alone
// would never match - the folder would travel.
function prefixExcludes(name, sel) { function prefixExcludes(name, sel) {
const rootRel = `wineprefixes/${name}` const rootRel = `wineprefixes/${name}`
const out = [] const out = []
for (const game of library.loadGames()) { for (const game of library.loadGames()) {
if (api.appCategory(game) !== 'apps-prefix' || game.prefix !== name) continue if (api.appCategory(game) !== 'apps-prefix') continue
if (sel.has(`app:${game.id}`, game.id)) continue if (sel.has(`app:${game.id}`, game.id)) continue
const rel = String(toPortablePath(library.appFolder(game)) || '') const folder = library.appFolder(game)
const inPrefix = prefixes.prefixRelFor(name, folder)
const candidates = [inPrefix ? `${rootRel}/${inPrefix}` : '', String(toPortablePath(folder) || '')]
for (const rel of candidates) {
if (rel === rootRel || !rel.startsWith(rootRel + '/')) continue if (rel === rootRel || !rel.startsWith(rootRel + '/')) continue
out.push(rel) if (!out.includes(rel)) out.push(rel)
}
} }
return out return out
} }
@ -306,7 +315,10 @@ function createPusher(api) {
default: defs.default || '', default: defs.default || '',
prefixes: defs.prefixes.map(p => ({ name: p.name, created: p.created, locked: !!p.locked })) prefixes: defs.prefixes.map(p => ({ name: p.name, created: p.created, locked: !!p.locked }))
} }
payload.appSettings = games.map(g => ({ // an app a selection leaves out is untouched there, config included
payload.appSettings = games
.filter(g => sel.has(`app:${g.id}`, g.id))
.map(g => ({
id: g.id, id: g.id,
name: g.name, name: g.name,
wine: g.wine || 'default', wine: g.wine || 'default',
@ -537,7 +549,11 @@ function createPusher(api) {
emit('wait', unit, null, null, true) emit('wait', unit, null, null, true)
}) })
if (abort.dead) throw new Error('receiver-lost') if (abort.dead) throw new Error('receiver-lost')
if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`) if (!plan.data || !plan.data.ok) {
// a path makes a layout refusal actionable (which folder to reconcile)
const detail = plan.data && Array.isArray(plan.data.paths) && plan.data.paths.length ? `:${plan.data.paths[0]}` : ''
throw new Error(((plan.data && plan.data.error) || `http-${plan.status}`) + detail)
}
// every file in the unit, including the ones already identical there // every file in the unit, including the ones already identical there
for (const entry of entries) if (entry.type === 'file') summary.sizeBytes += entry.size || 0 for (const entry of entries) if (entry.type === 'file') summary.sizeBytes += entry.size || 0
const upload = new Set(plan.data.upload || []) const upload = new Set(plan.data.upload || [])

View file

@ -98,6 +98,28 @@ function ensureUnifiedPrefix(name) {
} }
} }
// The prefix-relative path the prefix's own tree walk gives this folder. The
// walk never follows the unified `pfx` link, so a folder stored through it
// ("pfx/drive_c/...") is really named "drive_c/..." there: resolve both sides
// to their real place, or an exclude/keep list would never match. Falls back
// to the plain lexical path when that is all there is (folder gone, or a link
// pointing out of the prefix).
function prefixRelFor(name, absPath) {
if (!name || !absPath) return ''
const root = prefixPath(name)
const abs = path.resolve(String(absPath))
const toRel = (base, target) => {
const rel = path.relative(base, target)
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return ''
return rel.split(path.sep).join('/')
}
try {
const real = toRel(fs.realpathSync(root), fs.realpathSync(abs))
if (real) return real
} catch {}
return toRel(root, abs)
}
// True when a path IS one of a prefix's own roots (folder, pfx/, drive_c): // True when a path IS one of a prefix's own roots (folder, pfx/, drive_c):
// importing one would gut the prefix. // importing one would gut the prefix.
function isPrefixRoot(p) { function isPrefixRoot(p) {
@ -184,4 +206,4 @@ function setDefaultPrefix(name) {
return { ok: true } return { ok: true }
} }
module.exports = { isPrefixRel, PREFIX_REL_RE, LINUX_PFX, 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, prefixRelFor, healPrefix, ensureUnifiedPrefix, MAIN }