[TEST] selective sync

This commit is contained in:
olcxja 2026-10-19 11:02:52 +02:00
commit aee4d3d1f2
15 changed files with 1228 additions and 182 deletions

View file

@ -29,6 +29,12 @@ const HEARTBEAT_MS = Number(process.env.PLWL_LANSYNC_HEARTBEAT) || 20000
// One ping round trip; env override for tests.
const PING_TIMEOUT_MS = Number(process.env.PLWL_LANSYNC_PING_TIMEOUT) || 10000
// How often the sender asks the receiver's popup whether the deletion list is
// settled, and how long it waits for a human before giving up (nothing is
// deleted on a timeout, so the safe answer is also the fallback).
const REVIEW_POLL_MS = Number(process.env.PLWL_LANSYNC_REVIEW_POLL) || 1500
const REVIEW_WAIT_MS = Number(process.env.PLWL_LANSYNC_REVIEW_WAIT) || 15 * 60 * 1000
function createPusher(api) {
const {
CATEGORIES,
@ -54,8 +60,10 @@ function createPusher(api) {
function planUnit(device, token, unit, force, meta, produce) {
// announced up front, so the receiver's popup can say "Apps: 2/5"
const totals = meta && meta.totals ? `&totals=${encodeURIComponent(JSON.stringify(meta.totals))}` : ''
// folders a selection kept off the wire must not be pruned on the other side
const exclude = unit.exclude && unit.exclude.length ? `&exclude=${encodeURIComponent(JSON.stringify(unit.exclude))}` : ''
return new Promise((resolve, reject) => {
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' : ''}${totals}`
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' : ''}${totals}${exclude}`
const req = http.request(
{
host: device.address,
@ -166,26 +174,54 @@ function createPusher(api) {
// --- units ----------------------------------------------------------------
// `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) {
// A selection lists unit keys ("app:1", "wine:GE-Proton9-20", "prefix:Main");
// absent means everything travels. `apps` is the older app-id-only shape.
function selectionOf(selection) {
const units = selection && Array.isArray(selection.units) ? new Set(selection.units.map(String)) : null
const apps = selection && Array.isArray(selection.apps) ? new Set(selection.apps.map(String)) : null
return {
active: !!units || !!apps,
has(key, id) {
if (units && !units.has(key)) return false
if (apps && String(key).startsWith('app:') && !apps.has(String(id))) return false
return true
}
}
}
function appUnits(cats, sel) {
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
if (!sel.has(`app:${game.id}`, game.id)) continue
const roots = [`data/icons/${game.id}`]
if (category === 'apps-portable') roots.unshift(`apps/${game.id}`)
if (category === 'apps-portable') {
// the app's own folder, never apps/<id> guessed from the entry
const rel = String(toPortablePath(library.appFolder(game)) || '')
roots.unshift(rel.startsWith('apps/') ? rel : `apps/${game.id}`)
}
units.push({ key: `app:${game.id}`, kind: 'app', id: game.id, label: game.name, roots })
}
return units
}
function selectedApps(selection) {
return selection && Array.isArray(selection.apps) ? new Set(selection.apps.map(String)) : null
// A prefix app a selection leaves out stays on this side: its folder must not
// travel with the prefix, and the receiver must not prune it as a leftover.
function prefixExcludes(name, sel) {
const rootRel = `wineprefixes/${name}`
const out = []
for (const game of library.loadGames()) {
if (api.appCategory(game) !== 'apps-prefix' || game.prefix !== name) continue
if (sel.has(`app:${game.id}`, game.id)) continue
const rel = String(toPortablePath(library.appFolder(game)) || '')
if (rel === rootRel || !rel.startsWith(rootRel + '/')) continue
out.push(rel)
}
return out
}
function wineUnits() {
function wineUnits(sel) {
const units = new Map()
const add = (root, label) => {
if (units.has(root)) return
@ -198,21 +234,29 @@ function createPusher(api) {
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()]
return [...units.values()].filter(unit => sel.has(unit.key, unit.id))
}
function prefixUnits() {
function prefixUnits(sel) {
const names = new Set(prefixes.listPrefixes().prefixes.map(p => p.name))
for (const name of listDirs(DIRS.PREFIXES)) names.add(name)
return [...names].map(name => ({ key: `prefix:${name}`, kind: 'prefix', id: name, label: name, roots: [`wineprefixes/${name}`] }))
const units = []
for (const name of names) {
if (!sel.has(`prefix:${name}`)) continue
const unit = { key: `prefix:${name}`, kind: 'prefix', id: name, label: name, roots: [`wineprefixes/${name}`] }
const exclude = prefixExcludes(name, sel)
if (exclude.length) unit.exclude = exclude
units.push(unit)
}
return units
}
function buildUnits(cats, selection) {
const sel = selectionOf(selection)
const units = []
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())
if (cats.has('apps-portable') || cats.has('apps-prefix')) units.push(...appUnits(cats, sel))
if (cats.has('wines')) units.push(...wineUnits(sel))
if (cats.has('prefixes')) units.push(...prefixUnits(sel))
return units
}
@ -225,24 +269,25 @@ function createPusher(api) {
function buildRegistryPayload(categories, selection) {
const cats = new Set(categories)
const sel = selectionOf(selection)
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)
if (!category || !cats.has(category)) return false
return !selected || selected.has(String(game.id))
return sel.has(`app:${game.id}`, 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 (sel.active) payload.appScope = 'selected'
}
if (cats.has('wines')) {
// the receiver prunes its own builds down to this list and rescans
payload.wines = wineUnits().map(unit => ({ name: unit.id }))
// the receiver mirrors its own builds down to this list and rescans
payload.wines = wineUnits(sel).map(unit => ({ name: unit.id }))
if (sel.active) payload.wineScope = 'selected'
}
if (cats.has('settings')) {
// "Sync settings": registry-level values only; opt-in - apps must not
@ -280,8 +325,11 @@ function createPusher(api) {
const defs = prefixes.listPrefixes()
payload.prefixes = {
default: defs.default,
prefixes: defs.prefixes.map(p => ({ name: p.name, created: p.created, locked: !!p.locked }))
prefixes: defs.prefixes
.filter(p => sel.has(`prefix:${p.name}`))
.map(p => ({ name: p.name, created: p.created, locked: !!p.locked }))
}
if (sel.active) payload.prefixScope = 'selected'
}
return payload
}
@ -294,6 +342,8 @@ function createPusher(api) {
uploaded: 0,
skipped: 0,
deleted: 0,
// files the receiver asked to keep when the deletion list was reviewed
kept: 0,
bytes: 0,
// size of the synced trees as they now sit on the other device
sizeBytes: 0,
@ -387,6 +437,9 @@ function createPusher(api) {
if (error === 'auth-failed' || error === 'bad-code') forgetDevice(device.id || deviceId)
return fail(error)
}
// An older peer prunes inside its commit, with no list to approve: units
// must not travel there at all. Settings alone are additive and fine.
if (!verified.data.review && units.length) return fail('old-peer')
const token = verified.data.token
// paired now: the next sync needs no typing
rememberDevice(device.id || deviceId, code, device.name)
@ -417,6 +470,24 @@ function createPusher(api) {
await httpJson(device, 'POST', `/plwl/end?token=${encodeURIComponent(token)}`, { json: {} })
} catch {}
}
// The receiver's popup is a human, so the sender waits - visibly, and with
// a deadline. A timeout deletes nothing on purpose.
const waitForReview = async (device, token, review, emit, abort) => {
emit('review', null, null, null, true)
const deadline = Date.now() + REVIEW_WAIT_MS
while (Date.now() < deadline) {
if (abort.dead) throw new Error('receiver-lost')
try {
const res = await httpJson(device, 'GET', `/plwl/review?token=${encodeURIComponent(token)}&id=${encodeURIComponent(review.id)}`, { timeout: 20000 })
const data = res && res.data ? res.data : {}
if (data.state === 'done' || data.state === 'timeout' || data.state === 'skipped') {
return { deleted: Number(data.deleted) || 0, kept: Number(data.kept) || 0 }
}
} catch {}
await new Promise(resolve => setTimeout(resolve, REVIEW_POLL_MS))
}
return { deleted: 0, kept: 0 }
}
const runUnits = async () => {
for (const unit of units) {
if (abort.dead) throw new Error('receiver-lost')
@ -442,6 +513,8 @@ function createPusher(api) {
}
const plan = await planUnit(device, token, { ...unit, roots }, !!force, { totals }, async write => {
await buildManifestStream(roots, !!force, {
// an app the selection left out does not travel with its prefix
skip: unit.exclude,
// the receiver sizes its compare bar from this, before any hashing
onTotal: totalBytes => {
writes = writes.then(() => write({ unitBytes: totalBytes }))
@ -533,6 +606,13 @@ function createPusher(api) {
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
// Deletions wait for the receiver's approval; nothing is removed on a
// timeout, so a lost popup can only ever keep too much, never too little.
if (applied.data.review && applied.data.review.id) {
const outcome = await waitForReview(device, token, applied.data.review, emit, abort)
summary.deleted += outcome.deleted || 0
summary.kept += outcome.kept || 0
}
// a sync with nothing to send still ends with a full bar
barTotal = barTotal || 1
barBytes = barTotal
@ -551,9 +631,28 @@ function createPusher(api) {
return syncResult(true, null, summary)
}
// Everything a selection can name, in the same ids buildUnits uses - the UI
// must never derive a unit key on its own.
function items() {
const all = { active: false, has: () => true }
return {
apps: library
.loadGames()
.filter(game => api.appCategory(game))
.map(game => ({
key: `app:${game.id}`,
name: game.name || game.id,
category: api.appCategory(game),
prefix: game.prefix || ''
})),
wines: wineUnits(all).map(unit => ({ key: unit.key, name: unit.label })),
prefixes: prefixUnits(all).map(unit => ({ key: unit.key, name: unit.label }))
}
}
// putFile is exposed for the stall tests (it is the piece that must never
// leave a request half-open)
return { syncToDevice, buildUnits, buildRegistryPayload, putFile, CATEGORIES }
return { syncToDevice, buildUnits, buildRegistryPayload, items, putFile, CATEGORIES }
}
module.exports = createPusher