a LOT sync fixes...

This commit is contained in:
olcxja 2026-10-01 17:12:53 +02:00
commit 806e173e43
14 changed files with 1073 additions and 240 deletions

View file

@ -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,