319 lines
11 KiB
JavaScript
319 lines
11 KiB
JavaScript
'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 }
|