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 --------------------------------------------------------------