i hate those pfx links
This commit is contained in:
parent
02d296b7f1
commit
3e063c1420
6 changed files with 105 additions and 16 deletions
|
|
@ -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.
|
||||
- **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.
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -326,6 +326,7 @@
|
|||
"lansync.codeRerolled": "New sync code generated",
|
||||
"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.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.",
|
||||
"openwith.title": "Open with Wine",
|
||||
"openwith.file": "File",
|
||||
|
|
|
|||
|
|
@ -326,6 +326,7 @@
|
|||
"lansync.codeRerolled": "Wygenerowano nowy kod synchronizacji",
|
||||
"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.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.",
|
||||
"openwith.title": "Otwórz przez Wine",
|
||||
"openwith.file": "Plik",
|
||||
|
|
|
|||
|
|
@ -2004,6 +2004,7 @@ async function runLanSync(device, code, categories, force, selection) {
|
|||
: err === 'rejected' ? t('lansync.rejected')
|
||||
: err === 'incoming-timeout' ? t('lansync.incomingTimedOut')
|
||||
: err === 'old-peer' ? t('lansync.oldPeer')
|
||||
: err.startsWith('layout-mismatch') ? t('lansync.layoutMismatch', err.split(':').slice(1).join(':') || '—')
|
||||
: (err || t('lansync.unknownError'))
|
||||
toast(t('lansync.failed', message), 'error')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,31 @@ function filesPath(rel) {
|
|||
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.
|
||||
function ownedRoot(rel) {
|
||||
const clean = String(rel || '').replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
|
|
@ -948,6 +973,56 @@ async function handlePlan(state, session, url, req, res, hooks) {
|
|||
session.unitSizes.set(key, unitTotalBytes)
|
||||
session.sizeBytes = (session.sizeBytes || 0) - previousSize + unitTotalBytes
|
||||
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)
|
||||
// the mode each file has to end up with on this side
|
||||
const modes = new Map()
|
||||
|
|
@ -1004,7 +1079,9 @@ async function handlePut(session, url, req, res, hooks) {
|
|||
const rel = url.searchParams.get('path') || ''
|
||||
const unit = session.units.get(key)
|
||||
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()
|
||||
return sendJson(res, 400, { ok: false, error: 'bad-path' })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -315,19 +315,22 @@ function createPusher(api) {
|
|||
default: defs.default || '',
|
||||
prefixes: defs.prefixes.map(p => ({ name: p.name, created: p.created, locked: !!p.locked }))
|
||||
}
|
||||
payload.appSettings = games.map(g => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
wine: g.wine || 'default',
|
||||
prefix: g.prefix || '',
|
||||
args: g.args || '',
|
||||
env: g.env || {},
|
||||
dllOverrides: g.dllOverrides || [],
|
||||
disableUmu: !!g.disableUmu,
|
||||
// Linux-only app config, part of the app's configuration as well
|
||||
isolation: g.isolation,
|
||||
forceX11: !!g.forceX11
|
||||
}))
|
||||
// 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,
|
||||
name: g.name,
|
||||
wine: g.wine || 'default',
|
||||
prefix: g.prefix || '',
|
||||
args: g.args || '',
|
||||
env: g.env || {},
|
||||
dllOverrides: g.dllOverrides || [],
|
||||
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)
|
||||
}
|
||||
if (cats.has('prefixes')) {
|
||||
|
|
@ -546,7 +549,11 @@ function createPusher(api) {
|
|||
emit('wait', unit, null, null, true)
|
||||
})
|
||||
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
|
||||
for (const entry of entries) if (entry.type === 'file') summary.sizeBytes += entry.size || 0
|
||||
const upload = new Set(plan.data.upload || [])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue