cleanup & sync summary

This commit is contained in:
olcxja 2026-10-19 09:26:02 +02:00
commit be4f67fb60
17 changed files with 637 additions and 207 deletions

View file

@ -166,11 +166,14 @@ function createPusher(api) {
// --- units ----------------------------------------------------------------
function appUnits(cats) {
// `selected` is the optional app selection (a Set of ids or null = all);
// the seam a future "sync only these apps" UI builds on.
function appUnits(cats, selected) {
const units = []
for (const game of library.loadGames()) {
const category = api.appCategory(game)
if (!category || !cats.has(category)) continue
if (selected && !selected.has(String(game.id))) continue
const roots = [`data/icons/${game.id}`]
if (category === 'apps-portable') roots.unshift(`apps/${game.id}`)
units.push({ key: `app:${game.id}`, kind: 'app', id: game.id, label: game.name, roots })
@ -178,6 +181,10 @@ function createPusher(api) {
return units
}
function selectedApps(selection) {
return selection && Array.isArray(selection.apps) ? new Set(selection.apps.map(String)) : null
}
function wineUnits() {
const units = new Map()
const add = (root, label) => {
@ -200,9 +207,10 @@ function createPusher(api) {
return [...names].map(name => ({ key: `prefix:${name}`, kind: 'prefix', id: name, label: name, roots: [`wineprefixes/${name}`] }))
}
function buildUnits(cats) {
function buildUnits(cats, selection) {
const units = []
if (cats.has('apps-portable') || cats.has('apps-prefix')) units.push(...appUnits(cats))
const apps = selectedApps(selection)
if (cats.has('apps-portable') || cats.has('apps-prefix')) units.push(...appUnits(cats, apps))
if (cats.has('wines')) units.push(...wineUnits())
if (cats.has('prefixes')) units.push(...prefixUnits())
return units
@ -215,17 +223,22 @@ function createPusher(api) {
return library.portableGame(game)
}
function buildRegistryPayload(categories) {
function buildRegistryPayload(categories, selection) {
const cats = new Set(categories)
const payload = { categories: [...cats] }
const selected = selectedApps(selection)
if (cats.has('apps-portable') || cats.has('apps-prefix')) {
payload.apps = library
.loadGames()
.filter(game => {
const category = api.appCategory(game)
return category ? cats.has(category) : false
if (!category || !cats.has(category)) return false
return !selected || selected.has(String(game.id))
})
.map(portableApp)
// A partial list means "only these apps travel"; without the marker the
// receiver mirrors the category and drops the apps that were left out.
if (selected) payload.appScope = 'selected'
}
if (cats.has('wines')) {
// the receiver prunes its own builds down to this list and rescans
@ -282,6 +295,8 @@ function createPusher(api) {
skipped: 0,
deleted: 0,
bytes: 0,
// size of the synced trees as they now sit on the other device
sizeBytes: 0,
// the popup reports "3 games, 1 prefix" instead of file counts
items: { apps: 0, wines: 0, prefixes: 0 }
}
@ -293,14 +308,14 @@ function createPusher(api) {
}
// Push to the peer; a rejected code comes back as 'bad-code'.
async function syncToDevice({ deviceId, device: target, code, categories, force, onProgress }) {
async function syncToDevice({ deviceId, device: target, code, categories, force, selection, onProgress }) {
const fail = (error, summary) => syncResult(false, error, summary || newSummary(0))
const device = target || api.listDevices().find(d => d.id === deviceId)
if (!device || !device.address || !device.port) return fail('offline')
const cats = new Set(normalizeCategories(categories))
if (!cats.size) return fail('nothing-selected')
// units are known before anything is read: announce them with the verify
const units = buildUnits(cats)
const units = buildUnits(cats, selection)
const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps')
const totals = { apps: 0, wines: 0, prefixes: 0, settings: 0 }
for (const unit of units) totals[bucketOf(unit.kind)] += 1
@ -420,6 +435,11 @@ function createPusher(api) {
// for a receiver that still has to hash).
const entries = []
let writes = Promise.resolve()
// A read failure means this manifest is not the sender's whole tree:
// tell the receiver, so it never prunes what only looked missing.
const incomplete = () => {
writes = writes.then(() => write({ unitIncomplete: true }))
}
const plan = await planUnit(device, token, { ...unit, roots }, !!force, { totals }, async write => {
await buildManifestStream(roots, !!force, {
// the receiver sizes its compare bar from this, before any hashing
@ -430,6 +450,7 @@ function createPusher(api) {
entries.push(entry)
writes = writes.then(() => write(entry))
},
onIncomplete: incomplete,
onProgress: (hashedDone, hashedTotal, hashedBytes, totalBytes) => {
barBytes = hashedBytes
barTotal = totalBytes
@ -444,6 +465,8 @@ function createPusher(api) {
})
if (abort.dead) throw new Error('receiver-lost')
if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`)
// every file in the unit, including the ones already identical there
for (const entry of entries) if (entry.type === 'file') summary.sizeBytes += entry.size || 0
const upload = new Set(plan.data.upload || [])
const same = new Set(plan.data.same || [])
const filePaths = new Set(entries.filter(e => e.type === 'file').map(e => e.path))
@ -485,13 +508,20 @@ function createPusher(api) {
if (typeof api.mapLimit === 'function') await api.mapLimit(pending, UPLOAD_CONCURRENCY, send)
else for (const entry of pending) await send(entry)
if (failed) throw failed
// A dead receiver made `send` skip files; committing now would prune
// exactly the files that never arrived (the commit itself could even
// succeed while only the heartbeats were failing).
if (abort.dead) throw new Error('receiver-lost')
}
emit('copy', unit, allDone + pending.length, allTotal + pending.length, true)
// the totals count a unit's files only once they are on the wire
allDone += pending.length
allTotal += pending.length
const commit = await httpJson(device, 'POST', `/plwl/unit/commit?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}`, { json: {} })
if (commit.data && commit.data.deleted) summary.deleted += commit.data.deleted
// a unit that did not commit (unknown unit, failed prune) must not be
// counted as done while the sync moves on to the registry
if (!commit.data || !commit.data.ok) throw new Error((commit.data && commit.data.error) || `commit-${commit.status}`)
if (commit.data.deleted) summary.deleted += commit.data.deleted
finished[kind] += 1
// a finished unit leaves a finished bar behind, even one that sent none
barTotal = barTotal || 1
@ -499,7 +529,8 @@ function createPusher(api) {
emit('commit', unit, null, null, true)
}
emit('registry', null, null, null, true)
const applied = await httpJson(device, 'POST', `/plwl/apply?token=${encodeURIComponent(token)}`, { json: buildRegistryPayload([...cats]), timeout: 60000 })
if (abort.dead) throw new Error('receiver-lost')
const applied = await httpJson(device, 'POST', `/plwl/apply?token=${encodeURIComponent(token)}`, { json: buildRegistryPayload([...cats], selection), timeout: 60000 })
if (!applied.data || !applied.data.ok) throw new Error((applied.data && applied.data.error) || `http-${applied.status}`)
if (applied.data.deleted) summary.deleted += applied.data.deleted
// a sync with nothing to send still ends with a full bar