[TEST] selective sync
This commit is contained in:
parent
be4f67fb60
commit
aee4d3d1f2
15 changed files with 1228 additions and 182 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue