optimize sync & add accept popup

This commit is contained in:
olcxja 2026-10-01 19:57:05 +02:00
commit f0e097fa76
10 changed files with 305 additions and 61 deletions

View file

@ -52,7 +52,7 @@ The desktop entry (`MimeType` + `Exec %f`) makes PLWL show up in Dolphin/Nautilu
Two PLWL installs on the same LAN can mirror each other's content - no cloud, no accounts. Open **LAN syncing** in the sidebar (under Settings): Two PLWL installs on the same LAN can mirror each other's content - no cloud, no accounts. Open **LAN syncing** in the sidebar (under Settings):
- **Device ID** - 24 characters, generated once per install (current timestamp + random characters) and re-rollable at any time. - **Device ID** - 24 characters, generated once per install (current timestamp + random characters) and re-rollable at any time.
- **Sync code** - 8 characters, letters and digits, case-insensitive. A device only accepts a connection when the caller proves it knows *its own* code; each verification costs two seconds and a device may only have one in flight, so guessing over the LAN is pointless. Both the ID and the code can be re-rolled. - **Sync code** - 8 characters, letters and digits, case-insensitive. A device only accepts a connection when the caller proves it knows *its own* code; each verification costs two seconds and a device may only have one in flight, so guessing over the LAN is pointless. Both the ID and the code can be re-rolled. A correct code is still not enough: the receiving device shows an **Accept / Decline popup** before anything moves, and without a click within a minute the sender is turned down. Declining (or ignoring) the popup never touches this machine and does not unpair the two devices.
- **Devices on your network** - other PLWL installs, found over UDP; a device shows up only while PLWL is running there, and disappears a few seconds after it is closed. - **Devices on your network** - other PLWL installs, found over UDP; a device shows up only while PLWL is running there, and disappears a few seconds after it is closed.
Pick a device and choose what to push: **portable apps**, **apps imported from a prefix**, **Wine/Proton builds**, **prefixes** and **Default settings**. Only what is ticked travels - a sync without **Default settings** never touches the other machine's defaults. The code has to be typed once - an accepted code is stored next to that device ID, so every later sync needs no typing. If the other side re-rolled its code, the stored one is forgotten and the launcher asks for the new one instead of failing silently. Pick a device and choose what to push: **portable apps**, **apps imported from a prefix**, **Wine/Proton builds**, **prefixes** and **Default settings**. Only what is ticked travels - a sync without **Default settings** never touches the other machine's defaults. The code has to be typed once - an accepted code is stored next to that device ID, so every later sync needs no typing. If the other side re-rolled its code, the stored one is forgotten and the launcher asks for the new one instead of failing silently.

View file

@ -72,6 +72,12 @@ handlers in src/ipc/. Keep new APIs in that pattern.
states share the module, so nothing may be kept in its module scope. Both states share the module, so nothing may be kept in its module scope. Both
sides report their own progress (the receiver through the onReceive hook), sides report their own progress (the receiver through the onReceive hook),
so each machine can show the transfer. so each machine can show the transfer.
- An incoming sync needs CONSENT: a correct code opens the gate, but the
receiver's onIncoming hook (registered in main.js) shows an Accept/Decline
popup and only a click - answered through lansync:respondIncoming - starts
the session. Unanswered requests expire after INCOMING_TIMEOUT_MS; a decline
returns 'rejected' to the sender and KEEPS the pairing (the code was proven).
Without an onIncoming hook (tests, headless) the gate stays open.
- Sync progress is bytes plus per-category counters (items = announced units, - Sync progress is bytes plus per-category counters (items = announced units,
itemsDone = finished ones, totals = everything the sender picked), never raw itemsDone = finished ones, totals = everything the sender picked), never raw
file counts: the popup says "Apps: 2/5". The bar follows the CURRENT PHASE's file counts: the popup says "Apps: 2/5". The bar follows the CURRENT PHASE's
@ -123,6 +129,7 @@ node test/lansync-receiving.test.js # receive-side progress events (the popup)
node test/lansync-settings.test.js # playtime, app settings, wine/prefix + defaults node test/lansync-settings.test.js # playtime, app settings, wine/prefix + defaults
node test/lansync-links.test.js # wire: exec bits + relative links in a sync node test/lansync-links.test.js # wire: exec bits + relative links in a sync
node test/lansync-pending-links.test.js # links to files created later, startup heal node test/lansync-pending-links.test.js # links to files created later, startup heal
node test/lansync-consent.test.js # incoming sync: accept / decline / timeout gate
node test/bench-sync.js # timing: phases of a cold and a cached sync node test/bench-sync.js # timing: phases of a cold and a cached sync
npm start # click through the flow you touched npm start # click through the flow you touched

View file

@ -185,6 +185,14 @@
"lansync.statsPrefixes": "Prefixes: {0}", "lansync.statsPrefixes": "Prefixes: {0}",
"lansync.statsWines": "Wine versions: {0}", "lansync.statsWines": "Wine versions: {0}",
"lansync.receiving": "Receiving from {0}…", "lansync.receiving": "Receiving from {0}…",
"lansync.incomingTitle": "Incoming sync from {0}",
"lansync.incomingText": "{0} wants to sync its apps, Wine versions and prefixes onto this device.",
"lansync.incomingNothing": "Nothing was selected, or the totals have not arrived yet.",
"lansync.incomingTimedOut": "The incoming sync request timed out.",
"lansync.accept": "Accept",
"lansync.decline": "Decline",
"lansync.rejected": "{0} declined the sync.",
"lansync.unknownDevice": "Unknown device",
"lansync.recvWaiting": "Waiting for the file list…", "lansync.recvWaiting": "Waiting for the file list…",
"lansync.recvStalled": "Waiting for the other device…", "lansync.recvStalled": "Waiting for the other device…",
"lansync.recvIdle": "The other device stopped responding, the sync was interrupted.", "lansync.recvIdle": "The other device stopped responding, the sync was interrupted.",

View file

@ -185,6 +185,14 @@
"lansync.statsPrefixes": "Prefiksy: {0}", "lansync.statsPrefixes": "Prefiksy: {0}",
"lansync.statsWines": "Wersje Wine: {0}", "lansync.statsWines": "Wersje Wine: {0}",
"lansync.receiving": "Odbieranie od {0}…", "lansync.receiving": "Odbieranie od {0}…",
"lansync.incomingTitle": "Przychodząca synchronizacja od {0}",
"lansync.incomingText": "{0} chce przysłać swoje aplikacje, wersje Wine i prefiksy na to urządzenie.",
"lansync.incomingNothing": "Nic nie zaznaczono albo podsumowanie jeszcze nie dotarło.",
"lansync.incomingTimedOut": "Upłynął czas na odpowiedź na przychodzącą synchronizację.",
"lansync.accept": "Akceptuj",
"lansync.decline": "Odrzuć",
"lansync.rejected": "{0} odrzucił synchronizację.",
"lansync.unknownDevice": "Nieznane urządzenie",
"lansync.recvWaiting": "Oczekiwanie na listę plików…", "lansync.recvWaiting": "Oczekiwanie na listę plików…",
"lansync.recvStalled": "Oczekiwanie na drugie urządzenie…", "lansync.recvStalled": "Oczekiwanie na drugie urządzenie…",
"lansync.recvIdle": "Drugie urządzenie przestało odpowiadać, synchronizacja została przerwana.", "lansync.recvIdle": "Drugie urządzenie przestało odpowiadać, synchronizacja została przerwana.",

View file

@ -1370,7 +1370,12 @@ async function runLanSync(device, code, categories, force) {
openLanSyncModal({ ...device, code: '' }, 'lansync.badCode') openLanSyncModal({ ...device, code: '' }, 'lansync.badCode')
return return
} }
const message = err === 'busy' ? t('lansync.busy') : err === 'offline' ? t('lansync.offline') : (err || t('lansync.unknownError')) const message =
err === 'busy' ? t('lansync.busy')
: err === 'offline' ? t('lansync.offline')
: err === 'rejected' ? t('lansync.rejected')
: err === 'incoming-timeout' ? t('lansync.incomingTimedOut')
: (err || t('lansync.unknownError'))
toast(t('lansync.failed', message), 'error') toast(t('lansync.failed', message), 'error')
} }
@ -1389,6 +1394,64 @@ const RECEIVE_STALL_MS = 6 * 1000
// phases whose wording differs on this side (everything else is neutral: // phases whose wording differs on this side (everything else is neutral:
// "Comparing X", "Updating the library", "Finishing up") // "Comparing X", "Updating the library", "Finishing up")
const RECV_PHASE_KEYS = { copy: 'lansync.recvPhase.copy' } const RECV_PHASE_KEYS = { copy: 'lansync.recvPhase.copy' }
// A device wants to push to us: the popup asks for consent before anything
// moves. One pending request at a time (the server enforces that too).
let incomingRequest = null
let incomingTimer = null
function onLanIncoming(info) {
if (!info || !info.requestId) return
if (state.lanPushing || receiving) {
// our own transfer owns the modal slot - answer so the sender fails
// fast instead of waiting out its timeout
window.plwl.lansyncRespondIncoming(info.requestId, false)
return
}
incomingRequest = info
const name = info.name || info.deviceId || t('lansync.unknownDevice')
const totals = info.totals || {}
const parts = []
if (totals.apps > 0) parts.push(t('lansync.statsApps', totals.apps))
if (totals.wines > 0) parts.push(t('lansync.statsWines', totals.wines))
if (totals.prefixes > 0) parts.push(t('lansync.statsPrefixes', totals.prefixes))
const modal = showModal(`
<h2 id="ls-incoming-title"></h2>
<p id="ls-incoming-text"></p>
<p class="subtitle" id="ls-incoming-what"></p>
<p class="subtitle sync-warning" id="ls-incoming-warning"></p>
<div class="modal-actions">
<button class="button" id="ls-incoming-decline"></button>
<button class="button primary" id="ls-incoming-accept"></button>
</div>
`, { locked: true })
modal.querySelector('#ls-incoming-title').textContent = t('lansync.incomingTitle', name)
modal.querySelector('#ls-incoming-text').textContent = t('lansync.incomingText', name)
modal.querySelector('#ls-incoming-what').textContent = parts.length ? parts.join(' · ') : t('lansync.incomingNothing')
modal.querySelector('#ls-incoming-warning').textContent = t('lansync.mirrorWarning')
modal.querySelector('#ls-incoming-decline').textContent = t('lansync.decline')
modal.querySelector('#ls-incoming-accept').textContent = t('lansync.accept')
const answer = accept => {
if (!incomingRequest) return
const requestId = incomingRequest.requestId
incomingRequest = null
if (incomingTimer) {
clearTimeout(incomingTimer)
incomingTimer = null
}
closeModal()
window.plwl.lansyncRespondIncoming(requestId, accept)
}
modal.querySelector('#ls-incoming-accept').onclick = () => answer(true)
modal.querySelector('#ls-incoming-decline').onclick = () => answer(false)
// mirror the server's own expiry so the popup never outlives its request
incomingTimer = setTimeout(() => {
incomingTimer = null
incomingRequest = null
closeModal()
toast(t('lansync.incomingTimedOut'), 'error')
}, 60000)
}
let receiving = false let receiving = false
let receiveClosed = false let receiveClosed = false
let receiveWatchdog = null let receiveWatchdog = null
@ -1521,6 +1584,7 @@ function wireLanSync() {
if (state.currentView === 'lansync') renderLanDevices(devices) if (state.currentView === 'lansync') renderLanDevices(devices)
}) })
window.plwl.onLanSyncReceiving(info => onLanReceiving(info)) window.plwl.onLanSyncReceiving(info => onLanReceiving(info))
window.plwl.onLanSyncIncoming(info => onLanIncoming(info))
window.plwl.onLanSyncApplied(async info => { window.plwl.onLanSyncApplied(async info => {
toast(t('lansync.applied', info.from || '', info.files || 0, formatBytes(info.bytes || 0)), 'success') toast(t('lansync.applied', info.from || '', info.files || 0, formatBytes(info.bytes || 0)), 'success')
await reloadLocalData() await reloadLocalData()

View file

@ -19,6 +19,7 @@ function registerLanSyncIpc({ getWindow }) {
lansync.forgetDevice(id) lansync.forgetDevice(id)
return { ok: true } return { ok: true }
}) })
ipcMain.handle('lansync:respondIncoming', (e, requestId, accept) => lansync.respondIncoming(requestId, accept))
ipcMain.handle('lansync:sync', async (e, payload) => { ipcMain.handle('lansync:sync', async (e, payload) => {
try { try {
return await lansync.syncTo({ return await lansync.syncTo({

View file

@ -28,8 +28,11 @@ const CONFIG = path.join(DIRS.DATA, 'lansync.json')
const DISCOVERY_PORT = Number(process.env.PLWL_LANSYNC_PORT) || 47821 const DISCOVERY_PORT = Number(process.env.PLWL_LANSYNC_PORT) || 47821
const BEACON_TAG = 'plwl-lansync' const BEACON_TAG = 'plwl-lansync'
const PROTOCOL = 1 const PROTOCOL = 1
const BEACON_MS = 2000 // A device is only visible while it beacons; 2.5 s against a 6 s TTL leaves
const DEVICE_TTL_MS = 7000 // one missed datagram of headroom (the old 2 s/7 s pair woke the process just
// as often but dropped peers on the first lost packet).
const BEACON_MS = 2500
const DEVICE_TTL_MS = 6000
// Every verification costs this much and a device may only have one in // Every verification costs this much and a device may only have one in
// flight - together they make guessing an 8 character code over the LAN // flight - together they make guessing an 8 character code over the LAN
@ -37,6 +40,10 @@ const DEVICE_TTL_MS = 7000
const VERIFY_DELAY_MS = 2000 const VERIFY_DELAY_MS = 2000
const TOKEN_TTL_MS = 30 * 60 * 1000 const TOKEN_TTL_MS = 30 * 60 * 1000
const SESSION_IDLE_MS = 5 * 60 * 1000 const SESSION_IDLE_MS = 5 * 60 * 1000
// How long the receiving user has to click Accept before the request expires:
// the sender waits for this decision, so its own verify timeout has to be
// longer than this.
const INCOMING_TIMEOUT_MS = Number(process.env.PLWL_LANSYNC_INCOMING_TIMEOUT) || 60 * 1000
// The only trees a sync may touch. Everything else under files/ is local // The only trees a sync may touch. Everything else under files/ is local
// state (settings, translations, playtime, logs) that must never move // state (settings, translations, playtime, logs) that must never move
@ -142,9 +149,18 @@ function makeSyncCode() {
return randomChars(CODE_ALPHABET, 8) return randomChars(CODE_ALPHABET, 8)
} }
// The identity file is read once and then kept in memory: the beacon calls
// identity() every couple of seconds (and every pairing call in between), and
// a synchronous read per call is pure overhead. Nothing else writes this file
// (the ipc handlers all go through the same functions), so the cache cannot
// go stale.
let identityCache = null
function readConfig() { function readConfig() {
if (identityCache) return identityCache
const cfg = readJson(CONFIG, {}) const cfg = readJson(CONFIG, {})
return cfg && typeof cfg === 'object' ? cfg : {} identityCache = cfg && typeof cfg === 'object' ? cfg : {}
return identityCache
} }
// The id and the code are generated on FIRST use and then kept forever: // The id and the code are generated on FIRST use and then kept forever:
@ -242,9 +258,6 @@ const HASH_ALGO = (() => {
// digest itself: a few in flight at once keeps the disk and the cpu busy // digest itself: a few in flight at once keeps the disk and the cpu busy
// instead of waiting on one file at a time. // instead of waiting on one file at a time.
const HASH_CONCURRENCY = Math.max(4, Math.min(16, (os.cpus() || []).length || 4)) 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 // 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. // not change since the last sync is never read again - only stat'ed.
@ -281,6 +294,8 @@ function loadHashCache() {
// Written once per operation at most: this file can hold a quarter million // 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. // entries, and rewriting it per hashed file would cost more than the hashing.
// Compact JSON (the entries are already arrays) keeps a full cache at tens of
// milliseconds instead of the seconds pretty-printed JSON needs.
function saveHashCache() { function saveHashCache() {
if (!hashCacheDirty || !hashCache) return if (!hashCacheDirty || !hashCache) return
const now = Date.now() const now = Date.now()
@ -295,7 +310,8 @@ function saveHashCache() {
hashCacheDirty = false hashCacheDirty = false
hashCacheSavedAt = now hashCacheSavedAt = now
try { try {
writeJson(HASH_CACHE_FILE, { v: 1, algo: HASH_ALGO, files: hashCache }) fs.mkdirSync(path.dirname(HASH_CACHE_FILE), { recursive: true })
fs.writeFileSync(HASH_CACHE_FILE, JSON.stringify({ v: 1, algo: HASH_ALGO, files: hashCache }))
} catch {} } catch {}
} }
@ -306,8 +322,13 @@ function cachedHash(rel, abs, size, mtimeMs) {
const known = cache[rel] const known = cache[rel]
if (known && known[0] === size && known[1] === mtimeMs && typeof known[2] === 'string') { if (known && known[0] === size && known[1] === mtimeMs && typeof known[2] === 'string') {
hashStats.hits++ hashStats.hits++
known[3] = Date.now() // a hit only marks the cache dirty when its timestamp is actually stale:
hashCacheDirty = true // marking it every time would force a full rewrite of the file per sync
// even though nothing a second sync could reuse has changed
if (Date.now() - (known[3] || 0) > 86400000) {
known[3] = Date.now()
hashCacheDirty = true
}
return Promise.resolve(known[2]) return Promise.resolve(known[2])
} }
hashStats.misses++ hashStats.misses++
@ -320,13 +341,19 @@ function cachedHash(rel, abs, size, mtimeMs) {
}) })
} }
function hashFile(file) { // Streaming through one callback instead of accumulating 'data' chunks keeps
// memory flat for huge files, and a small highWaterMark gives the read pump
// real backpressure. Digest speed does not care about the chunk size, so the
// old 1 MB reads only ever paid for themselves in memory, not in time.
function hashFile(file, onBytes) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const hash = crypto.createHash(HASH_ALGO) const hash = crypto.createHash(HASH_ALGO)
const stream = fs.createReadStream(file, { highWaterMark: HASH_CHUNK }) const stream = fs.createReadStream(file, { highWaterMark: 64 * 1024 })
stream.on('error', reject) stream.on('error', reject)
if (onBytes) stream.on('data', chunk => onBytes(chunk.length))
stream.on('data', chunk => hash.update(chunk)) stream.on('data', chunk => hash.update(chunk))
stream.on('end', () => resolve(hash.digest('hex'))) stream.on('end', () => resolve(hash.digest('hex')))
stream.resume()
}) })
} }
@ -420,38 +447,60 @@ function portableLinkTarget(linkAbs, target) {
// Walk without following symlinks: builds and prefixes contain links that are // Walk without following symlinks: builds and prefixes contain links that are
// part of what is being synced (dosdevices, build internals), and following // part of what is being synced (dosdevices, build internals), and following
// them would both loop and copy the wrong thing. // 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.
const SCAN_CONCURRENCY = Math.max(4, HASH_CONCURRENCY)
async function scanTree(abs, rel, out, mtimes) { async function scanTree(abs, rel, out, mtimes) {
let entries const queue = [[abs, rel]]
try { while (queue.length) {
entries = await fsp.readdir(abs, { withFileTypes: true }) const [dirAbs, dirRel] = queue.pop()
} catch { let entries
return try {
} entries = await fsp.readdir(dirAbs, { withFileTypes: true })
for (const entry of entries) { } catch {
const absChild = path.join(abs, entry.name) continue
const relChild = `${rel}/${entry.name}` }
if (entry.isSymbolicLink()) { const dirs = []
let target = '' const leaves = []
try { for (const entry of entries) {
target = await fsp.readlink(absChild) const relChild = `${dirRel}/${entry.name}`
} catch {} if (entry.isDirectory()) {
out.push({ path: relChild, type: 'link', target: portableLinkTarget(absChild, target) }) out.push({ path: relChild, type: 'dir' })
} else if (entry.isDirectory()) { dirs.push([path.join(dirAbs, entry.name), relChild])
out.push({ path: relChild, type: 'dir' }) } else {
await scanTree(absChild, relChild, out, mtimes) leaves.push(entry)
} else if (entry.isFile()) { }
let st = null }
await mapLimit(leaves, SCAN_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 { try {
st = await fsp.stat(absChild) st = await fsp.stat(absChild)
} catch { } catch {
continue return
} }
if (!st.isFile()) return
// mode travels with the file: a wine build's `proton`/`wine` binary is // mode travels with the file: a wine build's `proton`/`wine` binary is
// useless on the other side without its executable bit // useless on the other side without its executable bit
out.push({ path: relChild, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null }) 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 // kept out of the manifest: only this machine can use its own mtimes
if (mtimes) mtimes.set(relChild, st.mtimeMs || 0) if (mtimes) mtimes.set(relChild, st.mtimeMs || 0)
} })
queue.push(...dirs)
} }
} }
@ -488,17 +537,42 @@ async function buildManifest(roots, force, onProgress) {
// start empty and fill with the bytes actually read // start empty and fill with the bytes actually read
const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0) const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0)
if (!force && files.length) { if (!force && files.length) {
const cache = loadHashCache()
let done = 0 let done = 0
let hashed = 0 let hashed = 0
await mapLimit(files, HASH_CONCURRENCY, async entry => { await mapLimit(files, HASH_CONCURRENCY, async entry => {
const abs = filesPath(entry.path) const abs = filesPath(entry.path)
try { try {
entry.hash = abs ? await cachedHash(entry.path, abs, entry.size, mtimes.get(entry.path) || 0) : null // inline cache hit: identical size and mtime means identical bytes, so
// the digest from an earlier sync still describes this file - the bar
// still advances by the file's size, because the check WAS the work
const mtimeMs = mtimes.get(entry.path) || 0
const known = cache[entry.path]
if (abs && known && known[0] === entry.size && known[1] === mtimeMs && typeof known[2] === 'string') {
hashStats.hits++
if (Date.now() - (known[3] || 0) > 86400000) {
known[3] = Date.now()
hashCacheDirty = true
}
entry.hash = known[2]
} else {
hashStats.misses++
entry.hash = abs
? await hashFile(abs, bytes => {
hashed += bytes
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
})
: null
if (entry.hash) {
cache[entry.path] = [entry.size, mtimeMs, entry.hash, Date.now()]
hashCacheDirty = true
}
}
} catch { } catch {
entry.hash = null entry.hash = null
} }
done++ done++
hashed += entry.size || 0 if (!entry.hash) hashed += entry.size || 0 // nothing read: still report the file as passed over
if (onProgress) onProgress(done, files.length, hashed, totalBytes) if (onProgress) onProgress(done, files.length, hashed, totalBytes)
}) })
} }
@ -649,9 +723,12 @@ async function entrySame(entry) {
} }
} }
if (!entry.hash) return false // forced sync (or unreadable file) -> replace it if (!entry.hash) return false // forced sync (or unreadable file) -> replace it
// A size mismatch alone settles it - reading the local file just to hash it
// would only confirm what the sizes already say. lstatSync because the
// answer (is it the regular file the manifest describes) needs no thread.
let st let st
try { try {
st = await fsp.lstat(abs) st = fs.lstatSync(abs)
} catch { } catch {
return false return false
} }
@ -732,7 +809,13 @@ function removeAbs(abs) {
function removeTree(rel) { function removeTree(rel) {
const abs = filesPath(rel) const abs = filesPath(rel)
return abs && removeAbs(abs) ? 1 : 0 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) { function removeOrphans(dir, keep) {
@ -793,11 +876,6 @@ function applyMode(abs, mode) {
function writeStream(abs, stream, onBytes) { function writeStream(abs, stream, onBytes) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let st = null
try {
st = fs.lstatSync(abs)
} catch {}
if (st && st.isDirectory()) removeAbs(abs)
try { try {
fs.mkdirSync(path.dirname(abs), { recursive: true }) fs.mkdirSync(path.dirname(abs), { recursive: true })
} catch {} } catch {}
@ -813,12 +891,16 @@ function writeStream(abs, stream, onBytes) {
}) })
out.on('finish', () => { out.on('finish', () => {
try { try {
if (st && st.isSymbolicLink()) removeAbs(abs) 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) fs.renameSync(tmp, abs)
resolve(out.bytesWritten || 0) resolve(out.bytesWritten || 0)
} catch (err) { } catch (err) {
// a sync race (the path changed between open and rename) must not
// undo the transfer itself - the next sync would just re-upload it
removeAbs(tmp) removeAbs(tmp)
reject(err) resolve(0)
} }
}) })
if (onBytes) { if (onBytes) {
@ -933,7 +1015,21 @@ function applyDefaults(payload, cats) {
// --- HTTP server (what other devices talk to) ------------------------------- // --- HTTP server (what other devices talk to) -------------------------------
function createServerState() { function createServerState() {
return { tokens: new Map(), verifying: new Set(), session: null } return { tokens: new Map(), verifying: new Set(), pending: new Map(), session: null }
}
// The user's answer to an incoming-sync popup. Resolves the verification that
// is waiting for it; unknown/expired ids are a no-op (the request already
// timed out server-side).
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
} }
// Which counter a sync unit feeds on the receiving side (the unit key looks // Which counter a sync unit feeds on the receiving side (the unit key looks
@ -1036,15 +1132,45 @@ async function handleVerify(state, req, res, hooks) {
if (state.verifying.has(from)) return sendJson(res, 429, { ok: false, error: 'busy' }) if (state.verifying.has(from)) return sendJson(res, 429, { ok: false, error: 'busy' })
const running = activeSession(state) const running = activeSession(state)
if (running && running.deviceId !== from) return sendJson(res, 429, { ok: false, error: 'busy' }) if (running && running.deviceId !== from) return sendJson(res, 429, { ok: false, error: 'busy' })
// one UNANSWERED consent popup at a time - a second sender would either
// overwrite the first popup or silently wait out its 60 seconds
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' }) if (hooks.isBusy && hooks.isBusy(from)) return sendJson(res, 429, { ok: false, error: 'busy' })
state.verifying.add(from) state.verifying.add(from)
try { try {
await new Promise(resolve => setTimeout(resolve, VERIFY_DELAY_MS)) 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' }) 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 sender may announce its totals with the verification, which is how
// the popup here can show "Apps: 0/5" before the first file list arrives // the popup here can show "Apps: 0/5" before the first file list arrives
const announced = payload.totals && typeof payload.totals === 'object' ? payload.totals : {} const announced = payload.totals && typeof payload.totals === 'object' ? payload.totals : {}
// Nothing moves without the user behind this window accepting the push:
// a correct code only proves the sender read the code, it is not consent
// to overwrite this library. Without a UI to ask (tests, headless use)
// the gate cannot be answered and stays open - the launcher registers
// onIncoming whenever it shows a window.
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 }
})
} 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 = { const session = {
token, token,
deviceId: from, deviceId: from,
@ -1174,8 +1300,10 @@ async function handlePlan(state, session, url, req, res, hooks) {
} }
async function handlePut(session, url, req, res, hooks) { async function handlePut(session, url, req, res, hooks) {
const key = url.searchParams.get('unit') || '' // unit and path come as headers: they are constant per request, and routing
const rel = url.searchParams.get('path') || '' // them through the query made the receiver parse a fresh URL per file
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 unit = session.units.get(key)
const abs = filesPath(rel) const abs = filesPath(rel)
if (!unit || !abs || !unit.paths.has(rel)) { if (!unit || !abs || !unit.paths.has(rel)) {
@ -1407,6 +1535,7 @@ function startServer(options = {}) {
if (options && typeof options === 'object') serverHooks = { ...serverHooks, ...options } if (options && typeof options === 'object') serverHooks = { ...serverHooks, ...options }
if (listening) return listening if (listening) return listening
const state = createServerState() const state = createServerState()
serverState = state // respondIncoming() answers the popups of THIS server
httpServer = createHttpServer(state, serverHooks) httpServer = createHttpServer(state, serverHooks)
httpServer.on('error', () => {}) httpServer.on('error', () => {})
const port = Number(options && options.port) || 0 const port = Number(options && options.port) || 0
@ -1433,6 +1562,12 @@ function stopServer() {
} catch {} } catch {}
httpServer = null 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 listening = null
httpPort = 0 httpPort = 0
} }
@ -1543,6 +1678,7 @@ const exported = {
knownPeers, knownPeers,
rememberDevice, rememberDevice,
forgetDevice, forgetDevice,
respondIncoming,
sameCode, sameCode,
HASH_ALGO, HASH_ALGO,
hashFile, hashFile,

View file

@ -82,14 +82,20 @@ function createPusher(api) {
function putFile(device, token, unitKey, rel, abs) { function putFile(device, token, unitKey, rel, abs) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const route = `/plwl/unit/file?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unitKey)}&path=${encodeURIComponent(rel)}` // unit and path travel as headers: the receiver used to parse a fresh
// query string per file for values that are constant per request
const req = http.request( const req = http.request(
{ {
host: device.address, host: device.address,
port: device.port, port: device.port,
path: route, path: `/plwl/unit/file?token=${encodeURIComponent(token)}`,
method: 'PUT', method: 'PUT',
headers: { 'content-type': 'application/octet-stream', 'content-length': fs.statSync(abs).size }, headers: {
'content-type': 'application/octet-stream',
'content-length': fs.statSync(abs).size,
'x-plwl-unit': unitKey,
'x-plwl-path': rel
},
timeout: 0 timeout: 0
}, },
res => { res => {
@ -262,18 +268,21 @@ function createPusher(api) {
let verified let verified
try { try {
// longer than the receiver's incoming-request timeout: the user over
// there gets a popup and this side waits for the click
verified = await httpJson(device, 'POST', '/plwl/verify', { verified = await httpJson(device, 'POST', '/plwl/verify', {
json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase(), totals }, json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase(), totals },
timeout: 20000 timeout: 90000
}) })
} catch (err) { } catch (err) {
return fail(String((err && err.message) || err)) return fail(String((err && err.message) || err))
} }
if (!verified.data || !verified.data.ok) { if (!verified.data || !verified.data.ok) {
const error = (verified.data && verified.data.error) || `http-${verified.status}` const error = (verified.data && verified.data.error) || `http-${verified.status}`
// the peer rejected the code (it was rotated there): drop the stored // the peer rotated its code: drop the stored pairing, so the next
// pairing, so the user is asked for the new code instead of every later // attempt asks for the new one instead of failing silently. A plain
// sync failing against a stale one // 'rejected' (the user over there declined the popup) keeps the pairing
// - the code was proven correct, the popup is the consent gate.
if (error === 'auth-failed' || error === 'bad-code') forgetDevice(device.id || deviceId) if (error === 'auth-failed' || error === 'bad-code') forgetDevice(device.id || deviceId)
return fail(error) return fail(error)
} }
@ -348,13 +357,11 @@ function createPusher(api) {
// already identical (the plan reports both) // already identical (the plan reports both)
bucket.copied += entries.length - same.size bucket.copied += entries.length - same.size
bucket.skipped += same.size bucket.skipped += same.size
summary.skipped += same.size
const pending = [] const pending = []
for (const entry of entries) { for (const entry of entries) {
if (entry.type !== 'file') continue if (entry.type !== 'file') continue
if (!upload.has(entry.path)) { if (!upload.has(entry.path)) continue // identical -> the receiver keeps its copy
summary.skipped++
continue
}
pending.push(entry) pending.push(entry)
plannedBytes += entry.size || 0 plannedBytes += entry.size || 0
} }

View file

@ -249,6 +249,8 @@ app.whenReady().then(async () => {
onDevices: devices => sendToWindow(win, 'lansync:devices', devices), onDevices: devices => sendToWindow(win, 'lansync:devices', devices),
onApplied: info => sendToWindow(win, 'lansync:applied', info), onApplied: info => sendToWindow(win, 'lansync:applied', info),
onReceive: info => sendToWindow(win, 'lansync:receiving', info), onReceive: info => sendToWindow(win, 'lansync:receiving', info),
// the consent popup for an incoming sync (see ipc/lansync.js)
onIncoming: info => sendToWindow(win, 'lansync:incoming', info),
isBusy: () => !!(gamesService && gamesService.listRunning().length) isBusy: () => !!(gamesService && gamesService.listRunning().length)
} }
try { try {

View file

@ -23,6 +23,13 @@ let lanSyncProgressCb = null
ipcRenderer.on('lansync:progress', (e, data) => { ipcRenderer.on('lansync:progress', (e, data) => {
if (lanSyncProgressCb) lanSyncProgressCb(data) if (lanSyncProgressCb) lanSyncProgressCb(data)
}) })
// Incoming sync consent: the receiver's Accept/Decline popup. Single slot for
// the same reason - one pending request at a time (the server enforces it).
let lanSyncIncomingCb = null
ipcRenderer.on('lansync:incoming', (e, data) => {
if (lanSyncIncomingCb) lanSyncIncomingCb(data)
})
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
@ -91,10 +98,14 @@ contextBridge.exposeInMainWorld('plwl', {
lansyncRerollSyncCode: () => ipcRenderer.invoke('lansync:rerollSyncCode'), lansyncRerollSyncCode: () => ipcRenderer.invoke('lansync:rerollSyncCode'),
lansyncForget: id => ipcRenderer.invoke('lansync:forget', id), lansyncForget: id => ipcRenderer.invoke('lansync:forget', id),
lansyncSync: payload => ipcRenderer.invoke('lansync:sync', payload), lansyncSync: payload => ipcRenderer.invoke('lansync:sync', payload),
lansyncRespondIncoming: (requestId, accept) => ipcRenderer.invoke('lansync:respondIncoming', requestId, accept),
onLanSyncDevices: cb => ipcRenderer.on('lansync:devices', (e, data) => cb(data)), onLanSyncDevices: cb => ipcRenderer.on('lansync:devices', (e, data) => cb(data)),
onLanSyncProgress: cb => { onLanSyncProgress: cb => {
lanSyncProgressCb = cb lanSyncProgressCb = cb
}, },
onLanSyncIncoming: cb => {
lanSyncIncomingCb = cb
},
onLanSyncApplied: cb => ipcRenderer.on('lansync:applied', (e, data) => cb(data)), onLanSyncApplied: cb => ipcRenderer.on('lansync:applied', (e, data) => cb(data)),
onLanSyncReceiving: cb => ipcRenderer.on('lansync:receiving', (e, data) => cb(data)) onLanSyncReceiving: cb => ipcRenderer.on('lansync:receiving', (e, data) => cb(data))
}) })