'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//... (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/` 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 }