better sync progress reporting

This commit is contained in:
olcxja 2026-10-06 08:11:31 +02:00
commit d4996988d9
3 changed files with 34 additions and 16 deletions

View file

@ -71,7 +71,8 @@ Renderer never touches Node/Electron directly — everything goes through the `w
- Two sides: the receiver lives in lansync.js, the sender in lansyncpush.js, built by createPusher(helpers) — several launcher states share the module, so nothing may sit in its module scope. Both sides report their own progress (receiver through the onReceive hook).
- An incoming sync needs CONSENT: a correct code opens the gate, but the receiver's onIncoming hook (registered in main.js) shows Accept/Decline, answered through lansync:respondIncoming. Unanswered requests expire (INCOMING_TIMEOUT_MS); a decline returns 'rejected' and KEEPS the pairing (the code was proven). Without an onIncoming hook (tests, headless) the gate stays open.
- 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.
- 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 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.
@ -100,6 +101,7 @@ node --check <changed js files> # syntax
node test/smoke.js # quick sanity
node test/lansync.test.js # LAN sync contract (identity, pairing, mirror)
node test/lansync-receiving.test.js # receive-side progress events (the popup)
node test/lansync-progress.test.js # live sender bytes, comparing bar widths
node test/lansync-settings.test.js # playtime, app settings, wine/prefix + defaults
node test/lansync-links.test.js # wire: exec bits + relative links
node test/lansync-pending-links.test.js # links to files created later, startup heal

View file

@ -915,14 +915,15 @@ async function handlePlan(state, session, url, req, res, hooks) {
let inFlight = 0
const settleOldest = () => Promise.race(jobs.splice(0, 1))
await readNdjson(req, async entry => {
if (!inlineSame(entry)) return
// the sender announces its unit total before hashing starts (a control
// record, no path): it carries the compare bar's width, so it must be
// read before the entry filter drops it
if (entry.unitBytes != null) {
// the sender announces its unit total before hashing starts; it can
// arrive after early compares (dirs/links), so it just overwrites
unitTotalBytes = Number(entry.unitBytes) || 0
session.barTotal = unitTotalBytes || 1
return
}
if (!inlineSame(entry)) return
entries.push(entry)
if (!withinRoots(entry.path, owned)) return
if (forced) {
@ -968,14 +969,16 @@ async function handlePlan(state, session, url, req, res, hooks) {
// this unit's files/bytes, reported as they arrive
session.planned += upload.length
session.plannedBytes += uploadBytes
// the bar restarts for the copy phase
session.unitWritten = 0
session.barTotal = uploadBytes
session.barBytes = 0
// the unit's label is announced up front, so file events can name it
session.unitLabel = label
sendJson(res, 200, { ok: true, upload, same, entries: paths.size })
// the compare is finished: report it full, then reset for the copy phase
// (the first byte that arrives emits the new bar)
session.barTotal = unitTotalBytes || session.barTotal || 1
session.barBytes = session.barTotal
announce({ unit: label }, true)
session.unitWritten = 0
session.unitLabel = label // named up front, so file events can label it
session.barBytes = 0
session.barTotal = uploadBytes
sendJson(res, 200, { ok: true, upload, same, entries: paths.size })
}
// One PUT, one file. The sender declares a content-length up front; if the
@ -1095,6 +1098,9 @@ async function handleApply(session, req, res, hooks) {
function handleEnd(state, session, res, hooks) {
state.tokens.delete(session.token)
if (state.session && state.session.token === session.token) state.session = null
// a finished sync leaves a finished bar, even one that moved no files
session.barTotal = session.barTotal || 1
session.barBytes = session.barTotal
sendJson(res, 200, { ok: true })
emitReceive(hooks, session, 'done', { deleted: session.deleted || 0 })
}

View file

@ -112,7 +112,7 @@ function createPusher(api) {
})
}
function putFile(device, token, unitKey, rel, abs) {
function putFile(device, token, unitKey, rel, abs, onBytes) {
return new Promise((resolve, reject) => {
const settle = (fn, value) => {
clearTimeout(timer)
@ -151,6 +151,9 @@ function createPusher(api) {
req.on('error', err => settle(reject, err))
const stream = fs.createReadStream(abs)
stream.on('error', err => settle(reject, err))
// live bytes: a multi-GB file has to move the counter while it flies,
// not only when its response lands (read = what this side hands over)
if (onBytes) stream.on('data', chunk => onBytes(chunk.length))
// pipe ends the request when the file ends; a body shorter than the
// declared content-length (file changed mid-sync) wedges the receiver
// - its own idle watchdog breaks that, this side's timer breaks every
@ -366,9 +369,12 @@ function createPusher(api) {
}
return data
}
// snapshot per file, forward a few times per second (phase changes always)
const gate = typeof api.progressGate === 'function' ? api.progressGate(progress) : data => progress(data)
const emit = (phase, unit, done, total, force) => gate(snapshot(phase, unit, done, total), !!force)
// Snapshot per file, forward a few times per second (phase changes
// always). The snapshot is built when the gate flushes, not when it is
// pushed: a throttled byte count must not be one gate window old.
const emitNow = data => progress(typeof data === 'function' ? data() : data)
const gate = typeof api.progressGate === 'function' ? api.progressGate(emitNow) : emitNow
const emit = (phase, unit, done, total, force) => gate(() => snapshot(phase, unit, done, total), !!force)
// consent may wait on a human: say so before anything else happens
emit('wait', null, null, null, true)
@ -488,15 +494,19 @@ function createPusher(api) {
let done = 0
const send = async entry => {
if (failed || abort.dead) return
const res = await putFile(device, token, unit.key, entry.path, filesPath(entry.path))
const res = await putFile(device, token, unit.key, entry.path, filesPath(entry.path), delta => {
summary.bytes += delta
barBytes += delta
emit('copy', unit, allDone + done, allTotal + pending.length)
})
if (res.status !== 200) {
failed = new Error(`upload-failed-${res.status}`)
return
}
done++
summary.uploaded++
summary.bytes += entry.size || 0
barBytes += entry.size || 0
// the byte counters already moved while the file was read; the
// completion still has to push the file count next
emit('copy', unit, allDone + done, allTotal + pending.length)
}
if (typeof api.mapLimit === 'function') await api.mapLimit(pending, UPLOAD_CONCURRENCY, send)