fix syncing unselected apps from prefix

This commit is contained in:
olcxja 2026-10-20 07:44:11 +02:00
commit 02d296b7f1
5 changed files with 58 additions and 9 deletions

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

@ -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

@ -848,6 +848,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 {}
} }
} }
} }

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
} }

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 }