'use strict' const fs = require('fs') const fsp = fs.promises const path = require('path') const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths') const { dedupeDir, copyTree, treeSize, removeTree } = require('./fsutil') const { normalizeLabel } = require('./names') const { rewireSymlinks, restoreExecutableBits, EXEC_NAMES } = require('./symlinks') const WINES_DIR = DIRS.WINES const REGISTRY = path.join(DIRS.DATA, 'wines.json') const DEFAULT_KEY = 'defaultWine' function exists(p) { try { fs.accessSync(p) return true } catch { return false } } function detectType(dir) { if (exists(path.join(dir, 'proton'))) return 'proton' for (const rel of ['files/bin/wine', 'bin/wine', 'dist/bin/wine']) { if (exists(path.join(dir, rel))) return 'wine' } return null } function findWineBinary(dir) { for (const rel of ['files/bin/wine', 'bin/wine', 'dist/bin/wine']) { const p = path.join(dir, rel) if (exists(p)) return p } return null } function versionOf(wineBin, env) { try { const { execFileSync } = require('child_process') const out = execFileSync(wineBin, ['--version'], { timeout: 15000, env: { ...process.env, ...env, WINEPREFIX: path.join(DIRS.DATA, 'probe-prefix') } }) return String(out).trim().replace(/^wine-?/i, '') } catch { return '' } } function labelFor(type, dir, version) { const name = path.basename(dir) if (type === 'proton') return /GE|CachyOS|TKG/i.test(name) ? name : (version ? `Proton ${version}` : name) return version ? `Wine ${version}` : name } async function scanBuild(dir, depth) { const type = detectType(dir) if (type) { const entry = { id: path.basename(dir).toLowerCase().replace(/[^a-z0-9._-]+/g, '-'), name: path.basename(dir), path: dir, type } if (type === 'proton') { entry.wineBin = path.join(dir, 'proton') } else if (type === 'wine') { const bin = findWineBinary(dir) if (bin) { entry.wineBin = bin const ver = versionOf(bin, {}) if (ver) entry.version = ver } } entry.label = labelFor(type, dir, entry.version) return [entry] } if (depth <= 0) return [] const out = [] let names = [] try { names = await fsp.readdir(dir) } catch { return out } for (const name of names) { const sub = path.join(dir, name) let st try { st = await fsp.stat(sub) } catch { continue } if (st.isDirectory() && name !== 'wineprefixes' && name !== 'share') { out.push(...(await scanBuild(sub, depth - 1))) } } return out } async function refreshWines() { let found = [] try { found = await scanBuild(WINES_DIR, 2) } catch { found = [] } 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. 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) const moved = !old || old.path !== w.path if (moved || buildAuditFailed(w.path, w.wineBin)) { try { await healBuild(w.path) } catch {} } } const disabled = new Set(prev.filter(w => w.disabled).map(w => w.id)) // a display name the user gave the build has to survive every rescan, just // like the disabled flag (both live in the registry, not in the folder) const renamed = new Map(prev.filter(w => w.displayName).map(w => [w.id, w.displayName])) for (const w of found) { if (disabled.has(w.id)) w.disabled = true if (renamed.has(w.id)) w.displayName = renamed.get(w.id) applyDisplayName(w) } writeJson(REGISTRY, found.map(wineRel)) return found } // Stored relative to FILES (portable), resolved to absolute for use - same // scheme as the game registry. `id`, `name`, `label`, `version`, `type` pass through. function wineAbs(w) { return applyDisplayName({ ...w, path: resolvePortablePath(w.path), wineBin: resolvePortablePath(w.wineBin) }) } function wineRel(w) { return { ...w, path: toPortablePath(w.path), wineBin: toPortablePath(w.wineBin) } } function getWines() { return readJson(REGISTRY, []).map(wineAbs) } function getWine(id) { return getWines().find(w => w.id === id) || null } // What the user typed wins over the folder-derived label everywhere the // build is shown (library config, settings, Open with popup). Every registry // read goes through here, so a stale stored label can never leak out. function applyDisplayName(w) { if (w && w.displayName) w.label = w.displayName return w } // A build keeps its folder and id (apps point at the id, and prefix symlinks // point inside the folder), so "renaming" only changes the shown label. function setWineDisplayName(id, name) { const wines = getWines() const w = wines.find(x => x.id === id) if (!w) return { ok: false, error: 'notFound' } if (w.type === 'system') return { ok: false, error: 'isSystem' } const clean = normalizeLabel(name) if (!clean) return { ok: false, error: 'invalidName' } w.displayName = clean applyDisplayName(w) writeJson(REGISTRY, wines.map(wineRel)) return { ok: true, displayName: clean } } function setWineDisabled(id, disabled) { const wines = getWines() const w = wines.find(x => x.id === id) if (w) { w.disabled = !!disabled writeJson(REGISTRY, wines.map(wineRel)) } return wines } function getDefaultWine() { return require('./library').loadSettings().defaultWine || 'system' } // Resolve the wine build a run should use: 'default'/'system'/unknown all // fall back to System Wine. Shared by the games service and the ad-hoc // "open with" runner. function resolveWine(id) { const system = { id: 'system', type: 'system', label: 'System Wine' } const wanted = id === 'default' ? getDefaultWine() : id if (!wanted || wanted === 'system') return system return getWine(wanted) || system } function setDefaultWine(id) { const wines = getWines() if (id !== 'system' && !wines.some(w => w.id === id)) return { ok: false, error: 'notFound' } require('./library').saveSettings({ defaultWine: id }) return { ok: true, defaultWine: id } } // Same measured removal as a prefix (staging, then a bar driven by the files // walking out): a build folder is multi-GB. async function deleteWine(id, onProgress) { const wines = getWines() const w = wines.find(x => x.id === id) if (!w || w.system) return { ok: false, error: w ? 'isSystem' : 'notFound' } const emit = (phase, detail) => { if (onProgress) onProgress({ phase, label: w.label || w.name || id, ...detail }) } emit('count', { files: 0, bytes: 0 }) const total = await treeSize(w.path) await removeTree(w.path, p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes })) writeJson(REGISTRY, wines.filter(x => x.id !== id).map(wineRel)) if (getDefaultWine() === id) require('./library').saveSettings({ defaultWine: 'system' }) return { ok: true } } // --- symlink healing ------------------------------------------------------- // Wine/Proton builds contain symlinks (e.g. inside the bundled default prefix) // 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). The rewiring itself lives in symlinks.js // because prefixes, synced apps and game imports need exactly the same thing. async function fixBuildSymlinks(root) { 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 } } // 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, onProgress) { const type = detectType(srcDir) let copySrc = srcDir if (!type) { // the user picked a folder one level above the actual build let subs = [] try { subs = (await fsp.readdir(srcDir, { withFileTypes: true })).filter(e => e.isDirectory()) } catch {} const build = subs.find(s => detectType(path.join(srcDir, s.name))) if (build) copySrc = path.join(srcDir, build.name) } const dst = dedupeDir(WINES_DIR, path.basename(copySrc)) // a wine/proton build is big enough that the copy needs a real bar: count it // first (staging), then report bytes as they land while it copies const emit = (phase, detail) => { if (onProgress) onProgress({ phase, label: path.basename(dst), ...detail }) } emit('count', { files: 0, bytes: 0 }) const total = await treeSize(copySrc) await copyTree(copySrc, dst, p => emit('copy', { files: p.done, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes })) let healed = { links: 0, executables: 0 } try { 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: healed.links, executablesFixed: healed.executables } } module.exports = { refreshWines, getWines, getWine, resolveWine, setWineDisabled, setWineDisplayName, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }