PLWL/src/symlinks.js
2026-10-01 17:12:53 +02:00

194 lines
7.2 KiB
JavaScript

'use strict'
// Symlink and permission healing, shared by wine builds, prefixes, game
// imports and LAN sync.
//
// Two things break when a tree is copied between machines (or the launcher is
// just moved):
// - symlinks stored ABSOLUTE keep pointing at the path they had on the
// machine that wrote them. A Proton prefix is full of them (fonts, built-in
// program stubs, dlls, all pointing into its build), and a wine build's
// bundled default prefix has the same problem.
// - the executable bit is dropped by every content-piping copy
// (createWriteStream, fs.copyFile), which makes an imported `proton` or
// `wine` binary unrunnable - the launcher then cannot start that build at
// all.
//
// Everything here rewires a link to a RELATIVE path when the file it points at
// can be found locally, so the tree stays relocatable forever, and restores
// the executable bit on the binaries a wine/proton build needs.
const fsp = require('fs').promises
const fs = require('fs')
const path = require('path')
function exists(p) {
try {
fs.accessSync(p)
return true
} catch {
return false
}
}
// dosdevices/ holds the prefix's drive mappings (c:, z:, d:, com1 ...). They
// are *supposed* to point outside the prefix - at the filesystem root, a
// mounted CD or a serial port - and wine rewrites them itself, so they are
// never touched.
const SKIP_DIRS = new Set(['dosdevices'])
async function collectSymlinks(dir, out) {
let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (entry.isSymbolicLink()) out.push(full)
else if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) await collectSymlinks(full, out)
}
}
// A target that does not exist here YET can only be something PLWL itself
// creates later: Proton links files/data/fake-steam/appcache into its prefix,
// a prefix links into files/wines/<build>/... (see the fallback pass below).
// Restricting the fallback to these trees keeps an unrelated /usr/lib/... link
// from being mistaken for a wine build's own files/lib/ directory.
const ANCHOR_DIRS = new Set(['wines', 'wineprefixes', 'apps', 'data', 'translations'])
// True when `candidate` sits under `root` and its deepest EXISTING parent is at
// least two components inside it. `files/data/fake-steam/appcache` passes even
// before appcache is created, because files/data/fake-steam is already there;
// `files/bin/<x>` does not, because a single-component parent is no evidence
// that the link really belongs to the launcher.
function anchoredInside(root, candidate) {
let p = candidate
while (p.length > root.length + 1) {
p = path.dirname(p)
if (p === root || !p.startsWith(root + path.sep)) return false
if (exists(p)) return path.relative(root, p).split(path.sep).length >= 2
}
return false
}
// The new target for an absolute link: the same file, reached from the link's
// own directory. `roots` are the trees the target may live in - the tree being
// healed first, then (for a prefix linking into its build) the launcher's
// files/ root. When the target is not directly inside one of them, the longest
// suffix that exists here is used instead, which is what makes a link written
// on another machine work on this one.
function relativeTarget(link, target, roots) {
const dir = path.dirname(link)
for (const root of roots) {
if (!root) continue
if (target.startsWith(root + path.sep)) {
const rel = path.relative(dir, target)
if (rel && !path.isAbsolute(rel)) return rel
}
}
// Second chance for links whose file is not here yet (or whose parent chain
// only partially exists): the relative path is still the right one, and
// writing it now beats leaving a link that can never resolve.
for (const requireLeaf of [true, false]) {
for (const root of roots) {
if (!root) continue
let i = target.indexOf(path.sep, 1)
while (i !== -1) {
const rest = target.slice(i + 1)
if (rest && !rest.split(path.sep).includes('..')) {
const candidate = path.join(root, rest)
if (candidate.startsWith(root + path.sep)) {
const ok = requireLeaf
? exists(candidate)
: ANCHOR_DIRS.has(rest.replace(/\\/g, '/').split('/')[0]) && anchoredInside(root, candidate)
if (ok) {
const rel = path.relative(dir, candidate)
if (rel && !path.isAbsolute(rel)) return rel
}
}
}
i = target.indexOf(path.sep, i + 1)
}
}
}
return null
}
// Rewire every absolute symlink under `root` that can be expressed relative to
// `roots` (usually [root, DIRS.FILES]). Returns how many links changed. A link
// may only become rewirable once another link it chains through has been
// fixed, so the pass repeats until nothing changes.
async function rewireSymlinks(root, roots = []) {
if (!root) return 0
const candidates = [root, ...roots.filter(r => r && r !== root)]
const links = []
await collectSymlinks(root, links)
let fixed = 0
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
}
if (!path.isAbsolute(target)) continue // already relocatable
const next = relativeTarget(link, path.normalize(target), candidates)
if (!next || next === target) continue
try {
await fsp.unlink(link)
await fsp.symlink(next, link)
fixed++
changed++
} catch {}
}
if (!changed) break
}
return fixed
}
// Binaries a wine/proton build has to be able to execute. A copy that pipes
// file contents (the LAN sync, fs.copyFile) drops the bit, and without it
// `spawn` fails with EACCES and the build can never run. Windows .exe files
// are NOT in here on purpose: wine starts those itself, they need no bit.
const EXEC_NAMES = new Set(['proton', 'wine', 'wine64', 'wine-preloader', 'wine64-preloader', 'wineserver', 'winetricks', 'wineboot'])
// Give the exec bit back to a build's binaries (and anything in a bin/ dir).
// Used after a sync or an import - and to repair a build that was synced
// before the sync knew about permissions, without re-copying it.
async function restoreExecutableBits(root) {
if (!root) return 0
let fixed = 0
const walk = async dir => {
let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (entry.isSymbolicLink()) continue
if (entry.isDirectory()) {
await walk(full)
continue
}
if (!entry.isFile()) continue
const inBin = path.basename(dir) === 'bin'
if (!inBin && !EXEC_NAMES.has(entry.name) && !entry.name.endsWith('.sh')) continue
try {
const st = await fsp.stat(full)
if (st.mode & 0o111) continue
await fsp.chmod(full, (st.mode & 0o777) | 0o755)
fixed++
} catch {}
}
}
await walk(root)
return fixed
}
module.exports = { rewireSymlinks, restoreExecutableBits, collectSymlinks, relativeTarget, EXEC_NAMES }