diff --git a/package.json b/package.json index 89f4169..b27a1b4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "PLWL", "productName": "PLWL", - "version": "0.5.4", + "version": "0.6.0", "description": "Portable Linux Wine Launcher", "main": "src/main.js", "scripts": { diff --git a/src/lansync-plan.js b/src/lansync-plan.js index 98b108f..055ff05 100644 --- a/src/lansync-plan.js +++ b/src/lansync-plan.js @@ -27,7 +27,10 @@ function keptBy(rel, keep) { // 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. -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 try { st = await fsp.lstat(abs) @@ -51,11 +54,15 @@ async function planEntry(abs, rel, keep) { let bytes = 0 for (const entry of entries) { const childRel = `${rel}/${entry.name}` + if (protect && protect.has(childRel)) { + survived++ + continue + } if (keep.has(childRel)) { survived++ continue } - const child = await planEntry(`${abs}/${entry.name}`, childRel, keep) + const child = await planEntry(`${abs}/${entry.name}`, childRel, keep, protect) if (!child) { survived++ continue @@ -64,6 +71,8 @@ async function planEntry(abs, rel, keep) { files += child.files 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 } } @@ -88,7 +97,7 @@ async function walkDeletions(abs, rel, keep, protect) { } 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) } return out diff --git a/src/lansync.js b/src/lansync.js index ece9db4..6f45c6e 100644 --- a/src/lansync.js +++ b/src/lansync.js @@ -848,6 +848,15 @@ async function handlePlan(state, session, url, req, res, hooks) { continue } 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 {} } } } diff --git a/src/lansyncpush.js b/src/lansyncpush.js index d0e0b7f..d85a258 100644 --- a/src/lansyncpush.js +++ b/src/lansyncpush.js @@ -208,15 +208,24 @@ function createPusher(api) { // 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. + // 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) { const rootRel = `wineprefixes/${name}` const out = [] 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 - const rel = String(toPortablePath(library.appFolder(game)) || '') - if (rel === rootRel || !rel.startsWith(rootRel + '/')) continue - out.push(rel) + 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 (!out.includes(rel)) out.push(rel) + } } return out } diff --git a/src/prefixes.js b/src/prefixes.js index 600ac3b..cbe36bf 100644 --- a/src/prefixes.js +++ b/src/prefixes.js @@ -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): // importing one would gut the prefix. function isPrefixRoot(p) { @@ -184,4 +206,4 @@ function setDefaultPrefix(name) { 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 }