a LOT sync fixes...

This commit is contained in:
olcxja 2026-10-01 17:12:53 +02:00
commit 806e173e43
14 changed files with 1073 additions and 240 deletions

View file

@ -5,6 +5,7 @@ const fsp = fs.promises
const path = require('path')
const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths')
const { dedupeDir } = require('./fsutil')
const { rewireSymlinks, restoreExecutableBits, EXEC_NAMES } = require('./symlinks')
const WINES_DIR = DIRS.WINES
const REGISTRY = path.join(DIRS.DATA, 'wines.json')
@ -109,12 +110,17 @@ async function refreshWines() {
const prev = readJson(REGISTRY, []).map(wineAbs)
const prevById = new Map(prev.map(w => [w.id, w]))
// A build whose path changed since last scan was moved (or the launcher
// itself was) - rewire its absolute symlinks to the current location.
// itself was) - rewire its absolute symlinks to the current location. A
// build that cannot be executed (no exec bit on proton/wine - exactly what
// a sync from a PLWL that predates permission transfer leaves behind) is
// healed here instead: two access() calls per build, and only a broken build
// pays for the walk. Without it such a build stays dead weight forever.
for (const w of found) {
const old = prevById.get(w.id)
if (!old || old.path !== w.path) {
const moved = !old || old.path !== w.path
if (moved || buildAuditFailed(w.path, w.wineBin)) {
try {
await fixBuildSymlinks(w.path)
await healBuild(w.path)
} catch {}
}
}
@ -190,77 +196,58 @@ function deleteWine(id) {
// that point to absolute paths from wherever the build lived before (zip
// layout, previous install dir). After the build or the whole launcher is
// moved those links dangle and Proton crashes during prefix setup
// (try_copy -> FileNotFoundError). Rewire every absolute symlink whose content
// can be located inside the build, always storing the new target as a
// *relative* path so the build stays relocatable forever.
async function collectSymlinks(dir, out) {
let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
return
}
for (const e of entries) {
const full = path.join(dir, e.name)
if (e.isSymbolicLink()) {
out.push(full)
} else if (e.isDirectory() && e.name !== 'dosdevices') {
// dosdevices/ holds prefix drive mappings (c:, z:, ...) which are
// *supposed* to point outside the build - never touch them.
await collectSymlinks(full, out)
}
}
}
function rewireTarget(root, link, target) {
if (!path.isAbsolute(target)) return null // already relocatable
// Absolute link that lives inside the build: make it relative.
if (target.startsWith(root + path.sep)) {
return path.relative(path.dirname(link), target)
}
// Absolute link to an old location: find the longest suffix of the target
// that exists inside the new build and point there instead.
let i = target.indexOf(path.sep, 1)
while (i !== -1) {
const rel = target.slice(i + 1)
if (rel) {
const candidate = path.join(root, rel)
if (!rel.split(path.sep).includes('..') && candidate.startsWith(root + path.sep) && exists(candidate)) {
return path.relative(path.dirname(link), candidate)
}
}
i = target.indexOf(path.sep, i + 1)
}
return null
}
// (try_copy -> FileNotFoundError). The rewiring itself lives in symlinks.js
// because prefixes, synced apps and game imports need exactly the same thing.
async function fixBuildSymlinks(root) {
const links = []
await collectSymlinks(root, links)
let fixed = 0
// A link may only become rewirable once another link it chains through has
// been fixed, so repeat until a full pass changes nothing.
for (let pass = 0; pass < 5; pass++) {
let changed = 0
for (const link of links) {
let target
try {
target = await fsp.readlink(link)
} catch {
continue
}
const newTarget = rewireTarget(root, link, path.normalize(target))
if (!newTarget || newTarget === target) continue
try {
await fsp.unlink(link)
await fsp.symlink(newTarget, link)
fixed++
changed++
} catch {}
}
if (!changed) break
return rewireSymlinks(root, [DIRS.FILES])
}
// Everything a freshly arrived build needs before it can run: relative links
// and executable binaries. Returns what was changed, for the callers' reports.
async function healBuild(root) {
const links = await fixBuildSymlinks(root)
const executables = await restoreExecutableBits(root)
return { links, executables }
}
function isExec(p) {
try {
fs.accessSync(p, fs.constants.X_OK)
return true
} catch {
return false
}
return fixed
}
// Where a wine/proton build keeps the programs wine itself spawns (wine,
// wineserver, wineboot, ...) and the loaders next to the shared libraries.
const AUDIT_DIRS = ['files/bin', 'bin', 'dist/bin', 'files/lib/wine/x86_64-unix', 'files/lib/wine/i386-unix']
// Is this build missing a permission it needs to run? A build synced from a
// PLWL that predates permission transfer has no executable bit anywhere, and
// that stays invisible until the user presses Play (spawn fails with EACCES).
// Auditing is a handful of directory listings - only a build that FAILS it
// pays for the full walk, and the audit says nothing about a healthy build on
// every later start.
function buildAuditFailed(root, wineBin) {
if (!root) return false
if (wineBin && !isExec(wineBin)) return true
for (const rel of AUDIT_DIRS) {
const dir = path.join(root, rel)
let entries = []
try {
entries = fs.readdirSync(dir, { withFileTypes: true })
} catch {
continue
}
const inBin = path.basename(rel) === 'bin'
for (const entry of entries) {
if (!entry.isFile()) continue
if (!inBin && !EXEC_NAMES.has(entry.name)) continue
if (!isExec(path.join(dir, entry.name))) return true
}
}
return false
}
async function importWineFolder(srcDir) {
@ -277,13 +264,13 @@ async function importWineFolder(srcDir) {
}
const dst = dedupeDir(WINES_DIR, path.basename(copySrc))
await fsp.cp(copySrc, dst, { recursive: true, verbatimSymlinks: true })
let symlinksFixed = 0
let healed = { links: 0, executables: 0 }
try {
symlinksFixed = await fixBuildSymlinks(dst)
healed = await healBuild(dst)
} catch {}
await refreshWines()
// the toast shows a portable-looking path (files/...) like everywhere else
return { ok: true, path: toPortablePath(dst), symlinksFixed }
return { ok: true, path: toPortablePath(dst), symlinksFixed: healed.links, executablesFixed: healed.executables }
}
module.exports = { refreshWines, getWines, getWine, resolveWine, setWineDisabled, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks }
module.exports = { refreshWines, getWines, getWine, resolveWine, setWineDisabled, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }