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

@ -17,6 +17,12 @@ const path = require('path')
// small files a game or prefix is mostly made of.
const UPLOAD_CONCURRENCY = 4
// Keeps the receiving side's session and popup alive while this device hashes
// a unit (minutes of complete silence from the network) - well under the
// receiving popup's watchdog, and cheap enough to run through the transfer too.
// Env override: tests cannot wait 20 s for a ping.
const HEARTBEAT_MS = Number(process.env.PLWL_LANSYNC_HEARTBEAT) || 20000
function createPusher(api) {
const {
CATEGORIES,
@ -131,9 +137,15 @@ function createPusher(api) {
units.set(root, { key: `wine:${root.slice('wines/'.length)}`, kind: 'wine', id: root.slice('wines/'.length), label: label || root, roots: [root] })
}
for (const wine of wines.getWines()) {
const rel = toPortablePath(wine.path)
const parts = String(rel).split('/')
add(parts.length === 2 && parts[0] === 'wines' ? rel : `wines/${path.basename(wine.path)}`, wine.label)
const rel = String(toPortablePath(wine.path) || '')
const parts = rel.split('/')
// a build folder can sit one level down (zip-extracted layouts are
// detected at any depth): the unit is always the TOP folder under
// wines/ - that is the folder mirrored as a whole, and listDirs()
// names it as well. Using the basename here would point the unit at a
// folder that does not exist and inflate the "Wine versions" totals
// with a phantom unit.
add(parts[0] === 'wines' && parts[1] ? `wines/${parts[1]}` : `wines/${path.basename(wine.path)}`, wine.label)
}
for (const name of listDirs(DIRS.WINES)) add(`wines/${name}`)
return [...units.values()]
@ -178,14 +190,39 @@ 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.
if (cats.has('settings')) {
// "Sync settings": every registry-level value that describes how the
// launcher is set up here - the defaults, the names/flags the user gave
// wine builds, the prefix registry, and each app's configuration plus
// its position in the library. The receiver only patches things it
// already has; this category creates and deletes no files. Opt-in on
// purpose - a sync of apps must not silently rewrite the other
// machine's settings.
const defs = prefixes.listPrefixes()
const games = library.loadGames()
payload.defaults = {
defaultWine: (library.loadSettings().defaultWine) || 'system',
defaultPrefix: prefixes.listPrefixes().default || ''
defaultPrefix: defs.default || ''
}
payload.wineSettings = wines
.getWines()
.filter(w => w.type !== 'system')
.map(w => ({ name: w.id, displayName: w.displayName || '', disabled: !!w.disabled }))
payload.prefixSettings = {
default: defs.default || '',
prefixes: defs.prefixes.map(p => ({ name: p.name, created: p.created, locked: !!p.locked }))
}
payload.appSettings = games.map(g => ({
id: g.id,
name: g.name,
wine: g.wine || 'default',
prefix: g.prefix || '',
args: g.args || '',
env: g.env || {},
dllOverrides: g.dllOverrides || [],
disableUmu: !!g.disableUmu
}))
payload.appOrder = games.map(g => g.id)
}
if (cats.has('prefixes')) {
const defs = prefixes.listPrefixes()
@ -291,6 +328,14 @@ function createPusher(api) {
// typing (a rejected code is never stored)
rememberDevice(device.id || deviceId, code, device.name)
// Hashing a big unit happens here, before a single byte is sent: without a
// sign of life the other side would see minutes of silence and its
// "receiving" popup could close while this device is still working.
const heartbeat = setInterval(() => {
httpJson(device, 'POST', `/plwl/ping?token=${encodeURIComponent(token)}`, { json: {}, timeout: 10000 }).catch(() => {})
}, HEARTBEAT_MS)
if (heartbeat.unref) heartbeat.unref()
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
@ -381,7 +426,6 @@ function createPusher(api) {
pending.push(entry)
plannedBytes += entry.size || 0
}
allTotal += pending.length
// 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
@ -439,6 +483,8 @@ function createPusher(api) {
} catch (err) {
await end()
return fail(String((err && err.message) || err), summary, events)
} finally {
clearInterval(heartbeat)
}
return syncResult(true, null, summary, events)
}