improvemets & fixes:

- added 'open with' handling to desktop shortcuts
- move logs button to app configuration
- fix sync progress disappearing after long hashing
- fix sync double file counter
- merge wine and proton prefix folders via symlink
This commit is contained in:
olcxja 2026-10-04 19:12:33 +02:00
commit 59ff0b1088
15 changed files with 520 additions and 113 deletions

View file

@ -53,7 +53,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', 'defaults']
const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'settings']
const CATEGORY_ALIASES = {
// plain "apps" means the whole app library: portable ones AND the ones
@ -74,12 +74,15 @@ const CATEGORY_ALIASES = {
wineprefixes: ['prefixes'],
prefixes: ['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']
// the launcher's own settings ("Sync settings"): the defaults, the names and
// disabled flags of wine builds, the prefix registry, and every app's
// configuration plus its place in the library. "defaults" is what this
// category used to be called - an older sender still names it that way.
settings: ['settings'],
syncsettings: ['settings'],
defaults: ['settings'],
default: ['settings'],
defaultsettings: ['settings']
}
// Which sync category an app entry belongs to. Portable copy/move imports own
@ -316,26 +319,33 @@ function saveHashCache() {
} catch {}
}
// The remembered digest of a file, or null when it has to be read again.
// A hit only marks the cache dirty when its timestamp is actually stale:
// marking it on every lookup would force a full rewrite of the file per sync
// even though nothing a second sync could reuse has changed.
function hashCacheHit(rel, size, mtimeMs) {
const cache = loadHashCache()
const known = cache[rel]
if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') {
hashStats.misses++
return null
}
hashStats.hits++
if (Date.now() - (known[3] || 0) > 86400000) {
known[3] = Date.now()
hashCacheDirty = true
}
return known[2]
}
// 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++
// a hit only marks the cache dirty when its timestamp is actually stale:
// marking it every time would force a full rewrite of the file per sync
// even though nothing a second sync could reuse has changed
if (Date.now() - (known[3] || 0) > 86400000) {
known[3] = Date.now()
hashCacheDirty = true
}
return Promise.resolve(known[2])
}
hashStats.misses++
const known = hashCacheHit(rel, size, mtimeMs)
if (known) return Promise.resolve(known)
return hashFile(abs).then(hash => {
if (hash) {
cache[rel] = [size, mtimeMs, hash, Date.now()]
loadHashCache()[rel] = [size, mtimeMs, hash, Date.now()]
hashCacheDirty = true
}
return hash
@ -499,26 +509,18 @@ async function buildManifest(roots, force, onProgress) {
// start empty and fill with the bytes actually read
const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0)
if (!force && files.length) {
const cache = loadHashCache()
let done = 0
let hashed = 0
await mapLimit(files, HASH_CONCURRENCY, async entry => {
const abs = filesPath(entry.path)
try {
// inline cache hit: identical size and mtime means identical bytes, so
// the digest from an earlier sync still describes this file - the bar
// still advances by the file's size, because the check WAS the work
// identical size and mtime means identical bytes, so the digest from
// an earlier sync still describes this file
const mtimeMs = mtimes.get(entry.path) || 0
const known = cache[entry.path]
if (abs && known && known[0] === entry.size && known[1] === mtimeMs && typeof known[2] === 'string') {
hashStats.hits++
if (Date.now() - (known[3] || 0) > 86400000) {
known[3] = Date.now()
hashCacheDirty = true
}
entry.hash = known[2]
const known = hashCacheHit(entry.path, entry.size, mtimeMs)
if (known && abs) {
entry.hash = known
} else {
hashStats.misses++
entry.hash = abs
? await hashFile(abs, bytes => {
hashed += bytes
@ -526,7 +528,7 @@ async function buildManifest(roots, force, onProgress) {
})
: null
if (entry.hash) {
cache[entry.path] = [entry.size, mtimeMs, entry.hash, Date.now()]
loadHashCache()[entry.path] = [entry.size, mtimeMs, entry.hash, Date.now()]
hashCacheDirty = true
}
}
@ -732,6 +734,10 @@ async function healUnitRoots(roots) {
const abs = filesPath(ownedRoot(rel))
if (!abs) continue
try {
// a unit root that is a symlink is the whole unit: its target is not
// ours to walk (it may live outside the launcher), and the real folder
// behind it is healed through its own unit
if (!fs.lstatSync(abs).isDirectory()) continue
healed.links += await rewireSymlinks(abs, [DIRS.FILES])
if (String(rel).startsWith('wines/')) healed.executables += await restoreExecutableBits(abs)
} catch {}
@ -795,19 +801,43 @@ function removeOrphans(dir, keep) {
return removed
}
// Folder names in a registry directory. A symlinked folder counts too: a
// wine build or prefix kept on another disk is still part of the tree, and the
// sync carries it as the link it is (following it would copy the target and,
// for the pfx self link, loop).
function listDirs(dir) {
const out = []
let entries
try {
return fs
.readdirSync(dir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
entries = fs.readdirSync(dir, { withFileTypes: true })
} catch {
return []
return out
}
for (const entry of entries) {
if (entry.isDirectory()) {
out.push(entry.name)
continue
}
if (!entry.isSymbolicLink()) continue
try {
if (fs.statSync(path.join(dir, entry.name)).isDirectory()) out.push(entry.name)
} catch {}
}
return out
}
// Delete everything under a unit root that is not in the sender's manifest.
async function pruneTree(abs, rel, keep) {
// A unit root can BE a symlink (a build or prefix kept on another disk).
// readdir would follow it and delete the real files behind it, which belong
// to the target, not to this unit.
let rootStat = null
try {
rootStat = await fsp.lstat(abs)
} catch {
return 0
}
if (!rootStat.isDirectory()) return 0
let entries
try {
entries = await fsp.readdir(abs, { withFileTypes: true })
@ -950,12 +980,129 @@ function applyPrefixes(payload, useSenderDefault) {
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
// Wine display names and disabled flags ("Sync settings"): registry-only
// values, matched by build id. Only builds registered HERE are touched, and an
// empty name CLEARS a custom label - syncing from a machine where the build
// was never renamed restores the folder-derived label there, exactly like the
// sender shows. System Wine has no registry entry and is never renamed.
function applyWineSettings(payload) {
const incoming = Array.isArray(payload.wineSettings) ? payload.wineSettings : []
let applied = 0
for (const entry of incoming) {
if (!entry || !entry.name) continue
const id = String(entry.name)
const wine = wines.getWine(id)
if (!wine || wine.type === 'system') continue
let changed = false
const wanted = typeof entry.displayName === 'string' ? entry.displayName.trim() : ''
if (wanted !== (wine.displayName || '')) {
// a sender that never renamed the build clears the custom label here
if (wanted) wines.setWineDisplayName(id, wanted)
else wines.clearWineDisplayName(id)
changed = true
}
if (entry.disabled !== undefined && !!entry.disabled !== !!wine.disabled) {
wines.setWineDisabled(id, !!entry.disabled)
changed = true
}
if (changed) applied++
}
return applied
}
// Prefix REGISTRY names ("Sync settings"): a prefix's name IS its folder, so
// this never renames or creates folders - folders and their removal belong to
// the "Wine prefixes" category. It registers the sender's entries so a prefix
// whose folder already arrived (or is about to be used as a default) is known
// here, and it leaves every local prefix alone.
function applyPrefixSettings(payload) {
const sender = payload.prefixSettings && typeof payload.prefixSettings === 'object' ? payload.prefixSettings : null
if (!sender) return 0
const incoming = (Array.isArray(sender.prefixes) ? sender.prefixes : []).filter(p => p && p.name)
if (!incoming.length) return 0
const defs = prefixes.listPrefixes()
const known = new Set(defs.prefixes.map(p => p.name))
let added = 0
for (const entry of incoming) {
const name = String(entry.name)
if (known.has(name)) continue
known.add(name)
defs.prefixes.push({ name, created: Number(entry.created) || Date.now(), locked: name === prefixes.MAIN ? true : !!entry.locked })
added++
}
if (added) prefixes.savePrefixes(defs)
return added
}
// Per-app configuration ("Sync settings"): apps are matched by id and only
// apps that already exist here are patched - a settings sync cannot create an
// app whose files it does not carry. A wine id or prefix this device does not
// know is skipped instead of silently breaking the app (it would fall back to
// System Wine / the default prefix), and prefix-import entries keep their own
// prefix no matter what the sender has (updateGame pins it).
function applyAppSettings(payload) {
const incoming = Array.isArray(payload.appSettings) ? payload.appSettings : []
if (!incoming.length) return 0
const byId = new Map(incoming.filter(a => a && a.id).map(a => [String(a.id), a]))
const knownPrefixes = new Set(prefixes.listPrefixes().prefixes.map(p => p.name))
let patched = 0
for (const game of library.loadGames()) {
const from = byId.get(game.id)
if (!from) continue
const patch = {}
if (typeof from.name === 'string' && from.name && from.name !== game.name) patch.name = from.name
if (from.wine !== undefined) {
const wanted = String(from.wine || 'default')
if ((wanted === 'default' || wanted === 'system' || wines.getWine(wanted)) && wanted !== (game.wine || 'default')) patch.wine = wanted
}
if (from.prefix !== undefined && game.importSource !== 'prefix') {
const wanted = String(from.prefix || '')
if ((!wanted || knownPrefixes.has(wanted)) && wanted !== (game.prefix || '')) patch.prefix = wanted
}
if (typeof from.args === 'string' && from.args !== (game.args || '')) patch.args = from.args
if (from.env && typeof from.env === 'object' && JSON.stringify(from.env) !== JSON.stringify(game.env || {})) patch.env = from.env
if (Array.isArray(from.dllOverrides) && JSON.stringify(from.dllOverrides) !== JSON.stringify(game.dllOverrides || [])) patch.dllOverrides = from.dllOverrides
if (from.disableUmu !== undefined && !!from.disableUmu !== !!game.disableUmu) patch.disableUmu = !!from.disableUmu
if (Object.keys(patch).length) {
library.updateGame(game.id, patch)
patched++
}
}
return patched
}
// The library ORDER ("Sync settings"): reorderGames keeps entries this device
// has that the sender does not know (they stay at the end), so a settings sync
// can reorder a library but never drop an app from it.
function applyAppOrder(payload) {
const order = Array.isArray(payload.appOrder) ? payload.appOrder.filter(id => typeof id === 'string' && id) : []
if (!order.length) return 0
const current = library.loadGames().map(g => g.id)
if (current.length === order.length && current.every((id, i) => id === order[i])) return 0
library.reorderGames(order)
return order.length
}
// Everything the "Sync settings" category carries. Order matters: prefix names
// are registered before the default prefix is resolved and before app configs
// are checked against them; the app order is applied last so the patched
// library is written in the sender's order.
function applySettings(payload, cats) {
if (!cats.has('settings')) return null
return {
prefixes: applyPrefixSettings(payload),
wines: applyWineSettings(payload),
apps: applyAppSettings(payload),
order: applyAppOrder(payload),
defaults: applyDefaults(payload)
}
}
// The launcher-wide defaults ("Sync settings"): 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) {
const wanted = payload.defaults && typeof payload.defaults === 'object' ? payload.defaults : {}
const applied = {}
if (wanted.defaultWine) {
@ -1067,6 +1214,7 @@ function createHttpServer(state, hooks) {
if (route === '/plwl/verify' && req.method === 'POST') return handleVerify(state, req, res, hooks)
const session = authorize(state, url.searchParams.get('token'))
if (!session) return sendJson(res, 401, { ok: false, error: 'unauthorized' })
if (route === '/plwl/ping' && req.method === 'POST') return handlePing(session, res, hooks)
if (route === '/plwl/unit/plan' && req.method === 'POST') return handlePlan(state, session, url, req, res, hooks)
if (route === '/plwl/unit/file' && req.method === 'PUT') return handlePut(session, url, req, res, hooks)
if (route === '/plwl/unit/commit' && req.method === 'POST') return handleCommit(session, url, res, hooks)
@ -1216,7 +1364,11 @@ async function handlePlan(state, session, url, req, res, hooks) {
compared++
comparedBytes += entry.size || 0
session.barBytes = comparedBytes
announce({ unit: label, done: compared, total: allowed.length })
// the file counter is not touched here: compared items include dirs and
// links, and until the upload starts nobody knows how many files will
// actually be written - the popup's counter freezes instead of jumping
// backwards when the copy phase starts its own count
announce({ unit: label })
return same === true
})
saveHashCache()
@ -1258,7 +1410,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 })
announce({ unit: label, done: allowed.length, total: allowed.length }, true)
announce({ unit: label }, true)
}
async function handlePut(session, url, req, res, hooks) {
@ -1325,13 +1477,15 @@ 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' })
const cats = new Set(Array.isArray(payload.categories) ? payload.categories : [])
// the categories come from another machine (and an older PLWL names the
// settings category "defaults"), so they go through the aliases here too
const cats = new Set(normalizeCategories(payload.categories))
const appsDeleted = applyApps(payload, cats)
const winesDeleted = cats.has('wines') ? await applyWines(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 prefixesDeleted = cats.has('prefixes') ? applyPrefixes(payload, cats.has('settings')) : 0
// after the registries are in place, so a synced build/prefix can actually
// be chosen as the default and app configs can be checked against them
const settings = applySettings(payload, cats)
const removed = appsDeleted + winesDeleted + prefixesDeleted
emitReceive(hooks, session, 'registry', { deleted: removed })
if (hooks.onApplied) {
@ -1343,7 +1497,8 @@ async function handleApply(session, req, res, hooks) {
bytes: session.bytes || 0,
items: { ...(session.items || {}) },
deleted: removed,
defaults
settings,
defaults: settings ? settings.defaults : null
})
}
sendJson(res, 200, {
@ -1352,7 +1507,8 @@ async function handleApply(session, req, res, hooks) {
apps: { deleted: appsDeleted },
wines: { deleted: winesDeleted },
prefixes: { deleted: prefixesDeleted },
defaults
settings,
defaults: settings ? settings.defaults : null
})
}
@ -1363,6 +1519,18 @@ function handleEnd(state, session, res, hooks) {
emitReceive(hooks, session, 'done', { deleted: session.deleted || 0 })
}
// Liveness ping from the pushing device. It hashes whole units before it
// sends them and that can take many minutes, during which no other request
// arrives: without this the receiving popup would go quiet and its watchdog
// would eventually close it while the sync is still very much alive. The
// event only re-arms that watchdog (the renderer ignores its stage), so a
// transfer in progress keeps showing what it is doing.
function handlePing(session, res, hooks) {
session.lastAt = Date.now()
emitReceive(hooks, session, 'wait')
sendJson(res, 200, { ok: true })
}
// --- discovery --------------------------------------------------------------
let beaconSocket = null