PLWL/src/wines.js

323 lines
10 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, pathExists } = 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'
const exists = pathExists
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]))
// heal moved builds and builds missing their exec bit; only a broken build
// pays for the walk
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))
// display name and disabled flag live in the registry and survive rescans
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 FILES-relative (portable); `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
}
// The user's label wins over the folder-derived one, everywhere.
function applyDisplayName(w) {
if (w && w.displayName) w.label = w.displayName
return w
}
// "Renaming" only changes the shown label; folder and id stay.
function setWineDisplayName(id, name) {
const clean = normalizeLabel(name)
if (!clean) return { ok: false, error: 'invalidName' }
return writeWineLabel(id, clean)
}
// Clears a custom label back to the folder-derived one (used by LAN sync).
function clearWineDisplayName(id) {
return writeWineLabel(id, '')
}
function writeWineLabel(id, clean) {
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' }
if (clean) {
w.displayName = clean
w.label = clean
} else {
if (!w.displayName) return { ok: true, displayName: '' }
delete w.displayName
w.label = labelFor(w.type, w.path, w.version)
}
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'
}
// 'default'/'system'/unknown all fall back to System Wine.
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 -------------------------------------------------------
// Builds contain absolute symlinks that dangle after a move and crash Proton
// during prefix setup; the rewiring lives in symlinks.js.
async function fixBuildSymlinks(root) {
return rewireSymlinks(root, [DIRS.FILES])
}
// Make a freshly arrived build runnable: relative links, exec bits.
async function healBuild(root) {
// a symlinked build folder is left alone: its target may be outside
let st = null
try {
st = fs.lstatSync(root)
} catch {
return { links: 0, executables: 0 }
}
if (!st.isDirectory()) return { links: 0, executables: 0 }
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 build keeps the programs wine spawns (wine, wineserver, ...).
const AUDIT_DIRS = ['files/bin', 'bin', 'dist/bin', 'files/lib/wine/x86_64-unix', 'files/lib/wine/i386-unix']
// Cheap audit for a build missing its exec bits (invisible until Play fails
// with EACCES); only a failing build pays for the full walk.
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))
// count first (staging), then report bytes as they land
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 }
}
// Pseudo entry for the distro's wine; version probe is best-effort.
function systemWine() {
let version = ''
try {
const { execFileSync } = require('child_process')
version = String(execFileSync('wine', ['--version'], { timeout: 15000 })).trim().replace(/^wine-?/i, '')
} catch {}
return { id: 'system', type: 'system', label: 'System Wine', version }
}
module.exports = { refreshWines, getWines, getWine, resolveWine, systemWine, setWineDisabled, setWineDisplayName, clearWineDisplayName, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }