194 lines
7.2 KiB
JavaScript
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 }
|