PLWL/src/lansync.js

1307 lines
46 KiB
JavaScript

'use strict'
// 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')
const os = require('os')
const path = require('path')
const fs = require('fs')
const fsp = fs.promises
const readline = require('readline')
const { DIRS, readJson, writeJson, toPortablePath } = require('./paths')
const { rewireSymlinks, restoreExecutableBits } = require('./symlinks')
const { progressGate, mapLimit } = require('./fsutil')
const { createHashCache } = require('./hashcache')
const { createIdentity, sameCode } = require('./lansync-identity')
const { createManifestBuilder } = require('./lansync-manifest')
const { createDiscovery } = require('./lansync-discovery')
const library = require('./library')
const wines = require('./wines')
const prefixes = require('./prefixes')
const { isPrefixRel } = prefixes
const CONFIG = path.join(DIRS.DATA, 'lansync.json')
// 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
// 2.5 s beacon against 6 s TTL: one lost datagram still fits.
const BEACON_MS = 2500
const DEVICE_TTL_MS = 6000
// Cost + one-in-flight limit make guessing the 8 character code pointless.
const VERIFY_DELAY_MS = 2000
// 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
// 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; the rest of files/ is local state.
const SYNC_ROOTS = ['apps/', 'wines/', 'wineprefixes/', 'data/icons/']
// Sync categories plus lenient aliases for older senders.
const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'settings']
const CATEGORY_ALIASES = {
// the whole app library: portable + prefix-import launcher entries
apps: ['apps-portable', 'apps-prefix'],
app: ['apps-portable', 'apps-prefix'],
appsportable: ['apps-portable'],
portableapps: ['apps-portable'],
portable: ['apps-portable'],
appsprefix: ['apps-prefix'],
prefixapps: ['apps-prefix'],
appsimportedfromprefix: ['apps-prefix'],
wine: ['wines'],
wines: ['wines'],
wineversions: ['wines'],
wineprefix: ['prefixes'],
wineprefixes: ['prefixes'],
prefixes: ['prefixes'],
prefix: ['prefixes'],
// launcher settings: defaults, wine labels, prefix registry, app configs
settings: ['settings'],
syncsettings: ['settings'],
defaults: ['settings'],
default: ['settings'],
defaultsettings: ['settings']
}
// 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'
if (game.imported === true || game.mode === 'copy' || game.mode === 'move') return 'apps-portable'
return null
}
// 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
// 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)
if (isPrefixRel(value)) return 'prefix'
if (/^apps\//.test(value)) return 'portable'
return outside ? 'external' : null
}
function normalizeCategories(list) {
const out = new Set()
for (const raw of Array.isArray(list) ? list : []) {
const aliases = CATEGORY_ALIASES[String(raw || '').toLowerCase().replace(/[^a-z]+/g, '')]
for (const alias of aliases || []) out.add(alias)
}
return [...out]
}
// --- identity ---------------------------------------------------------------
const identityApi = createIdentity(CONFIG)
const { identity, rotateDeviceId, rotateSyncCode, knownDevice, knownPeers, rememberDevice, forgetDevice, makeDeviceId, makeSyncCode } = identityApi
// --- files: hashing, paths, manifests ---------------------------------------
// 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 cached per file (size + mtime); one store per launcher state.
const hashStore = createHashCache(DIRS.DATA)
const hashFile = hashStore.hashFile
// 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)
const abs = path.resolve(root, rel)
if (abs !== root && !abs.startsWith(root + path.sep)) return null
return abs
}
// A sync unit may only own one of the sync trees, nothing else.
function ownedRoot(rel) {
const clean = String(rel || '').replace(/\\/g, '/').replace(/\/+$/, '')
if (!clean || clean.split('/').includes('..')) return null
return SYNC_ROOTS.some(prefix => clean.startsWith(prefix)) ? clean : null
}
// --- manifests (the sender's hashing) ---------------------------------------
const manifest = createManifestBuilder({ filesPath, filesRoot: DIRS.FILES, hash: hashStore, mapLimit, concurrency: HASH_CONCURRENCY })
const buildManifest = manifest.buildManifest
// --- HTTP helpers (shared with the pusher) ----------------------------------
// 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(
{
host: device.address,
port: device.port,
path: route,
method,
headers: json ? { 'content-type': 'application/json' } : {},
timeout
},
res => {
const chunks = []
res.on('data', chunk => chunks.push(chunk))
res.on('end', () => {
let data = null
try {
data = JSON.parse(Buffer.concat(chunks).toString('utf8'))
} catch {}
resolve({ status: res.statusCode, data })
})
}
)
req.on('timeout', () => req.destroy(new Error('timeout')))
req.on('error', reject)
if (json) req.write(JSON.stringify(json))
req.end()
})
}
// Accept a discovered device record, a bare port (tests) or 'host:port'.
function targetOf(target) {
if (typeof target === 'number') return { address: '127.0.0.1', port: target }
if (typeof target === 'string') {
const [host, port] = target.split(':')
return { address: host || '127.0.0.1', port: Number(port) || 0 }
}
if (target && typeof target === 'object') {
return { address: target.address || target.host || target.hostname || '127.0.0.1', port: Number(target.port) || 0 }
}
return null
}
// Trade the sync code for a token; a rejected code is never stored.
async function verifyPeer(target, 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' }
let res
try {
res = await httpJson(device, 'POST', '/plwl/verify', {
json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase() },
timeout: 20000
})
} catch (err) {
return { ok: false, error: String((err && err.message) || err) }
}
if (!res.data || !res.data.ok) return { ok: false, error: (res.data && res.data.error) || `http-${res.status}` }
const peer = res.data.device || { id: res.data.deviceId, name: res.data.name }
const peerId = (target && target.deviceId) || (target && target.id) || (peer && peer.id)
if (peerId) rememberDevice(peerId, code, (peer && peer.name) || (target && target.name))
return { ok: true, token: res.data.token, deviceId: peer && peer.id, device: peer, peer: peer }
}
// --- receiving side ---------------------------------------------------------
function sendJson(res, status, data) {
if (res.writableEnded) return
const body = JSON.stringify(data)
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) })
res.end(body)
}
function readJsonBody(req, limit = 16 * 1024 * 1024) {
return new Promise(resolve => {
let size = 0
const chunks = []
req.on('data', chunk => {
size += chunk.length
if (size > limit) {
req.destroy()
return
}
chunks.push(chunk)
})
req.on('end', () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
} catch {
resolve(null)
}
})
req.on('error', () => resolve(null))
})
}
function readNdjson(req, onEntry) {
return new Promise(resolve => {
const rl = readline.createInterface({ input: req, crlfDelay: Infinity })
let chain = Promise.resolve()
rl.on('line', line => {
if (!line.trim()) return
// entries are handled strictly in order: a handler may be async (the
// streamed plan compares/hashes while the body keeps arriving), but
// the response must never reorder the manifest
let parsed = null
try {
parsed = JSON.parse(line)
} catch {
return
}
chain = chain.then(() => onEntry(parsed)).catch(() => {})
})
const done = () => chain.then(resolve, () => resolve())
rl.on('close', done)
rl.on('error', done)
})
}
function inlineSame(entry) {
return entry && typeof entry.path === 'string' && (entry.type === 'dir' || entry.type === 'link' || entry.type === 'file')
}
function withinRoots(rel, roots) {
return roots.some(root => rel === root || rel.startsWith(root + '/'))
}
async function entrySame(entry) {
const abs = filesPath(entry.path)
if (!abs) return false
if (entry.type === 'dir') {
try {
const st = await fsp.lstat(abs)
return st.isDirectory() && !st.isSymbolicLink()
} catch {
return false
}
}
if (entry.type === 'link') {
try {
return (await fsp.readlink(abs)) === entry.target
} catch {
return false
}
}
if (!entry.hash) return false // forced sync (or unreadable file) -> replace it
// A size mismatch alone settles it - no need to hash.
let st
try {
st = fs.lstatSync(abs)
} catch {
return false
}
if (!st.isFile() || st.size !== entry.size) return false
let same = false
try {
same = (await hashStore.cachedHash(entry.path, abs, st.size, st.mtimeMs || 0)) === entry.hash
} catch {
return false
}
if (!same) return false
// 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
}
function ensureDir(abs) {
let st = null
try {
st = fs.lstatSync(abs)
} catch {}
if (st && st.isDirectory() && !st.isSymbolicLink()) return
if (st) removeAbs(abs)
try {
fs.mkdirSync(abs, { recursive: true })
} catch {}
}
// 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 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)
} catch {}
}
return healed
}
function ensureLink(abs, target) {
if (typeof target !== 'string' || !target) return
let current = null
try {
current = fs.readlinkSync(abs)
} catch {}
if (current === target) return
removeAbs(abs)
try {
fs.mkdirSync(path.dirname(abs), { recursive: true })
fs.symlinkSync(target, abs)
} catch {}
}
// rmSync(force) "succeeds" on missing paths; only real removals count.
function removeAbs(abs) {
try {
if (!fs.lstatSync(abs)) return false
} catch {
return false
}
try {
fs.rmSync(abs, { recursive: true, force: true })
return true
} catch {
return false
}
}
function removeTree(rel) {
const abs = filesPath(rel)
if (!abs) return 0
try {
if (!fs.lstatSync(abs)) return 0 // nothing to delete -> do not count it
} catch {
return 0
}
return removeAbs(abs) ? 1 : 0
}
function removeOrphans(dir, keep) {
let names = []
try {
names = fs.readdirSync(dir)
} catch {
return 0
}
let removed = 0
for (const name of names) {
if (keep.has(name)) continue
if (removeAbs(path.join(dir, name))) removed++
}
return removed
}
// Registry folder names; symlinked folders count as the link they are.
function listDirs(dir) {
const out = []
let entries
try {
entries = fs.readdirSync(dir, { withFileTypes: true })
} catch {
return out
}
for (const entry of entries) {
if (entry.isDirectory()) {
out.push(entry.name)
continue
}
if (!entry.isSymbolicLink()) continue
try {
if (fs.statSync(path.join(dir, entry.name)).isDirectory()) out.push(entry.name)
} catch {}
}
return out
}
// Delete everything under a unit root that is not in the sender's manifest.
async function pruneTree(abs, rel, keep) {
// A symlinked unit root: readdir would delete the target's real files.
let rootStat = null
try {
rootStat = await fsp.lstat(abs)
} catch {
return 0
}
if (!rootStat.isDirectory()) return 0
let entries
try {
entries = await fsp.readdir(abs, { withFileTypes: true })
} catch {
return 0
}
let removed = 0
for (const entry of entries) {
const childAbs = path.join(abs, entry.name)
const childRel = `${rel}/${entry.name}`
if (!keep.has(childRel)) {
if (removeAbs(childAbs)) removed++
continue
}
if (entry.isDirectory()) removed += await pruneTree(childAbs, childRel, keep)
}
return removed
}
// 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 {
fs.chmodSync(abs, mode & 0o777)
} catch {}
}
function writeStream(abs, stream, onBytes) {
return new Promise((resolve, reject) => {
try {
fs.mkdirSync(path.dirname(abs), { recursive: true })
} catch {}
const tmp = `${abs}.plwl-sync-tmp`
const out = fs.createWriteStream(tmp)
stream.on('error', err => {
removeAbs(tmp)
reject(err)
})
out.on('error', err => {
removeAbs(tmp)
reject(err)
})
out.on('finish', () => {
try {
if (fs.lstatSync(abs).isDirectory()) removeAbs(abs)
} catch {} // nothing there (the common case) or a link - both fine to rename over
try {
fs.renameSync(tmp, abs)
resolve(out.bytesWritten || 0)
} catch (err) {
// a rename race must not undo the transfer
removeAbs(tmp)
resolve(0)
}
})
if (onBytes) {
// flowing mode delivers every chunk to each listener
stream.on('data', chunk => onBytes(chunk.length))
}
stream.pipe(out)
})
}
// --- registries on the receiving side ---------------------------------------
function applyApps(payload, cats) {
if (!cats.has('apps-portable') && !cats.has('apps-prefix')) return 0
const incoming = (Array.isArray(payload.apps) ? payload.apps : []).filter(g => g && g.id)
const incomingIds = new Set(incoming.map(g => g.id))
const existing = library.loadGames()
const kept = []
const dropped = new Set()
for (const game of existing) {
const category = appCategory(game)
if (category && cats.has(category)) {
if (!incomingIds.has(game.id)) dropped.add(game.id)
continue // replaced by the sender's copy
}
kept.push(game)
}
// 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)
const hasPlay = Number.isFinite(Number(g.playSeconds))
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; write it too
if (hasPlay) library.writePlaySeconds(g.id, playSeconds)
}
library.saveGames(merged)
let removed = 0
for (const id of dropped) removed += removeTree(`data/icons/${id}`)
const iconsRemoved = removed
if (cats.has('apps-portable')) {
// files/apps holds only portable imports; the rest is mirror leftover
removed += removeOrphans(DIRS.APPS, incomingIds)
}
return removed
}
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 derives from disk; a rescan picks up the synced builds
await wines.refreshWines()
return removed
}
function applyPrefixes(payload, useSenderDefault) {
const sender = payload.prefixes && typeof payload.prefixes === 'object' ? payload.prefixes : {}
const incoming = (Array.isArray(sender.prefixes) ? sender.prefixes : []).filter(p => p && p.name)
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]))
// 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 }))
// 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/disabled flags by build id; empty name clears the label.
function applyWineSettings(payload) {
const incoming = Array.isArray(payload.wineSettings) ? payload.wineSettings : []
let applied = 0
for (const entry of incoming) {
if (!entry || !entry.name) continue
const id = String(entry.name)
const wine = wines.getWine(id)
if (!wine || wine.type === 'system') continue
let changed = false
const wanted = typeof entry.displayName === 'string' ? entry.displayName.trim() : ''
if (wanted !== (wine.displayName || '')) {
// a sender that never renamed the build clears the custom label here
if (wanted) wines.setWineDisplayName(id, wanted)
else wines.clearWineDisplayName(id)
changed = true
}
if (entry.disabled !== undefined && !!entry.disabled !== !!wine.disabled) {
wines.setWineDisabled(id, !!entry.disabled)
changed = true
}
if (changed) applied++
}
return applied
}
// 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
const incoming = (Array.isArray(sender.prefixes) ? sender.prefixes : []).filter(p => p && p.name)
if (!incoming.length) return 0
const defs = prefixes.listPrefixes()
const known = new Set(defs.prefixes.map(p => p.name))
let added = 0
for (const entry of incoming) {
const name = String(entry.name)
if (known.has(name)) continue
known.add(name)
defs.prefixes.push({ name, created: Number(entry.created) || Date.now(), locked: name === prefixes.MAIN ? true : !!entry.locked })
added++
}
if (added) prefixes.savePrefixes(defs)
return added
}
// 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
const byId = new Map(incoming.filter(a => a && a.id).map(a => [String(a.id), a]))
const knownPrefixes = new Set(prefixes.listPrefixes().prefixes.map(p => p.name))
let patched = 0
for (const game of library.loadGames()) {
const from = byId.get(game.id)
if (!from) continue
const patch = {}
if (typeof from.name === 'string' && from.name && from.name !== game.name) patch.name = from.name
if (from.wine !== undefined) {
const wanted = String(from.wine || 'default')
if ((wanted === 'default' || wanted === 'system' || wines.getWine(wanted)) && wanted !== (game.wine || 'default')) patch.wine = wanted
}
if (from.prefix !== undefined && game.importSource !== 'prefix') {
const wanted = String(from.prefix || '')
if ((!wanted || knownPrefixes.has(wanted)) && wanted !== (game.prefix || '')) patch.prefix = wanted
}
if (typeof from.args === 'string' && from.args !== (game.args || '')) patch.args = from.args
if (from.env && typeof from.env === 'object' && JSON.stringify(from.env) !== JSON.stringify(game.env || {})) patch.env = from.env
if (Array.isArray(from.dllOverrides) && JSON.stringify(from.dllOverrides) !== JSON.stringify(game.dllOverrides || [])) patch.dllOverrides = from.dllOverrides
if (from.disableUmu !== undefined && !!from.disableUmu !== !!game.disableUmu) patch.disableUmu = !!from.disableUmu
if (Object.keys(patch).length) {
library.updateGame(game.id, patch)
patched++
}
}
return patched
}
// 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
const current = library.loadGames().map(g => g.id)
if (current.length === order.length && current.every((id, i) => id === order[i])) return 0
library.reorderGames(order)
return order.length
}
// "Sync settings" payload; order matters (names -> defaults -> configs ->
// order).
function applySettings(payload, cats) {
if (!cats.has('settings')) return null
return {
prefixes: applyPrefixSettings(payload),
wines: applyWineSettings(payload),
apps: applyAppSettings(payload),
order: applyAppOrder(payload),
defaults: applyDefaults(payload)
}
}
// Launcher defaults; only values that exist here are taken.
function applyDefaults(payload) {
const wanted = payload.defaults && typeof payload.defaults === 'object' ? payload.defaults : {}
const applied = {}
if (wanted.defaultWine) {
const res = wines.setDefaultWine(String(wanted.defaultWine))
applied.defaultWine = res && res.ok ? String(wanted.defaultWine) : null
}
if (wanted.defaultPrefix) {
const defs = prefixes.listPrefixes()
if (defs.prefixes.some(p => p.name === wanted.defaultPrefix)) {
prefixes.setDefaultPrefix(String(wanted.defaultPrefix))
applied.defaultPrefix = String(wanted.defaultPrefix)
} else {
applied.defaultPrefix = null
}
}
return applied
}
// --- HTTP server (what other devices talk to) -------------------------------
function createServerState() {
return { tokens: new Map(), verifying: new Set(), pending: new Map(), session: null }
}
// The user's popup answer; unknown/expired ids are a no-op.
let serverState = null
function respondIncoming(requestId, accept) {
const key = String(requestId || '')
const resolve = serverState && serverState.pending ? serverState.pending.get(key) : null
if (!resolve) return false
serverState.pending.delete(key)
resolve(accept ? 'accept' : 'reject')
return true
}
// 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, for the receiver's popup.
function emitReceive(hooks, session, stage, extra = {}) {
if (!hooks || !hooks.onReceive) return
try {
hooks.onReceive({
stage,
phase: stage,
from: session.name || '',
deviceId: session.deviceId || '',
unit: extra.unit || '',
label: extra.unit || '',
done: extra.done == null ? session.received || 0 : extra.done,
total: extra.total == null ? session.planned || 0 : extra.total,
bytes: session.bytes || 0,
totalBytes: session.plannedBytes || 0,
// items: announced units, itemsDone: written units, totals: sender's plan
items: { ...(session.items || {}) },
itemsDone: { ...(session.itemsDone || {}) },
totals: { ...(session.totals || {}) },
// the bar shows the current phase, so hashing starts empty
barBytes: session.barBytes || 0,
barTotalBytes: session.barTotal || 0,
deleted: extra.deleted || 0
})
} catch {}
}
function activeSession(state) {
if (!state.session) return null
if (Date.now() - state.session.lastAt > SESSION_IDLE_MS) {
state.session = null
return null
}
return state.session
}
function authorize(state, token) {
const session = token && state.tokens.get(token)
if (!session) return null
if (Date.now() > session.expires) {
state.tokens.delete(token)
return null
}
session.lastAt = Date.now()
// 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
}
function createHttpServer(state, hooks) {
const server = http.createServer(async (req, res) => {
let url = null
try {
url = new URL(req.url, 'http://plwl.local')
} catch {}
if (!url) return sendJson(res, 400, { ok: false, error: 'bad-request' })
const route = url.pathname
try {
if (route === '/plwl/info' && req.method === 'GET') {
const me = identity()
return sendJson(res, 200, { ok: true, protocol: PROTOCOL, id: me.deviceId, name: os.hostname() })
}
if (route === '/plwl/verify' && req.method === 'POST') return handleVerify(state, req, res, hooks)
const session = authorize(state, url.searchParams.get('token'))
if (!session) return sendJson(res, 401, { ok: false, error: 'unauthorized' })
if (route === '/plwl/ping' && req.method === 'POST') return handlePing(session, res, hooks)
if (route === '/plwl/unit/plan' && req.method === 'POST') return handlePlan(state, session, url, req, res, hooks)
if (route === '/plwl/unit/file' && req.method === 'PUT') return handlePut(session, url, req, res, hooks)
if (route === '/plwl/unit/commit' && req.method === 'POST') return handleCommit(session, url, res, hooks)
if (route === '/plwl/apply' && req.method === 'POST') return handleApply(session, req, res, hooks)
if (route === '/plwl/end' && req.method === 'POST') return handleEnd(state, session, res, hooks)
sendJson(res, 404, { ok: false, error: 'not-found' })
} catch (err) {
sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
}
})
// a prefix manifest can take minutes to hash on the receiving side
server.requestTimeout = 0
server.headersTimeout = 60000
server.timeout = 0
return server
}
async function handleVerify(state, req, res, hooks) {
const payload = await readJsonBody(req)
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
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
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 totals up front ("0/5" in the popup)
const announced = payload.totals && typeof payload.totals === 'object' ? payload.totals : {}
// 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 => {
const timer = setTimeout(() => {
state.pending.delete(requestId)
resolve('timeout')
}, INCOMING_TIMEOUT_MS)
if (timer.unref) timer.unref()
state.pending.set(requestId, resolve)
try {
hooks.onIncoming({
requestId,
deviceId: from,
name: String(payload.name || ''),
totals: { apps: Number(announced.apps) || 0, wines: Number(announced.wines) || 0, prefixes: Number(announced.prefixes) || 0, settings: Number(announced.settings) || 0 }
})
} catch {
resolve('reject') // no window to answer with -> never auto-accept silently
}
})
if (decision !== 'accept') return sendJson(res, 403, { ok: false, error: decision === 'reject' ? 'rejected' : 'incoming-timeout' })
}
const token = crypto.randomBytes(24).toString('hex')
const session = {
token,
deviceId: from,
name: String(payload.name || ''),
expires: Date.now() + TOKEN_TTL_MS,
lastAt: Date.now(),
units: new Map(),
// popup counters: files, bytes, sender's totals learned per unit
counted: new Set(),
received: 0,
planned: 0,
bytes: 0,
plannedBytes: 0,
items: { apps: 0, wines: 0, prefixes: 0 },
itemsDone: { apps: 0, wines: 0, prefixes: 0 },
barBytes: 0,
barTotal: 0,
totals: {
apps: Number(announced.apps) || 0,
wines: Number(announced.wines) || 0,
prefixes: Number(announced.prefixes) || 0
}
}
state.tokens.set(token, session)
state.session = session
emitReceive(hooks, session, 'start')
sendJson(res, 200, {
ok: true,
token,
deviceId: me.deviceId,
name: os.hostname(),
device: { id: me.deviceId, name: os.hostname() }
})
} finally {
state.verifying.delete(from)
}
}
async function handlePlan(state, session, url, req, res, hooks) {
const key = url.searchParams.get('unit') || ''
const label = url.searchParams.get('label') || ''
let roots = []
try {
roots = JSON.parse(url.searchParams.get('roots') || '[]')
} catch {}
const owned = (Array.isArray(roots) ? roots : []).map(ownedRoot).filter(Boolean)
if (!key || !owned.length) return sendJson(res, 400, { ok: false, error: 'bad-unit' })
// comparing is its own phase: the bar restarts for the copy phase
const announce = progressGate((data, force) => emitReceive(hooks, session, 'compare', data))
// the unit counts from the start of its comparison, not from its commit
const kind = UNIT_KINDS[String(key).split(':')[0]]
if (kind && !session.counted.has(key)) {
session.counted.add(key)
session.items[kind] = (session.items[kind] || 0) + 1
}
// a force sync compares nothing: every entry counts as different
const forced = url.searchParams.get('force') === '1'
// 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') {
session.totals = {
apps: Number(totals.apps) || 0,
wines: Number(totals.wines) || 0,
prefixes: Number(totals.prefixes) || 0
}
}
} catch {}
// The manifest is compared as it streams in (both sides hash at the same
// time). The sender sends its unit total first, so the bar starts with its
// full width; files missing from the sender's total compare as the usual
// entries - the bar just grows when that happens.
const entries = []
const results = new Map()
let unitTotalBytes = 0
let compared = 0
let comparedBytes = 0
const compareOne = async entry => {
const same = forced ? false : await entrySame(entry)
results.set(entry.path, same === true)
compared++
comparedBytes += entry.size || 0
session.barBytes = comparedBytes
// the sender's announced total (or a provisional full bar until it
// arrives) - clamped at the top by the renderer, never over 100%
session.barTotal = unitTotalBytes || 1
// no file counting yet: dirs/links are compared too
announce({ unit: label })
}
// files compared concurrently: the sender streams its manifest, this side
// keeps at most HASH_CONCURRENCY digests in flight (the sender's hashing
// queue is the wider throttle)
const jobs = []
let inFlight = 0
const settleOldest = () => Promise.race(jobs.splice(0, 1))
await readNdjson(req, async entry => {
// the sender announces its unit total before hashing starts (a control
// record, no path): it carries the compare bar's width, so it must be
// read before the entry filter drops it
if (entry.unitBytes != null) {
unitTotalBytes = Number(entry.unitBytes) || 0
session.barTotal = unitTotalBytes || 1
return
}
if (!inlineSame(entry)) return
entries.push(entry)
if (!withinRoots(entry.path, owned)) return
if (forced) {
await compareOne(entry)
return
}
// the sender's hashing queue is the throttle on its side; this side's
// own concurrency limit keeps a saturated disk from stacking up here
if (inFlight >= HASH_CONCURRENCY) await settleOldest()
inFlight++
const job = compareOne(entry).finally(() => {
inFlight--
})
jobs.push(job)
})
await Promise.all(jobs)
hashStore.save()
const allowed = entries.filter(entry => withinRoots(entry.path, owned))
const identical = allowed.map(entry => results.get(entry.path) === true)
// the mode each file has to end up with on this side
const modes = new Map()
for (const entry of allowed) if (entry.type === 'file' && typeof entry.mode === 'number') modes.set(entry.path, entry.mode)
const paths = new Set()
const upload = []
const same = []
let uploadBytes = 0
allowed.forEach((entry, index) => {
const abs = filesPath(entry.path)
if (!abs) return
paths.add(entry.path)
if (identical[index]) {
same.push(entry.path)
return
}
if (entry.type === 'dir') ensureDir(abs)
else if (entry.type === 'link') ensureLink(abs, entry.target)
else {
upload.push(entry.path)
uploadBytes += entry.size || 0
}
})
session.units.set(key, { roots: owned, paths, label, modes })
// this unit's files/bytes, reported as they arrive
session.planned += upload.length
session.plannedBytes += uploadBytes
// the compare is finished: report it full, then reset for the copy phase
// (the first byte that arrives emits the new bar)
session.barTotal = unitTotalBytes || session.barTotal || 1
session.barBytes = session.barTotal
announce({ unit: label }, true)
session.unitWritten = 0
session.unitLabel = label // named up front, so file events can label it
session.barBytes = 0
session.barTotal = uploadBytes
sendJson(res, 200, { ok: true, upload, same, entries: paths.size })
}
// One PUT, one file. The sender declares a content-length up front; if the
// body stops short of it (sender crashed, lost link, file changed size after
// the manifest), the request would otherwise stay open forever and wedge
// this file, the session and the sync. Any arriving byte re-arms the timer,
// so a slow honest transfer never trips it. Env override for tests.
const PUT_IDLE_MS = Number(process.env.PLWL_LANSYNC_PUT_IDLE) || 60000
async function handlePut(session, url, req, res, hooks) {
// 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)
const abs = filesPath(rel)
if (!unit || !abs || !unit.paths.has(rel)) {
req.resume()
return sendJson(res, 400, { ok: false, error: 'bad-path' })
}
// report bytes while the file is still arriving
const announce = progressGate((data, force) => emitReceive(hooks, session, 'copy', data))
// a ping must not flash "waiting" while transfers are running
session.writing = (session.writing || 0) + 1
let idleTimer = null
const armIdle = () => {
if (idleTimer) clearTimeout(idleTimer)
idleTimer = setTimeout(() => {
// the write's stream 'error' handler removes the tmp file; the sender's
// request errors out and its own timer/heartbeat handles the rest
req.destroy(new Error('body-stalled'))
}, PUT_IDLE_MS)
if (idleTimer.unref) idleTimer.unref()
}
armIdle()
try {
await writeStream(abs, req, delta => {
armIdle()
session.bytes = (session.bytes || 0) + delta
session.unitWritten = (session.unitWritten || 0) + delta
session.barBytes = session.unitWritten
announce({ unit: session.unitLabel || '' })
})
// 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 })
announce({ unit: session.unitLabel || '' }, true)
} catch (err) {
sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
} finally {
if (idleTimer) clearTimeout(idleTimer)
session.writing--
}
}
async function handleCommit(session, url, res, hooks) {
const key = url.searchParams.get('unit') || ''
const unit = session.units.get(key)
if (!unit) return sendJson(res, 400, { ok: false, error: 'no-unit' })
let removed = 0
for (const root of unit.roots) {
const abs = filesPath(root)
if (abs) removed += await pruneTree(abs, root, unit.paths)
}
// 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 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
session.barTotal = session.barTotal || 1
session.barBytes = session.barTotal
sendJson(res, 200, { ok: true, deleted: removed })
emitReceive(hooks, session, 'commit', { unit: session.unitLabel || '', deleted: removed })
}
async function handleApply(session, req, res, hooks) {
const payload = await readJsonBody(req)
if (!payload) return sendJson(res, 400, { ok: false, error: 'bad-request' })
// 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, so synced builds/prefixes are usable as defaults
const settings = applySettings(payload, cats)
const removed = appsDeleted + winesDeleted + prefixesDeleted
emitReceive(hooks, session, 'registry', { deleted: removed })
if (hooks.onApplied) {
hooks.onApplied({
from: session.name,
deviceId: session.deviceId,
categories: [...cats],
files: session.received || 0,
bytes: session.bytes || 0,
items: { ...(session.items || {}) },
deleted: removed,
settings,
defaults: settings ? settings.defaults : null
})
}
sendJson(res, 200, {
ok: true,
deleted: removed,
apps: { deleted: appsDeleted },
wines: { deleted: winesDeleted },
prefixes: { deleted: prefixesDeleted },
settings,
defaults: settings ? settings.defaults : null
})
}
function handleEnd(state, session, res, hooks) {
state.tokens.delete(session.token)
if (state.session && state.session.token === session.token) state.session = null
// a finished sync leaves a finished bar, even one that moved no files
session.barTotal = session.barTotal || 1
session.barBytes = session.barTotal
sendJson(res, 200, { ok: true })
emitReceive(hooks, session, 'done', { deleted: session.deleted || 0 })
}
// Liveness ping: hashing can take minutes; re-arms the popup watchdog. The
// event must repeat on every ping (it is what keeps the popup's watchdog
// armed), but never while a file is being written: a copy in flight reports
// itself and must not flash the waiting text over the receiving phase.
function handlePing(session, res, hooks) {
session.lastAt = Date.now()
if (!session.writing) emitReceive(hooks, session, 'wait')
sendJson(res, 200, { ok: true })
}
// --- discovery --------------------------------------------------------------
const discovery = createDiscovery({
identity,
tag: BEACON_TAG,
protocol: PROTOCOL,
port: DISCOVERY_PORT,
beaconMs: BEACON_MS,
deviceTtlMs: DEVICE_TTL_MS,
getPort: () => httpPort
})
const startDiscovery = discovery.start
const stopDiscovery = discovery.stop
const listDevices = discovery.listDevices
// --- service ----------------------------------------------------------------
let httpPort = 0
let httpServer = null
let serverHooks = {}
let listening = null // promise resolved once the receiving server is up
// Bind the receiving server; resolves with { port, close() }.
function startServer(options = {}) {
if (options && typeof options === 'object') serverHooks = { ...serverHooks, ...options }
if (listening) return listening
const state = createServerState()
serverState = state // respondIncoming() answers the popups of THIS server
httpServer = createHttpServer(state, serverHooks)
httpServer.on('error', () => {})
const port = Number(options && options.port) || 0
listening = new Promise(resolve => {
httpServer.listen(port, '0.0.0.0', () => {
const address = httpServer && httpServer.address()
httpPort = address && typeof address === 'object' ? address.port : 0
resolve({
ok: true,
port: httpPort,
deviceId: identity().deviceId,
server: httpServer,
close: () => stopServer()
})
})
})
return listening
}
function stopServer() {
// throttled mid-sync saves may have skipped the last digests
hashStore.save(true)
if (httpServer) {
try {
httpServer.close()
} catch {}
httpServer = null
}
// a waiting popup would never be answered: resolve it as declined
if (serverState && serverState.pending) {
for (const resolve of serverState.pending.values()) resolve('reject')
serverState.pending.clear()
}
serverState = null
listening = null
httpPort = 0
}
function start(hooks = {}) {
serverHooks = { ...serverHooks, ...hooks }
return startServer().then(info => {
startDiscovery(serverHooks.onDevices)
return info
})
}
function stop() {
stopDiscovery()
stopServer()
}
function serverPort() {
return httpPort
}
function getState() {
const me = identity()
return { deviceId: me.deviceId, syncCode: me.syncCode, hash: hashStore.HASH_ALGO, port: httpPort, devices: listDevices() }
}
// The pusher is built with this module's helpers, so launcher states never mix.
let pusher = null
function getPusher() {
if (!pusher) {
pusher = require('./lansyncpush')({
CATEGORIES,
normalizeCategories,
appCategory,
identity,
rememberDevice,
forgetDevice,
httpJson,
filesPath,
ownedRoot,
buildManifest,
buildManifestStream: manifest.buildManifestStream,
listDirs,
listDevices,
mapLimit,
progressGate,
hashFile,
DIRS,
toPortablePath,
library,
wines,
prefixes
})
}
return pusher
}
function syncTo(payload) {
return getPusher().syncToDevice(payload)
}
// 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
// paired before -> reuse the stored code, no typing needed
const stored = (deviceId && knownDevice(deviceId)) || null
const result = getPusher().syncToDevice({
device: target,
deviceId,
code: options.code || (target && target.code) || (stored && stored.code),
categories: options.categories || options.kinds || CATEGORIES,
force: !!options.force,
onProgress: [onProgress, options.onProgress, options.onEvent, options.progress].find(fn => typeof fn === 'function')
})
return result
}
const exported = {
start,
startServer,
stop,
stopServer,
closeServer: stopServer,
close: stopServer,
httpJson,
verifyPeer,
runSync,
appCategory,
classifyApp,
CATEGORIES,
normalizeCategories,
serverPort,
getServerPort: serverPort,
getState,
listDevices,
identity,
loadIdentity: identity,
makeDeviceId,
makeSyncCode,
rotateDeviceId,
rotateSyncCode,
rerollDeviceId: rotateDeviceId,
rerollSyncCode: rotateSyncCode,
knownDevice,
knownPeers,
rememberDevice,
forgetDevice,
respondIncoming,
sameCode,
HASH_ALGO: hashStore.HASH_ALGO,
hashFile,
hashCacheStats: hashStore.stats,
resetHashCacheStats: hashStore.resetStats,
forgetHashCache: hashStore.forget,
clearHashCache: hashStore.clear,
HASH_CONCURRENCY,
progressGate,
filesPath,
ownedRoot,
buildManifest,
buildManifestStream: manifest.buildManifestStream,
mapLimit,
listDirs,
syncTo
}
module.exports = exported