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
277
src/lansync.js
277
src/lansync.js
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue