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,26 +1,19 @@
'use strict'
// The pushing side of a LAN sync: this device is the source of truth and
// drives the whole transfer. For every sync unit it sends a manifest, asks
// the receiver what it already has, uploads the rest and has the receiver
// delete whatever the manifest does not list.
//
// Exported as a factory: src/lansync.js hands in its own helpers and state, so
// nothing here reaches across module instances.
// The pushing side of a LAN sync: per unit, send a manifest, learn what the
// receiver has, upload the rest, delete the surplus. Exported as a factory;
// src/lansync.js hands in its own helpers and state.
const http = require('http')
const os = require('os')
const fs = require('fs')
const path = require('path')
// Files sent at once: one at a time wastes most of a LAN round trip on the
// small files a game or prefix is mostly made of.
// Files sent at once: one at a time wastes LAN round trips on small files.
const UPLOAD_CONCURRENCY = 4
// Keeps the receiving side's session and popup alive while this device hashes
// a unit (minutes of complete silence from the network) - well under the
// receiving popup's watchdog, and cheap enough to run through the transfer too.
// Env override: tests cannot wait 20 s for a ping.
// Keeps the receiver alive while this side hashes (minutes of silence); env
// override for tests.
const HEARTBEAT_MS = Number(process.env.PLWL_LANSYNC_HEARTBEAT) || 20000
function createPusher(api) {
@ -42,11 +35,9 @@ function createPusher(api) {
prefixes
} = api
// Manifest in, list of files to upload out. NDJSON keeps a prefix with tens
// of thousands of entries from being one giant string in memory either side.
// Manifest in, upload list out; NDJSON avoids one giant string.
function planUnit(device, token, unit, entries, force, meta) {
// the receiver is told how many apps/prefixes/wines the whole sync carries
// up front, which is what lets its popup say "Apps: 2/5"
// announced up front, so the receiver's popup can say "Apps: 2/5"
const totals = meta && meta.totals ? `&totals=${encodeURIComponent(JSON.stringify(meta.totals))}` : ''
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}`
@ -88,8 +79,7 @@ function createPusher(api) {
function putFile(device, token, unitKey, rel, abs) {
return new Promise((resolve, reject) => {
// unit and path travel as headers: the receiver used to parse a fresh
// query string per file for values that are constant per request
// unit and path travel as headers: constant per request
const req = http.request(
{
host: device.address,
@ -139,12 +129,7 @@ function createPusher(api) {
for (const wine of wines.getWines()) {
const rel = String(toPortablePath(wine.path) || '')
const parts = rel.split('/')
// a build folder can sit one level down (zip-extracted layouts are
// detected at any depth): the unit is always the TOP folder under
// wines/ - that is the folder mirrored as a whole, and listDirs()
// names it as well. Using the basename here would point the unit at a
// folder that does not exist and inflate the "Wine versions" totals
// with a phantom unit.
// the unit is the TOP folder under wines/, not the detected build dir
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}`)
@ -167,9 +152,7 @@ function createPusher(api) {
// --- registry payload -----------------------------------------------------
// Everything the app config holds travels: paths (portableGame rewrites them
// to the FILES/prefix relative form both machines understand), the wine
// build and prefix it runs in, args/env/dllOverrides and the play counters.
// The whole app config travels (paths rewritten to relative form).
function portableApp(game) {
return library.portableGame(game)
}
@ -191,13 +174,8 @@ function createPusher(api) {
payload.wines = wineUnits().map(unit => ({ name: unit.id }))
}
if (cats.has('settings')) {
// "Sync settings": every registry-level value that describes how the
// launcher is set up here - the defaults, the names/flags the user gave
// wine builds, the prefix registry, and each app's configuration plus
// its position in the library. The receiver only patches things it
// already has; this category creates and deletes no files. Opt-in on
// purpose - a sync of apps must not silently rewrite the other
// machine's settings.
// "Sync settings": registry-level values only; opt-in - apps must not
// silently rewrite the other machine's settings.
const defs = prefixes.listPrefixes()
const games = library.loadGames()
payload.defaults = {
@ -243,9 +221,7 @@ function createPusher(api) {
skipped: 0,
deleted: 0,
bytes: 0,
// how many apps / wine builds / prefixes this sync walked over: the
// progress popup reports "3 games, 1 prefix" instead of file counts,
// which mean nothing to the user
// the popup reports "3 games, 1 prefix" instead of file counts
items: { apps: 0, wines: 0, prefixes: 0 },
apps: { copied: 0, skipped: 0, deleted: 0 },
wines: { copied: 0, skipped: 0, deleted: 0 },
@ -253,8 +229,7 @@ function createPusher(api) {
}
}
// Every sync resolves to the same shape, successes and failures alike, so
// callers can read counters/events without checking first.
// Every sync resolves to the same shape, success or failure.
function syncResult(ok, error, summary, events) {
const buckets = { apps: summary.apps, prefixes: summary.prefixes, wines: summary.wines }
summary.progress = events.length
@ -280,23 +255,19 @@ function createPusher(api) {
}
}
// Push the selected categories onto the peer. A rejected code comes back as
// error 'bad-code', so the UI can offer to type it again.
// Push to the peer; a rejected code comes back as 'bad-code'.
async function syncToDevice({ deviceId, device: target, code, categories, force, onProgress }) {
const fail = (error, summary, events) => syncResult(false, error, summary || newSummary(0), events || [])
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')
// The units are known before anything is read, so how many apps / wine
// builds / prefixes this sync carries is announced with the verification:
// the other side can show "Apps: 0/5" from its first event.
// units are known before anything is read: announce them with the verify
const units = buildUnits(cats)
const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps')
const totals = { apps: 0, wines: 0, prefixes: 0 }
for (const unit of units) totals[bucketOf(unit.kind)] += 1
// every progress event is kept (and handed to onProgress when given) so
// callers can report on the transfer without subscribing first
// every progress event is kept, so callers can report without subscribing
const events = []
const progress = data => {
events.push(data)
@ -305,8 +276,7 @@ function createPusher(api) {
let verified
try {
// longer than the receiver's incoming-request timeout: the user over
// there gets a popup and this side waits for the click
// longer than the receiver's incoming-request timeout (popup wait)
verified = await httpJson(device, 'POST', '/plwl/verify', {
json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase(), totals },
timeout: 90000
@ -316,21 +286,15 @@ function createPusher(api) {
}
if (!verified.data || !verified.data.ok) {
const error = (verified.data && verified.data.error) || `http-${verified.status}`
// the peer rotated its code: drop the stored pairing, so the next
// attempt asks for the new one instead of failing silently. A plain
// 'rejected' (the user over there declined the popup) keeps the pairing
// - the code was proven correct, the popup is the consent gate.
// a rotated code drops the pairing; a plain 'rejected' keeps it
if (error === 'auth-failed' || error === 'bad-code') forgetDevice(device.id || deviceId)
return fail(error)
}
const token = verified.data.token
// the code worked, so this device is paired now: the next sync needs no
// typing (a rejected code is never stored)
// paired now: the next sync needs no typing
rememberDevice(device.id || deviceId, code, device.name)
// Hashing a big unit happens here, before a single byte is sent: without a
// sign of life the other side would see minutes of silence and its
// "receiving" popup could close while this device is still working.
// hashing precedes any byte: keep the receiver's popup alive meanwhile
const heartbeat = setInterval(() => {
httpJson(device, 'POST', `/plwl/ping?token=${encodeURIComponent(token)}`, { json: {}, timeout: 10000 }).catch(() => {})
}, HEARTBEAT_MS)
@ -338,17 +302,13 @@ function createPusher(api) {
const summary = newSummary(units.length)
const finished = { apps: 0, wines: 0, prefixes: 0 }
// bytes the units planned so far will put on the wire: the popup can then
// show "1.2 GB / 3.4 GB" instead of only what has already arrived
// planned bytes so far, so the popup can show "1.2 GB / 3.4 GB"
let plannedBytes = 0
let itemsSeen = 0
// files uploaded/announced in PREVIOUS units: the popup's file counter is
// cumulative across the whole sync (a per-unit counter resets with every
// next app, which reads like progress going backwards)
// files from previous units: the popup counter is cumulative
let allDone = 0
let allTotal = 0
// the bar tracks the PHASE being worked on (its own bytes), never a
// leftover from the phase before: hashing starts from an empty bar
// the bar tracks the current phase only: hashing starts empty
let barBytes = 0
let barTotal = 0
const snapshot = (phase, unit, done, total) => {
@ -371,19 +331,14 @@ function createPusher(api) {
barBytes,
barTotalBytes: barTotal
}
// only phases that actually move the file counter carry one (the copy
// phase passes absolute, cumulative values); the rest omit it, so the
// popup freezes the last count instead of jumping backwards while this
// unit is still being scanned or compared
// only the copy phase carries a file count, so the popup never jumps back
if (Number.isFinite(done) && Number.isFinite(total)) {
data.done = done
data.total = total
}
return data
}
// hashing and copying walk thousands of files: the state is snapshotted on
// every file but forwarded a few times per second (phase changes always
// go through, so nothing on screen ever jumps backwards)
// snapshot per file, forward a few times per second (phase changes always)
const gate = typeof api.progressGate === 'function' ? api.progressGate(progress) : data => progress(data)
const emit = (phase, unit, done, total, force) => gate(snapshot(phase, unit, done, total), !!force)
const end = async () => {
@ -405,8 +360,7 @@ function createPusher(api) {
const entries = await buildManifest(roots, !!force, (done, total, hashedBytes, totalBytes) => {
barBytes = hashedBytes
barTotal = totalBytes
// the file counter is not touched here: until the plan comes back
// nobody knows how many of this unit's files will actually move
// no file count until the plan returns
emit('hash', unit, null, null)
})
emit('plan', unit, null, null, true)
@ -414,9 +368,7 @@ function createPusher(api) {
if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`)
const upload = new Set(plan.data.upload || [])
const same = new Set(plan.data.same || [])
// per category the FILES count: created/overwritten vs already
// identical (the plan reports both; dirs and links are re-ensured
// for free and are not files that moved)
// per category: files created/overwritten vs already identical
const fileEntries = entries.filter(e => e.type === 'file')
const filePaths = new Set(fileEntries.map(e => e.path))
let sameFiles = 0
@ -431,10 +383,8 @@ function createPusher(api) {
pending.push(entry)
plannedBytes += entry.size || 0
}
// a few files in flight at once: with thousands of small ones the
// round trip to the other machine costs more than the bytes do
// the files that were already identical never move, so the bar counts
// only the bytes that do - and starts empty for the transfer phase
// a few files in flight at once: small files cost round trips
// the bar counts only bytes that move, and starts empty for the copy
if (pending.length) {
barBytes = 0
barTotal = pending.reduce((sum, entry) => sum + (entry.size || 0), 0)
@ -459,9 +409,7 @@ function createPusher(api) {
else for (const entry of pending) await send(entry)
if (failed) throw failed
emit('copy', unit, allDone + done, allTotal + pending.length, true)
// this unit's files count towards the running totals only once they
// are really on the wire - the counter holds there while the receiver
// prunes and the next unit is scanned/compared
// 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: {} })