more accurate sync progress report

This commit is contained in:
olcxja 2026-10-01 14:58:58 +02:00
commit c0f6e802ed
12 changed files with 390 additions and 63 deletions

View file

@ -57,6 +57,8 @@ Two PLWL installs on the same LAN can mirror each other's content - no cloud, no
Pick a device and choose what to push: **portable apps**, **apps imported from a prefix**, **Wine/Proton builds** and **prefixes**. 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** and **prefixes**. 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.
**Both machines show what is happening.** The sender gets a progress popup (phase, files, how many games / prefixes / Wine builds take part, and the transferred vs. planned size in GB); the receiving device gets the same popup titled *Receiving from X…*, driven by the transfer as it arrives. A running sync is not cancellable from the outside: clicking the backdrop or pressing Escape does nothing, the popup closes itself when the transfer ends - or after five minutes without progress, when the other side has vanished.
The **sending device is the source of truth**: files that differ are overwritten, missing ones are written, and anything living only on the receiving side inside the synced trees (`files/apps/`, `files/wines/`, `files/wineprefixes/`, `files/data/icons/`) is deleted. Files are compared with a fast, collision-resistant hash (BLAKE2b-512, SHA-512/SHA-256 fallback); **Force sync all files** skips the comparison and overwrites everything instead. Everything else stays local: settings, logs, playtime/last played, translations, and external (leave) apps - prefixes are always kept usable, so app entries pointing inside a prefix keep their prefix-relative paths and the receiving side never loses its own `Main` prefix. The **sending device is the source of truth**: files that differ are overwritten, missing ones are written, and anything living only on the receiving side inside the synced trees (`files/apps/`, `files/wines/`, `files/wineprefixes/`, `files/data/icons/`) is deleted. Files are compared with a fast, collision-resistant hash (BLAKE2b-512, SHA-512/SHA-256 fallback); **Force sync all files** skips the comparison and overwrites everything instead. Everything else stays local: settings, logs, playtime/last played, translations, and external (leave) apps - prefixes are always kept usable, so app entries pointing inside a prefix keep their prefix-relative paths and the receiving side never loses its own `Main` prefix.
## Wine & Proton support ## Wine & Proton support

View file

@ -23,7 +23,8 @@ src/ main process (Node/Electron backend)
paths.js portable layout, FILES-relative path helpers, translation sync paths.js portable layout, FILES-relative path helpers, translation sync
i18n.js translation loading + {0} placeholder filling i18n.js translation loading + {0} placeholder filling
preload.js contextBridge API exposed to the renderer preload.js contextBridge API exposed to the renderer
lansync.js LAN sync: identity + pairing, hashing, HTTP receiver, UDP discovery lansync.js LAN sync: identity + pairing, hashing, HTTP receiver (with its
receive-progress hook), UDP discovery
lansyncpush.js the pushing side (createPusher factory, keeps helper state out) lansyncpush.js the pushing side (createPusher factory, keeps helper state out)
appicon.js window/taskbar icon appicon.js window/taskbar icon
renderer/ UI (plain HTML/CSS/JS, no framework) renderer/ UI (plain HTML/CSS/JS, no framework)
@ -66,7 +67,12 @@ handlers in src/ipc/. Keep new APIs in that pattern.
the flex gap of the container, not per-element magic numbers. the flex gap of the container, not per-element magic numbers.
- LAN sync has two sides: the receiver lives in lansync.js, the sender in - LAN sync has two sides: the receiver lives in lansync.js, the sender in
lansyncpush.js and is built by createPusher(helpers) - several launcher lansyncpush.js and is built by createPusher(helpers) - several launcher
states share the module, so nothing may be kept in its module scope. 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.
- A modal showing a running operation (a sync) opens locked:
showModal(html, { locked: true }) ignores the backdrop and Escape, because
the user cannot cancel the operation by dismissing it.
- Tests are plain assert-by-print scripts (see test/): set PLWL_BUILD_DIR to - Tests are plain assert-by-print scripts (see test/): set PLWL_BUILD_DIR to
a temp dir, check(), exit 1 on failure. Add one when fixing a bug. a temp dir, check(), exit 1 on failure. Add one when fixing a bug.
@ -76,5 +82,6 @@ handlers in src/ipc/. Keep new APIs in that pattern.
node --check <changed js files> # syntax node --check <changed js files> # syntax
node test/import.test.js # ...and the other test files node test/import.test.js # ...and the other test files
node test/lansync.test.js # LAN sync contract (identity, pairing, mirror) node test/lansync.test.js # LAN sync contract (identity, pairing, mirror)
node test/lansync-receiving.test.js # receive-side progress events (the popup)
npm start # click through the flow you touched npm start # click through the flow you touched
``` ```

View file

@ -173,14 +173,24 @@
"lansync.phase.plan": "Comparing {0}", "lansync.phase.plan": "Comparing {0}",
"lansync.phase.copy": "Sending {0}", "lansync.phase.copy": "Sending {0}",
"lansync.phase.registry": "Updating the library", "lansync.phase.registry": "Updating the library",
"lansync.phase.commit": "Removing extra files",
"lansync.phase.done": "Finishing up",
"lansync.progressFiles": "{0} / {1} files", "lansync.progressFiles": "{0} / {1} files",
"lansync.done": "Synced to {0}: {1} sent, {2} already up to date, {3} removed", "lansync.progressBytes": "{0} / {1} transferred",
"lansync.progressSent": "{0} transferred",
"lansync.statsApps": "Games: {0}",
"lansync.statsPrefixes": "Prefixes: {0}",
"lansync.statsWines": "Wine versions: {0}",
"lansync.receiving": "Receiving from {0}…",
"lansync.recvWaiting": "Waiting for the file list…",
"lansync.recvIdle": "The other device stopped responding - the sync was interrupted.",
"lansync.done": "Synced to {0}: {1} sent ({4}), {2} already up to date, {3} removed",
"lansync.failed": "Sync failed: {0}", "lansync.failed": "Sync failed: {0}",
"lansync.badCode": "Wrong sync code. Enter the code shown on the other device.", "lansync.badCode": "Wrong sync code. Enter the code shown on the other device.",
"lansync.busy": "The other device is busy (another sync or a running app).", "lansync.busy": "The other device is busy (another sync or a running app).",
"lansync.offline": "That device is no longer on the network.", "lansync.offline": "That device is no longer on the network.",
"lansync.noneSelected": "Pick at least one thing to sync.", "lansync.noneSelected": "Pick at least one thing to sync.",
"lansync.applied": "Synced from {0}: {1} files received", "lansync.applied": "Synced from {0}: {1} files received ({2})",
"lansync.idRerolled": "New device ID generated", "lansync.idRerolled": "New device ID generated",
"lansync.codeRerolled": "New sync code generated", "lansync.codeRerolled": "New sync code generated",
"lansync.unknownError": "unknown error", "lansync.unknownError": "unknown error",

View file

@ -173,14 +173,24 @@
"lansync.phase.plan": "Porównywanie {0}", "lansync.phase.plan": "Porównywanie {0}",
"lansync.phase.copy": "Wysyłanie {0}", "lansync.phase.copy": "Wysyłanie {0}",
"lansync.phase.registry": "Aktualizowanie biblioteki", "lansync.phase.registry": "Aktualizowanie biblioteki",
"lansync.phase.commit": "Usuwanie zbędnych plików",
"lansync.phase.done": "Kończenie",
"lansync.progressFiles": "{0} / {1} plików", "lansync.progressFiles": "{0} / {1} plików",
"lansync.done": "Zsynchronizowano na {0}: wysłano {1}, aktualnych {2}, usunięto {3}", "lansync.progressBytes": "{0} / {1} przesłane",
"lansync.progressSent": "przesłano {0}",
"lansync.statsApps": "Gry: {0}",
"lansync.statsPrefixes": "Prefiksy: {0}",
"lansync.statsWines": "Wersje Wine: {0}",
"lansync.receiving": "Odbieranie od {0}…",
"lansync.recvWaiting": "Oczekiwanie na listę plików…",
"lansync.recvIdle": "Drugie urządzenie przestało odpowiadać - synchronizacja została przerwana.",
"lansync.done": "Zsynchronizowano na {0}: wysłano {1} ({4}), aktualnych {2}, usunięto {3}",
"lansync.failed": "Synchronizacja nie powiodła się: {0}", "lansync.failed": "Synchronizacja nie powiodła się: {0}",
"lansync.badCode": "Błędny kod synchronizacji. Wpisz kod pokazany na drugim urządzeniu.", "lansync.badCode": "Błędny kod synchronizacji. Wpisz kod pokazany na drugim urządzeniu.",
"lansync.busy": "Drugie urządzenie jest zajęte (inna synchronizacja lub uruchomiona aplikacja).", "lansync.busy": "Drugie urządzenie jest zajęte (inna synchronizacja lub uruchomiona aplikacja).",
"lansync.offline": "To urządzenie zniknęło z sieci.", "lansync.offline": "To urządzenie zniknęło z sieci.",
"lansync.noneSelected": "Wybierz przynajmniej jedną rzecz do synchronizacji.", "lansync.noneSelected": "Wybierz przynajmniej jedną rzecz do synchronizacji.",
"lansync.applied": "Zsynchronizowano z {0}: odebrano {1} plików", "lansync.applied": "Zsynchronizowano z {0}: odebrano {1} plików ({2})",
"lansync.idRerolled": "Wygenerowano nowe ID urządzenia", "lansync.idRerolled": "Wygenerowano nowe ID urządzenia",
"lansync.codeRerolled": "Wygenerowano nowy kod synchronizacji", "lansync.codeRerolled": "Wygenerowano nowy kod synchronizacji",
"lansync.unknownError": "nieznany błąd", "lansync.unknownError": "nieznany błąd",

View file

@ -15,6 +15,10 @@ const state = {
games: [], games: [],
currentGame: null, currentGame: null,
lansync: null, lansync: null,
// true while this device pushes a sync (and while it shows the progress
// popup): only one such popup exists, the pushing side wins over an
// incoming sync
lanPushing: false,
currentView: 'library' currentView: 'library'
} }
@ -114,8 +118,13 @@ function toast(message, kind) {
// cancels any pending teardown and clears the closing state. // cancels any pending teardown and clears the closing state.
let modalTeardownTimer = null let modalTeardownTimer = null
let modalSwapTimer = null let modalSwapTimer = null
// A running sync must not be thrown away by a stray click: a locked modal
// ignores the backdrop and Escape, only its own buttons (or the end of the
// operation) close it.
let modalLocked = false
function showModal(html) { function showModal(html, opts) {
modalLocked = !!(opts && opts.locked)
if (modalTeardownTimer) { if (modalTeardownTimer) {
clearTimeout(modalTeardownTimer) clearTimeout(modalTeardownTimer)
modalTeardownTimer = null modalTeardownTimer = null
@ -189,6 +198,7 @@ function closeModal() {
modal.classList.remove('closing', 'swapping', 'resizing') modal.classList.remove('closing', 'swapping', 'resizing')
modal.style.height = '' modal.style.height = ''
modal.innerHTML = '' modal.innerHTML = ''
modalLocked = false
}, 160) }, 160)
} }
@ -1137,6 +1147,41 @@ const LAN_CATEGORIES = [
{ id: 'prefixes', label: 'lansync.prefixes' } { id: 'prefixes', label: 'lansync.prefixes' }
] ]
// Byte sizes for the progress popups. Decimal units, the way a file manager
// or a download shows them, because that is what a "1.2 GB" on screen means
// to the user.
function formatBytes(bytes) {
const value = Math.max(0, Number(bytes) || 0)
if (value < 1000) return `${Math.round(value)} B`
if (value < 1000 * 1000) return `${(value / 1000).toFixed(1)} kB`
if (value < 1000 * 1000 * 1000) return `${(value / 1000 / 1000).toFixed(1)} MB`
return `${(value / 1000 / 1000 / 1000).toFixed(2)} GB`
}
// "Games: 3 · Prefixes: 1 · Wine versions: 2" - only the categories this
// sync actually carries are listed.
function lanStatsLine(items) {
if (!items) return ''
const rows = [
[items.apps, 'lansync.statsApps'],
[items.prefixes, 'lansync.statsPrefixes'],
[items.wines, 'lansync.statsWines']
]
return rows
.filter(([count]) => count > 0)
.map(([count, key]) => t(key, count))
.join(' · ')
}
// "340 MB / 1.2 GB" once the total is known (the sending side learns it as it
// plans the units, the receiving side as the manifests arrive), otherwise
// just what has moved so far - so the line is never a lying zero.
function lanBytesLine(bytes, totalBytes) {
const sent = formatBytes(bytes)
if (!totalBytes) return bytes > 0 ? t('lansync.progressSent', sent) : ''
return t('lansync.progressBytes', sent, formatBytes(totalBytes))
}
async function renderLanSync() { async function renderLanSync() {
const info = await window.plwl.lansyncGet() const info = await window.plwl.lansyncGet()
state.lansync = info state.lansync = info
@ -1243,17 +1288,23 @@ async function openLanSyncModal(device, errorKey) {
} }
async function runLanSync(device, code, categories, force) { async function runLanSync(device, code, categories, force) {
// locked: a running sync is not cancellable by a stray click on the backdrop
const modal = showModal(` const modal = showModal(`
<h2 id="ls-run-title"></h2> <h2 id="ls-run-title"></h2>
<div class="progress-row"><progress></progress></div> <div class="progress-row"><progress></progress></div>
<p class="subtitle" id="ls-run-phase"></p> <p class="subtitle" id="ls-run-phase"></p>
<p class="subtitle" id="ls-run-count"></p> <p class="subtitle" id="ls-run-count"></p>
`) <p class="subtitle" id="ls-run-stats"></p>
<p class="subtitle" id="ls-run-bytes"></p>
`, { locked: true })
modal.querySelector('#ls-run-title').textContent = t('lansync.progress', device.name) modal.querySelector('#ls-run-title').textContent = t('lansync.progress', device.name)
const bar = modal.querySelector('progress') const bar = modal.querySelector('progress')
const phase = modal.querySelector('#ls-run-phase') const phase = modal.querySelector('#ls-run-phase')
const count = modal.querySelector('#ls-run-count') const count = modal.querySelector('#ls-run-count')
const stats = modal.querySelector('#ls-run-stats')
const bytes = modal.querySelector('#ls-run-bytes')
let active = true let active = true
state.lanPushing = true
window.plwl.onLanSyncProgress(data => { window.plwl.onLanSyncProgress(data => {
if (!active) return if (!active) return
const key = 'lansync.phase.' + data.phase const key = 'lansync.phase.' + data.phase
@ -1266,13 +1317,22 @@ async function runLanSync(device, code, categories, force) {
bar.removeAttribute('value') bar.removeAttribute('value')
count.textContent = '' count.textContent = ''
} }
stats.textContent = lanStatsLine(data.items)
bytes.textContent = lanBytesLine(data.bytes, data.totalBytes)
}) })
const res = await window.plwl.lansyncSync({ deviceId: device.id, code, categories, force }) let res = null
active = false try {
res = await window.plwl.lansyncSync({ deviceId: device.id, code, categories, force })
} catch (err) {
res = { ok: false, error: (err && err.message) || '' }
} finally {
active = false
state.lanPushing = false
}
closeModal() closeModal()
if (res && res.ok) { if (res && res.ok) {
const summary = res.summary || {} const summary = res.summary || {}
toast(t('lansync.done', device.name, summary.uploaded || 0, summary.skipped || 0, summary.deleted || 0), 'success') toast(t('lansync.done', device.name, summary.uploaded || 0, summary.skipped || 0, summary.deleted || 0, formatBytes(summary.bytes || 0)), 'success')
await renderLanSync() await renderLanSync()
return return
} }
@ -1287,6 +1347,107 @@ async function runLanSync(device, code, categories, force) {
toast(t('lansync.failed', message), 'error') toast(t('lansync.failed', message), 'error')
} }
// --- receiving a sync (the other device is the source of truth) -------------
// A sync pushed onto THIS device must also be visible here - otherwise the
// library changes under the user's hands with no explanation. Same popup, but
// driven by the events the receiving server emits, and equally not closable
// by a click.
const RECEIVE_WATCHDOG_MS = 5 * 60 * 1000
let receiving = false
let receiveWatchdog = null
let receiveCloseTimer = null
function receiveModalParts() {
const modal = qs('#modal')
if (!receiving || !modal) return null
const title = modal.querySelector('#ls-recv-title')
if (!title) return null
return {
title,
bar: modal.querySelector('#ls-recv-modal progress'),
phase: modal.querySelector('#ls-recv-phase'),
count: modal.querySelector('#ls-recv-count'),
stats: modal.querySelector('#ls-recv-stats'),
bytes: modal.querySelector('#ls-recv-bytes')
}
}
function endReceiving(message) {
if (receiveWatchdog) {
clearTimeout(receiveWatchdog)
receiveWatchdog = null
}
if (receiveCloseTimer) {
clearTimeout(receiveCloseTimer)
receiveCloseTimer = null
}
const wasReceiving = receiving
receiving = false
if (!wasReceiving) return
if (message) toast(message, 'error')
closeModal()
}
// A sender that crashed halfway through must not leave this window locked: the
// popup closes itself if no event arrives for a while (the server drops the
// session after the same idle time).
function armReceiveWatchdog() {
if (receiveWatchdog) clearTimeout(receiveWatchdog)
receiveWatchdog = setTimeout(() => endReceiving(t('lansync.recvIdle')), RECEIVE_WATCHDOG_MS)
}
function onLanReceiving(info) {
if (!info || !info.stage) return
if (state.lanPushing) return // our own push owns the (single) popup
if (!receiving) {
// open the popup only for the start of a sync, and only when the user is
// not in the middle of another dialog (there is one modal at a time)
if (info.stage !== 'start') return
if (qs('#modal-overlay').style.display !== 'none') return
receiving = true
const modal = showModal(`
<h2 id="ls-recv-title"></h2>
<div class="progress-row" id="ls-recv-modal"><progress></progress></div>
<p class="subtitle" id="ls-recv-phase"></p>
<p class="subtitle" id="ls-recv-count"></p>
<p class="subtitle" id="ls-recv-stats"></p>
<p class="subtitle" id="ls-recv-bytes"></p>
`, { locked: true })
modal.querySelector('#ls-recv-title').textContent = t('lansync.receiving', info.from || info.deviceId || '')
armReceiveWatchdog()
return renderReceiving(info)
}
armReceiveWatchdog()
renderReceiving(info)
}
function renderReceiving(info) {
const parts = receiveModalParts()
if (!parts) return
const phaseKey = 'lansync.phase.' + info.stage
parts.phase.textContent = (info.stage === 'start' ? t('lansync.recvWaiting') : t(phaseKey, info.label || '')).trim()
if (info.total > 0) {
parts.bar.max = info.total
parts.bar.value = info.done
parts.count.textContent = t('lansync.progressFiles', info.done, info.total)
} else {
parts.bar.removeAttribute('value')
parts.count.textContent = ''
}
parts.stats.textContent = lanStatsLine(info.items)
parts.bytes.textContent = lanBytesLine(info.bytes, info.totalBytes)
if (info.stage === 'done') {
// let the totals be read, then get out of the way (the library reloads
// itself through lansync:applied)
if (receiveCloseTimer) clearTimeout(receiveCloseTimer)
receiveCloseTimer = setTimeout(() => {
receiveCloseTimer = null
endReceiving('')
}, 4000)
}
}
// Data received from another device changed the local files: reload every // Data received from another device changed the local files: reload every
// list so the views show the merged library instead of stale state. // list so the views show the merged library instead of stale state.
async function reloadLocalData() { async function reloadLocalData() {
@ -1308,8 +1469,9 @@ function wireLanSync() {
if (state.lansync) state.lansync.devices = devices if (state.lansync) state.lansync.devices = devices
if (state.currentView === 'lansync') renderLanDevices(devices) if (state.currentView === 'lansync') renderLanDevices(devices)
}) })
window.plwl.onLanSyncReceiving(info => onLanReceiving(info))
window.plwl.onLanSyncApplied(async info => { window.plwl.onLanSyncApplied(async info => {
toast(t('lansync.applied', info.from || '', info.files || 0), 'success') toast(t('lansync.applied', info.from || '', info.files || 0, formatBytes(info.bytes || 0)), 'success')
await reloadLocalData() await reloadLocalData()
}) })
qs('#ls-reroll-id').addEventListener('click', async () => { qs('#ls-reroll-id').addEventListener('click', async () => {
@ -1422,10 +1584,11 @@ function wireEvents() {
toast(t('config.playtimeCleared'), 'success') toast(t('config.playtimeCleared'), 'success')
}) })
qs('#modal-overlay').addEventListener('click', e => { qs('#modal-overlay').addEventListener('click', e => {
if (modalLocked) return // a sync in progress: the backdrop is not a cancel button
if (e.target === qs('#modal-overlay')) closeModal() if (e.target === qs('#modal-overlay')) closeModal()
}) })
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeModal() if (e.key === 'Escape' && !modalLocked) closeModal()
}) })
} }

View file

@ -464,6 +464,9 @@ select {
white-space: nowrap; white-space: nowrap;
user-select: text; user-select: text;
} }
/* the reroll button shares the row with the code boxes, so it has to be just
as tall as them (the shared .small button is shorter) */
.code-row .button { height: var(--button-height); }
.code-row #ls-sync-code { font-size: 1.25rem; font-weight: 700; letter-spacing: 0.3em; } .code-row #ls-sync-code { font-size: 1.25rem; font-weight: 700; letter-spacing: 0.3em; }
.check-group { display: flex; flex-direction: column; } .check-group { display: flex; flex-direction: column; }
.check-group .check-label { margin-top: 0.2rem; margin-bottom: 0.2rem; } .check-group .check-label { margin-top: 0.2rem; margin-bottom: 0.2rem; }

View file

@ -623,7 +623,7 @@ function writeStream(abs, stream) {
try { try {
if (st && st.isSymbolicLink()) removeAbs(abs) if (st && st.isSymbolicLink()) removeAbs(abs)
fs.renameSync(tmp, abs) fs.renameSync(tmp, abs)
resolve() resolve(out.bytesWritten || 0)
} catch (err) { } catch (err) {
removeAbs(tmp) removeAbs(tmp)
reject(err) reject(err)
@ -701,6 +701,34 @@ function createServerState() {
return { tokens: new Map(), verifying: new Set(), session: null } return { tokens: new Map(), verifying: new Set(), session: null }
} }
// Which counter a sync unit feeds on the receiving side (the unit key looks
// like "app:<id>", "wine:<name>" or "prefix:<name>").
const UNIT_KINDS = { app: 'apps', wine: 'wines', prefix: 'prefixes' }
// Progress of a sync THIS device receives. The pushing device drives the
// transfer, but the user here also needs to see what is being written to
// their library: the launcher forwards this to its window, which shows a
// "Receiving from X…" popup with a bar.
function emitReceive(hooks, session, stage, extra = {}) {
if (!hooks || !hooks.onReceive) return
try {
hooks.onReceive({
stage,
phase: stage,
from: session.name || '',
deviceId: session.deviceId || '',
unit: extra.unit || '',
label: extra.unit || '',
done: session.received || 0,
total: session.planned || 0,
bytes: session.bytes || 0,
totalBytes: session.plannedBytes || 0,
items: { ...(session.items || {}) },
deleted: extra.deleted || 0
})
} catch {}
}
function activeSession(state) { function activeSession(state) {
if (!state.session) return null if (!state.session) return null
if (Date.now() - state.session.lastAt > SESSION_IDLE_MS) { if (Date.now() - state.session.lastAt > SESSION_IDLE_MS) {
@ -738,11 +766,11 @@ function createHttpServer(state, hooks) {
if (route === '/plwl/verify' && req.method === 'POST') return handleVerify(state, req, res, hooks) if (route === '/plwl/verify' && req.method === 'POST') return handleVerify(state, req, res, hooks)
const session = authorize(state, url.searchParams.get('token')) const session = authorize(state, url.searchParams.get('token'))
if (!session) return sendJson(res, 401, { ok: false, error: 'unauthorized' }) if (!session) return sendJson(res, 401, { ok: false, error: 'unauthorized' })
if (route === '/plwl/unit/plan' && req.method === 'POST') return handlePlan(state, session, url, req, res) if (route === '/plwl/unit/plan' && req.method === 'POST') return handlePlan(state, session, url, req, res, hooks)
if (route === '/plwl/unit/file' && req.method === 'PUT') return handlePut(session, url, req, res, hooks) if (route === '/plwl/unit/file' && req.method === 'PUT') return handlePut(session, url, req, res, hooks)
if (route === '/plwl/unit/commit' && req.method === 'POST') return handleCommit(session, url, res) if (route === '/plwl/unit/commit' && req.method === 'POST') return handleCommit(session, url, res, hooks)
if (route === '/plwl/apply' && req.method === 'POST') return handleApply(session, req, res, hooks) if (route === '/plwl/apply' && req.method === 'POST') return handleApply(session, req, res, hooks)
if (route === '/plwl/end' && req.method === 'POST') return handleEnd(state, session, res) if (route === '/plwl/end' && req.method === 'POST') return handleEnd(state, session, res, hooks)
sendJson(res, 404, { ok: false, error: 'not-found' }) sendJson(res, 404, { ok: false, error: 'not-found' })
} catch (err) { } catch (err) {
sendJson(res, 500, { ok: false, error: String((err && err.message) || err) }) sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
@ -777,10 +805,19 @@ async function handleVerify(state, req, res, hooks) {
name: String(payload.name || ''), name: String(payload.name || ''),
expires: Date.now() + TOKEN_TTL_MS, expires: Date.now() + TOKEN_TTL_MS,
lastAt: Date.now(), lastAt: Date.now(),
units: new Map() units: new Map(),
// counters behind the "Receiving from X…" popup: files written, bytes
// written, and the same totals the sender planned (learned per unit)
counted: new Set(),
received: 0,
planned: 0,
bytes: 0,
plannedBytes: 0,
items: { apps: 0, wines: 0, prefixes: 0 }
} }
state.tokens.set(token, session) state.tokens.set(token, session)
state.session = session state.session = session
emitReceive(hooks, session, 'start')
sendJson(res, 200, { sendJson(res, 200, {
ok: true, ok: true,
token, token,
@ -793,8 +830,9 @@ async function handleVerify(state, req, res, hooks) {
} }
} }
async function handlePlan(state, session, url, req, res) { async function handlePlan(state, session, url, req, res, hooks) {
const key = url.searchParams.get('unit') || '' const key = url.searchParams.get('unit') || ''
const label = url.searchParams.get('label') || ''
let roots = [] let roots = []
try { try {
roots = JSON.parse(url.searchParams.get('roots') || '[]') roots = JSON.parse(url.searchParams.get('roots') || '[]')
@ -810,6 +848,7 @@ async function handlePlan(state, session, url, req, res) {
const paths = new Set() const paths = new Set()
const upload = [] const upload = []
const same = [] const same = []
let uploadBytes = 0
for (const entry of entries) { for (const entry of entries) {
if (!withinRoots(entry.path, owned)) continue if (!withinRoots(entry.path, owned)) continue
const abs = filesPath(entry.path) const abs = filesPath(entry.path)
@ -822,10 +861,26 @@ async function handlePlan(state, session, url, req, res) {
} }
if (entry.type === 'dir') ensureDir(abs) if (entry.type === 'dir') ensureDir(abs)
else if (entry.type === 'link') ensureLink(abs, entry.target) else if (entry.type === 'link') ensureLink(abs, entry.target)
else upload.push(entry.path) else {
upload.push(entry.path)
uploadBytes += entry.size || 0
}
} }
session.units.set(key, { roots: owned, paths }) session.units.set(key, { roots: owned, paths })
// what this unit adds to the transfer: the popup reports files and bytes
// against these as they arrive
session.planned += upload.length
session.plannedBytes += uploadBytes
const kind = UNIT_KINDS[String(key).split(':')[0]]
if (kind && !session.counted.has(key)) {
session.counted.add(key)
session.items[kind] = (session.items[kind] || 0) + 1
}
// the unit's human label (game / prefix / wine name) is announced while it
// is planned, so the later file events can name it in the popup
session.unitLabel = label
sendJson(res, 200, { ok: true, upload, same, entries: paths.size }) sendJson(res, 200, { ok: true, upload, same, entries: paths.size })
emitReceive(hooks, session, 'plan', { unit: label })
} }
async function handlePut(session, url, req, res, hooks) { async function handlePut(session, url, req, res, hooks) {
@ -838,15 +893,17 @@ async function handlePut(session, url, req, res, hooks) {
return sendJson(res, 400, { ok: false, error: 'bad-path' }) return sendJson(res, 400, { ok: false, error: 'bad-path' })
} }
try { try {
await writeStream(abs, req) const written = await writeStream(abs, req)
session.received = (session.received || 0) + 1 session.received = (session.received || 0) + 1
session.bytes = (session.bytes || 0) + (written || 0)
sendJson(res, 200, { ok: true }) sendJson(res, 200, { ok: true })
emitReceive(hooks, session, 'copy', { unit: session.unitLabel || '' })
} catch (err) { } catch (err) {
sendJson(res, 500, { ok: false, error: String((err && err.message) || err) }) sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
} }
} }
async function handleCommit(session, url, res) { async function handleCommit(session, url, res, hooks) {
const key = url.searchParams.get('unit') || '' const key = url.searchParams.get('unit') || ''
const unit = session.units.get(key) const unit = session.units.get(key)
if (!unit) return sendJson(res, 400, { ok: false, error: 'no-unit' }) if (!unit) return sendJson(res, 400, { ok: false, error: 'no-unit' })
@ -856,7 +913,9 @@ async function handleCommit(session, url, res) {
if (abs) removed += await pruneTree(abs, root, unit.paths) if (abs) removed += await pruneTree(abs, root, unit.paths)
} }
session.units.delete(key) session.units.delete(key)
session.deleted = (session.deleted || 0) + removed
sendJson(res, 200, { ok: true, deleted: removed }) sendJson(res, 200, { ok: true, deleted: removed })
emitReceive(hooks, session, 'commit', { unit: session.unitLabel || '', deleted: removed })
} }
async function handleApply(session, req, res, hooks) { async function handleApply(session, req, res, hooks) {
@ -867,8 +926,17 @@ async function handleApply(session, req, res, hooks) {
const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0 const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0
const prefixesDeleted = cats.has('prefixes') ? applyPrefixes(payload) : 0 const prefixesDeleted = cats.has('prefixes') ? applyPrefixes(payload) : 0
const removed = appsDeleted + winesDeleted + prefixesDeleted const removed = appsDeleted + winesDeleted + prefixesDeleted
emitReceive(hooks, session, 'registry', { deleted: removed })
if (hooks.onApplied) { if (hooks.onApplied) {
hooks.onApplied({ from: session.name, deviceId: session.deviceId, categories: [...cats], files: session.received || 0, deleted: removed }) hooks.onApplied({
from: session.name,
deviceId: session.deviceId,
categories: [...cats],
files: session.received || 0,
bytes: session.bytes || 0,
items: { ...(session.items || {}) },
deleted: removed
})
} }
sendJson(res, 200, { sendJson(res, 200, {
ok: true, ok: true,
@ -879,10 +947,11 @@ async function handleApply(session, req, res, hooks) {
}) })
} }
function handleEnd(state, session, res) { function handleEnd(state, session, res, hooks) {
state.tokens.delete(session.token) state.tokens.delete(session.token)
if (state.session && state.session.token === session.token) state.session = null if (state.session && state.session.token === session.token) state.session = null
sendJson(res, 200, { ok: true }) sendJson(res, 200, { ok: true })
emitReceive(hooks, session, 'done', { deleted: session.deleted || 0 })
} }
// --- discovery -------------------------------------------------------------- // --- discovery --------------------------------------------------------------

View file

@ -36,7 +36,7 @@ function createPusher(api) {
// of thousands of entries from being one giant string in memory either side. // of thousands of entries from being one giant string in memory either side.
function planUnit(device, token, unit, entries, force) { function planUnit(device, token, unit, entries, force) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const route = `/plwl/unit/plan?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}&roots=${encodeURIComponent(JSON.stringify(unit.roots))}${force ? '&force=1' : ''}` const route = `/plwl/unit/plan?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}&label=${encodeURIComponent(unit.label || '')}&roots=${encodeURIComponent(JSON.stringify(unit.roots))}${force ? '&force=1' : ''}`
const req = http.request( const req = http.request(
{ {
host: device.address, host: device.address,
@ -185,6 +185,10 @@ function createPusher(api) {
skipped: 0, skipped: 0,
deleted: 0, deleted: 0,
bytes: 0, bytes: 0,
// how many apps / wine builds / prefixes this sync walked over: the
// progress popup reports "3 games, 1 prefix" instead of file counts,
// which mean nothing to the user
items: { apps: 0, wines: 0, prefixes: 0 },
apps: { copied: 0, skipped: 0, deleted: 0 }, apps: { copied: 0, skipped: 0, deleted: 0 },
wines: { copied: 0, skipped: 0, deleted: 0 }, wines: { copied: 0, skipped: 0, deleted: 0 },
prefixes: { copied: 0, skipped: 0, deleted: 0 } prefixes: { copied: 0, skipped: 0, deleted: 0 }
@ -233,19 +237,6 @@ function createPusher(api) {
events.push(data) events.push(data)
if (onProgress) onProgress(data) if (onProgress) onProgress(data)
} }
const emit = (phase, unit, done, total) =>
progress({
stage: phase,
phase,
kind: (unit && unit.kind) || '',
id: (unit && unit.id) || '',
key: (unit && unit.key) || '',
unit: (unit && unit.label) || '',
label: (unit && unit.label) || '',
name: (unit && unit.label) || '',
done,
total
})
let verified let verified
try { try {
@ -272,6 +263,28 @@ function createPusher(api) {
const units = buildUnits(cats) const units = buildUnits(cats)
const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps') const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps')
const summary = newSummary(units.length) const summary = newSummary(units.length)
// bytes the units planned so far will put on the wire: the popup can then
// show "1.2 GB / 3.4 GB" instead of only what has already arrived
let plannedBytes = 0
let itemsSeen = 0
const emit = (phase, unit, done, total) =>
progress({
stage: phase,
phase,
kind: (unit && unit.kind) || '',
id: (unit && unit.id) || '',
key: (unit && unit.key) || '',
unit: (unit && unit.label) || '',
label: (unit && unit.label) || '',
name: (unit && unit.label) || '',
done,
total,
bytes: summary.bytes,
totalBytes: plannedBytes,
units: summary.units,
unitsDone: itemsSeen,
items: { ...summary.items }
})
const end = async () => { const end = async () => {
try { try {
await httpJson(device, 'POST', `/plwl/end?token=${encodeURIComponent(token)}`, { json: {} }) await httpJson(device, 'POST', `/plwl/end?token=${encodeURIComponent(token)}`, { json: {} })
@ -281,6 +294,9 @@ function createPusher(api) {
for (const unit of units) { for (const unit of units) {
const roots = unit.roots.filter(ownedRoot) const roots = unit.roots.filter(ownedRoot)
if (!roots.length) continue if (!roots.length) continue
const bucket = summary[bucketOf(unit.kind)]
summary.items[bucketOf(unit.kind)] += 1
itemsSeen += 1
emit('scan', unit, 0, 0) emit('scan', unit, 0, 0)
const entries = await buildManifest(roots, !!force, (done, total) => emit('hash', unit, done, total)) const entries = await buildManifest(roots, !!force, (done, total) => emit('hash', unit, done, total))
emit('plan', unit, 0, 0) emit('plan', unit, 0, 0)
@ -288,11 +304,13 @@ function createPusher(api) {
if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`) if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`)
const upload = new Set(plan.data.upload || []) const upload = new Set(plan.data.upload || [])
const same = new Set(plan.data.same || []) const same = new Set(plan.data.same || [])
const bucket = summary[bucketOf(unit.kind)]
// per category every manifest entry counts: created/overwritten vs // per category every manifest entry counts: created/overwritten vs
// 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
for (const entry of entries) {
if (entry.type === 'file' && upload.has(entry.path)) plannedBytes += entry.size || 0
}
let done = 0 let done = 0
for (const entry of entries) { for (const entry of entries) {
if (entry.type !== 'file') continue if (entry.type !== 'file') continue

View file

@ -93,10 +93,16 @@ function trayIconPath() {
function createTray() { function createTray() {
const { Tray, Menu } = require('electron') const { Tray, Menu } = require('electron')
const icon = trayIconPath()
try { try {
tray = new Tray(trayIconPath()) tray = new Tray(icon)
} catch { } catch (err) {
return // no icon available; window still works normally // never fatal (the window keeps working), but say why the icon is missing
// instead of failing silently - a missing tray looks like a hung app when
// the launcher runs in the background
console.error(`[tray] could not create the tray icon (${icon || 'no icon file found'}): ${(err && err.message) || err}`)
tray = null
return
} }
tray.setToolTip('PLWL') tray.setToolTip('PLWL')
rebuildTrayMenu() rebuildTrayMenu()
@ -233,16 +239,25 @@ app.whenReady().then(async () => {
const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu }) const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu })
gamesService = games gamesService = games
// LAN sync runs whenever the launcher runs - that is what makes this // the tray comes first: nothing (least of all the network) may keep it from
// device show up on the other machines' client lists // coming up - a launcher without a tray icon looks simply broken
lansync.start({
onDevices: devices => sendToWindow(win, 'lansync:devices', devices),
onApplied: info => sendToWindow(win, 'lansync:applied', info),
isBusy: () => !!(gamesService && gamesService.listRunning().length)
})
createTray() createTray()
// LAN sync runs whenever the launcher runs - that is what makes this
// device show up on the other machines' client lists
const lanHooks = {
onDevices: devices => sendToWindow(win, 'lansync:devices', devices),
onApplied: info => sendToWindow(win, 'lansync:applied', info),
onReceive: info => sendToWindow(win, 'lansync:receiving', info),
isBusy: () => !!(gamesService && gamesService.listRunning().length)
}
try {
Promise.resolve(lansync.start(lanHooks)).catch(err => {
console.error(`[lansync] start failed: ${(err && err.message) || err}`)
})
} catch (err) {
console.error(`[lansync] start failed: ${(err && err.message) || err}`)
}
if (headless) { if (headless) {
// shortcut boot: run the game without opening the launcher window // shortcut boot: run the game without opening the launcher window
launchGame(autoLaunchId) launchGame(autoLaunchId)
@ -259,5 +274,7 @@ app.whenReady().then(async () => {
app.on('before-quit', () => { app.on('before-quit', () => {
quitting = true quitting = true
lansync.stop() try {
lansync.stop()
} catch {}
}) })

View file

@ -115,21 +115,47 @@ function resolvePortablePath(p) {
} }
// The launcher's own icon (renderer/plwl.png), used by the tray, the app // The launcher's own icon (renderer/plwl.png), used by the tray, the app
// icon and as the .desktop shortcut fallback. Cached: it never moves at // icon and as the .desktop shortcut fallback.
// runtime. //
// The module-relative copy comes FIRST: it is the only candidate that exists
// in every layout, because a packaged build keeps renderer/ inside
// app.asar and Electron's patched fs reads straight out of the archive (a
// release only ships olcxja.plwl.png next to the portable root, so looking at
// APP_ROOT alone made the tray icon vanish in installed builds). Cached: it
// never moves at runtime.
function launcherIconCandidates() {
const bundled = path.join(__dirname, '..', 'renderer')
return [
path.join(bundled, 'plwl.png'),
path.join(bundled, 'plwl.svg'),
path.join(APP_ROOT, 'renderer', 'plwl.png'),
path.join(APP_ROOT, 'renderer', 'plwl.svg'),
path.join(APP_ROOT, 'olcxja.plwl.png')
]
}
function firstExisting(candidates) {
for (const candidate of candidates) {
try {
if (fs.existsSync(candidate)) return candidate
} catch {}
}
return null
}
let launcherIcon = null let launcherIcon = null
function launcherIconPath() { function launcherIconPath() {
if (launcherIcon === null) { if (launcherIcon === null) launcherIcon = firstExisting(launcherIconCandidates()) || ''
const p = path.join(APP_ROOT, 'renderer', 'plwl.png')
try {
launcherIcon = fs.existsSync(p) ? p : ''
} catch {
launcherIcon = ''
}
}
return launcherIcon || null return launcherIcon || null
} }
// The same icon, but only as a real file on disk: a .desktop entry's Icon= is
// resolved by the desktop environment, which cannot look inside app.asar.
function launcherIconDiskPath() {
const onDisk = launcherIconCandidates().filter(candidate => !candidate.includes(`.asar${path.sep}`))
return firstExisting(onDisk)
}
function readJson(file, fallback) { function readJson(file, fallback) {
try { try {
return JSON.parse(fs.readFileSync(file, 'utf8')) return JSON.parse(fs.readFileSync(file, 'utf8'))
@ -145,4 +171,4 @@ function writeJson(file, data) {
fs.renameSync(tmp, file) fs.renameSync(tmp, file)
} }
module.exports = { RESOURCES, APP_ROOT, BUILD, FILES, DATA, DIRS, ensureDirs, syncBundledTranslations, launcherIconPath, readJson, writeJson, toPortablePath, resolvePortablePath } module.exports = { RESOURCES, APP_ROOT, BUILD, FILES, DATA, DIRS, ensureDirs, syncBundledTranslations, launcherIconPath, launcherIconDiskPath, readJson, writeJson, toPortablePath, resolvePortablePath }

View file

@ -95,5 +95,6 @@ contextBridge.exposeInMainWorld('plwl', {
onLanSyncProgress: cb => { onLanSyncProgress: cb => {
lanSyncProgressCb = cb lanSyncProgressCb = 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))
}) })

View file

@ -7,7 +7,7 @@
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
const { DIRS, resolvePortablePath } = require('./paths') const { DIRS, resolvePortablePath, launcherIconDiskPath } = require('./paths')
const DATA_DIR = process.env.PLWL_BUILD_DIR ? path.join(process.env.PLWL_BUILD_DIR, 'files', 'data') : DIRS.DATA const DATA_DIR = process.env.PLWL_BUILD_DIR ? path.join(process.env.PLWL_BUILD_DIR, 'files', 'data') : DIRS.DATA
@ -62,8 +62,9 @@ function buildDesktopEntry(game) {
if (found) icon = path.join(iconDir, found) if (found) icon = path.join(iconDir, found)
} catch {} } catch {}
if (!icon) { if (!icon) {
// fall back to the launcher icon so the shortcut is never naked // fall back to the launcher icon so the shortcut is never naked (the
icon = require('./paths').launcherIconPath() || '' // on-disk copy: the desktop environment cannot read inside app.asar)
icon = launcherIconDiskPath() || ''
} }
const lines = [ const lines = [
'[Desktop Entry]', '[Desktop Entry]',