optimize sync & add accept popup
This commit is contained in:
parent
c9f604863a
commit
f0e097fa76
10 changed files with 305 additions and 61 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
232
src/lansync.js
232
src/lansync.js
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue