code refactor & sync bugfixes

This commit is contained in:
olcxja 2026-10-05 13:43:49 +02:00
commit 9edcc9841b
20 changed files with 820 additions and 812 deletions

152
src/lansync-manifest.js Normal file
View file

@ -0,0 +1,152 @@
'use strict'
// Sender-side hashing: walking sync units into a manifest of dirs, links and
// files, with the digest of every file. Exported as a factory over the path
// guards and the hash store, so every launcher state (and every test device)
// builds manifests for its own files.
const path = require('path')
const fs = require('fs')
const fsp = fs.promises
function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurrency }) {
// 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(filesRoot)
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.
//
// Iterative (a queue instead of an await-per-directory recursion) and with
// the stat/readlink calls of one directory in flight together: walking a
// prefix is dominated by those two syscalls, and serializing them costs more
// than the readdir itself. Regular files are re-verified with one stat
// (size, mode, mtime feed both the manifest and the hash cache), everything
// else is skipped.
async function scanTree(abs, rel, out, mtimes) {
const queue = [[abs, rel]]
while (queue.length) {
const [dirAbs, dirRel] = queue.pop()
let entries
try {
entries = await fsp.readdir(dirAbs, { withFileTypes: true })
} catch {
continue
}
const dirs = []
const leaves = []
for (const entry of entries) {
const relChild = `${dirRel}/${entry.name}`
if (entry.isDirectory()) {
out.push({ path: relChild, type: 'dir' })
dirs.push([path.join(dirAbs, entry.name), relChild])
} else {
leaves.push(entry)
}
}
await mapLimit(leaves, concurrency, async entry => {
const absChild = path.join(dirAbs, entry.name)
const relChild = `${dirRel}/${entry.name}`
if (entry.isSymbolicLink()) {
let target = ''
try {
target = await fsp.readlink(absChild)
} catch {}
out.push({ path: relChild, type: 'link', target: portableLinkTarget(absChild, target) })
return
}
let st
try {
st = await fsp.stat(absChild)
} catch {
return
}
if (!st.isFile()) return
// 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)
})
queue.push(...dirs)
}
}
// Manifest of one sync unit. `force` skips hashing entirely - the receiver
// 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
let st
try {
st = await fsp.lstat(abs)
} catch {
continue
}
if (st.isSymbolicLink()) {
let target = ''
try {
target = await fsp.readlink(abs)
} catch {}
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, mtimes)
} else if (st.isFile()) {
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
let hashed = 0
await mapLimit(files, concurrency, async entry => {
const abs = filesPath(entry.path)
try {
// identical size and mtime means identical bytes, so the digest from
// an earlier sync still describes this file
const mtimeMs = mtimes.get(entry.path) || 0
entry.hash = abs
? await hash.cachedHash(entry.path, abs, entry.size, mtimeMs, bytes => {
hashed += bytes
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
})
: null
} catch {
entry.hash = null
}
done++
if (!entry.hash) hashed += entry.size || 0 // nothing read: still report the file as passed over
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
})
}
hash.save()
return entries
}
return { buildManifest, scanTree, portableLinkTarget }
}
module.exports = { createManifestBuilder }