code cleanup

This commit is contained in:
olcxja 2026-10-07 16:29:40 +02:00
commit d21b873327
12 changed files with 40 additions and 213 deletions

View file

@ -83,7 +83,7 @@ Renderer never touches Node/Electron directly — everything goes through the `w
### Native Linux apps
- Entry point detection (linuxapp.js): ELF, `.AppImage`, or a `.desktop` whose `Exec=` points at one; `lib/`, `share/` … are skipped. The icon comes from the `.desktop` `Icon=`, an image next to the program, or the system theme — `extractIconFromExe` is PE-only.
- The redirected home starts as a COPY of the desktop preferences (kdeglobals, kdedefaults/kdeglobals, gtk-3.0|gtk-4.0/settings.ini, qt5ct/qt6ct, Trolltech.conf, user-dirs.dirs). Without the seed Qt falls back to its built-in light palette (measured: `#eff0f1` against the host's `#202326`). The copy keeps following the host while the app has not touched it: the hashes of what PLWL wrote live in `{prefix}/linux_pfx/.seeded.json`, OUTSIDE the app's home, and a copy with no marker entry (what an older launcher left) counts as ours and is refreshed. The PulseAudio cookie is seeded once, because with a redirected home Pulse looks in `$HOME/.config/pulse/cookie` and the app otherwise comes up silent; `~/.Xauthority` is refreshed instead, since nothing exports XAUTHORITY to a sandboxed app.
- The redirected home starts as a COPY of the desktop preferences (kdeglobals, kdedefaults/kdeglobals, gtk-3.0|gtk-4.0/settings.ini, qt5ct/qt6ct, Trolltech.conf, user-dirs.dirs). Without the seed Qt falls back to its built-in light palette (measured: `#eff0f1` against the host's `#202326`). The copy keeps following the host while the app has not touched it: the hashes of what PLWL wrote live in `{prefix}/linux_pfx/.seeded.json`, OUTSIDE the app's home, and a copy with no marker entry (or one the app has since changed) is the app's own and is never overwritten. The PulseAudio cookie is seeded once, because with a redirected home Pulse looks in `$HOME/.config/pulse/cookie` and the app otherwise comes up silent; `~/.Xauthority` is refreshed instead, since nothing exports XAUTHORITY to a sandboxed app.
- Host fonts/icons/themes/colour schemes are not copied but `--ro-bind` under `/tmp/.plwl-host/…` and prepended to XDG_DATA_DIRS: live, and unable to write back into the user's `~`. Symlinks were measured and rejected — the prefix is mounted ON `~`, so a link to `~/.config/kdeglobals` points at itself (`Too many levels of symbolic links`); without bwrap the same link writes through into the real home.
- `forceX11` (per app) sets XDG_SESSION_TYPE=x11 + QT_QPA_PLATFORM=xcb + GDK_BACKEND=x11 so the app and everything it launches falls back to XWayland, where window icons still exist at all (Wayland forbids them; a compiled GLFW probe showed XDG_SESSION_TYPE picks X11 vs Wayland, `GLFW_PLATFORM` does not exist). `game.env` still wins.
- SESSION_MANAGER is dropped in the sandbox: the session socket sits in the private `/tmp`, so the pointer led nowhere (Qt: "Could not open network socket").
@ -106,14 +106,14 @@ Renderer never touches Node/Electron directly — everything goes through the `w
- Progress is bytes plus per-category counters (items = announced units, itemsDone = finished, totals = everything picked) — "Apps: 2/5". The bar follows the CURRENT PHASE's bytes (barBytes/barTotalBytes) so a phase starts from an empty bar. Hashing and copying walk tens of thousands of files: every per-file update goes through progressGate (~10/s, phase changes forced) — never one IPC message per file. The gate holds a snapshot FUNCTION on the sender side: the snapshot is built when it flushes, so a throttled update never shows a byte count one gate window old.
- The sender's transferred counter counts bytes AS THEY ARE READ (putFile's onBytes callback), not on upload completion: a multi-GB file has to move it while it flies or the sender shows hundreds of MB behind the receiver. A unit's byte total travels as the `{ unitBytes }` control record BEFORE its files; the receiver has to handle it before the entry filter (it has no path/type, so a filter-first order dropped it and the comparing bar measured against a placeholder total of 1 — read as 100% for the whole phase). The comparing phase ends on a full bar, then the copy bar restarts empty (its first PUT byte emits it).
- While the receiver answers the consent popup (and whenever its side waits on the other one), both popups show "Waiting for the other device…" (lansync.waiting): the sender emits a 'wait' phase before verify, the receiver's ping handler emits stage 'wait'. A unit counts toward "Apps: 1/5" as soon as its comparison starts (not when it commits), and phases without a file count (hash/compare/commit) clear the counter so a stale "3 / 10 files" never lingers. On the wire the receiver's comparing phase is called 'compare' (sender-side 'plan' is unchanged).
- Registry-level settings are opt-in (the "settings" category, old name "defaults" still accepted as an alias on both sides). The payload carries defaults, wineSettings (displayName + disabled per build), prefixSettings, appSettings (name, wine, prefix, args, env, dllOverrides, disableUmu, isolation and forceX11 per app) and appOrder. The receiver only patches what already exists (unknown ids skipped, nothing created or deleted, unknown local entries kept at the end of the order) through the normal helpers (setWineDisplayName, library.updateGame, reorderGames).
- Registry-level settings are opt-in (the "settings" category). The payload carries defaults, wineSettings (displayName + disabled per build), prefixSettings, appSettings (name, wine, prefix, args, env, dllOverrides, disableUmu, isolation and forceX11 per app) and appOrder. The receiver only patches what already exists (unknown ids skipped, nothing created or deleted, unknown local entries kept at the end of the order) through the normal helpers (setWineDisplayName, library.updateGame, reorderGames).
- File hashes are remembered in files/data/lansync-hashes.json (path -> size + mtime + hash; machine-local, never synced): equal size and mtime means equal bytes, so a repeat sync only stats the tree. Hashing and the receiver's comparison both run HASH_CONCURRENCY files at a time.
- A file's unit and path travel in the PUT's query, percent-encoded, and the receiver still reads the old `x-plwl-unit`/`x-plwl-path` headers first: Node rejects any header value holding a code above U+00FF (`ERR_INVALID_CHAR`), and a real library has those names (Minecraft pack names, Polish file names) — one such file killed the whole sync, leaving the receiver with half an app and no registry entry.
- A file's unit and path travel in the PUT's query, not in headers: Node rejects any header value holding a code above U+00FF (`ERR_INVALID_CHAR`), and a real library has those names (Minecraft pack names, Polish file names) — one such file killed the whole sync, leaving the receiver with half an app and no registry entry. Categories are matched by exact name; anything this build does not know is ignored.
- File permissions travel with the manifest (`mode`) and are applied after the bytes; a file with the right hash but wrong mode is repaired without re-upload.
- A sender hashing a big unit is minutes of silence: it pings `/plwl/ping` every HEARTBEAT_MS, the receiver re-arms its popup watchdog (stage 'wait' shows "Waiting for the other device…" but never while a file is being written). A receiving popup its watchdog closed reopens on the next real event — only 'done' ends a sync, and reopening never asks for consent again (consent is the onIncoming popup at verify time; the progress popup is driven by the same token's events).
- Native Linux app data (`{prefix}/linux_pfx/`: the redirected home, configs, caches, PulseAudio cookie, state) travels INSIDE its prefix unit like the Windows side of the same prefix: the manifest walks it and the mirror prunes it, so no file of the launcher's own trees is special-cased on the wire. Native apps sit in the same categories as Windows ones (`apps-portable`/`apps-prefix`), and only the per-app fields that are Linux-only (isolation, forceX11) are named as such in the settings payload.
- A sync must never walk through a symlinked ROOT: pruneTree/healUnitRoots (sync), removeTree/treeSize (fsutil) and healBuild (wines) lstat the root first. `listDirs()` counts symlinked folders as units, so a build on another disk still travels — as the link it is (`wineUnits` maps nested builds to their TOP folder under wines/).
- Wine/Proton rewrite prefix links to absolute targets whenever they update a prefix, so on-disk links are not always relative after a run: the sync relativizes them on the wire (portableLinkTarget) and refreshWines()/healPrefix() repair what arrived from an older PLWL. A build whose binaries lost the executable bit is detected in refreshWines() and healed once (spawn() on a non-executable `proton` fails with EACCES while the game looks started).
- Wine/Proton rewrite prefix links to absolute targets whenever they update a prefix, so on-disk links are not always relative after a run: the sync relativizes them on the wire (portableLinkTarget) and refreshWines()/healPrefix() repair absolute links that reached disk anyway. A build whose binaries lost the executable bit is detected in refreshWines() and healed once (spawn() on a non-executable `proton` fails with EACCES while the game looks started).
### Paths and prefixes

View file

@ -1597,10 +1597,10 @@ function renderProgressBar(bar, bytes, totalBytes) {
bar.value = Math.min(sent, bar.max)
}
// Older peers only send the overall byte totals - then those drive the bar.
// The bar follows the current phase's bytes (a phase starts empty).
function barValues(info) {
let bytes = Math.max(0, Number(info.barBytes == null ? info.bytes : info.barBytes) || 0)
let total = Math.max(0, Number(info.barTotalBytes == null ? info.totalBytes : info.barTotalBytes) || 0)
let bytes = Math.max(0, Number(info.barBytes) || 0)
let total = Math.max(0, Number(info.barTotalBytes) || 0)
// a provisional total can be smaller than what already moved (it grows as
// entries arrive): the bar reads full, never over 100%
if (total > 0 && bytes > total) bytes = total

View file

@ -42,7 +42,6 @@ function createHashCache(dataDir) {
let cache = null
let dirty = false
let savedAt = 0
const stats = { hits: 0, misses: 0 }
function load() {
if (cache) return cache
@ -80,11 +79,7 @@ function createHashCache(dataDir) {
// the cache dirty only when the timestamp is stale.
function hit(rel, size, mtimeMs) {
const known = load()[rel]
if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') {
stats.misses++
return null
}
stats.hits++
if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') return null
if (Date.now() - (known[3] || 0) > 86400000) {
known[3] = Date.now()
dirty = true
@ -111,35 +106,13 @@ function createHashCache(dataDir) {
cache = null
dirty = false
savedAt = 0
stats.hits = 0
stats.misses = 0
try {
fs.rmSync(FILE, { force: true })
fs.rmSync(`${FILE}.tmp`, { force: true })
} catch {}
}
// Dropping the memory copy makes the next lookup re-read from disk (tests).
function forget() {
cache = null
dirty = false
}
return {
HASH_ALGO,
hashFile,
hit,
remember,
cachedHash,
save,
clear,
forget,
stats: () => ({ ...stats, size: cache ? Object.keys(cache).length : 0, algo: HASH_ALGO }),
resetStats: () => {
stats.hits = 0
stats.misses = 0
}
}
return { HASH_ALGO, hashFile, hit, remember, cachedHash, save, clear }
}
module.exports = { createHashCache, hashFile, HASH_ALGO }

View file

@ -170,7 +170,7 @@ function buildIco(images) {
planes = dims.planes
bpp = dims.bpp
} else {
// legacy fallback: ICONDIRENTRY-style first bytes (PNG-era heuristic)
// nothing measurable: the first bytes carry the size
w = img.readUInt8(0) || 256
h = img.readUInt8(1) || 256
colors = img.readUInt8(2)

View file

@ -8,11 +8,11 @@ function registerLanSyncIpc({ getWindow }) {
ipcMain.handle('lansync:get', () => lansync.getState())
ipcMain.handle('lansync:devices', () => lansync.listDevices())
ipcMain.handle('lansync:rerollDeviceId', () => {
const identity = lansync.rerollDeviceId()
const identity = lansync.rotateDeviceId()
return { deviceId: identity.deviceId }
})
ipcMain.handle('lansync:rerollSyncCode', () => {
const identity = lansync.rerollSyncCode()
const identity = lansync.rotateSyncCode()
return { syncCode: identity.syncCode }
})
ipcMain.handle('lansync:forget', (e, id) => {

View file

@ -93,7 +93,7 @@ function createIdentity(configFile) {
function rememberDevice(id, code, name) {
const cfg = identity()
const stored = String(code || '').trim().toUpperCase()
cfg.known = { ...cfg.known, [id]: { code: stored, syncCode: stored, name: name || '', at: Date.now() } }
cfg.known = { ...cfg.known, [id]: { code: stored, name: name || '', at: Date.now() } }
writeJson(configFile, cfg)
}

View file

@ -44,60 +44,23 @@ const INCOMING_TIMEOUT_MS = Number(process.env.PLWL_LANSYNC_INCOMING_TIMEOUT) ||
// The only trees a sync may touch; the rest of files/ is local state.
const SYNC_ROOTS = ['apps/', 'wines/', 'wineprefixes/', 'data/icons/']
// Sync categories plus lenient aliases for older senders.
// What a sync may carry, one name per category.
const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'settings']
const CATEGORY_ALIASES = {
// the whole app library: portable + prefix-import launcher entries
apps: ['apps-portable', 'apps-prefix'],
app: ['apps-portable', 'apps-prefix'],
appsportable: ['apps-portable'],
portableapps: ['apps-portable'],
portable: ['apps-portable'],
appsprefix: ['apps-prefix'],
prefixapps: ['apps-prefix'],
appsimportedfromprefix: ['apps-prefix'],
wine: ['wines'],
wines: ['wines'],
wineversions: ['wines'],
wineprefix: ['prefixes'],
wineprefixes: ['prefixes'],
prefixes: ['prefixes'],
prefix: ['prefixes'],
// launcher settings: defaults, wine labels, prefix registry, app configs
settings: ['settings'],
syncsettings: ['settings'],
defaults: ['settings'],
default: ['settings'],
defaultsettings: ['settings']
}
// An app entry's category: portable imports own their apps/ folder.
function appCategory(game) {
if (!game || typeof game !== 'object') return null
if (game.importSource === 'prefix' || game.mode === 'prefix') return 'apps-prefix'
if (game.imported === true || game.mode === 'copy' || game.mode === 'move') return 'apps-portable'
if (game.importSource === 'prefix') return 'apps-prefix'
if (game.imported === true) return 'apps-portable'
return null
}
// An app entry's files, read off its stored path; outside the data = not synced.
function classifyApp(app) {
if (!app || (typeof app !== 'string' && typeof app !== 'object')) return null
const raw = typeof app === 'string' ? app : app.path || app.exePath || app.workdir
// external entry: nothing to sync
if (!raw || typeof raw !== 'string') return 'external'
const value = raw.replace(/\\/g, '/')
const outside = value.startsWith('..') || path.isAbsolute(raw) || /^[a-z]:\//i.test(value) || /^[a-z][a-z0-9+.-]*:\/\//i.test(value)
if (isPrefixRel(value)) return 'prefix'
if (/^apps\//.test(value)) return 'portable'
return outside ? 'external' : null
}
// The list comes from the wire: anything this build does not know is ignored.
function normalizeCategories(list) {
const out = new Set()
for (const raw of Array.isArray(list) ? list : []) {
const aliases = CATEGORY_ALIASES[String(raw || '').toLowerCase().replace(/[^a-z]+/g, '')]
for (const alias of aliases || []) out.add(alias)
const name = String(raw || '')
if (CATEGORIES.includes(name)) out.add(name)
}
return [...out]
}
@ -183,28 +146,6 @@ function targetOf(target) {
return null
}
// Trade the sync code for a token; a rejected code is never stored.
async function verifyPeer(target, code) {
// { host, port, code } in one object, or a target plus a separate code
if (target && typeof target === 'object' && code === undefined && target.code !== undefined) code = target.code
const device = targetOf(target)
if (!device || !device.port) return { ok: false, error: 'bad-target' }
let res
try {
res = await httpJson(device, 'POST', '/plwl/verify', {
json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase() },
timeout: 20000
})
} catch (err) {
return { ok: false, error: String((err && err.message) || err) }
}
if (!res.data || !res.data.ok) return { ok: false, error: (res.data && res.data.error) || `http-${res.status}` }
const peer = res.data.device || { id: res.data.deviceId, name: res.data.name }
const peerId = (target && target.deviceId) || (target && target.id) || (peer && peer.id)
if (peerId) rememberDevice(peerId, code, (peer && peer.name) || (target && target.name))
return { ok: true, token: res.data.token, deviceId: peer && peer.id, device: peer, peer: peer }
}
// --- receiving side ---------------------------------------------------------
function sendJson(res, status, data) {
@ -992,10 +933,8 @@ async function handlePlan(state, session, url, req, res, hooks) {
const PUT_IDLE_MS = Number(process.env.PLWL_LANSYNC_PUT_IDLE) || 60000
async function handlePut(session, url, req, res, hooks) {
// current senders put the pair in the query (percent-encoded); 0.5.x used
// headers, which cannot carry a code above U+00FF at all
const key = req.headers['x-plwl-unit'] || url.searchParams.get('unit') || ''
const rel = req.headers['x-plwl-path'] || url.searchParams.get('path') || ''
const key = url.searchParams.get('unit') || ''
const rel = url.searchParams.get('path') || ''
const unit = session.units.get(key)
const abs = filesPath(rel)
if (!unit || !abs || !unit.paths.has(rel)) {
@ -1066,7 +1005,7 @@ async function handleCommit(session, url, res, hooks) {
async function handleApply(session, req, res, hooks) {
const payload = await readJsonBody(req)
if (!payload) return sendJson(res, 400, { ok: false, error: 'bad-request' })
// sender categories go through the aliases (older PLWL says "defaults")
// only the categories this build knows, whatever the sender sent
const cats = new Set(normalizeCategories(payload.categories))
const appsDeleted = applyApps(payload, cats)
const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0
@ -1091,9 +1030,6 @@ async function handleApply(session, req, res, hooks) {
sendJson(res, 200, {
ok: true,
deleted: removed,
apps: { deleted: appsDeleted },
wines: { deleted: winesDeleted },
prefixes: { deleted: prefixesDeleted },
settings,
defaults: settings ? settings.defaults : null
})
@ -1264,27 +1200,19 @@ const exported = {
startServer,
stop,
stopServer,
closeServer: stopServer,
close: stopServer,
httpJson,
verifyPeer,
runSync,
appCategory,
classifyApp,
CATEGORIES,
normalizeCategories,
serverPort,
getServerPort: serverPort,
getState,
listDevices,
identity,
loadIdentity: identity,
makeDeviceId,
makeSyncCode,
rotateDeviceId,
rotateSyncCode,
rerollDeviceId: rotateDeviceId,
rerollSyncCode: rotateSyncCode,
knownDevice,
knownPeers,
rememberDevice,
@ -1293,9 +1221,6 @@ const exported = {
sameCode,
HASH_ALGO: hashStore.HASH_ALGO,
hashFile,
hashCacheStats: hashStore.stats,
resetHashCacheStats: hashStore.resetStats,
forgetHashCache: hashStore.forget,
clearHashCache: hashStore.clear,
HASH_CONCURRENCY,
progressGate,

View file

@ -283,42 +283,18 @@ function createPusher(api) {
deleted: 0,
bytes: 0,
// the popup reports "3 games, 1 prefix" instead of file counts
items: { apps: 0, wines: 0, prefixes: 0 },
apps: { copied: 0, skipped: 0, deleted: 0 },
wines: { copied: 0, skipped: 0, deleted: 0 },
prefixes: { copied: 0, skipped: 0, deleted: 0 }
items: { apps: 0, wines: 0, prefixes: 0 }
}
}
// Every sync resolves to the same shape, success or failure.
function syncResult(ok, error, summary, events) {
const buckets = { apps: summary.apps, prefixes: summary.prefixes, wines: summary.wines }
summary.progress = events.length
summary.events = events.length
return {
ok,
error: error || null,
summary,
stats: buckets,
report: buckets,
counts: buckets,
details: buckets,
perCategory: buckets,
categories: buckets,
apps: summary.apps,
prefixes: summary.prefixes,
wines: summary.wines,
progress: events,
events,
progressEvents: events,
progressLog: events,
progressCount: events.length
}
function syncResult(ok, error, summary) {
return { ok, error: error || null, summary }
}
// Push to the peer; a rejected code comes back as 'bad-code'.
async function syncToDevice({ deviceId, device: target, code, categories, force, onProgress }) {
const fail = (error, summary, events) => syncResult(false, error, summary || newSummary(0), events || [])
const fail = (error, summary) => syncResult(false, error, summary || newSummary(0))
const device = target || api.listDevices().find(d => d.id === deviceId)
if (!device || !device.address || !device.port) return fail('offline')
const cats = new Set(normalizeCategories(categories))
@ -329,10 +305,7 @@ function createPusher(api) {
const totals = { apps: 0, wines: 0, prefixes: 0, settings: 0 }
for (const unit of units) totals[bucketOf(unit.kind)] += 1
if (cats.has('settings')) totals.settings = 1
// every progress event is kept, so callers can report without subscribing
const events = []
const progress = data => {
events.push(data)
if (onProgress) onProgress(data)
}
@ -435,7 +408,6 @@ function createPusher(api) {
const roots = unit.roots.filter(ownedRoot)
if (!roots.length) continue
const kind = bucketOf(unit.kind)
const bucket = summary[kind]
summary.items[kind] += 1
itemsSeen += 1
barBytes = 0
@ -474,13 +446,9 @@ function createPusher(api) {
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 || [])
// per category: files created/overwritten vs already identical
const fileEntries = entries.filter(e => e.type === 'file')
const filePaths = new Set(fileEntries.map(e => e.path))
const filePaths = new Set(entries.filter(e => e.type === 'file').map(e => e.path))
let sameFiles = 0
for (const p of same) if (filePaths.has(p)) sameFiles++
bucket.copied += fileEntries.length - sameFiles
bucket.skipped += sameFiles
summary.skipped += sameFiles
const pending = []
for (const entry of entries) {
@ -523,10 +491,7 @@ function createPusher(api) {
allDone += pending.length
allTotal += pending.length
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
}
if (commit.data && commit.data.deleted) summary.deleted += commit.data.deleted
finished[kind] += 1
// a finished unit leaves a finished bar behind, even one that sent none
barTotal = barTotal || 1
@ -536,10 +501,6 @@ function createPusher(api) {
emit('registry', null, null, null, 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']) {
const deleted = applied.data[key] && applied.data[key].deleted
if (deleted) summary[key].deleted += deleted
}
if (applied.data.deleted) summary.deleted += applied.data.deleted
// a sync with nothing to send still ends with a full bar
barTotal = barTotal || 1
@ -552,11 +513,11 @@ function createPusher(api) {
} catch (err) {
abort.dead = true
await end()
return fail(String((err && err.message) || err), summary, events)
return fail(String((err && err.message) || err), summary)
} finally {
clearInterval(heartbeat)
}
return syncResult(true, null, summary, events)
return syncResult(true, null, summary)
}
// putFile is exposed for the stall tests (it is the piece that must never

View file

@ -109,8 +109,7 @@ function gamesFollowingDefault(running) {
})
}
// Windows is the default: entries written before Linux apps existed have no
// kind at all.
// `kind` is only ever written as 'linux'; a missing one is a Windows app.
function isLinuxGame(game) {
return !!game && game.kind === 'linux'
}

View file

@ -265,13 +265,11 @@ function syncDesktopConfig(root) {
const dst = path.join(root, 'config', rel)
const hostHash = hashOf(src)
if (!hostHash) continue
const known = marker[rel]
if (fs.existsSync(dst) && known) {
if (fs.existsSync(dst)) {
// only a marked copy is provably ours, and only while it is untouched
const current = hashOf(dst)
// the app owns it now, or there is nothing to do
if (current !== known || current === hostHash) continue
if (current !== marker[rel] || current === hostHash) continue
}
// no marker entry: the copy is ours (seeded before the marker existed)
if (copyFromHost(src, dst, { refresh: true })) {
marker[rel] = hostHash
changed = true

View file

@ -24,25 +24,8 @@ function clearPending() {
pending = null
}
// Fall back to English keys on older installs.
function textsFor(code) {
let texts = {}
try {
texts = require('./i18n').getLang(code).texts || {}
} catch {}
const fallback = {
'openwith.title': 'Open with Wine',
'openwith.file': 'File',
'openwith.fileMissing': 'This file no longer exists.',
'openwith.wine': 'Wine / Proton version',
'openwith.wineDefault': 'Default ({0})',
'openwith.prefix': 'Wine prefix',
'openwith.prefixDefault': 'Default prefix ({0})',
'openwith.run': 'Run',
'openwith.startFailed': 'Could not start {0}: {1}',
'openwith.notWindows': 'This is not a Windows executable (exe/msi/bat/cmd/lnk).'
}
return { ...fallback, ...texts }
return require('./i18n').getLang(code).texts || {}
}
function resolveWine(id) {

View file

@ -88,28 +88,16 @@ async function healPrefixOnce(game) {
}
}
// Proton's STEAM_COMPAT_DATA_PATH must be a symlink to the launcher prefix.
// A leftover EMPTY compat dir (an older PLWL bug) is replaced by the link; a
// populated one is left alone.
// Proton's STEAM_COMPAT_DATA_PATH must be a symlink to the launcher prefix;
// a real compat dir already sitting there is not ours to replace.
function linkPrefix(compatData, prefixDir) {
try {
fs.mkdirSync(path.dirname(compatData), { recursive: true })
} catch {}
try {
const st = fs.lstatSync(compatData)
if (st.isSymbolicLink()) {
if (!fs.lstatSync(compatData).isSymbolicLink()) return
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
}
} catch {}
try {
fs.symlinkSync(prefixDir, compatData, 'dir')