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:
parent
8c6db38fab
commit
59ff0b1088
15 changed files with 520 additions and 113 deletions
280
src/lansync.js
280
src/lansync.js
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue