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):
- **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.
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
sides report their own progress (the receiver through the onReceive hook),
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,
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
@ -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-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-consent.test.js # incoming sync: accept / decline / timeout gate
node test/bench-sync.js # timing: phases of a cold and a cached sync
npm start # click through the flow you touched

View file

@ -185,6 +185,14 @@
"lansync.statsPrefixes": "Prefixes: {0}",
"lansync.statsWines": "Wine versions: {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.recvStalled": "Waiting for the other device…",
"lansync.recvIdle": "The other device stopped responding, the sync was interrupted.",

View file

@ -185,6 +185,14 @@
"lansync.statsPrefixes": "Prefiksy: {0}",
"lansync.statsWines": "Wersje Wine: {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.recvStalled": "Oczekiwanie na drugie urządzenie…",
"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')
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')
}
@ -1389,6 +1394,64 @@ const RECEIVE_STALL_MS = 6 * 1000
// phases whose wording differs on this side (everything else is neutral:
// "Comparing X", "Updating the library", "Finishing up")
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 receiveClosed = false
let receiveWatchdog = null
@ -1521,6 +1584,7 @@ function wireLanSync() {
if (state.currentView === 'lansync') renderLanDevices(devices)
})
window.plwl.onLanSyncReceiving(info => onLanReceiving(info))
window.plwl.onLanSyncIncoming(info => onLanIncoming(info))
window.plwl.onLanSyncApplied(async info => {
toast(t('lansync.applied', info.from || '', info.files || 0, formatBytes(info.bytes || 0)), 'success')
await reloadLocalData()

View file

@ -19,6 +19,7 @@ function registerLanSyncIpc({ getWindow }) {
lansync.forgetDevice(id)
return { ok: true }
})
ipcMain.handle('lansync:respondIncoming', (e, requestId, accept) => lansync.respondIncoming(requestId, accept))
ipcMain.handle('lansync:sync', async (e, payload) => {
try {
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 BEACON_TAG = 'plwl-lansync'
const PROTOCOL = 1
const BEACON_MS = 2000
const DEVICE_TTL_MS = 7000
// A device is only visible while it beacons; 2.5 s against a 6 s TTL leaves
// 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
// 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 TOKEN_TTL_MS = 30 * 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
// state (settings, translations, playtime, logs) that must never move
@ -142,9 +149,18 @@ function makeSyncCode() {
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() {
if (identityCache) return identityCache
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:
@ -242,9 +258,6 @@ const HASH_ALGO = (() => {
// 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.
@ -281,6 +294,8 @@ function loadHashCache() {
// 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.
// 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() {
if (!hashCacheDirty || !hashCache) return
const now = Date.now()
@ -295,7 +310,8 @@ function saveHashCache() {
hashCacheDirty = false
hashCacheSavedAt = now
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 {}
}
@ -306,8 +322,13 @@ function cachedHash(rel, abs, size, mtimeMs) {
const known = cache[rel]
if (known && known[0] === size && known[1] === mtimeMs && typeof known[2] === 'string') {
hashStats.hits++
known[3] = Date.now()
hashCacheDirty = true
// a hit only marks the cache dirty when its timestamp is actually stale:
// 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])
}
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) => {
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)
if (onBytes) stream.on('data', chunk => onBytes(chunk.length))
stream.on('data', chunk => hash.update(chunk))
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
// 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.
const SCAN_CONCURRENCY = Math.max(4, HASH_CONCURRENCY)
async function scanTree(abs, rel, out, mtimes) {
let entries
try {
entries = await fsp.readdir(abs, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const absChild = path.join(abs, entry.name)
const relChild = `${rel}/${entry.name}`
if (entry.isSymbolicLink()) {
let target = ''
try {
target = await fsp.readlink(absChild)
} catch {}
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, mtimes)
} else if (entry.isFile()) {
let st = null
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, 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 {
st = await fsp.stat(absChild)
} catch {
continue
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)
}
}
@ -488,17 +537,42 @@ async function buildManifest(roots, force, onProgress) {
// start empty and fill with the bytes actually read
const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0)
if (!force && files.length) {
const cache = loadHashCache()
let done = 0
let hashed = 0
await mapLimit(files, HASH_CONCURRENCY, async entry => {
const abs = filesPath(entry.path)
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 {
entry.hash = null
}
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)
})
}
@ -649,9 +723,12 @@ async function entrySame(entry) {
}
}
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
try {
st = await fsp.lstat(abs)
st = fs.lstatSync(abs)
} catch {
return false
}
@ -732,7 +809,13 @@ function removeAbs(abs) {
function removeTree(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) {
@ -793,11 +876,6 @@ function applyMode(abs, mode) {
function writeStream(abs, stream, onBytes) {
return new Promise((resolve, reject) => {
let st = null
try {
st = fs.lstatSync(abs)
} catch {}
if (st && st.isDirectory()) removeAbs(abs)
try {
fs.mkdirSync(path.dirname(abs), { recursive: true })
} catch {}
@ -813,12 +891,16 @@ function writeStream(abs, stream, onBytes) {
})
out.on('finish', () => {
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)
resolve(out.bytesWritten || 0)
} 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)
reject(err)
resolve(0)
}
})
if (onBytes) {
@ -933,7 +1015,21 @@ function applyDefaults(payload, cats) {
// --- HTTP server (what other devices talk to) -------------------------------
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
@ -1036,15 +1132,45 @@ async function handleVerify(state, req, res, hooks) {
if (state.verifying.has(from)) return sendJson(res, 429, { ok: false, error: 'busy' })
const running = activeSession(state)
if (running && running.deviceId !== from) return sendJson(res, 429, { ok: false, error: 'busy' })
// one UNANSWERED consent popup at a time - 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' })
state.verifying.add(from)
try {
await new Promise(resolve => setTimeout(resolve, VERIFY_DELAY_MS))
if (!sameCode(payload.code, me.syncCode)) return sendJson(res, 403, { ok: false, error: 'auth-failed', reason: 'wrong-code' })
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 : {}
// 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 = {
token,
deviceId: from,
@ -1174,8 +1300,10 @@ async function handlePlan(state, session, url, req, res, hooks) {
}
async function handlePut(session, url, req, res, hooks) {
const key = url.searchParams.get('unit') || ''
const rel = url.searchParams.get('path') || ''
// unit and path come as headers: they are constant per request, and routing
// 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 abs = filesPath(rel)
if (!unit || !abs || !unit.paths.has(rel)) {
@ -1407,6 +1535,7 @@ function startServer(options = {}) {
if (options && typeof options === 'object') serverHooks = { ...serverHooks, ...options }
if (listening) return listening
const state = createServerState()
serverState = state // respondIncoming() answers the popups of THIS server
httpServer = createHttpServer(state, serverHooks)
httpServer.on('error', () => {})
const port = Number(options && options.port) || 0
@ -1433,6 +1562,12 @@ function stopServer() {
} catch {}
httpServer = null
}
// a waiting popup would never be answered: resolve it as declined
if (serverState && serverState.pending) {
for (const resolve of serverState.pending.values()) resolve('reject')
serverState.pending.clear()
}
serverState = null
listening = null
httpPort = 0
}
@ -1543,6 +1678,7 @@ const exported = {
knownPeers,
rememberDevice,
forgetDevice,
respondIncoming,
sameCode,
HASH_ALGO,
hashFile,

View file

@ -82,14 +82,20 @@ function createPusher(api) {
function putFile(device, token, unitKey, rel, abs) {
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(
{
host: device.address,
port: device.port,
path: route,
path: `/plwl/unit/file?token=${encodeURIComponent(token)}`,
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
},
res => {
@ -262,18 +268,21 @@ function createPusher(api) {
let verified
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', {
json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase(), totals },
timeout: 20000
timeout: 90000
})
} catch (err) {
return fail(String((err && err.message) || err))
}
if (!verified.data || !verified.data.ok) {
const error = (verified.data && verified.data.error) || `http-${verified.status}`
// the peer rejected the code (it was rotated there): drop the stored
// pairing, so the user is asked for the new code instead of every later
// sync failing against a stale one
// the peer rotated its code: drop the stored pairing, so the next
// attempt asks for the new one instead of failing silently. A plain
// '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)
return fail(error)
}
@ -348,13 +357,11 @@ function createPusher(api) {
// already identical (the plan reports both)
bucket.copied += entries.length - same.size
bucket.skipped += same.size
summary.skipped += same.size
const pending = []
for (const entry of entries) {
if (entry.type !== 'file') continue
if (!upload.has(entry.path)) {
summary.skipped++
continue
}
if (!upload.has(entry.path)) continue // identical -> the receiver keeps its copy
pending.push(entry)
plannedBytes += entry.size || 0
}

View file

@ -249,6 +249,8 @@ app.whenReady().then(async () => {
onDevices: devices => sendToWindow(win, 'lansync:devices', devices),
onApplied: info => sendToWindow(win, 'lansync:applied', 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)
}
try {

View file

@ -23,6 +23,13 @@ let lanSyncProgressCb = null
ipcRenderer.on('lansync:progress', (e, 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 path = require('path')
@ -91,10 +98,14 @@ contextBridge.exposeInMainWorld('plwl', {
lansyncRerollSyncCode: () => ipcRenderer.invoke('lansync:rerollSyncCode'),
lansyncForget: id => ipcRenderer.invoke('lansync:forget', id),
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)),
onLanSyncProgress: cb => {
lanSyncProgressCb = cb
},
onLanSyncIncoming: cb => {
lanSyncIncomingCb = cb
},
onLanSyncApplied: cb => ipcRenderer.on('lansync:applied', (e, data) => cb(data)),
onLanSyncReceiving: cb => ipcRenderer.on('lansync:receiving', (e, data) => cb(data))
})