more accurate sync progress report

This commit is contained in:
olcxja 2026-10-01 14:58:58 +02:00
commit c0f6e802ed
12 changed files with 390 additions and 63 deletions

View file

@ -623,7 +623,7 @@ function writeStream(abs, stream) {
try {
if (st && st.isSymbolicLink()) removeAbs(abs)
fs.renameSync(tmp, abs)
resolve()
resolve(out.bytesWritten || 0)
} catch (err) {
removeAbs(tmp)
reject(err)
@ -701,6 +701,34 @@ function createServerState() {
return { tokens: new Map(), verifying: new Set(), session: null }
}
// Which counter a sync unit feeds on the receiving side (the unit key looks
// like "app:<id>", "wine:<name>" or "prefix:<name>").
const UNIT_KINDS = { app: 'apps', wine: 'wines', prefix: 'prefixes' }
// Progress of a sync THIS device receives. The pushing device drives the
// transfer, but the user here also needs to see what is being written to
// their library: the launcher forwards this to its window, which shows a
// "Receiving from X…" popup with a bar.
function emitReceive(hooks, session, stage, extra = {}) {
if (!hooks || !hooks.onReceive) return
try {
hooks.onReceive({
stage,
phase: stage,
from: session.name || '',
deviceId: session.deviceId || '',
unit: extra.unit || '',
label: extra.unit || '',
done: session.received || 0,
total: session.planned || 0,
bytes: session.bytes || 0,
totalBytes: session.plannedBytes || 0,
items: { ...(session.items || {}) },
deleted: extra.deleted || 0
})
} catch {}
}
function activeSession(state) {
if (!state.session) return null
if (Date.now() - state.session.lastAt > SESSION_IDLE_MS) {
@ -738,11 +766,11 @@ 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/unit/plan' && req.method === 'POST') return handlePlan(state, session, url, req, res)
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)
if (route === '/plwl/unit/commit' && req.method === 'POST') return handleCommit(session, url, res, hooks)
if (route === '/plwl/apply' && req.method === 'POST') return handleApply(session, req, res, hooks)
if (route === '/plwl/end' && req.method === 'POST') return handleEnd(state, session, res)
if (route === '/plwl/end' && req.method === 'POST') return handleEnd(state, session, res, hooks)
sendJson(res, 404, { ok: false, error: 'not-found' })
} catch (err) {
sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
@ -777,10 +805,19 @@ async function handleVerify(state, req, res, hooks) {
name: String(payload.name || ''),
expires: Date.now() + TOKEN_TTL_MS,
lastAt: Date.now(),
units: new Map()
units: new Map(),
// counters behind the "Receiving from X…" popup: files written, bytes
// written, and the same totals the sender planned (learned per unit)
counted: new Set(),
received: 0,
planned: 0,
bytes: 0,
plannedBytes: 0,
items: { apps: 0, wines: 0, prefixes: 0 }
}
state.tokens.set(token, session)
state.session = session
emitReceive(hooks, session, 'start')
sendJson(res, 200, {
ok: true,
token,
@ -793,8 +830,9 @@ async function handleVerify(state, req, res, hooks) {
}
}
async function handlePlan(state, session, url, req, res) {
async function handlePlan(state, session, url, req, res, hooks) {
const key = url.searchParams.get('unit') || ''
const label = url.searchParams.get('label') || ''
let roots = []
try {
roots = JSON.parse(url.searchParams.get('roots') || '[]')
@ -810,6 +848,7 @@ async function handlePlan(state, session, url, req, res) {
const paths = new Set()
const upload = []
const same = []
let uploadBytes = 0
for (const entry of entries) {
if (!withinRoots(entry.path, owned)) continue
const abs = filesPath(entry.path)
@ -822,10 +861,26 @@ async function handlePlan(state, session, url, req, res) {
}
if (entry.type === 'dir') ensureDir(abs)
else if (entry.type === 'link') ensureLink(abs, entry.target)
else upload.push(entry.path)
else {
upload.push(entry.path)
uploadBytes += entry.size || 0
}
}
session.units.set(key, { roots: owned, paths })
// 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
const kind = UNIT_KINDS[String(key).split(':')[0]]
if (kind && !session.counted.has(key)) {
session.counted.add(key)
session.items[kind] = (session.items[kind] || 0) + 1
}
// the unit's human label (game / prefix / wine name) is announced while it
// 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 })
}
async function handlePut(session, url, req, res, hooks) {
@ -838,15 +893,17 @@ async function handlePut(session, url, req, res, hooks) {
return sendJson(res, 400, { ok: false, error: 'bad-path' })
}
try {
await writeStream(abs, req)
const written = await writeStream(abs, req)
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 || '' })
} catch (err) {
sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
}
}
async function handleCommit(session, url, res) {
async function handleCommit(session, url, res, hooks) {
const key = url.searchParams.get('unit') || ''
const unit = session.units.get(key)
if (!unit) return sendJson(res, 400, { ok: false, error: 'no-unit' })
@ -856,7 +913,9 @@ async function handleCommit(session, url, res) {
if (abs) removed += await pruneTree(abs, root, unit.paths)
}
session.units.delete(key)
session.deleted = (session.deleted || 0) + removed
sendJson(res, 200, { ok: true, deleted: removed })
emitReceive(hooks, session, 'commit', { unit: session.unitLabel || '', deleted: removed })
}
async function handleApply(session, req, res, hooks) {
@ -867,8 +926,17 @@ async function handleApply(session, req, res, hooks) {
const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0
const prefixesDeleted = cats.has('prefixes') ? applyPrefixes(payload) : 0
const removed = appsDeleted + winesDeleted + prefixesDeleted
emitReceive(hooks, session, 'registry', { deleted: removed })
if (hooks.onApplied) {
hooks.onApplied({ from: session.name, deviceId: session.deviceId, categories: [...cats], files: session.received || 0, deleted: removed })
hooks.onApplied({
from: session.name,
deviceId: session.deviceId,
categories: [...cats],
files: session.received || 0,
bytes: session.bytes || 0,
items: { ...(session.items || {}) },
deleted: removed
})
}
sendJson(res, 200, {
ok: true,
@ -879,10 +947,11 @@ async function handleApply(session, req, res, hooks) {
})
}
function handleEnd(state, session, res) {
function handleEnd(state, session, res, hooks) {
state.tokens.delete(session.token)
if (state.session && state.session.token === session.token) state.session = null
sendJson(res, 200, { ok: true })
emitReceive(hooks, session, 'done', { deleted: session.deleted || 0 })
}
// --- discovery --------------------------------------------------------------

View file

@ -36,7 +36,7 @@ function createPusher(api) {
// of thousands of entries from being one giant string in memory either side.
function planUnit(device, token, unit, entries, force) {
return new Promise((resolve, reject) => {
const route = `/plwl/unit/plan?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}&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' : ''}`
const req = http.request(
{
host: device.address,
@ -185,6 +185,10 @@ function createPusher(api) {
skipped: 0,
deleted: 0,
bytes: 0,
// how many apps / wine builds / prefixes this sync walked over: the
// progress popup reports "3 games, 1 prefix" instead of file counts,
// which mean nothing to the user
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 }
@ -233,19 +237,6 @@ function createPusher(api) {
events.push(data)
if (onProgress) onProgress(data)
}
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
})
let verified
try {
@ -272,6 +263,28 @@ function createPusher(api) {
const units = buildUnits(cats)
const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps')
const summary = newSummary(units.length)
// 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 }
})
const end = async () => {
try {
await httpJson(device, 'POST', `/plwl/end?token=${encodeURIComponent(token)}`, { json: {} })
@ -281,6 +294,9 @@ 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
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)
@ -288,11 +304,13 @@ 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 || [])
const bucket = summary[bucketOf(unit.kind)]
// per category every manifest entry counts: created/overwritten vs
// 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
for (const entry of entries) {
if (entry.type !== 'file') continue

View file

@ -93,10 +93,16 @@ function trayIconPath() {
function createTray() {
const { Tray, Menu } = require('electron')
const icon = trayIconPath()
try {
tray = new Tray(trayIconPath())
} catch {
return // no icon available; window still works normally
tray = new Tray(icon)
} catch (err) {
// never fatal (the window keeps working), but say why the icon is missing
// instead of failing silently - a missing tray looks like a hung app when
// the launcher runs in the background
console.error(`[tray] could not create the tray icon (${icon || 'no icon file found'}): ${(err && err.message) || err}`)
tray = null
return
}
tray.setToolTip('PLWL')
rebuildTrayMenu()
@ -233,16 +239,25 @@ app.whenReady().then(async () => {
const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu })
gamesService = games
// LAN sync runs whenever the launcher runs - that is what makes this
// device show up on the other machines' client lists
lansync.start({
onDevices: devices => sendToWindow(win, 'lansync:devices', devices),
onApplied: info => sendToWindow(win, 'lansync:applied', info),
isBusy: () => !!(gamesService && gamesService.listRunning().length)
})
// the tray comes first: nothing (least of all the network) may keep it from
// coming up - a launcher without a tray icon looks simply broken
createTray()
// LAN sync runs whenever the launcher runs - that is what makes this
// device show up on the other machines' client lists
const lanHooks = {
onDevices: devices => sendToWindow(win, 'lansync:devices', devices),
onApplied: info => sendToWindow(win, 'lansync:applied', info),
onReceive: info => sendToWindow(win, 'lansync:receiving', info),
isBusy: () => !!(gamesService && gamesService.listRunning().length)
}
try {
Promise.resolve(lansync.start(lanHooks)).catch(err => {
console.error(`[lansync] start failed: ${(err && err.message) || err}`)
})
} catch (err) {
console.error(`[lansync] start failed: ${(err && err.message) || err}`)
}
if (headless) {
// shortcut boot: run the game without opening the launcher window
launchGame(autoLaunchId)
@ -259,5 +274,7 @@ app.whenReady().then(async () => {
app.on('before-quit', () => {
quitting = true
lansync.stop()
try {
lansync.stop()
} catch {}
})

View file

@ -115,21 +115,47 @@ function resolvePortablePath(p) {
}
// The launcher's own icon (renderer/plwl.png), used by the tray, the app
// icon and as the .desktop shortcut fallback. Cached: it never moves at
// runtime.
// icon and as the .desktop shortcut fallback.
//
// The module-relative copy comes FIRST: it is the only candidate that exists
// in every layout, because a packaged build keeps renderer/ inside
// app.asar and Electron's patched fs reads straight out of the archive (a
// release only ships olcxja.plwl.png next to the portable root, so looking at
// APP_ROOT alone made the tray icon vanish in installed builds). Cached: it
// never moves at runtime.
function launcherIconCandidates() {
const bundled = path.join(__dirname, '..', 'renderer')
return [
path.join(bundled, 'plwl.png'),
path.join(bundled, 'plwl.svg'),
path.join(APP_ROOT, 'renderer', 'plwl.png'),
path.join(APP_ROOT, 'renderer', 'plwl.svg'),
path.join(APP_ROOT, 'olcxja.plwl.png')
]
}
function firstExisting(candidates) {
for (const candidate of candidates) {
try {
if (fs.existsSync(candidate)) return candidate
} catch {}
}
return null
}
let launcherIcon = null
function launcherIconPath() {
if (launcherIcon === null) {
const p = path.join(APP_ROOT, 'renderer', 'plwl.png')
try {
launcherIcon = fs.existsSync(p) ? p : ''
} catch {
launcherIcon = ''
}
}
if (launcherIcon === null) launcherIcon = firstExisting(launcherIconCandidates()) || ''
return launcherIcon || null
}
// The same icon, but only as a real file on disk: a .desktop entry's Icon= is
// resolved by the desktop environment, which cannot look inside app.asar.
function launcherIconDiskPath() {
const onDisk = launcherIconCandidates().filter(candidate => !candidate.includes(`.asar${path.sep}`))
return firstExisting(onDisk)
}
function readJson(file, fallback) {
try {
return JSON.parse(fs.readFileSync(file, 'utf8'))
@ -145,4 +171,4 @@ function writeJson(file, data) {
fs.renameSync(tmp, file)
}
module.exports = { RESOURCES, APP_ROOT, BUILD, FILES, DATA, DIRS, ensureDirs, syncBundledTranslations, launcherIconPath, readJson, writeJson, toPortablePath, resolvePortablePath }
module.exports = { RESOURCES, APP_ROOT, BUILD, FILES, DATA, DIRS, ensureDirs, syncBundledTranslations, launcherIconPath, launcherIconDiskPath, readJson, writeJson, toPortablePath, resolvePortablePath }

View file

@ -95,5 +95,6 @@ contextBridge.exposeInMainWorld('plwl', {
onLanSyncProgress: cb => {
lanSyncProgressCb = cb
},
onLanSyncApplied: cb => ipcRenderer.on('lansync:applied', (e, data) => cb(data))
onLanSyncApplied: cb => ipcRenderer.on('lansync:applied', (e, data) => cb(data)),
onLanSyncReceiving: cb => ipcRenderer.on('lansync:receiving', (e, data) => cb(data))
})

View file

@ -7,7 +7,7 @@
const fs = require('fs')
const path = require('path')
const { DIRS, resolvePortablePath } = require('./paths')
const { DIRS, resolvePortablePath, launcherIconDiskPath } = require('./paths')
const DATA_DIR = process.env.PLWL_BUILD_DIR ? path.join(process.env.PLWL_BUILD_DIR, 'files', 'data') : DIRS.DATA
@ -62,8 +62,9 @@ function buildDesktopEntry(game) {
if (found) icon = path.join(iconDir, found)
} catch {}
if (!icon) {
// fall back to the launcher icon so the shortcut is never naked
icon = require('./paths').launcherIconPath() || ''
// fall back to the launcher icon so the shortcut is never naked (the
// on-disk copy: the desktop environment cannot read inside app.asar)
icon = launcherIconDiskPath() || ''
}
const lines = [
'[Desktop Entry]',