cleanup comments, update translations, bump to 0.4.1
This commit is contained in:
parent
9edcc9841b
commit
7d8ab87eee
28 changed files with 412 additions and 1114 deletions
|
|
@ -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: {} })
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue