a LOT sync fixes...
This commit is contained in:
parent
c0f6e802ed
commit
806e173e43
14 changed files with 1073 additions and 240 deletions
411
src/lansync.js
411
src/lansync.js
|
|
@ -16,6 +16,7 @@ const fsp = fs.promises
|
|||
const readline = require('readline')
|
||||
|
||||
const { DIRS, readJson, writeJson, toPortablePath } = require('./paths')
|
||||
const { rewireSymlinks, restoreExecutableBits } = require('./symlinks')
|
||||
const library = require('./library')
|
||||
const wines = require('./wines')
|
||||
const prefixes = require('./prefixes')
|
||||
|
|
@ -44,7 +45,7 @@ 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.
|
||||
const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes']
|
||||
const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'defaults']
|
||||
|
||||
const CATEGORY_ALIASES = {
|
||||
// plain "apps" means the whole app library: portable ones AND the ones
|
||||
|
|
@ -64,7 +65,13 @@ const CATEGORY_ALIASES = {
|
|||
wineprefix: ['prefixes'],
|
||||
wineprefixes: ['prefixes'],
|
||||
prefixes: ['prefixes'],
|
||||
prefix: ['prefixes']
|
||||
prefix: ['prefixes'],
|
||||
// the launcher's own defaults ("Default settings"): which wine everything
|
||||
// falls back to and where a new import lands
|
||||
defaults: ['defaults'],
|
||||
default: ['defaults'],
|
||||
defaultsettings: ['defaults'],
|
||||
settings: ['defaults']
|
||||
}
|
||||
|
||||
// Which sync category an app entry belongs to. Portable copy/move imports own
|
||||
|
|
@ -231,10 +238,92 @@ const HASH_ALGO = (() => {
|
|||
return 'sha256'
|
||||
})()
|
||||
|
||||
// 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.
|
||||
const HASH_CONCURRENCY = Math.max(4, Math.min(16, (os.cpus() || []).length || 4))
|
||||
// One megabyte per read: a 64 kB stream chunk spends most of its time in
|
||||
// callbacks when the file is large.
|
||||
const HASH_CHUNK = 1024 * 1024
|
||||
|
||||
// Hash results are remembered per file (size + mtime), so a library that did
|
||||
// not change since the last sync is never read again - only stat'ed.
|
||||
const HASH_CACHE_FILE = path.join(DIRS.DATA, 'lansync-hashes.json')
|
||||
const HASH_CACHE_MAX = 250000
|
||||
let hashCache = null
|
||||
let hashCacheDirty = false
|
||||
let hashCacheSavedAt = 0
|
||||
const hashStats = { hits: 0, misses: 0 }
|
||||
|
||||
function hashCacheStats() {
|
||||
return { ...hashStats, size: hashCache ? Object.keys(hashCache).length : 0, algo: HASH_ALGO }
|
||||
}
|
||||
|
||||
function resetHashCacheStats() {
|
||||
hashStats.hits = 0
|
||||
hashStats.misses = 0
|
||||
}
|
||||
|
||||
// Dropping the in-memory copy makes the next lookup re-read the file from
|
||||
// disk - what tests use to prove a file was hashed instead of remembered.
|
||||
function forgetHashCache() {
|
||||
hashCache = null
|
||||
hashCacheDirty = false
|
||||
}
|
||||
|
||||
function loadHashCache() {
|
||||
if (!hashCache) {
|
||||
const stored = readJson(HASH_CACHE_FILE, null)
|
||||
hashCache = stored && typeof stored === 'object' && stored.files && typeof stored.files === 'object' ? stored.files : {}
|
||||
}
|
||||
return hashCache
|
||||
}
|
||||
|
||||
// Written once per operation at most: this file can hold a quarter million
|
||||
// entries, and rewriting it per hashed file would cost more than the hashing.
|
||||
function saveHashCache() {
|
||||
if (!hashCacheDirty || !hashCache) return
|
||||
const now = Date.now()
|
||||
if (now - hashCacheSavedAt < 60000) return
|
||||
const names = Object.keys(hashCache)
|
||||
if (names.length > HASH_CACHE_MAX) {
|
||||
names.sort((a, b) => (hashCache[b][3] || 0) - (hashCache[a][3] || 0))
|
||||
const trimmed = {}
|
||||
for (const name of names.slice(0, HASH_CACHE_MAX)) trimmed[name] = hashCache[name]
|
||||
hashCache = trimmed
|
||||
}
|
||||
hashCacheDirty = false
|
||||
hashCacheSavedAt = now
|
||||
try {
|
||||
writeJson(HASH_CACHE_FILE, { v: 1, algo: HASH_ALGO, files: hashCache })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Hash of a file we already stat'ed: size and mtime unchanged means the bytes
|
||||
// are the same, and the digest from an earlier sync still describes them.
|
||||
function cachedHash(rel, abs, size, mtimeMs) {
|
||||
const cache = loadHashCache()
|
||||
const known = cache[rel]
|
||||
if (known && known[0] === size && known[1] === mtimeMs && typeof known[2] === 'string') {
|
||||
hashStats.hits++
|
||||
known[3] = Date.now()
|
||||
hashCacheDirty = true
|
||||
return Promise.resolve(known[2])
|
||||
}
|
||||
hashStats.misses++
|
||||
return hashFile(abs).then(hash => {
|
||||
if (hash) {
|
||||
cache[rel] = [size, mtimeMs, hash, Date.now()]
|
||||
hashCacheDirty = true
|
||||
}
|
||||
return hash
|
||||
})
|
||||
}
|
||||
|
||||
function hashFile(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash(HASH_ALGO)
|
||||
const stream = fs.createReadStream(file)
|
||||
const stream = fs.createReadStream(file, { highWaterMark: HASH_CHUNK })
|
||||
stream.on('error', reject)
|
||||
stream.on('data', chunk => hash.update(chunk))
|
||||
stream.on('end', () => resolve(hash.digest('hex')))
|
||||
|
|
@ -259,6 +348,45 @@ function ownedRoot(rel) {
|
|||
return SYNC_ROOTS.some(prefix => clean.startsWith(prefix)) ? clean : null
|
||||
}
|
||||
|
||||
// Progress is cheap to produce and expensive to render: hashing or copying a
|
||||
// prefix can emit tens of thousands of events, and forwarding every single one
|
||||
// over IPC makes the window stutter instead of showing progress. Events are
|
||||
// rate limited to ~10/s per phase; `force` lets a caller push the value that
|
||||
// matters (the end of a unit) through immediately.
|
||||
const PROGRESS_MIN_MS = 100
|
||||
|
||||
function progressGate(emit) {
|
||||
let lastAt = 0
|
||||
let pending = null
|
||||
let timer = null
|
||||
const flush = () => {
|
||||
timer = null
|
||||
if (!pending) return
|
||||
const data = pending
|
||||
pending = null
|
||||
lastAt = Date.now()
|
||||
emit(data)
|
||||
}
|
||||
return (data, force) => {
|
||||
const now = Date.now()
|
||||
if (force || now - lastAt >= PROGRESS_MIN_MS) {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
pending = null
|
||||
lastAt = now
|
||||
emit(data)
|
||||
return
|
||||
}
|
||||
pending = data
|
||||
if (!timer) {
|
||||
timer = setTimeout(flush, PROGRESS_MIN_MS - (now - lastAt))
|
||||
if (timer.unref) timer.unref()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function mapLimit(items, limit, fn) {
|
||||
const out = new Array(items.length)
|
||||
let next = 0
|
||||
|
|
@ -272,10 +400,27 @@ async function mapLimit(items, limit, fn) {
|
|||
return out
|
||||
}
|
||||
|
||||
// Symlink targets travel RELATIVE whenever they point inside the launcher data
|
||||
// (a prefix linking into its wine build, an app linking to its own assets): the
|
||||
// mirrored trees have the same internal layout on both machines, so a relative
|
||||
// target is correct over there while an absolute one would point at the
|
||||
// sender's own folders - which is exactly how a synced prefix ends up with
|
||||
// hundreds of dangling links. Targets outside files/ (a system font, a mounted
|
||||
// drive, /) are machine-specific and stay as they are.
|
||||
function portableLinkTarget(linkAbs, target) {
|
||||
if (typeof target !== 'string' || !target || !path.isAbsolute(target)) return target
|
||||
const rel = path.relative(path.dirname(linkAbs), target)
|
||||
if (!rel || path.isAbsolute(rel)) return target
|
||||
const root = path.resolve(DIRS.FILES)
|
||||
const resolved = path.resolve(path.dirname(linkAbs), rel)
|
||||
if (resolved !== root && !resolved.startsWith(root + path.sep)) return target
|
||||
return rel
|
||||
}
|
||||
|
||||
// Walk without following symlinks: builds and prefixes contain links that are
|
||||
// part of what is being synced (dosdevices, build internals), and following
|
||||
// them would both loop and copy the wrong thing.
|
||||
async function scanTree(abs, rel, out) {
|
||||
async function scanTree(abs, rel, out, mtimes) {
|
||||
let entries
|
||||
try {
|
||||
entries = await fsp.readdir(abs, { withFileTypes: true })
|
||||
|
|
@ -290,18 +435,22 @@ async function scanTree(abs, rel, out) {
|
|||
try {
|
||||
target = await fsp.readlink(absChild)
|
||||
} catch {}
|
||||
out.push({ path: relChild, type: 'link', target })
|
||||
out.push({ path: relChild, type: 'link', target: portableLinkTarget(absChild, target) })
|
||||
} else if (entry.isDirectory()) {
|
||||
out.push({ path: relChild, type: 'dir' })
|
||||
await scanTree(absChild, relChild, out)
|
||||
await scanTree(absChild, relChild, out, mtimes)
|
||||
} else if (entry.isFile()) {
|
||||
let size = 0
|
||||
let st = null
|
||||
try {
|
||||
size = (await fsp.stat(absChild)).size
|
||||
st = await fsp.stat(absChild)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
out.push({ path: relChild, type: 'file', size, hash: null })
|
||||
// mode travels with the file: a wine build's `proton`/`wine` binary is
|
||||
// useless on the other side without its executable bit
|
||||
out.push({ path: relChild, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null })
|
||||
// kept out of the manifest: only this machine can use its own mtimes
|
||||
if (mtimes) mtimes.set(relChild, st.mtimeMs || 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -310,6 +459,7 @@ async function scanTree(abs, rel, out) {
|
|||
// then treats every file as different ("force sync all files").
|
||||
async function buildManifest(roots, force, onProgress) {
|
||||
const entries = []
|
||||
const mtimes = new Map()
|
||||
for (const rel of roots) {
|
||||
const abs = filesPath(rel)
|
||||
if (!abs) continue
|
||||
|
|
@ -324,27 +474,35 @@ async function buildManifest(roots, force, onProgress) {
|
|||
try {
|
||||
target = await fsp.readlink(abs)
|
||||
} catch {}
|
||||
entries.push({ path: rel, type: 'link', target })
|
||||
entries.push({ path: rel, type: 'link', target: portableLinkTarget(abs, target) })
|
||||
} else if (st.isDirectory()) {
|
||||
entries.push({ path: rel, type: 'dir' })
|
||||
await scanTree(abs, rel, entries)
|
||||
await scanTree(abs, rel, entries, mtimes)
|
||||
} else if (st.isFile()) {
|
||||
entries.push({ path: rel, type: 'file', size: st.size, hash: null })
|
||||
entries.push({ path: rel, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null })
|
||||
mtimes.set(rel, st.mtimeMs || 0)
|
||||
}
|
||||
}
|
||||
const files = entries.filter(e => e.type === 'file')
|
||||
// the unit's total size is known before a byte is hashed, so the bar can
|
||||
// start empty and fill with the bytes actually read
|
||||
const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0)
|
||||
if (!force && files.length) {
|
||||
let done = 0
|
||||
await mapLimit(files, 4, async entry => {
|
||||
let hashed = 0
|
||||
await mapLimit(files, HASH_CONCURRENCY, async entry => {
|
||||
const abs = filesPath(entry.path)
|
||||
try {
|
||||
entry.hash = await hashFile(filesPath(entry.path))
|
||||
entry.hash = abs ? await cachedHash(entry.path, abs, entry.size, mtimes.get(entry.path) || 0) : null
|
||||
} catch {
|
||||
entry.hash = null
|
||||
}
|
||||
done++
|
||||
if (onProgress) onProgress(done, files.length)
|
||||
hashed += entry.size || 0
|
||||
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
|
||||
})
|
||||
}
|
||||
saveHashCache()
|
||||
return entries
|
||||
}
|
||||
|
||||
|
|
@ -498,11 +656,18 @@ async function entrySame(entry) {
|
|||
return false
|
||||
}
|
||||
if (!st.isFile() || st.size !== entry.size) return false
|
||||
let same = false
|
||||
try {
|
||||
return (await hashFile(abs)) === entry.hash
|
||||
same = (await cachedHash(entry.path, abs, st.size, st.mtimeMs || 0)) === entry.hash
|
||||
} catch {
|
||||
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.
|
||||
if (typeof entry.mode === 'number' && (st.mode & 0o777) !== entry.mode) applyMode(abs, entry.mode)
|
||||
return true
|
||||
}
|
||||
|
||||
function ensureDir(abs) {
|
||||
|
|
@ -517,6 +682,24 @@ 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.
|
||||
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 {
|
||||
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
|
||||
|
|
@ -599,7 +782,16 @@ async function pruneTree(abs, rel, keep) {
|
|||
return removed
|
||||
}
|
||||
|
||||
function writeStream(abs, stream) {
|
||||
// Modes are applied after the bytes are in place: createWriteStream cannot set
|
||||
// one, and a wine build without its executable 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) => {
|
||||
let st = null
|
||||
try {
|
||||
|
|
@ -629,6 +821,11 @@ function writeStream(abs, stream) {
|
|||
reject(err)
|
||||
}
|
||||
})
|
||||
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
|
||||
stream.on('data', chunk => onBytes(chunk.length))
|
||||
}
|
||||
stream.pipe(out)
|
||||
})
|
||||
}
|
||||
|
|
@ -650,12 +847,22 @@ function applyApps(payload, cats) {
|
|||
}
|
||||
kept.push(game)
|
||||
}
|
||||
// playtime is per machine, the sender does not push any - keep the counters
|
||||
// of apps this device already had
|
||||
const merged = [...kept, ...incoming.map(g => {
|
||||
// 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.
|
||||
const merged = [...kept]
|
||||
for (const g of incoming) {
|
||||
const prev = existing.find(x => x.id === g.id)
|
||||
return prev ? { ...g, playSeconds: prev.playSeconds, lastPlayed: prev.lastPlayed } : g
|
||||
})]
|
||||
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, so a synced counter
|
||||
// has to be written there too or loadGames would keep reporting the old one
|
||||
if (hasPlay) library.writePlaySeconds(g.id, playSeconds)
|
||||
}
|
||||
library.saveGames(merged)
|
||||
let removed = 0
|
||||
for (const id of dropped) removed += removeTree(`data/icons/${id}`)
|
||||
|
|
@ -677,7 +884,7 @@ async function applyWines(payload) {
|
|||
return removed
|
||||
}
|
||||
|
||||
function applyPrefixes(payload) {
|
||||
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()
|
||||
|
|
@ -690,11 +897,39 @@ function applyPrefixes(payload) {
|
|||
}
|
||||
const removed = removeOrphans(DIRS.PREFIXES, new Set(keep.keys()))
|
||||
const list = [...keep.values()].map(p => ({ ...p, locked: p.name === prefixes.MAIN ? true : !!p.locked }))
|
||||
const def = keep.has(sender.default) ? sender.default : prefixes.MAIN
|
||||
// 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).
|
||||
const wanted = useSenderDefault ? sender.default : existing.default
|
||||
const def = keep.has(wanted) ? wanted : prefixes.MAIN
|
||||
prefixes.savePrefixes({ default: def, prefixes: list })
|
||||
return removed
|
||||
}
|
||||
|
||||
// The launcher-wide defaults, asked for with the "Default settings" category:
|
||||
// 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.
|
||||
function applyDefaults(payload, cats) {
|
||||
if (!cats.has('defaults')) return null
|
||||
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() {
|
||||
|
|
@ -719,11 +954,19 @@ function emitReceive(hooks, session, stage, extra = {}) {
|
|||
deviceId: session.deviceId || '',
|
||||
unit: extra.unit || '',
|
||||
label: extra.unit || '',
|
||||
done: session.received || 0,
|
||||
total: session.planned || 0,
|
||||
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: units this sync announced, itemsDone: units fully written,
|
||||
// totals: everything the sender picked (so the popup can say "2/5")
|
||||
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
|
||||
barBytes: session.barBytes || 0,
|
||||
barTotalBytes: session.barTotal || 0,
|
||||
deleted: extra.deleted || 0
|
||||
})
|
||||
} catch {}
|
||||
|
|
@ -799,6 +1042,9 @@ async function handleVerify(state, req, res, hooks) {
|
|||
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' })
|
||||
const token = crypto.randomBytes(24).toString('hex')
|
||||
// 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
|
||||
const announced = payload.totals && typeof payload.totals === 'object' ? payload.totals : {}
|
||||
const session = {
|
||||
token,
|
||||
deviceId: from,
|
||||
|
|
@ -813,7 +1059,15 @@ async function handleVerify(state, req, res, hooks) {
|
|||
planned: 0,
|
||||
bytes: 0,
|
||||
plannedBytes: 0,
|
||||
items: { apps: 0, wines: 0, prefixes: 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
|
||||
|
|
@ -843,21 +1097,52 @@ async function handlePlan(state, session, url, req, res, hooks) {
|
|||
await readNdjson(req, entry => {
|
||||
if (inlineSame(entry)) entries.push(entry)
|
||||
})
|
||||
// the mode each file has to end up with on this side
|
||||
const modes = new Map()
|
||||
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
|
||||
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 {}
|
||||
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.
|
||||
const unitTotalBytes = allowed.reduce((sum, entry) => sum + (entry.size || 0), 0)
|
||||
const announce = progressGate((data, force) => emitReceive(hooks, session, 'plan', data))
|
||||
let compared = 0
|
||||
let comparedBytes = 0
|
||||
session.barTotal = unitTotalBytes
|
||||
session.barBytes = 0
|
||||
const identical = await mapLimit(allowed, HASH_CONCURRENCY, async entry => {
|
||||
const same = forced ? false : await entrySame(entry)
|
||||
compared++
|
||||
comparedBytes += entry.size || 0
|
||||
session.barBytes = comparedBytes
|
||||
announce({ unit: label, done: compared, total: allowed.length })
|
||||
return same === true
|
||||
})
|
||||
saveHashCache()
|
||||
const paths = new Set()
|
||||
const upload = []
|
||||
const same = []
|
||||
let uploadBytes = 0
|
||||
for (const entry of entries) {
|
||||
if (!withinRoots(entry.path, owned)) continue
|
||||
allowed.forEach((entry, index) => {
|
||||
const abs = filesPath(entry.path)
|
||||
if (!abs) continue
|
||||
if (!abs) return
|
||||
paths.add(entry.path)
|
||||
const identical = forced ? false : await entrySame(entry)
|
||||
if (identical) {
|
||||
if (identical[index]) {
|
||||
same.push(entry.path)
|
||||
continue
|
||||
return
|
||||
}
|
||||
if (entry.type === 'dir') ensureDir(abs)
|
||||
else if (entry.type === 'link') ensureLink(abs, entry.target)
|
||||
|
|
@ -865,12 +1150,17 @@ async function handlePlan(state, session, url, req, res, hooks) {
|
|||
upload.push(entry.path)
|
||||
uploadBytes += entry.size || 0
|
||||
}
|
||||
}
|
||||
session.units.set(key, { roots: owned, paths })
|
||||
})
|
||||
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
|
||||
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
|
||||
session.unitWritten = 0
|
||||
session.barTotal = uploadBytes
|
||||
session.barBytes = 0
|
||||
const kind = UNIT_KINDS[String(key).split(':')[0]]
|
||||
if (kind && !session.counted.has(key)) {
|
||||
session.counted.add(key)
|
||||
|
|
@ -880,7 +1170,7 @@ async function handlePlan(state, session, url, req, res, hooks) {
|
|||
// is planned, so the later file events can name it in the popup
|
||||
session.unitLabel = label
|
||||
sendJson(res, 200, { ok: true, upload, same, entries: paths.size })
|
||||
emitReceive(hooks, session, 'plan', { unit: label })
|
||||
announce({ unit: label, done: allowed.length, total: allowed.length }, true)
|
||||
}
|
||||
|
||||
async function handlePut(session, url, req, res, hooks) {
|
||||
|
|
@ -892,12 +1182,24 @@ 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).
|
||||
const announce = progressGate((data, force) => emitReceive(hooks, session, 'copy', data))
|
||||
try {
|
||||
const written = await writeStream(abs, req)
|
||||
await writeStream(abs, req, delta => {
|
||||
session.bytes = (session.bytes || 0) + delta
|
||||
session.unitWritten = (session.unitWritten || 0) + delta
|
||||
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
|
||||
applyMode(abs, unit.modes.get(rel))
|
||||
session.received = (session.received || 0) + 1
|
||||
session.bytes = (session.bytes || 0) + (written || 0)
|
||||
sendJson(res, 200, { ok: true })
|
||||
emitReceive(hooks, session, 'copy', { unit: session.unitLabel || '' })
|
||||
announce({ unit: session.unitLabel || '' }, true)
|
||||
} catch (err) {
|
||||
sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
||||
}
|
||||
|
|
@ -912,8 +1214,20 @@ 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)
|
||||
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
|
||||
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)
|
||||
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 })
|
||||
}
|
||||
|
|
@ -924,7 +1238,10 @@ async function handleApply(session, req, res, hooks) {
|
|||
const cats = new Set(Array.isArray(payload.categories) ? payload.categories : [])
|
||||
const appsDeleted = applyApps(payload, cats)
|
||||
const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0
|
||||
const prefixesDeleted = cats.has('prefixes') ? applyPrefixes(payload) : 0
|
||||
const prefixesDeleted = cats.has('prefixes') ? applyPrefixes(payload, cats.has('defaults')) : 0
|
||||
// after the registries are in place, so a synced build/prefix can actually be
|
||||
// chosen as the default
|
||||
const defaults = applyDefaults(payload, cats)
|
||||
const removed = appsDeleted + winesDeleted + prefixesDeleted
|
||||
emitReceive(hooks, session, 'registry', { deleted: removed })
|
||||
if (hooks.onApplied) {
|
||||
|
|
@ -935,7 +1252,8 @@ async function handleApply(session, req, res, hooks) {
|
|||
files: session.received || 0,
|
||||
bytes: session.bytes || 0,
|
||||
items: { ...(session.items || {}) },
|
||||
deleted: removed
|
||||
deleted: removed,
|
||||
defaults
|
||||
})
|
||||
}
|
||||
sendJson(res, 200, {
|
||||
|
|
@ -943,7 +1261,8 @@ async function handleApply(session, req, res, hooks) {
|
|||
deleted: removed,
|
||||
apps: { deleted: appsDeleted },
|
||||
wines: { deleted: winesDeleted },
|
||||
prefixes: { deleted: prefixesDeleted }
|
||||
prefixes: { deleted: prefixesDeleted },
|
||||
defaults
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1159,6 +1478,9 @@ function getPusher() {
|
|||
buildManifest,
|
||||
listDirs,
|
||||
listDevices,
|
||||
mapLimit,
|
||||
progressGate,
|
||||
hashFile,
|
||||
DIRS,
|
||||
toPortablePath,
|
||||
library,
|
||||
|
|
@ -1224,6 +1546,11 @@ const exported = {
|
|||
sameCode,
|
||||
HASH_ALGO,
|
||||
hashFile,
|
||||
hashCacheStats,
|
||||
resetHashCacheStats,
|
||||
forgetHashCache,
|
||||
HASH_CONCURRENCY,
|
||||
progressGate,
|
||||
filesPath,
|
||||
ownedRoot,
|
||||
buildManifest,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue