a LOT sync fixes...
This commit is contained in:
parent
c0f6e802ed
commit
806e173e43
14 changed files with 1073 additions and 240 deletions
|
|
@ -6,20 +6,38 @@ const { DIRS, resolvePortablePath } = require('./paths')
|
|||
const { newGame, newGameId, addGame } = require('./library')
|
||||
const { extractIconFromExe } = require('./icons')
|
||||
const { listPrefixes, prefixDriveRoot } = require('./prefixes')
|
||||
const { rewireSymlinks } = require('./symlinks')
|
||||
|
||||
// Directories that typically hold installers/redistributables, not the game.
|
||||
const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc'])
|
||||
|
||||
// A game folder has to arrive as it was: symlinks are recreated (copyFile
|
||||
// silently dropped them, so a linked asset or save folder vanished) and the
|
||||
// permission bits are carried over (copyFile writes 0644, killing scripts and
|
||||
// anything the game executes itself).
|
||||
function copyRecursive(src, dst, onProgress) {
|
||||
return fsp.readdir(src, { withFileTypes: true }).then(async entries => {
|
||||
await fsp.mkdir(dst, { recursive: true })
|
||||
for (const e of entries) {
|
||||
const s = path.join(src, e.name)
|
||||
const d = path.join(dst, e.name)
|
||||
if (e.isDirectory()) {
|
||||
if (e.isSymbolicLink()) {
|
||||
let target = null
|
||||
try {
|
||||
target = await fsp.readlink(s)
|
||||
} catch {}
|
||||
if (!target) continue
|
||||
try {
|
||||
await fsp.symlink(target, d)
|
||||
} catch {}
|
||||
if (onProgress) onProgress(s)
|
||||
} else if (e.isDirectory()) {
|
||||
await copyRecursive(s, d, onProgress)
|
||||
} else if (e.isFile()) {
|
||||
await fsp.copyFile(s, d)
|
||||
try {
|
||||
await fsp.chmod(d, (await fsp.stat(s)).mode & 0o777)
|
||||
} catch {}
|
||||
if (onProgress) onProgress(s)
|
||||
}
|
||||
}
|
||||
|
|
@ -47,6 +65,12 @@ async function importGame(srcDir, mode, onProgress) {
|
|||
if (mode === 'copy') await copyRecursive(srcDir, dst, onProgress)
|
||||
else await moveFolder(srcDir, dst, onProgress)
|
||||
workdir = dst
|
||||
// anything the game linked to INSIDE the launcher data (its own folder, an
|
||||
// icon, a shared prefix file) becomes a relative link, so the imported app
|
||||
// survives the launcher being moved - the same healing wine builds get
|
||||
try {
|
||||
await rewireSymlinks(dst, [DIRS.FILES])
|
||||
} catch {}
|
||||
}
|
||||
const exePath = (await findDefaultExe(workdir)) || preExe
|
||||
if (!exePath) {
|
||||
|
|
|
|||
|
|
@ -20,16 +20,50 @@ function sendToWindow(win, channel, data) {
|
|||
// Game-process state: id -> pid (or null while the process is starting).
|
||||
// onGamesChanged fires in the MAIN process whenever the running-games set
|
||||
// changes (the tray menu rebuilds itself from it).
|
||||
const STARTUP_GRACE_MS = 60000
|
||||
|
||||
function createGamesService({ getWindow, onGamesChanged }) {
|
||||
const running = new Map()
|
||||
const startedAt = new Map()
|
||||
const killRequests = new Set()
|
||||
let onGameExit = null
|
||||
const notifyChanged = () => {
|
||||
if (typeof onGamesChanged === 'function') onGamesChanged()
|
||||
}
|
||||
|
||||
const isRunning = id => running.has(id)
|
||||
const listRunning = () => [...running.keys()]
|
||||
function alive(pid) {
|
||||
if (!pid) return false
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// A game is "running" only while its process really is. An entry left behind
|
||||
// by a launch that never got a pid (the binary could not be executed, so the
|
||||
// spawn failed) or whose pid is gone (the game crashed, the launcher was
|
||||
// restarted) would otherwise answer every later Play with "already
|
||||
// running".
|
||||
function prune() {
|
||||
for (const [id, pid] of [...running]) {
|
||||
if (alive(pid)) continue
|
||||
const age = Date.now() - (startedAt.get(id) || 0)
|
||||
if (!pid && age < STARTUP_GRACE_MS) continue // may still be starting
|
||||
running.delete(id)
|
||||
startedAt.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
const isRunning = id => {
|
||||
prune()
|
||||
return running.has(id)
|
||||
}
|
||||
const listRunning = () => {
|
||||
prune()
|
||||
return [...running.keys()]
|
||||
}
|
||||
|
||||
// "Open with PLWL" handoff: queue the file for the renderer (picker popup
|
||||
// with wine version + prefix). Safe to call at any time - if the window is
|
||||
|
|
@ -45,6 +79,7 @@ function createGamesService({ getWindow, onGamesChanged }) {
|
|||
|
||||
function handleExit(id) {
|
||||
running.delete(id)
|
||||
startedAt.delete(id)
|
||||
sendToWindow(getWindow(), 'games:changed')
|
||||
notifyChanged()
|
||||
if (onGameExit) onGameExit(id)
|
||||
|
|
@ -53,42 +88,59 @@ function createGamesService({ getWindow, onGamesChanged }) {
|
|||
async function start(win, id) {
|
||||
const game = library.getGame(id)
|
||||
if (!game) return { ok: false, error: 'game-not-found' }
|
||||
prune()
|
||||
if (running.has(id)) return { ok: false, error: 'already-running' }
|
||||
running.set(id, null)
|
||||
startedAt.set(id, Date.now())
|
||||
let wine
|
||||
try {
|
||||
wine = resolveWine(game)
|
||||
} catch {
|
||||
running.delete(id)
|
||||
startedAt.delete(id)
|
||||
return { ok: false, error: 'wine-not-found' }
|
||||
}
|
||||
try {
|
||||
const res = await runGame({ game, wine, systemWine: wine.type === 'system', onExit: () => handleExit(id) })
|
||||
if (res.pid) running.set(id, res.pid)
|
||||
// stop was requested while the process was still starting
|
||||
// stop was requested while the process was still starting: kill it and
|
||||
// leave nothing in the running set
|
||||
if (killRequests.has(id)) {
|
||||
killRequests.delete(id)
|
||||
if (res.pid) killProcessGroup(res.pid)
|
||||
running.delete(id)
|
||||
startedAt.delete(id)
|
||||
sendToWindow(win, 'games:changed')
|
||||
notifyChanged()
|
||||
return { ok: true, pid: null, logFile: res.logFile || null }
|
||||
}
|
||||
// no pid means the process was not started after all - never keep such
|
||||
// an entry, it would block the next launch with "already running"
|
||||
if (!res.pid) {
|
||||
running.delete(id)
|
||||
startedAt.delete(id)
|
||||
return { ok: false, error: 'not-started' }
|
||||
}
|
||||
running.set(id, res.pid)
|
||||
sendToWindow(win, 'games:changed')
|
||||
notifyChanged()
|
||||
return { ok: true, pid: res.pid || null, logFile: res.logFile || null }
|
||||
return { ok: true, pid: res.pid, logFile: res.logFile || null }
|
||||
} catch (err) {
|
||||
running.delete(id)
|
||||
startedAt.delete(id)
|
||||
return { ok: false, error: String((err && err.message) || err) }
|
||||
}
|
||||
}
|
||||
|
||||
// Stop always clears the state: whether the process was running, still
|
||||
// starting (kill request for as soon as it has a pid) or already gone.
|
||||
async function kill(id) {
|
||||
const pid = running.get(id)
|
||||
if (pid) {
|
||||
killProcessGroup(pid)
|
||||
running.delete(id)
|
||||
sendToWindow(getWindow(), 'games:changed')
|
||||
notifyChanged()
|
||||
} else if (running.has(id)) {
|
||||
killRequests.add(id)
|
||||
}
|
||||
if (pid) killProcessGroup(pid)
|
||||
else if (running.has(id)) killRequests.add(id)
|
||||
running.delete(id)
|
||||
startedAt.delete(id)
|
||||
sendToWindow(getWindow(), 'games:changed')
|
||||
notifyChanged()
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
|
|
|
|||
411
src/lansync.js
411
src/lansync.js
|
|
@ -16,6 +16,7 @@ const fsp = fs.promises
|
|||
const readline = require('readline')
|
||||
|
||||
const { DIRS, readJson, writeJson, toPortablePath } = require('./paths')
|
||||
const { rewireSymlinks, restoreExecutableBits } = require('./symlinks')
|
||||
const library = require('./library')
|
||||
const wines = require('./wines')
|
||||
const prefixes = require('./prefixes')
|
||||
|
|
@ -44,7 +45,7 @@ const SYNC_ROOTS = ['apps/', 'wines/', 'wineprefixes/', 'data/icons/']
|
|||
|
||||
// What a sync can carry, plus lenient aliases so callers can name the
|
||||
// categories the way the UI/README does.
|
||||
const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes']
|
||||
const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'defaults']
|
||||
|
||||
const CATEGORY_ALIASES = {
|
||||
// plain "apps" means the whole app library: portable ones AND the ones
|
||||
|
|
@ -64,7 +65,13 @@ const CATEGORY_ALIASES = {
|
|||
wineprefix: ['prefixes'],
|
||||
wineprefixes: ['prefixes'],
|
||||
prefixes: ['prefixes'],
|
||||
prefix: ['prefixes']
|
||||
prefix: ['prefixes'],
|
||||
// the launcher's own defaults ("Default settings"): which wine everything
|
||||
// falls back to and where a new import lands
|
||||
defaults: ['defaults'],
|
||||
default: ['defaults'],
|
||||
defaultsettings: ['defaults'],
|
||||
settings: ['defaults']
|
||||
}
|
||||
|
||||
// Which sync category an app entry belongs to. Portable copy/move imports own
|
||||
|
|
@ -231,10 +238,92 @@ const HASH_ALGO = (() => {
|
|||
return 'sha256'
|
||||
})()
|
||||
|
||||
// Hashing a tree is dominated by opening the (often tiny) files, not by the
|
||||
// digest itself: a few in flight at once keeps the disk and the cpu busy
|
||||
// instead of waiting on one file at a time.
|
||||
const HASH_CONCURRENCY = Math.max(4, Math.min(16, (os.cpus() || []).length || 4))
|
||||
// One megabyte per read: a 64 kB stream chunk spends most of its time in
|
||||
// callbacks when the file is large.
|
||||
const HASH_CHUNK = 1024 * 1024
|
||||
|
||||
// Hash results are remembered per file (size + mtime), so a library that did
|
||||
// not change since the last sync is never read again - only stat'ed.
|
||||
const HASH_CACHE_FILE = path.join(DIRS.DATA, 'lansync-hashes.json')
|
||||
const HASH_CACHE_MAX = 250000
|
||||
let hashCache = null
|
||||
let hashCacheDirty = false
|
||||
let hashCacheSavedAt = 0
|
||||
const hashStats = { hits: 0, misses: 0 }
|
||||
|
||||
function hashCacheStats() {
|
||||
return { ...hashStats, size: hashCache ? Object.keys(hashCache).length : 0, algo: HASH_ALGO }
|
||||
}
|
||||
|
||||
function resetHashCacheStats() {
|
||||
hashStats.hits = 0
|
||||
hashStats.misses = 0
|
||||
}
|
||||
|
||||
// Dropping the in-memory copy makes the next lookup re-read the file from
|
||||
// disk - what tests use to prove a file was hashed instead of remembered.
|
||||
function forgetHashCache() {
|
||||
hashCache = null
|
||||
hashCacheDirty = false
|
||||
}
|
||||
|
||||
function loadHashCache() {
|
||||
if (!hashCache) {
|
||||
const stored = readJson(HASH_CACHE_FILE, null)
|
||||
hashCache = stored && typeof stored === 'object' && stored.files && typeof stored.files === 'object' ? stored.files : {}
|
||||
}
|
||||
return hashCache
|
||||
}
|
||||
|
||||
// Written once per operation at most: this file can hold a quarter million
|
||||
// entries, and rewriting it per hashed file would cost more than the hashing.
|
||||
function saveHashCache() {
|
||||
if (!hashCacheDirty || !hashCache) return
|
||||
const now = Date.now()
|
||||
if (now - hashCacheSavedAt < 60000) return
|
||||
const names = Object.keys(hashCache)
|
||||
if (names.length > HASH_CACHE_MAX) {
|
||||
names.sort((a, b) => (hashCache[b][3] || 0) - (hashCache[a][3] || 0))
|
||||
const trimmed = {}
|
||||
for (const name of names.slice(0, HASH_CACHE_MAX)) trimmed[name] = hashCache[name]
|
||||
hashCache = trimmed
|
||||
}
|
||||
hashCacheDirty = false
|
||||
hashCacheSavedAt = now
|
||||
try {
|
||||
writeJson(HASH_CACHE_FILE, { v: 1, algo: HASH_ALGO, files: hashCache })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Hash of a file we already stat'ed: size and mtime unchanged means the bytes
|
||||
// are the same, and the digest from an earlier sync still describes them.
|
||||
function cachedHash(rel, abs, size, mtimeMs) {
|
||||
const cache = loadHashCache()
|
||||
const known = cache[rel]
|
||||
if (known && known[0] === size && known[1] === mtimeMs && typeof known[2] === 'string') {
|
||||
hashStats.hits++
|
||||
known[3] = Date.now()
|
||||
hashCacheDirty = true
|
||||
return Promise.resolve(known[2])
|
||||
}
|
||||
hashStats.misses++
|
||||
return hashFile(abs).then(hash => {
|
||||
if (hash) {
|
||||
cache[rel] = [size, mtimeMs, hash, Date.now()]
|
||||
hashCacheDirty = true
|
||||
}
|
||||
return hash
|
||||
})
|
||||
}
|
||||
|
||||
function hashFile(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash(HASH_ALGO)
|
||||
const stream = fs.createReadStream(file)
|
||||
const stream = fs.createReadStream(file, { highWaterMark: HASH_CHUNK })
|
||||
stream.on('error', reject)
|
||||
stream.on('data', chunk => hash.update(chunk))
|
||||
stream.on('end', () => resolve(hash.digest('hex')))
|
||||
|
|
@ -259,6 +348,45 @@ function ownedRoot(rel) {
|
|||
return SYNC_ROOTS.some(prefix => clean.startsWith(prefix)) ? clean : null
|
||||
}
|
||||
|
||||
// Progress is cheap to produce and expensive to render: hashing or copying a
|
||||
// prefix can emit tens of thousands of events, and forwarding every single one
|
||||
// over IPC makes the window stutter instead of showing progress. Events are
|
||||
// rate limited to ~10/s per phase; `force` lets a caller push the value that
|
||||
// matters (the end of a unit) through immediately.
|
||||
const PROGRESS_MIN_MS = 100
|
||||
|
||||
function progressGate(emit) {
|
||||
let lastAt = 0
|
||||
let pending = null
|
||||
let timer = null
|
||||
const flush = () => {
|
||||
timer = null
|
||||
if (!pending) return
|
||||
const data = pending
|
||||
pending = null
|
||||
lastAt = Date.now()
|
||||
emit(data)
|
||||
}
|
||||
return (data, force) => {
|
||||
const now = Date.now()
|
||||
if (force || now - lastAt >= PROGRESS_MIN_MS) {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
pending = null
|
||||
lastAt = now
|
||||
emit(data)
|
||||
return
|
||||
}
|
||||
pending = data
|
||||
if (!timer) {
|
||||
timer = setTimeout(flush, PROGRESS_MIN_MS - (now - lastAt))
|
||||
if (timer.unref) timer.unref()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function mapLimit(items, limit, fn) {
|
||||
const out = new Array(items.length)
|
||||
let next = 0
|
||||
|
|
@ -272,10 +400,27 @@ async function mapLimit(items, limit, fn) {
|
|||
return out
|
||||
}
|
||||
|
||||
// Symlink targets travel RELATIVE whenever they point inside the launcher data
|
||||
// (a prefix linking into its wine build, an app linking to its own assets): the
|
||||
// mirrored trees have the same internal layout on both machines, so a relative
|
||||
// target is correct over there while an absolute one would point at the
|
||||
// sender's own folders - which is exactly how a synced prefix ends up with
|
||||
// hundreds of dangling links. Targets outside files/ (a system font, a mounted
|
||||
// drive, /) are machine-specific and stay as they are.
|
||||
function portableLinkTarget(linkAbs, target) {
|
||||
if (typeof target !== 'string' || !target || !path.isAbsolute(target)) return target
|
||||
const rel = path.relative(path.dirname(linkAbs), target)
|
||||
if (!rel || path.isAbsolute(rel)) return target
|
||||
const root = path.resolve(DIRS.FILES)
|
||||
const resolved = path.resolve(path.dirname(linkAbs), rel)
|
||||
if (resolved !== root && !resolved.startsWith(root + path.sep)) return target
|
||||
return rel
|
||||
}
|
||||
|
||||
// Walk without following symlinks: builds and prefixes contain links that are
|
||||
// part of what is being synced (dosdevices, build internals), and following
|
||||
// them would both loop and copy the wrong thing.
|
||||
async function scanTree(abs, rel, out) {
|
||||
async function scanTree(abs, rel, out, mtimes) {
|
||||
let entries
|
||||
try {
|
||||
entries = await fsp.readdir(abs, { withFileTypes: true })
|
||||
|
|
@ -290,18 +435,22 @@ async function scanTree(abs, rel, out) {
|
|||
try {
|
||||
target = await fsp.readlink(absChild)
|
||||
} catch {}
|
||||
out.push({ path: relChild, type: 'link', target })
|
||||
out.push({ path: relChild, type: 'link', target: portableLinkTarget(absChild, target) })
|
||||
} else if (entry.isDirectory()) {
|
||||
out.push({ path: relChild, type: 'dir' })
|
||||
await scanTree(absChild, relChild, out)
|
||||
await scanTree(absChild, relChild, out, mtimes)
|
||||
} else if (entry.isFile()) {
|
||||
let size = 0
|
||||
let st = null
|
||||
try {
|
||||
size = (await fsp.stat(absChild)).size
|
||||
st = await fsp.stat(absChild)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
out.push({ path: relChild, type: 'file', size, hash: null })
|
||||
// mode travels with the file: a wine build's `proton`/`wine` binary is
|
||||
// useless on the other side without its executable bit
|
||||
out.push({ path: relChild, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null })
|
||||
// kept out of the manifest: only this machine can use its own mtimes
|
||||
if (mtimes) mtimes.set(relChild, st.mtimeMs || 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -310,6 +459,7 @@ async function scanTree(abs, rel, out) {
|
|||
// then treats every file as different ("force sync all files").
|
||||
async function buildManifest(roots, force, onProgress) {
|
||||
const entries = []
|
||||
const mtimes = new Map()
|
||||
for (const rel of roots) {
|
||||
const abs = filesPath(rel)
|
||||
if (!abs) continue
|
||||
|
|
@ -324,27 +474,35 @@ async function buildManifest(roots, force, onProgress) {
|
|||
try {
|
||||
target = await fsp.readlink(abs)
|
||||
} catch {}
|
||||
entries.push({ path: rel, type: 'link', target })
|
||||
entries.push({ path: rel, type: 'link', target: portableLinkTarget(abs, target) })
|
||||
} else if (st.isDirectory()) {
|
||||
entries.push({ path: rel, type: 'dir' })
|
||||
await scanTree(abs, rel, entries)
|
||||
await scanTree(abs, rel, entries, mtimes)
|
||||
} else if (st.isFile()) {
|
||||
entries.push({ path: rel, type: 'file', size: st.size, hash: null })
|
||||
entries.push({ path: rel, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null })
|
||||
mtimes.set(rel, st.mtimeMs || 0)
|
||||
}
|
||||
}
|
||||
const files = entries.filter(e => e.type === 'file')
|
||||
// the unit's total size is known before a byte is hashed, so the bar can
|
||||
// start empty and fill with the bytes actually read
|
||||
const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0)
|
||||
if (!force && files.length) {
|
||||
let done = 0
|
||||
await mapLimit(files, 4, async entry => {
|
||||
let hashed = 0
|
||||
await mapLimit(files, HASH_CONCURRENCY, async entry => {
|
||||
const abs = filesPath(entry.path)
|
||||
try {
|
||||
entry.hash = await hashFile(filesPath(entry.path))
|
||||
entry.hash = abs ? await cachedHash(entry.path, abs, entry.size, mtimes.get(entry.path) || 0) : null
|
||||
} catch {
|
||||
entry.hash = null
|
||||
}
|
||||
done++
|
||||
if (onProgress) onProgress(done, files.length)
|
||||
hashed += entry.size || 0
|
||||
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
|
||||
})
|
||||
}
|
||||
saveHashCache()
|
||||
return entries
|
||||
}
|
||||
|
||||
|
|
@ -498,11 +656,18 @@ async function entrySame(entry) {
|
|||
return false
|
||||
}
|
||||
if (!st.isFile() || st.size !== entry.size) return false
|
||||
let same = false
|
||||
try {
|
||||
return (await hashFile(abs)) === entry.hash
|
||||
same = (await cachedHash(entry.path, abs, st.size, st.mtimeMs || 0)) === entry.hash
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (!same) return false
|
||||
// Same bytes, wrong permissions (a build synced before modes travelled, or a
|
||||
// copy that dropped the bit): repaired right here instead of pulling the
|
||||
// whole file over the network again.
|
||||
if (typeof entry.mode === 'number' && (st.mode & 0o777) !== entry.mode) applyMode(abs, entry.mode)
|
||||
return true
|
||||
}
|
||||
|
||||
function ensureDir(abs) {
|
||||
|
|
@ -517,6 +682,24 @@ function ensureDir(abs) {
|
|||
} catch {}
|
||||
}
|
||||
|
||||
// After a unit has landed, its links and permissions have to be this machine's
|
||||
// (the sender stored whatever was true over there): absolute symlinks pointing
|
||||
// into the launcher data become relative, and a build gets back the executable
|
||||
// bit a content copy drops. Without this a synced prefix/proton build is dead
|
||||
// weight - the launcher cannot start it.
|
||||
async function healUnitRoots(roots) {
|
||||
const healed = { links: 0, executables: 0 }
|
||||
for (const rel of Array.isArray(roots) ? roots : []) {
|
||||
const abs = filesPath(ownedRoot(rel))
|
||||
if (!abs) continue
|
||||
try {
|
||||
healed.links += await rewireSymlinks(abs, [DIRS.FILES])
|
||||
if (String(rel).startsWith('wines/')) healed.executables += await restoreExecutableBits(abs)
|
||||
} catch {}
|
||||
}
|
||||
return healed
|
||||
}
|
||||
|
||||
function ensureLink(abs, target) {
|
||||
if (typeof target !== 'string' || !target) return
|
||||
let current = null
|
||||
|
|
@ -599,7 +782,16 @@ async function pruneTree(abs, rel, keep) {
|
|||
return removed
|
||||
}
|
||||
|
||||
function writeStream(abs, stream) {
|
||||
// Modes are applied after the bytes are in place: createWriteStream cannot set
|
||||
// one, and a wine build without its executable bit is unusable.
|
||||
function applyMode(abs, mode) {
|
||||
if (typeof mode !== 'number' || !mode) return
|
||||
try {
|
||||
fs.chmodSync(abs, mode & 0o777)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function writeStream(abs, stream, onBytes) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let st = null
|
||||
try {
|
||||
|
|
@ -629,6 +821,11 @@ function writeStream(abs, stream) {
|
|||
reject(err)
|
||||
}
|
||||
})
|
||||
if (onBytes) {
|
||||
// the pipe in flowing mode delivers every chunk to each 'data' listener,
|
||||
// so counting here does not steal the bytes from the file being written
|
||||
stream.on('data', chunk => onBytes(chunk.length))
|
||||
}
|
||||
stream.pipe(out)
|
||||
})
|
||||
}
|
||||
|
|
@ -650,12 +847,22 @@ function applyApps(payload, cats) {
|
|||
}
|
||||
kept.push(game)
|
||||
}
|
||||
// playtime is per machine, the sender does not push any - keep the counters
|
||||
// of apps this device already had
|
||||
const merged = [...kept, ...incoming.map(g => {
|
||||
// Playtime and the app's own settings (wine build, prefix, args, env,
|
||||
// dllOverrides) travel with the app: paths are left exactly as the sender
|
||||
// stored them (portable/prefix-relative), so a synced entry keeps working.
|
||||
// Playtime only ever GROWS - the higher counter wins, so pushing from a
|
||||
// freshly installed machine cannot wipe a library's play history.
|
||||
const merged = [...kept]
|
||||
for (const g of incoming) {
|
||||
const prev = existing.find(x => x.id === g.id)
|
||||
return prev ? { ...g, playSeconds: prev.playSeconds, lastPlayed: prev.lastPlayed } : g
|
||||
})]
|
||||
const hasPlay = Number.isFinite(Number(g.playSeconds))
|
||||
const playSeconds = hasPlay ? Math.max(Number(prev && prev.playSeconds) || 0, Number(g.playSeconds)) : prev && prev.playSeconds
|
||||
const lastPlayed = Math.max(Number(prev && prev.lastPlayed) || 0, Number(g.lastPlayed) || 0)
|
||||
merged.push({ ...g, playSeconds, lastPlayed })
|
||||
// the per-app playtime file wins over the registry, so a synced counter
|
||||
// has to be written there too or loadGames would keep reporting the old one
|
||||
if (hasPlay) library.writePlaySeconds(g.id, playSeconds)
|
||||
}
|
||||
library.saveGames(merged)
|
||||
let removed = 0
|
||||
for (const id of dropped) removed += removeTree(`data/icons/${id}`)
|
||||
|
|
@ -677,7 +884,7 @@ async function applyWines(payload) {
|
|||
return removed
|
||||
}
|
||||
|
||||
function applyPrefixes(payload) {
|
||||
function applyPrefixes(payload, useSenderDefault) {
|
||||
const sender = payload.prefixes && typeof payload.prefixes === 'object' ? payload.prefixes : {}
|
||||
const incoming = (Array.isArray(sender.prefixes) ? sender.prefixes : []).filter(p => p && p.name)
|
||||
const existing = prefixes.listPrefixes()
|
||||
|
|
@ -690,11 +897,39 @@ function applyPrefixes(payload) {
|
|||
}
|
||||
const removed = removeOrphans(DIRS.PREFIXES, new Set(keep.keys()))
|
||||
const list = [...keep.values()].map(p => ({ ...p, locked: p.name === prefixes.MAIN ? true : !!p.locked }))
|
||||
const def = keep.has(sender.default) ? sender.default : prefixes.MAIN
|
||||
// Which prefix is the default only moves when the user asked for the default
|
||||
// settings to be synced; otherwise this device keeps its own (as long as the
|
||||
// prefix still exists here).
|
||||
const wanted = useSenderDefault ? sender.default : existing.default
|
||||
const def = keep.has(wanted) ? wanted : prefixes.MAIN
|
||||
prefixes.savePrefixes({ default: def, prefixes: list })
|
||||
return removed
|
||||
}
|
||||
|
||||
// The launcher-wide defaults, asked for with the "Default settings" category:
|
||||
// the wine every app falls back to and the prefix new imports land in. Only
|
||||
// values that exist HERE are taken - a default pointing at a build or prefix
|
||||
// this device does not have would be worse than keeping the current one.
|
||||
function applyDefaults(payload, cats) {
|
||||
if (!cats.has('defaults')) return null
|
||||
const wanted = payload.defaults && typeof payload.defaults === 'object' ? payload.defaults : {}
|
||||
const applied = {}
|
||||
if (wanted.defaultWine) {
|
||||
const res = wines.setDefaultWine(String(wanted.defaultWine))
|
||||
applied.defaultWine = res && res.ok ? String(wanted.defaultWine) : null
|
||||
}
|
||||
if (wanted.defaultPrefix) {
|
||||
const defs = prefixes.listPrefixes()
|
||||
if (defs.prefixes.some(p => p.name === wanted.defaultPrefix)) {
|
||||
prefixes.setDefaultPrefix(String(wanted.defaultPrefix))
|
||||
applied.defaultPrefix = String(wanted.defaultPrefix)
|
||||
} else {
|
||||
applied.defaultPrefix = null
|
||||
}
|
||||
}
|
||||
return applied
|
||||
}
|
||||
|
||||
// --- HTTP server (what other devices talk to) -------------------------------
|
||||
|
||||
function createServerState() {
|
||||
|
|
@ -719,11 +954,19 @@ function emitReceive(hooks, session, stage, extra = {}) {
|
|||
deviceId: session.deviceId || '',
|
||||
unit: extra.unit || '',
|
||||
label: extra.unit || '',
|
||||
done: session.received || 0,
|
||||
total: session.planned || 0,
|
||||
done: extra.done == null ? session.received || 0 : extra.done,
|
||||
total: extra.total == null ? session.planned || 0 : extra.total,
|
||||
bytes: session.bytes || 0,
|
||||
totalBytes: session.plannedBytes || 0,
|
||||
// items: units this sync announced, itemsDone: units fully written,
|
||||
// totals: everything the sender picked (so the popup can say "2/5")
|
||||
items: { ...(session.items || {}) },
|
||||
itemsDone: { ...(session.itemsDone || {}) },
|
||||
totals: { ...(session.totals || {}) },
|
||||
// what the BAR shows: the phase being worked on right now, so hashing
|
||||
// starts empty instead of continuing a full bar from earlier
|
||||
barBytes: session.barBytes || 0,
|
||||
barTotalBytes: session.barTotal || 0,
|
||||
deleted: extra.deleted || 0
|
||||
})
|
||||
} catch {}
|
||||
|
|
@ -799,6 +1042,9 @@ async function handleVerify(state, req, res, hooks) {
|
|||
await new Promise(resolve => setTimeout(resolve, VERIFY_DELAY_MS))
|
||||
if (!sameCode(payload.code, me.syncCode)) return sendJson(res, 403, { ok: false, error: 'auth-failed', reason: 'wrong-code' })
|
||||
const token = crypto.randomBytes(24).toString('hex')
|
||||
// the sender may announce its totals with the verification, which is how
|
||||
// the popup here can show "Apps: 0/5" before the first file list arrives
|
||||
const announced = payload.totals && typeof payload.totals === 'object' ? payload.totals : {}
|
||||
const session = {
|
||||
token,
|
||||
deviceId: from,
|
||||
|
|
@ -813,7 +1059,15 @@ async function handleVerify(state, req, res, hooks) {
|
|||
planned: 0,
|
||||
bytes: 0,
|
||||
plannedBytes: 0,
|
||||
items: { apps: 0, wines: 0, prefixes: 0 }
|
||||
items: { apps: 0, wines: 0, prefixes: 0 },
|
||||
itemsDone: { apps: 0, wines: 0, prefixes: 0 },
|
||||
barBytes: 0,
|
||||
barTotal: 0,
|
||||
totals: {
|
||||
apps: Number(announced.apps) || 0,
|
||||
wines: Number(announced.wines) || 0,
|
||||
prefixes: Number(announced.prefixes) || 0
|
||||
}
|
||||
}
|
||||
state.tokens.set(token, session)
|
||||
state.session = session
|
||||
|
|
@ -843,21 +1097,52 @@ async function handlePlan(state, session, url, req, res, hooks) {
|
|||
await readNdjson(req, entry => {
|
||||
if (inlineSame(entry)) entries.push(entry)
|
||||
})
|
||||
// the mode each file has to end up with on this side
|
||||
const modes = new Map()
|
||||
for (const entry of entries) if (entry.type === 'file' && typeof entry.mode === 'number') modes.set(entry.path, entry.mode)
|
||||
// a force sync compares nothing: every entry counts as different
|
||||
const forced = url.searchParams.get('force') === '1'
|
||||
// how many apps/prefixes/wines the sender picked in total: without it the
|
||||
// popup here could only ever say what has already arrived
|
||||
try {
|
||||
const totals = JSON.parse(url.searchParams.get('totals') || 'null')
|
||||
if (totals && typeof totals === 'object') {
|
||||
session.totals = {
|
||||
apps: Number(totals.apps) || 0,
|
||||
wines: Number(totals.wines) || 0,
|
||||
prefixes: Number(totals.prefixes) || 0
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
const allowed = entries.filter(entry => withinRoots(entry.path, owned))
|
||||
// Comparing this unit is a phase of its own: the bar fills with the bytes
|
||||
// checked here, then starts over empty for the files that follow.
|
||||
const unitTotalBytes = allowed.reduce((sum, entry) => sum + (entry.size || 0), 0)
|
||||
const announce = progressGate((data, force) => emitReceive(hooks, session, 'plan', data))
|
||||
let compared = 0
|
||||
let comparedBytes = 0
|
||||
session.barTotal = unitTotalBytes
|
||||
session.barBytes = 0
|
||||
const identical = await mapLimit(allowed, HASH_CONCURRENCY, async entry => {
|
||||
const same = forced ? false : await entrySame(entry)
|
||||
compared++
|
||||
comparedBytes += entry.size || 0
|
||||
session.barBytes = comparedBytes
|
||||
announce({ unit: label, done: compared, total: allowed.length })
|
||||
return same === true
|
||||
})
|
||||
saveHashCache()
|
||||
const paths = new Set()
|
||||
const upload = []
|
||||
const same = []
|
||||
let uploadBytes = 0
|
||||
for (const entry of entries) {
|
||||
if (!withinRoots(entry.path, owned)) continue
|
||||
allowed.forEach((entry, index) => {
|
||||
const abs = filesPath(entry.path)
|
||||
if (!abs) continue
|
||||
if (!abs) return
|
||||
paths.add(entry.path)
|
||||
const identical = forced ? false : await entrySame(entry)
|
||||
if (identical) {
|
||||
if (identical[index]) {
|
||||
same.push(entry.path)
|
||||
continue
|
||||
return
|
||||
}
|
||||
if (entry.type === 'dir') ensureDir(abs)
|
||||
else if (entry.type === 'link') ensureLink(abs, entry.target)
|
||||
|
|
@ -865,12 +1150,17 @@ async function handlePlan(state, session, url, req, res, hooks) {
|
|||
upload.push(entry.path)
|
||||
uploadBytes += entry.size || 0
|
||||
}
|
||||
}
|
||||
session.units.set(key, { roots: owned, paths })
|
||||
})
|
||||
session.units.set(key, { roots: owned, paths, label, modes })
|
||||
// what this unit adds to the transfer: the popup reports files and bytes
|
||||
// against these as they arrive
|
||||
session.planned += upload.length
|
||||
session.plannedBytes += uploadBytes
|
||||
// the files are next: the bar counts the bytes about to be written, from an
|
||||
// empty bar - a unit that needs nothing at all is simply finished
|
||||
session.unitWritten = 0
|
||||
session.barTotal = uploadBytes
|
||||
session.barBytes = 0
|
||||
const kind = UNIT_KINDS[String(key).split(':')[0]]
|
||||
if (kind && !session.counted.has(key)) {
|
||||
session.counted.add(key)
|
||||
|
|
@ -880,7 +1170,7 @@ async function handlePlan(state, session, url, req, res, hooks) {
|
|||
// is planned, so the later file events can name it in the popup
|
||||
session.unitLabel = label
|
||||
sendJson(res, 200, { ok: true, upload, same, entries: paths.size })
|
||||
emitReceive(hooks, session, 'plan', { unit: label })
|
||||
announce({ unit: label, done: allowed.length, total: allowed.length }, true)
|
||||
}
|
||||
|
||||
async function handlePut(session, url, req, res, hooks) {
|
||||
|
|
@ -892,12 +1182,24 @@ async function handlePut(session, url, req, res, hooks) {
|
|||
req.resume()
|
||||
return sendJson(res, 400, { ok: false, error: 'bad-path' })
|
||||
}
|
||||
// Bytes are reported while the file is still arriving: waiting for the whole
|
||||
// file to land would leave the popup frozen for the entire transfer of a
|
||||
// single big asset (that is what made it look like it only moved once per
|
||||
// received item).
|
||||
const announce = progressGate((data, force) => emitReceive(hooks, session, 'copy', data))
|
||||
try {
|
||||
const written = await writeStream(abs, req)
|
||||
await writeStream(abs, req, delta => {
|
||||
session.bytes = (session.bytes || 0) + delta
|
||||
session.unitWritten = (session.unitWritten || 0) + delta
|
||||
session.barBytes = session.unitWritten
|
||||
announce({ unit: session.unitLabel || '' })
|
||||
})
|
||||
// permissions do not travel with the bytes: the executable bit of a wine
|
||||
// build's binaries has to be restored from the sender's manifest
|
||||
applyMode(abs, unit.modes.get(rel))
|
||||
session.received = (session.received || 0) + 1
|
||||
session.bytes = (session.bytes || 0) + (written || 0)
|
||||
sendJson(res, 200, { ok: true })
|
||||
emitReceive(hooks, session, 'copy', { unit: session.unitLabel || '' })
|
||||
announce({ unit: session.unitLabel || '' }, true)
|
||||
} catch (err) {
|
||||
sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
||||
}
|
||||
|
|
@ -912,8 +1214,20 @@ async function handleCommit(session, url, res, hooks) {
|
|||
const abs = filesPath(root)
|
||||
if (abs) removed += await pruneTree(abs, root, unit.paths)
|
||||
}
|
||||
// the unit is complete - rewiring its links is only worth doing once the
|
||||
// whole tree is there (a prefix's links chain through its own files)
|
||||
try {
|
||||
await healUnitRoots(unit.roots)
|
||||
} catch {}
|
||||
session.units.delete(key)
|
||||
session.deleted = (session.deleted || 0) + removed
|
||||
// a finished unit: this is what turns "Apps: 2/5" from the sender's totals
|
||||
// and the units written here
|
||||
const kind = UNIT_KINDS[String(key).split(':')[0]]
|
||||
if (kind) session.itemsDone[kind] = (session.itemsDone[kind] || 0) + 1
|
||||
// a finished unit leaves a finished bar behind (even one that needed no bytes)
|
||||
session.barTotal = session.barTotal || 1
|
||||
session.barBytes = session.barTotal
|
||||
sendJson(res, 200, { ok: true, deleted: removed })
|
||||
emitReceive(hooks, session, 'commit', { unit: session.unitLabel || '', deleted: removed })
|
||||
}
|
||||
|
|
@ -924,7 +1238,10 @@ async function handleApply(session, req, res, hooks) {
|
|||
const cats = new Set(Array.isArray(payload.categories) ? payload.categories : [])
|
||||
const appsDeleted = applyApps(payload, cats)
|
||||
const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0
|
||||
const prefixesDeleted = cats.has('prefixes') ? applyPrefixes(payload) : 0
|
||||
const prefixesDeleted = cats.has('prefixes') ? applyPrefixes(payload, cats.has('defaults')) : 0
|
||||
// after the registries are in place, so a synced build/prefix can actually be
|
||||
// chosen as the default
|
||||
const defaults = applyDefaults(payload, cats)
|
||||
const removed = appsDeleted + winesDeleted + prefixesDeleted
|
||||
emitReceive(hooks, session, 'registry', { deleted: removed })
|
||||
if (hooks.onApplied) {
|
||||
|
|
@ -935,7 +1252,8 @@ async function handleApply(session, req, res, hooks) {
|
|||
files: session.received || 0,
|
||||
bytes: session.bytes || 0,
|
||||
items: { ...(session.items || {}) },
|
||||
deleted: removed
|
||||
deleted: removed,
|
||||
defaults
|
||||
})
|
||||
}
|
||||
sendJson(res, 200, {
|
||||
|
|
@ -943,7 +1261,8 @@ async function handleApply(session, req, res, hooks) {
|
|||
deleted: removed,
|
||||
apps: { deleted: appsDeleted },
|
||||
wines: { deleted: winesDeleted },
|
||||
prefixes: { deleted: prefixesDeleted }
|
||||
prefixes: { deleted: prefixesDeleted },
|
||||
defaults
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1159,6 +1478,9 @@ function getPusher() {
|
|||
buildManifest,
|
||||
listDirs,
|
||||
listDevices,
|
||||
mapLimit,
|
||||
progressGate,
|
||||
hashFile,
|
||||
DIRS,
|
||||
toPortablePath,
|
||||
library,
|
||||
|
|
@ -1224,6 +1546,11 @@ const exported = {
|
|||
sameCode,
|
||||
HASH_ALGO,
|
||||
hashFile,
|
||||
hashCacheStats,
|
||||
resetHashCacheStats,
|
||||
forgetHashCache,
|
||||
HASH_CONCURRENCY,
|
||||
progressGate,
|
||||
filesPath,
|
||||
ownedRoot,
|
||||
buildManifest,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ const os = require('os')
|
|||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
// Files sent at once: one at a time wastes most of a LAN round trip on the
|
||||
// small files a game or prefix is mostly made of.
|
||||
const UPLOAD_CONCURRENCY = 4
|
||||
|
||||
function createPusher(api) {
|
||||
const {
|
||||
CATEGORIES,
|
||||
|
|
@ -34,9 +38,12 @@ function createPusher(api) {
|
|||
|
||||
// Manifest in, list of files to upload out. NDJSON keeps a prefix with tens
|
||||
// of thousands of entries from being one giant string in memory either side.
|
||||
function planUnit(device, token, unit, entries, force) {
|
||||
function planUnit(device, token, unit, entries, force, meta) {
|
||||
// the receiver is told how many apps/prefixes/wines the whole sync carries
|
||||
// up front, which is what lets its popup say "Apps: 2/5"
|
||||
const totals = meta && meta.totals ? `&totals=${encodeURIComponent(JSON.stringify(meta.totals))}` : ''
|
||||
return new Promise((resolve, reject) => {
|
||||
const route = `/plwl/unit/plan?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}&label=${encodeURIComponent(unit.label || '')}&roots=${encodeURIComponent(JSON.stringify(unit.roots))}${force ? '&force=1' : ''}`
|
||||
const route = `/plwl/unit/plan?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}&label=${encodeURIComponent(unit.label || '')}&roots=${encodeURIComponent(JSON.stringify(unit.roots))}${force ? '&force=1' : ''}${totals}`
|
||||
const req = http.request(
|
||||
{
|
||||
host: device.address,
|
||||
|
|
@ -142,12 +149,11 @@ function createPusher(api) {
|
|||
|
||||
// --- registry payload -----------------------------------------------------
|
||||
|
||||
// Everything the app config holds travels: paths (portableGame rewrites them
|
||||
// to the FILES/prefix relative form both machines understand), the wine
|
||||
// build and prefix it runs in, args/env/dllOverrides and the play counters.
|
||||
function portableApp(game) {
|
||||
const out = library.portableGame(game)
|
||||
// playtime/lastPlayed are per machine, the receiver keeps its own counters
|
||||
delete out.playSeconds
|
||||
delete out.lastPlayed
|
||||
return out
|
||||
return library.portableGame(game)
|
||||
}
|
||||
|
||||
function buildRegistryPayload(categories) {
|
||||
|
|
@ -166,6 +172,15 @@ function createPusher(api) {
|
|||
// the receiver prunes its own builds down to this list and rescans
|
||||
payload.wines = wineUnits().map(unit => ({ name: unit.id }))
|
||||
}
|
||||
if (cats.has('defaults')) {
|
||||
// "Default settings": which wine everything falls back to and where a new
|
||||
// import lands. Opt-in on purpose - a sync of apps must not silently
|
||||
// change the other machine's defaults.
|
||||
payload.defaults = {
|
||||
defaultWine: (library.loadSettings().defaultWine) || 'system',
|
||||
defaultPrefix: prefixes.listPrefixes().default || ''
|
||||
}
|
||||
}
|
||||
if (cats.has('prefixes')) {
|
||||
const defs = prefixes.listPrefixes()
|
||||
payload.prefixes = {
|
||||
|
|
@ -230,6 +245,13 @@ function createPusher(api) {
|
|||
if (!device || !device.address || !device.port) return fail('offline')
|
||||
const cats = new Set(normalizeCategories(categories))
|
||||
if (!cats.size) return fail('nothing-selected')
|
||||
// The units are known before anything is read, so how many apps / wine
|
||||
// builds / prefixes this sync carries is announced with the verification:
|
||||
// the other side can show "Apps: 0/5" from its first event.
|
||||
const units = buildUnits(cats)
|
||||
const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps')
|
||||
const totals = { apps: 0, wines: 0, prefixes: 0 }
|
||||
for (const unit of units) totals[bucketOf(unit.kind)] += 1
|
||||
// every progress event is kept (and handed to onProgress when given) so
|
||||
// callers can report on the transfer without subscribing first
|
||||
const events = []
|
||||
|
|
@ -241,7 +263,7 @@ function createPusher(api) {
|
|||
let verified
|
||||
try {
|
||||
verified = await httpJson(device, 'POST', '/plwl/verify', {
|
||||
json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase() },
|
||||
json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase(), totals },
|
||||
timeout: 20000
|
||||
})
|
||||
} catch (err) {
|
||||
|
|
@ -260,31 +282,42 @@ function createPusher(api) {
|
|||
// typing (a rejected code is never stored)
|
||||
rememberDevice(device.id || deviceId, code, device.name)
|
||||
|
||||
const units = buildUnits(cats)
|
||||
const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps')
|
||||
const summary = newSummary(units.length)
|
||||
const finished = { apps: 0, wines: 0, prefixes: 0 }
|
||||
// bytes the units planned so far will put on the wire: the popup can then
|
||||
// show "1.2 GB / 3.4 GB" instead of only what has already arrived
|
||||
let plannedBytes = 0
|
||||
let itemsSeen = 0
|
||||
const emit = (phase, unit, done, total) =>
|
||||
progress({
|
||||
stage: phase,
|
||||
phase,
|
||||
kind: (unit && unit.kind) || '',
|
||||
id: (unit && unit.id) || '',
|
||||
key: (unit && unit.key) || '',
|
||||
unit: (unit && unit.label) || '',
|
||||
label: (unit && unit.label) || '',
|
||||
name: (unit && unit.label) || '',
|
||||
done,
|
||||
total,
|
||||
bytes: summary.bytes,
|
||||
totalBytes: plannedBytes,
|
||||
units: summary.units,
|
||||
unitsDone: itemsSeen,
|
||||
items: { ...summary.items }
|
||||
})
|
||||
// the bar tracks the PHASE being worked on (its own bytes), never a
|
||||
// leftover from the phase before: hashing starts from an empty bar
|
||||
let barBytes = 0
|
||||
let barTotal = 0
|
||||
const snapshot = (phase, unit, done, total) => ({
|
||||
stage: phase,
|
||||
phase,
|
||||
kind: (unit && unit.kind) || '',
|
||||
id: (unit && unit.id) || '',
|
||||
key: (unit && unit.key) || '',
|
||||
unit: (unit && unit.label) || '',
|
||||
label: (unit && unit.label) || '',
|
||||
name: (unit && unit.label) || '',
|
||||
done,
|
||||
total,
|
||||
bytes: summary.bytes,
|
||||
totalBytes: plannedBytes,
|
||||
units: summary.units,
|
||||
unitsDone: itemsSeen,
|
||||
items: { ...summary.items },
|
||||
itemsDone: { ...finished },
|
||||
totals: { ...totals },
|
||||
barBytes,
|
||||
barTotalBytes: barTotal
|
||||
})
|
||||
// hashing and copying walk thousands of files: the state is snapshotted on
|
||||
// every file but forwarded a few times per second (phase changes always
|
||||
// go through, so nothing on screen ever jumps backwards)
|
||||
const gate = typeof api.progressGate === 'function' ? api.progressGate(progress) : data => progress(data)
|
||||
const emit = (phase, unit, done, total, force) => gate(snapshot(phase, unit, done, total), !!force)
|
||||
const end = async () => {
|
||||
try {
|
||||
await httpJson(device, 'POST', `/plwl/end?token=${encodeURIComponent(token)}`, { json: {} })
|
||||
|
|
@ -294,13 +327,20 @@ function createPusher(api) {
|
|||
for (const unit of units) {
|
||||
const roots = unit.roots.filter(ownedRoot)
|
||||
if (!roots.length) continue
|
||||
const bucket = summary[bucketOf(unit.kind)]
|
||||
summary.items[bucketOf(unit.kind)] += 1
|
||||
const kind = bucketOf(unit.kind)
|
||||
const bucket = summary[kind]
|
||||
summary.items[kind] += 1
|
||||
itemsSeen += 1
|
||||
emit('scan', unit, 0, 0)
|
||||
const entries = await buildManifest(roots, !!force, (done, total) => emit('hash', unit, done, total))
|
||||
emit('plan', unit, 0, 0)
|
||||
const plan = await planUnit(device, token, { ...unit, roots }, entries, !!force)
|
||||
barBytes = 0
|
||||
barTotal = 0
|
||||
emit('scan', unit, 0, 0, true)
|
||||
const entries = await buildManifest(roots, !!force, (done, total, hashedBytes, totalBytes) => {
|
||||
barBytes = hashedBytes
|
||||
barTotal = totalBytes
|
||||
emit('hash', unit, done, total)
|
||||
})
|
||||
emit('plan', unit, 0, 0, true)
|
||||
const plan = await planUnit(device, token, { ...unit, roots }, entries, !!force, { totals })
|
||||
if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`)
|
||||
const upload = new Set(plan.data.upload || [])
|
||||
const same = new Set(plan.data.same || [])
|
||||
|
|
@ -308,31 +348,56 @@ function createPusher(api) {
|
|||
// already identical (the plan reports both)
|
||||
bucket.copied += entries.length - same.size
|
||||
bucket.skipped += same.size
|
||||
for (const entry of entries) {
|
||||
if (entry.type === 'file' && upload.has(entry.path)) plannedBytes += entry.size || 0
|
||||
}
|
||||
let done = 0
|
||||
const pending = []
|
||||
for (const entry of entries) {
|
||||
if (entry.type !== 'file') continue
|
||||
if (!upload.has(entry.path)) {
|
||||
summary.skipped++
|
||||
continue
|
||||
}
|
||||
emit('copy', unit, done, upload.size)
|
||||
pending.push(entry)
|
||||
plannedBytes += entry.size || 0
|
||||
}
|
||||
// a few files in flight at once: with thousands of small ones the
|
||||
// round trip to the other machine costs more than the bytes do
|
||||
// the files that were already identical never move, so the bar counts
|
||||
// only the bytes that do - and starts empty for the transfer phase
|
||||
if (pending.length) {
|
||||
barBytes = 0
|
||||
barTotal = pending.reduce((sum, entry) => sum + (entry.size || 0), 0)
|
||||
emit('copy', unit, 0, pending.length, true)
|
||||
}
|
||||
let failed = null
|
||||
let done = 0
|
||||
const send = async entry => {
|
||||
if (failed) return
|
||||
const res = await putFile(device, token, unit.key, entry.path, filesPath(entry.path))
|
||||
if (res.status !== 200) throw new Error(`upload-failed-${res.status}`)
|
||||
if (res.status !== 200) {
|
||||
failed = new Error(`upload-failed-${res.status}`)
|
||||
return
|
||||
}
|
||||
done++
|
||||
summary.uploaded++
|
||||
summary.bytes += entry.size || 0
|
||||
barBytes += entry.size || 0
|
||||
emit('copy', unit, done, pending.length)
|
||||
}
|
||||
emit('copy', unit, done, upload.size)
|
||||
if (typeof api.mapLimit === 'function') await api.mapLimit(pending, UPLOAD_CONCURRENCY, send)
|
||||
else for (const entry of pending) await send(entry)
|
||||
if (failed) throw failed
|
||||
emit('copy', unit, done, pending.length, true)
|
||||
const commit = await httpJson(device, 'POST', `/plwl/unit/commit?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}`, { json: {} })
|
||||
if (commit.data && commit.data.deleted) {
|
||||
summary.deleted += commit.data.deleted
|
||||
bucket.deleted += commit.data.deleted
|
||||
}
|
||||
finished[kind] += 1
|
||||
// a finished unit leaves a finished bar behind, even one that sent none
|
||||
barTotal = barTotal || 1
|
||||
barBytes = barTotal
|
||||
emit('commit', unit, 0, 0, true)
|
||||
}
|
||||
emit('registry', null, 0, 0)
|
||||
emit('registry', null, 0, 0, true)
|
||||
const applied = await httpJson(device, 'POST', `/plwl/apply?token=${encodeURIComponent(token)}`, { json: buildRegistryPayload([...cats]), timeout: 60000 })
|
||||
if (!applied.data || !applied.data.ok) throw new Error((applied.data && applied.data.error) || `http-${applied.status}`)
|
||||
for (const key of ['apps', 'wines', 'prefixes']) {
|
||||
|
|
@ -340,7 +405,7 @@ function createPusher(api) {
|
|||
if (deleted) summary[key].deleted += deleted
|
||||
}
|
||||
if (applied.data.deleted) summary.deleted += applied.data.deleted
|
||||
emit('done', null, summary.uploaded, summary.uploaded)
|
||||
emit('done', null, summary.uploaded, summary.uploaded, true)
|
||||
await end()
|
||||
} catch (err) {
|
||||
await end()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const { DIRS, readJson, writeJson } = require('./paths')
|
||||
const { rewireSymlinks } = require('./symlinks')
|
||||
|
||||
const REGISTRY = path.join(DIRS.DATA, 'prefixes.json')
|
||||
const MAIN = 'Main'
|
||||
|
|
@ -85,6 +86,15 @@ function deletePrefix(name) {
|
|||
return { ok: true }
|
||||
}
|
||||
|
||||
// A prefix that arrived from another machine (or a launcher that was moved) is
|
||||
// full of absolute symlinks into its wine/proton build - fonts, built-in
|
||||
// program stubs, dlls. Rewire them to relative paths so the prefix keeps
|
||||
// working here and stays relocatable (same healing wine builds get on import).
|
||||
async function healPrefix(name) {
|
||||
if (!name) return 0
|
||||
return rewireSymlinks(prefixPath(name), [DIRS.FILES])
|
||||
}
|
||||
|
||||
function setDefaultPrefix(name) {
|
||||
const defs = listPrefixes()
|
||||
if (name && !defs.prefixes.some(p => p.name === name)) return { ok: false, error: 'notFound' }
|
||||
|
|
@ -93,4 +103,4 @@ function setDefaultPrefix(name) {
|
|||
return { ok: true }
|
||||
}
|
||||
|
||||
module.exports = { listPrefixes, savePrefixes, createPrefix, deletePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoot, normalizeName, MAIN }
|
||||
module.exports = { listPrefixes, savePrefixes, createPrefix, deletePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoot, healPrefix, normalizeName, MAIN }
|
||||
|
|
|
|||
112
src/runner.js
112
src/runner.js
|
|
@ -6,18 +6,66 @@ const { spawn } = require('child_process')
|
|||
const { DIRS } = require('./paths')
|
||||
const { prefixPath } = require('./prefixes')
|
||||
|
||||
// The fake Steam install Proton is pointed at through
|
||||
// STEAM_COMPAT_CLIENT_INSTALL_PATH. Both folders Proton links into its prefix
|
||||
// have to exist: it symlinks <prefix>/drive_c/Program Files (x86)/Steam/
|
||||
// appcache -> <fake>/appcache while setting the prefix up, and a link into a
|
||||
// missing folder is a dangling link in a tree that gets synced.
|
||||
function ensureFakeSteam() {
|
||||
const fake = DIRS.FAKE_STEAM
|
||||
fs.mkdirSync(path.join(fake, 'steamapps'), { recursive: true })
|
||||
fs.mkdirSync(path.join(fake, 'appcache'), { recursive: true })
|
||||
return fake
|
||||
}
|
||||
|
||||
// The prefix a run will use, by NAME (buildEnv resolves the same fallback).
|
||||
function prefixNameFor(game) {
|
||||
if (game && game.prefix) return game.prefix
|
||||
const defs = require('./prefixes').listPrefixes()
|
||||
return defs.default || 'default'
|
||||
}
|
||||
|
||||
// Wine only ever repairs links that are BROKEN: a prefix that came from
|
||||
// another machine (or from an older PLWL) can still hold absolute links into
|
||||
// its build that happen to resolve here, and those would be synced out again.
|
||||
// Healing once per prefix per launch keeps the whole tree relocatable and
|
||||
// costs a single walk in the common (already relative) case.
|
||||
const healedPrefixes = new Set()
|
||||
async function healPrefixOnce(game) {
|
||||
const name = prefixNameFor(game)
|
||||
const key = `${name}\u0000${DIRS.BUILD}`
|
||||
if (!name || healedPrefixes.has(key)) return 0
|
||||
healedPrefixes.add(key)
|
||||
try {
|
||||
return await require('./prefixes').healPrefix(name)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// Proton gets its prefix through STEAM_COMPAT_DATA_PATH, which has to be a
|
||||
// symlink to the launcher's own prefix folder. The parent directory is created
|
||||
// FIRST: symlink() fails with ENOENT when compat/ does not exist yet, and the
|
||||
// old fallback then created a plain empty directory instead - Proton silently
|
||||
// started with an empty prefix (the game never saw its own data). A leftover
|
||||
// EMPTY directory from exactly that situation is safe to replace with the
|
||||
// link; a populated one is left alone.
|
||||
function linkPrefix(compatData, prefixDir) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(compatData), { recursive: true })
|
||||
} catch {}
|
||||
try {
|
||||
const st = fs.lstatSync(compatData)
|
||||
if (st.isSymbolicLink()) {
|
||||
if (fs.readlinkSync(compatData) === prefixDir) return
|
||||
fs.unlinkSync(compatData)
|
||||
} else if (st.isDirectory()) {
|
||||
let names = []
|
||||
try {
|
||||
names = fs.readdirSync(compatData)
|
||||
} catch {}
|
||||
if (names.length) return
|
||||
fs.rmdirSync(compatData)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
|
@ -110,7 +158,8 @@ function openLatestLog(gameId) {
|
|||
return fs.createWriteStream(logFile, { flags: 'w' })
|
||||
}
|
||||
|
||||
function runGame({ game, wine, systemWine, onExit }) {
|
||||
async function runGame({ game, wine, systemWine, onExit }) {
|
||||
await healPrefixOnce(game)
|
||||
const isProton = wine && wine.type === 'proton'
|
||||
const { env } = buildEnv({ game, wine })
|
||||
const exe = game.exePath
|
||||
|
|
@ -140,34 +189,47 @@ function runGame({ game, wine, systemWine, onExit }) {
|
|||
} catch (err) {
|
||||
return reject(err)
|
||||
}
|
||||
const logFile = latestLogPath(game.id)
|
||||
const ws = openLatestLog(game.id)
|
||||
child.stdout.pipe(ws)
|
||||
child.stderr.pipe(ws)
|
||||
child.on('error', reject)
|
||||
child.unref()
|
||||
const startedAt = Date.now()
|
||||
// read the LIVE playtime file, not the (possibly shared/stale) registry
|
||||
// value - ad-hoc "open with" runs share one id and must not mix playtime
|
||||
const basePlaySeconds = require('./library').readPlaySeconds(game.id) ?? (game.playSeconds || 0)
|
||||
const stopTicker = startPlaytimeTicker({ game, startedAt, base: basePlaySeconds })
|
||||
// stamp "last played" once at start so it is correct even if we crash later
|
||||
require('./library').updateGame(game.id, { lastPlayed: startedAt })
|
||||
child.on('exit', (code, signal) => {
|
||||
stopTicker()
|
||||
ws.end()
|
||||
const counted = Math.max(0, Math.round((Date.now() - startedAt - GRACE_MS) / 1000))
|
||||
require('./library').updateGame(game.id, {
|
||||
lastPlayed: startedAt,
|
||||
playSeconds: basePlaySeconds + counted
|
||||
})
|
||||
if (onExit) onExit(code, signal)
|
||||
// spawn() returns before the OS actually starts the program: when the
|
||||
// binary is missing or has no executable bit (a wine build copied without
|
||||
// its permissions, a wrong path) the failure only arrives as an 'error'
|
||||
// event, AFTER this function would have resolved. Resolving there marked
|
||||
// games that never started as running forever, so the promise only settles
|
||||
// once the process really exists ('spawn').
|
||||
let started = false
|
||||
child.once('error', err => {
|
||||
if (!started) reject(err)
|
||||
})
|
||||
child.once('spawn', () => {
|
||||
started = true
|
||||
const logFile = latestLogPath(game.id)
|
||||
const ws = openLatestLog(game.id)
|
||||
child.stdout.pipe(ws)
|
||||
child.stderr.pipe(ws)
|
||||
child.unref()
|
||||
const startedAt = Date.now()
|
||||
// read the LIVE playtime file, not the (possibly shared/stale) registry
|
||||
// value - ad-hoc "open with" runs share one id and must not mix playtime
|
||||
const basePlaySeconds = require('./library').readPlaySeconds(game.id) ?? (game.playSeconds || 0)
|
||||
const stopTicker = startPlaytimeTicker({ game, startedAt, base: basePlaySeconds })
|
||||
// stamp "last played" once at start so it is correct even if we crash later
|
||||
require('./library').updateGame(game.id, { lastPlayed: startedAt })
|
||||
child.on('exit', (code, signal) => {
|
||||
stopTicker()
|
||||
ws.end()
|
||||
const counted = Math.max(0, Math.round((Date.now() - startedAt - GRACE_MS) / 1000))
|
||||
require('./library').updateGame(game.id, {
|
||||
lastPlayed: startedAt,
|
||||
playSeconds: basePlaySeconds + counted
|
||||
})
|
||||
if (onExit) onExit(code, signal)
|
||||
})
|
||||
resolve({ pid: child.pid, logFile })
|
||||
})
|
||||
resolve({ pid: child.pid, logFile })
|
||||
})
|
||||
}
|
||||
|
||||
function runTool({ game, wine, tool, args, systemWine }) {
|
||||
async function runTool({ game, wine, tool, args, systemWine }) {
|
||||
await healPrefixOnce(game)
|
||||
const isProton = wine && wine.type === 'proton'
|
||||
const { env } = buildEnv({ game, wine })
|
||||
const exe = game.exePath
|
||||
|
|
|
|||
194
src/symlinks.js
Normal file
194
src/symlinks.js
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
'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 }
|
||||
139
src/wines.js
139
src/wines.js
|
|
@ -5,6 +5,7 @@ const fsp = fs.promises
|
|||
const path = require('path')
|
||||
const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths')
|
||||
const { dedupeDir } = require('./fsutil')
|
||||
const { rewireSymlinks, restoreExecutableBits, EXEC_NAMES } = require('./symlinks')
|
||||
|
||||
const WINES_DIR = DIRS.WINES
|
||||
const REGISTRY = path.join(DIRS.DATA, 'wines.json')
|
||||
|
|
@ -109,12 +110,17 @@ async function refreshWines() {
|
|||
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.
|
||||
// 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)
|
||||
if (!old || old.path !== w.path) {
|
||||
const moved = !old || old.path !== w.path
|
||||
if (moved || buildAuditFailed(w.path, w.wineBin)) {
|
||||
try {
|
||||
await fixBuildSymlinks(w.path)
|
||||
await healBuild(w.path)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
|
@ -190,77 +196,58 @@ function deleteWine(id) {
|
|||
// 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). Rewire every absolute symlink whose content
|
||||
// can be located inside the build, always storing the new target as a
|
||||
// *relative* path so the build stays relocatable forever.
|
||||
async function collectSymlinks(dir, out) {
|
||||
let entries
|
||||
try {
|
||||
entries = await fsp.readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name)
|
||||
if (e.isSymbolicLink()) {
|
||||
out.push(full)
|
||||
} else if (e.isDirectory() && e.name !== 'dosdevices') {
|
||||
// dosdevices/ holds prefix drive mappings (c:, z:, ...) which are
|
||||
// *supposed* to point outside the build - never touch them.
|
||||
await collectSymlinks(full, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rewireTarget(root, link, target) {
|
||||
if (!path.isAbsolute(target)) return null // already relocatable
|
||||
// Absolute link that lives inside the build: make it relative.
|
||||
if (target.startsWith(root + path.sep)) {
|
||||
return path.relative(path.dirname(link), target)
|
||||
}
|
||||
// Absolute link to an old location: find the longest suffix of the target
|
||||
// that exists inside the new build and point there instead.
|
||||
let i = target.indexOf(path.sep, 1)
|
||||
while (i !== -1) {
|
||||
const rel = target.slice(i + 1)
|
||||
if (rel) {
|
||||
const candidate = path.join(root, rel)
|
||||
if (!rel.split(path.sep).includes('..') && candidate.startsWith(root + path.sep) && exists(candidate)) {
|
||||
return path.relative(path.dirname(link), candidate)
|
||||
}
|
||||
}
|
||||
i = target.indexOf(path.sep, i + 1)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// (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) {
|
||||
const links = []
|
||||
await collectSymlinks(root, links)
|
||||
let fixed = 0
|
||||
// A link may only become rewirable once another link it chains through has
|
||||
// been fixed, so repeat until a full pass changes nothing.
|
||||
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
|
||||
}
|
||||
const newTarget = rewireTarget(root, link, path.normalize(target))
|
||||
if (!newTarget || newTarget === target) continue
|
||||
try {
|
||||
await fsp.unlink(link)
|
||||
await fsp.symlink(newTarget, link)
|
||||
fixed++
|
||||
changed++
|
||||
} catch {}
|
||||
}
|
||||
if (!changed) break
|
||||
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
|
||||
}
|
||||
return fixed
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
|
@ -277,13 +264,13 @@ async function importWineFolder(srcDir) {
|
|||
}
|
||||
const dst = dedupeDir(WINES_DIR, path.basename(copySrc))
|
||||
await fsp.cp(copySrc, dst, { recursive: true, verbatimSymlinks: true })
|
||||
let symlinksFixed = 0
|
||||
let healed = { links: 0, executables: 0 }
|
||||
try {
|
||||
symlinksFixed = await fixBuildSymlinks(dst)
|
||||
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 }
|
||||
return { ok: true, path: toPortablePath(dst), symlinksFixed: healed.links, executablesFixed: healed.executables }
|
||||
}
|
||||
|
||||
module.exports = { refreshWines, getWines, getWine, resolveWine, setWineDisabled, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks }
|
||||
module.exports = { refreshWines, getWines, getWine, resolveWine, setWineDisabled, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue