cleanup comments, update translations, bump to 0.4.1

This commit is contained in:
olcxja 2026-10-05 15:42:44 +02:00
commit 7d8ab87eee
28 changed files with 412 additions and 1114 deletions

View file

@ -1,10 +1,7 @@
'use strict'
// LAN syncing. Every running PLWL instance beacons on the local network and
// runs a small HTTP server other instances can push to. Whoever starts a sync
// is the source of truth: the receiver ends up with exactly the sender's
// files (extra files are deleted) so a half copied library is never left
// behind, and paths stored in the registries stay relative as they are.
// LAN sync: instances beacon and push to each other over HTTP. The sender's
// tree wins; extra receiver files are deleted.
const http = require('http')
const crypto = require('crypto')
@ -28,43 +25,30 @@ const { isPrefixRel } = prefixes
const CONFIG = path.join(DIRS.DATA, 'lansync.json')
// The beacon doubles as a liveness signal: a device drops off the list a few
// seconds after its PLWL is closed.
// The beacon doubles as liveness: a device drops off a few seconds after close.
const DISCOVERY_PORT = Number(process.env.PLWL_LANSYNC_PORT) || 47821
const BEACON_TAG = 'plwl-lansync'
const PROTOCOL = 1
// A device is only visible while it beacons; 2.5 s against a 6 s TTL leaves
// one missed datagram of headroom (the old 2 s/7 s pair woke the process just
// as often but dropped peers on the first lost packet).
// 2.5 s beacon against 6 s TTL: one lost datagram still fits.
const BEACON_MS = 2500
const DEVICE_TTL_MS = 6000
// Every verification costs this much and a device may only have one in
// flight - together they make guessing an 8 character code over the LAN
// pointless (and a failed guess cannot be retried in a tight loop).
// Cost + one-in-flight limit make guessing the 8 character code pointless.
const VERIFY_DELAY_MS = 2000
// How long a session token stays valid without any request. Env override for
// tests, which cannot wait half an hour to prove expiry.
// Session token TTL; env override for tests.
const TOKEN_TTL_MS = Number(process.env.PLWL_LANSYNC_TOKEN_TTL) || 30 * 60 * 1000
const SESSION_IDLE_MS = 5 * 60 * 1000
// How long the receiving user has to click Accept before the request expires:
// the sender waits for this decision, so its own verify timeout has to be
// longer than this.
// Receiver consent window; the sender's verify timeout must exceed it.
const INCOMING_TIMEOUT_MS = Number(process.env.PLWL_LANSYNC_INCOMING_TIMEOUT) || 60 * 1000
// The only trees a sync may touch. Everything else under files/ is local
// state (settings, translations, playtime, logs) that must never move
// between machines.
// The only trees a sync may touch; the rest of files/ is local state.
const SYNC_ROOTS = ['apps/', 'wines/', 'wineprefixes/', 'data/icons/']
// What a sync can carry, plus lenient aliases so callers can name the
// categories the way the UI/README does.
// Sync categories plus lenient aliases for older senders.
const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'settings']
const CATEGORY_ALIASES = {
// plain "apps" means the whole app library: portable ones AND the ones
// imported from a prefix (only the launcher entries, their files stay in
// the prefix)
// the whole app library: portable + prefix-import launcher entries
apps: ['apps-portable', 'apps-prefix'],
app: ['apps-portable', 'apps-prefix'],
appsportable: ['apps-portable'],
@ -80,10 +64,7 @@ const CATEGORY_ALIASES = {
wineprefixes: ['prefixes'],
prefixes: ['prefixes'],
prefix: ['prefixes'],
// the launcher's own settings ("Sync settings"): the defaults, the names and
// disabled flags of wine builds, the prefix registry, and every app's
// configuration plus its place in the library. "defaults" is what this
// category used to be called - an older sender still names it that way.
// launcher settings: defaults, wine labels, prefix registry, app configs
settings: ['settings'],
syncsettings: ['settings'],
defaults: ['settings'],
@ -91,9 +72,7 @@ const CATEGORY_ALIASES = {
defaultsettings: ['settings']
}
// Which sync category an app entry belongs to. Portable copy/move imports own
// their folder in files/apps; prefix imports only have a launcher entry (their
// files stay inside the prefix).
// An app entry's category: portable imports own their apps/ folder.
function appCategory(game) {
if (!game || typeof game !== 'object') return null
if (game.importSource === 'prefix' || game.mode === 'prefix') return 'apps-prefix'
@ -101,16 +80,11 @@ function appCategory(game) {
return null
}
// Where an app entry's files live, read off its stored path: a portable
// import owns a folder under apps/ (FILES-relative), a prefix import points
// inside its prefix ("drive_c/..." for plain wine, "pfx/drive_c/..." for
// Proton-managed prefixes) and anything absolute or ".." points outside the
// launcher data, so it is never part of a sync.
// An app entry's files, read off its stored path; outside the data = not synced.
function classifyApp(app) {
if (!app || (typeof app !== 'string' && typeof app !== 'object')) return null
const raw = typeof app === 'string' ? app : app.path || app.exePath || app.workdir
// nothing that points anywhere inside the launcher data: the app is left
// where it is (an external entry), nothing of it can travel in a sync
// external entry: nothing to sync
if (!raw || typeof raw !== 'string') return 'external'
const value = raw.replace(/\\/g, '/')
const outside = value.startsWith('..') || path.isAbsolute(raw) || /^[a-z]:\//i.test(value) || /^[a-z][a-z0-9+.-]*:\/\//i.test(value)
@ -135,20 +109,14 @@ const { identity, rotateDeviceId, rotateSyncCode, knownDevice, knownPeers, remem
// --- files: hashing, paths, manifests ---------------------------------------
// Hashing a tree is dominated by opening the (often tiny) files, not by the
// digest itself: a few in flight at once keeps the disk and the cpu busy
// instead of waiting on one file at a time.
// A few digests in flight keep the disk busy on many small files.
const HASH_CONCURRENCY = Math.max(4, Math.min(16, (os.cpus() || []).length || 4))
// Digests are remembered per file (size + mtime), so a library that did not
// change since the last sync is never read again - only stat'ed. One store per
// launcher state (tests re-require this module per device).
// Digests cached per file (size + mtime); one store per launcher state.
const hashStore = createHashCache(DIRS.DATA)
const hashFile = hashStore.hashFile
// Protocol paths are FILES-relative with '/' separators. Anything escaping
// files/ (a '..' segment, an absolute path) is rejected - manifests come from
// another machine.
// Protocol paths are FILES-relative; escapes are rejected (untrusted).
function filesPath(rel) {
if (typeof rel !== 'string' || !rel || rel.includes('\0') || path.isAbsolute(rel)) return null
const root = path.resolve(DIRS.FILES)
@ -171,8 +139,7 @@ const buildManifest = manifest.buildManifest
// --- HTTP helpers (shared with the pusher) ----------------------------------
// One-shot JSON request against another instance. `timeout: 0` means no socket
// timeout - hashing a big prefix on the other side takes a while.
// One-shot JSON request; timeout 0 = none (remote hashing is slow).
function httpJson(device, method, route, { json, timeout = 15000 } = {}) {
return new Promise((resolve, reject) => {
const req = http.request(
@ -216,10 +183,9 @@ function targetOf(target) {
return null
}
// Send our sync code to the other device (the receiver checks it against its
// own code and hands back a token). A rejected code never gets stored.
// Trade the sync code for a token; a rejected code is never stored.
async function verifyPeer(target, code) {
// one object with { host, port, code } or a target plus a separate code
// { host, port, code } in one object, or a target plus a separate code
if (target && typeof target === 'object' && code === undefined && target.code !== undefined) code = target.code
const device = targetOf(target)
if (!device || !device.port) return { ok: false, error: 'bad-target' }
@ -312,9 +278,7 @@ async function entrySame(entry) {
}
}
if (!entry.hash) return false // forced sync (or unreadable file) -> replace it
// A size mismatch alone settles it - reading the local file just to hash it
// would only confirm what the sizes already say. lstatSync because the
// answer (is it the regular file the manifest describes) needs no thread.
// A size mismatch alone settles it - no need to hash.
let st
try {
st = fs.lstatSync(abs)
@ -329,9 +293,7 @@ async function entrySame(entry) {
return false
}
if (!same) return false
// Same bytes, wrong permissions (a build synced before modes travelled, or a
// copy that dropped the bit): repaired right here instead of pulling the
// whole file over the network again.
// Same bytes, wrong mode: repair locally instead of re-downloading.
if (typeof entry.mode === 'number' && (st.mode & 0o777) !== entry.mode) applyMode(abs, entry.mode)
return true
}
@ -348,20 +310,14 @@ function ensureDir(abs) {
} catch {}
}
// After a unit has landed, its links and permissions have to be this machine's
// (the sender stored whatever was true over there): absolute symlinks pointing
// into the launcher data become relative, and a build gets back the executable
// bit a content copy drops. Without this a synced prefix/proton build is dead
// weight - the launcher cannot start it.
// After a unit lands, rewire links and restore modes, or it cannot start.
async function healUnitRoots(roots) {
const healed = { links: 0, executables: 0 }
for (const rel of Array.isArray(roots) ? roots : []) {
const abs = filesPath(ownedRoot(rel))
if (!abs) continue
try {
// a unit root that is a symlink is the whole unit: its target is not
// ours to walk (it may live outside the launcher), and the real folder
// behind it is healed through its own unit
// a symlinked unit root is not ours to walk; its own unit heals it
if (!fs.lstatSync(abs).isDirectory()) continue
healed.links += await rewireSymlinks(abs, [DIRS.FILES])
if (String(rel).startsWith('wines/')) healed.executables += await restoreExecutableBits(abs)
@ -384,8 +340,7 @@ function ensureLink(abs, target) {
} catch {}
}
// Reports whether something was ACTUALLY removed: rmSync with force also
// "succeeds" on a missing path, which would inflate the delete counters.
// rmSync(force) "succeeds" on missing paths; only real removals count.
function removeAbs(abs) {
try {
if (!fs.lstatSync(abs)) return false
@ -426,10 +381,7 @@ function removeOrphans(dir, keep) {
return removed
}
// Folder names in a registry directory. A symlinked folder counts too: a
// wine build or prefix kept on another disk is still part of the tree, and the
// sync carries it as the link it is (following it would copy the target and,
// for the pfx self link, loop).
// Registry folder names; symlinked folders count as the link they are.
function listDirs(dir) {
const out = []
let entries
@ -453,9 +405,7 @@ function listDirs(dir) {
// Delete everything under a unit root that is not in the sender's manifest.
async function pruneTree(abs, rel, keep) {
// A unit root can BE a symlink (a build or prefix kept on another disk).
// readdir would follow it and delete the real files behind it, which belong
// to the target, not to this unit.
// A symlinked unit root: readdir would delete the target's real files.
let rootStat = null
try {
rootStat = await fsp.lstat(abs)
@ -482,8 +432,7 @@ async function pruneTree(abs, rel, keep) {
return removed
}
// Modes are applied after the bytes are in place: createWriteStream cannot set
// one, and a wine build without its executable bit is unusable.
// Modes go on after the bytes; a build without the exec bit is unusable.
function applyMode(abs, mode) {
if (typeof mode !== 'number' || !mode) return
try {
@ -514,15 +463,13 @@ function writeStream(abs, stream, onBytes) {
fs.renameSync(tmp, abs)
resolve(out.bytesWritten || 0)
} catch (err) {
// a sync race (the path changed between open and rename) must not
// undo the transfer itself - the next sync would just re-upload it
// a rename race must not undo the transfer
removeAbs(tmp)
resolve(0)
}
})
if (onBytes) {
// the pipe in flowing mode delivers every chunk to each 'data' listener,
// so counting here does not steal the bytes from the file being written
// flowing mode delivers every chunk to each listener
stream.on('data', chunk => onBytes(chunk.length))
}
stream.pipe(out)
@ -546,11 +493,7 @@ function applyApps(payload, cats) {
}
kept.push(game)
}
// Playtime and the app's own settings (wine build, prefix, args, env,
// dllOverrides) travel with the app: paths are left exactly as the sender
// stored them (portable/prefix-relative), so a synced entry keeps working.
// Playtime only ever GROWS - the higher counter wins, so pushing from a
// freshly installed machine cannot wipe a library's play history.
// App settings travel as stored; playtime only grows (higher wins).
const merged = [...kept]
for (const g of incoming) {
const prev = existing.find(x => x.id === g.id)
@ -558,8 +501,7 @@ function applyApps(payload, cats) {
const playSeconds = hasPlay ? Math.max(Number(prev && prev.playSeconds) || 0, Number(g.playSeconds)) : prev && prev.playSeconds
const lastPlayed = Math.max(Number(prev && prev.lastPlayed) || 0, Number(g.lastPlayed) || 0)
merged.push({ ...g, playSeconds, lastPlayed })
// the per-app playtime file wins over the registry, so a synced counter
// has to be written there too or loadGames would keep reporting the old one
// the per-app playtime file wins over the registry; write it too
if (hasPlay) library.writePlaySeconds(g.id, playSeconds)
}
library.saveGames(merged)
@ -567,8 +509,7 @@ function applyApps(payload, cats) {
for (const id of dropped) removed += removeTree(`data/icons/${id}`)
const iconsRemoved = removed
if (cats.has('apps-portable')) {
// files/apps only ever holds portable (copy/move) imports, so anything
// the sender does not own is a leftover of the mirror
// files/apps holds only portable imports; the rest is mirror leftover
removed += removeOrphans(DIRS.APPS, incomingIds)
}
return removed
@ -577,8 +518,7 @@ function applyApps(payload, cats) {
async function applyWines(payload) {
const names = new Set((Array.isArray(payload.wines) ? payload.wines : []).map(w => w && w.name).filter(Boolean))
const removed = removeOrphans(DIRS.WINES, names)
// the registry is derived from the builds on disk, so a rescan is all that
// is needed to drop removed builds and pick up the synced ones
// the registry derives from disk; a rescan picks up the synced builds
await wines.refreshWines()
return removed
}
@ -589,27 +529,20 @@ function applyPrefixes(payload, useSenderDefault) {
const existing = prefixes.listPrefixes()
const byName = new Map(existing.prefixes.map(p => [p.name, p]))
const keep = new Map(incoming.map(p => [p.name, p]))
// every launcher seeds its own Main prefix - never delete the receiver's
// copy just because the sender does not know it
// never delete the receiver's own Main prefix
if (!keep.has(prefixes.MAIN)) {
keep.set(prefixes.MAIN, byName.get(prefixes.MAIN) || { name: prefixes.MAIN, created: Date.now() })
}
const removed = removeOrphans(DIRS.PREFIXES, new Set(keep.keys()))
const list = [...keep.values()].map(p => ({ ...p, locked: p.name === prefixes.MAIN ? true : !!p.locked }))
// Which prefix is the default only moves when the user asked for the default
// settings to be synced; otherwise this device keeps its own (as long as the
// prefix still exists here).
// the default prefix moves only with synced default settings
const wanted = useSenderDefault ? sender.default : existing.default
const def = keep.has(wanted) ? wanted : prefixes.MAIN
prefixes.savePrefixes({ default: def, prefixes: list })
return removed
}
// Wine display names and disabled flags ("Sync settings"): registry-only
// values, matched by build id. Only builds registered HERE are touched, and an
// empty name CLEARS a custom label - syncing from a machine where the build
// was never renamed restores the folder-derived label there, exactly like the
// sender shows. System Wine has no registry entry and is never renamed.
// Wine display names/disabled flags by build id; empty name clears the label.
function applyWineSettings(payload) {
const incoming = Array.isArray(payload.wineSettings) ? payload.wineSettings : []
let applied = 0
@ -635,11 +568,7 @@ function applyWineSettings(payload) {
return applied
}
// Prefix REGISTRY names ("Sync settings"): a prefix's name IS its folder, so
// this never renames or creates folders - folders and their removal belong to
// the "Wine prefixes" category. It registers the sender's entries so a prefix
// whose folder already arrived (or is about to be used as a default) is known
// here, and it leaves every local prefix alone.
// Prefix registry names only; registers sender entries as known here.
function applyPrefixSettings(payload) {
const sender = payload.prefixSettings && typeof payload.prefixSettings === 'object' ? payload.prefixSettings : null
if (!sender) return 0
@ -659,12 +588,7 @@ function applyPrefixSettings(payload) {
return added
}
// Per-app configuration ("Sync settings"): apps are matched by id and only
// apps that already exist here are patched - a settings sync cannot create an
// app whose files it does not carry. A wine id or prefix this device does not
// know is skipped instead of silently breaking the app (it would fall back to
// System Wine / the default prefix), and prefix-import entries keep their own
// prefix no matter what the sender has (updateGame pins it).
// Per-app config: patch existing apps only; unknown ids are skipped.
function applyAppSettings(payload) {
const incoming = Array.isArray(payload.appSettings) ? payload.appSettings : []
if (!incoming.length) return 0
@ -696,9 +620,7 @@ function applyAppSettings(payload) {
return patched
}
// The library ORDER ("Sync settings"): reorderGames keeps entries this device
// has that the sender does not know (they stay at the end), so a settings sync
// can reorder a library but never drop an app from it.
// Library order: entries unknown to the sender stay at the end.
function applyAppOrder(payload) {
const order = Array.isArray(payload.appOrder) ? payload.appOrder.filter(id => typeof id === 'string' && id) : []
if (!order.length) return 0
@ -708,10 +630,8 @@ function applyAppOrder(payload) {
return order.length
}
// Everything the "Sync settings" category carries. Order matters: prefix names
// are registered before the default prefix is resolved and before app configs
// are checked against them; the app order is applied last so the patched
// library is written in the sender's order.
// "Sync settings" payload; order matters (names -> defaults -> configs ->
// order).
function applySettings(payload, cats) {
if (!cats.has('settings')) return null
return {
@ -723,10 +643,7 @@ function applySettings(payload, cats) {
}
}
// The launcher-wide defaults ("Sync settings"): the wine every app falls back
// to and the prefix new imports land in. Only values that exist HERE are taken
// - a default pointing at a build or prefix this device does not have would be
// worse than keeping the current one.
// Launcher defaults; only values that exist here are taken.
function applyDefaults(payload) {
const wanted = payload.defaults && typeof payload.defaults === 'object' ? payload.defaults : {}
const applied = {}
@ -752,9 +669,7 @@ function createServerState() {
return { tokens: new Map(), verifying: new Set(), pending: new Map(), session: null }
}
// The user's answer to an incoming-sync popup. Resolves the verification that
// is waiting for it; unknown/expired ids are a no-op (the request already
// timed out server-side).
// The user's popup answer; unknown/expired ids are a no-op.
let serverState = null
function respondIncoming(requestId, accept) {
@ -766,14 +681,10 @@ function respondIncoming(requestId, accept) {
return true
}
// Which counter a sync unit feeds on the receiving side (the unit key looks
// like "app:<id>", "wine:<name>" or "prefix:<name>").
// The receiving-side counter a unit feeds on ("app:<id>" etc.).
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.
// Progress of a sync this device receives, for the receiver's popup.
function emitReceive(hooks, session, stage, extra = {}) {
if (!hooks || !hooks.onReceive) return
try {
@ -788,13 +699,11 @@ function emitReceive(hooks, session, stage, extra = {}) {
total: extra.total == null ? session.planned || 0 : extra.total,
bytes: session.bytes || 0,
totalBytes: session.plannedBytes || 0,
// items: units this sync announced, itemsDone: units fully written,
// totals: everything the sender picked (so the popup can say "2/5")
// items: announced units, itemsDone: written units, totals: sender's plan
items: { ...(session.items || {}) },
itemsDone: { ...(session.itemsDone || {}) },
totals: { ...(session.totals || {}) },
// what the BAR shows: the phase being worked on right now, so hashing
// starts empty instead of continuing a full bar from earlier
// the bar shows the current phase, so hashing starts empty
barBytes: session.barBytes || 0,
barTotalBytes: session.barTotal || 0,
deleted: extra.deleted || 0
@ -819,12 +728,7 @@ function authorize(state, token) {
return null
}
session.lastAt = Date.now()
// The expiry SLIDES with every request: hashing a big unit takes longer than
// any fixed TTL, and the request that finally moves files would arrive on a
// token that died while the sender was still hashing - the sync then ends
// right when it is ready to send. The heartbeat keeps the token alive through
// such a phase; a sender that really vanished still ages out this long after
// its last request.
// The expiry slides with every request: hashing can outlive any fixed TTL.
session.expires = Date.now() + TOKEN_TTL_MS
if (state.session && state.session.token === token) state.session.lastAt = session.lastAt
return session
@ -869,27 +773,20 @@ async function handleVerify(state, req, res, hooks) {
if (!payload || !payload.deviceId) return sendJson(res, 400, { ok: false, error: 'bad-request' })
const me = identity()
const from = String(payload.deviceId)
// one verification per device at a time; other devices may not slip their
// verification into an ongoing sync either
// one verification per device at a time
if (state.verifying.has(from)) return sendJson(res, 429, { ok: false, error: 'busy' })
const running = activeSession(state)
if (running && running.deviceId !== from) return sendJson(res, 429, { ok: false, error: 'busy' })
// one UNANSWERED consent popup at a time - a second sender would either
// overwrite the first popup or silently wait out its 60 seconds
// one unanswered consent popup at a time
if (state.pending.size) return sendJson(res, 429, { ok: false, error: 'busy' })
if (hooks.isBusy && hooks.isBusy(from)) return sendJson(res, 429, { ok: false, error: 'busy' })
state.verifying.add(from)
try {
await new Promise(resolve => setTimeout(resolve, VERIFY_DELAY_MS))
if (!sameCode(payload.code, me.syncCode)) return sendJson(res, 403, { ok: false, error: 'auth-failed', reason: 'wrong-code' })
// the sender may announce its totals with the verification, which is how
// the popup here can show "Apps: 0/5" before the first file list arrives
// the sender may announce totals up front ("0/5" in the popup)
const announced = payload.totals && typeof payload.totals === 'object' ? payload.totals : {}
// Nothing moves without the user behind this window accepting the push:
// a correct code only proves the sender read the code, it is not consent
// to overwrite this library. Without a UI to ask (tests, headless use)
// the gate cannot be answered and stays open - the launcher registers
// onIncoming whenever it shows a window.
// a correct code is not consent; without a UI to ask, the gate stays open
if (typeof hooks.onIncoming === 'function') {
const requestId = crypto.randomBytes(12).toString('hex')
const decision = await new Promise(resolve => {
@ -920,8 +817,7 @@ async function handleVerify(state, req, res, hooks) {
expires: Date.now() + TOKEN_TTL_MS,
lastAt: Date.now(),
units: new Map(),
// counters behind the "Receiving from X…" popup: files written, bytes
// written, and the same totals the sender planned (learned per unit)
// popup counters: files, bytes, sender's totals learned per unit
counted: new Set(),
received: 0,
planned: 0,
@ -970,8 +866,7 @@ async function handlePlan(state, session, url, req, res, hooks) {
for (const entry of entries) if (entry.type === 'file' && typeof entry.mode === 'number') modes.set(entry.path, entry.mode)
// a force sync compares nothing: every entry counts as different
const forced = url.searchParams.get('force') === '1'
// how many apps/prefixes/wines the sender picked in total: without it the
// popup here could only ever say what has already arrived
// the sender's totals, so the popup can say "2/5" from the start
try {
const totals = JSON.parse(url.searchParams.get('totals') || 'null')
if (totals && typeof totals === 'object') {
@ -983,8 +878,7 @@ async function handlePlan(state, session, url, req, res, hooks) {
}
} catch {}
const allowed = entries.filter(entry => withinRoots(entry.path, owned))
// Comparing this unit is a phase of its own: the bar fills with the bytes
// checked here, then starts over empty for the files that follow.
// comparing is its own phase: the bar restarts for the copy phase
const unitTotalBytes = allowed.reduce((sum, entry) => sum + (entry.size || 0), 0)
const announce = progressGate((data, force) => emitReceive(hooks, session, 'plan', data))
let compared = 0
@ -996,10 +890,7 @@ async function handlePlan(state, session, url, req, res, hooks) {
compared++
comparedBytes += entry.size || 0
session.barBytes = comparedBytes
// the file counter is not touched here: compared items include dirs and
// links, and until the upload starts nobody knows how many files will
// actually be written - the popup's counter freezes instead of jumping
// backwards when the copy phase starts its own count
// no file counting yet: dirs/links are compared too
announce({ unit: label })
return same === true
})
@ -1024,12 +915,10 @@ async function handlePlan(state, session, url, req, res, hooks) {
}
})
session.units.set(key, { roots: owned, paths, label, modes })
// what this unit adds to the transfer: the popup reports files and bytes
// against these as they arrive
// this unit's files/bytes, reported as they arrive
session.planned += upload.length
session.plannedBytes += uploadBytes
// the files are next: the bar counts the bytes about to be written, from an
// empty bar - a unit that needs nothing at all is simply finished
// the bar restarts for the copy phase
session.unitWritten = 0
session.barTotal = uploadBytes
session.barBytes = 0
@ -1038,16 +927,14 @@ async function handlePlan(state, session, url, req, res, hooks) {
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
// 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 })
announce({ unit: label }, true)
}
async function handlePut(session, url, req, res, hooks) {
// unit and path come as headers: they are constant per request, and routing
// them through the query made the receiver parse a fresh URL per file
// unit and path travel as headers: constant per request
const key = req.headers['x-plwl-unit'] || url.searchParams.get('unit') || ''
const rel = req.headers['x-plwl-path'] || url.searchParams.get('path') || ''
const unit = session.units.get(key)
@ -1056,10 +943,7 @@ async function handlePut(session, url, req, res, hooks) {
req.resume()
return sendJson(res, 400, { ok: false, error: 'bad-path' })
}
// Bytes are reported while the file is still arriving: waiting for the whole
// file to land would leave the popup frozen for the entire transfer of a
// single big asset (that is what made it look like it only moved once per
// received item).
// report bytes while the file is still arriving
const announce = progressGate((data, force) => emitReceive(hooks, session, 'copy', data))
try {
await writeStream(abs, req, delta => {
@ -1068,8 +952,7 @@ async function handlePut(session, url, req, res, hooks) {
session.barBytes = session.unitWritten
announce({ unit: session.unitLabel || '' })
})
// permissions do not travel with the bytes: the executable bit of a wine
// build's binaries has to be restored from the sender's manifest
// modes do not travel with the bytes; restore from the manifest
applyMode(abs, unit.modes.get(rel))
session.received = (session.received || 0) + 1
sendJson(res, 200, { ok: true })
@ -1088,18 +971,16 @@ async function handleCommit(session, url, res, hooks) {
const abs = filesPath(root)
if (abs) removed += await pruneTree(abs, root, unit.paths)
}
// the unit is complete - rewiring its links is only worth doing once the
// whole tree is there (a prefix's links chain through its own files)
// rewire links only once the whole tree is there
try {
await healUnitRoots(unit.roots)
} catch {}
session.units.delete(key)
session.deleted = (session.deleted || 0) + removed
// a finished unit: this is what turns "Apps: 2/5" from the sender's totals
// and the units written here
// a finished unit completes its "2/5" slice
const kind = UNIT_KINDS[String(key).split(':')[0]]
if (kind) session.itemsDone[kind] = (session.itemsDone[kind] || 0) + 1
// a finished unit leaves a finished bar behind (even one that needed no bytes)
// a finished unit leaves a finished bar behind
session.barTotal = session.barTotal || 1
session.barBytes = session.barTotal
sendJson(res, 200, { ok: true, deleted: removed })
@ -1109,14 +990,12 @@ async function handleCommit(session, url, res, hooks) {
async function handleApply(session, req, res, hooks) {
const payload = await readJsonBody(req)
if (!payload) return sendJson(res, 400, { ok: false, error: 'bad-request' })
// the categories come from another machine (and an older PLWL names the
// settings category "defaults"), so they go through the aliases here too
// sender categories go through the aliases (older PLWL says "defaults")
const cats = new Set(normalizeCategories(payload.categories))
const appsDeleted = applyApps(payload, cats)
const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0
const prefixesDeleted = cats.has('prefixes') ? applyPrefixes(payload, cats.has('settings')) : 0
// after the registries are in place, so a synced build/prefix can actually
// be chosen as the default and app configs can be checked against them
// after the registries, so synced builds/prefixes are usable as defaults
const settings = applySettings(payload, cats)
const removed = appsDeleted + winesDeleted + prefixesDeleted
emitReceive(hooks, session, 'registry', { deleted: removed })
@ -1151,12 +1030,7 @@ function handleEnd(state, session, res, hooks) {
emitReceive(hooks, session, 'done', { deleted: session.deleted || 0 })
}
// Liveness ping from the pushing device. It hashes whole units before it
// sends them and that can take many minutes, during which no other request
// arrives: without this the receiving popup would go quiet and its watchdog
// would eventually close it while the sync is still very much alive. The
// event only re-arms that watchdog (the renderer ignores its stage), so a
// transfer in progress keeps showing what it is doing.
// Liveness ping: hashing can take minutes; re-arms the popup watchdog.
function handlePing(session, res, hooks) {
session.lastAt = Date.now()
emitReceive(hooks, session, 'wait')
@ -1185,8 +1059,7 @@ let httpServer = null
let serverHooks = {}
let listening = null // promise resolved once the receiving server is up
// Bind the receiving HTTP server. Resolves with the port it got and a close()
// for tests; the app itself only needs start()/stop().
// Bind the receiving server; resolves with { port, close() }.
function startServer(options = {}) {
if (options && typeof options === 'object') serverHooks = { ...serverHooks, ...options }
if (listening) return listening
@ -1212,8 +1085,7 @@ function startServer(options = {}) {
}
function stopServer() {
// new digests must not die with the process: the throttled mid-sync saves
// may have skipped the last few units
// throttled mid-sync saves may have skipped the last digests
hashStore.save(true)
if (httpServer) {
try {
@ -1253,9 +1125,7 @@ function getState() {
return { deviceId: me.deviceId, syncCode: me.syncCode, hash: hashStore.HASH_ALGO, port: httpPort, devices: listDevices() }
}
// The pusher lives in its own module and is built with THIS module's helpers
// (identity, paths, registries), so one copy of it can serve several launcher
// states without mixing them up.
// The pusher is built with this module's helpers, so launcher states never mix.
let pusher = null
function getPusher() {
if (!pusher) {
@ -1289,8 +1159,7 @@ function syncTo(payload) {
return getPusher().syncToDevice(payload)
}
// Push to one peer in a single call. `options` may carry the peer's record
// (host/port/id/name), the sync code, the categories to push and force.
// Push to one peer; options: peer record, code, categories, force.
function runSync(options = {}, onProgress) {
const target = options.port || options.host || options.address ? targetOf(options) : null
const deviceId = options.deviceId || options.id