diff --git a/README.md b/README.md index f0e155b..5b4b36c 100644 --- a/README.md +++ b/README.md @@ -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. +**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. ## Wine & Proton support diff --git a/development.md b/development.md index 4f58e2f..7859e51 100644 --- a/development.md +++ b/development.md @@ -23,7 +23,8 @@ src/ main process (Node/Electron backend) paths.js portable layout, FILES-relative path helpers, translation sync i18n.js translation loading + {0} placeholder filling 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) appicon.js window/taskbar icon 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. - LAN sync has two sides: the receiver lives in lansync.js, the sender in 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 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 # syntax node test/import.test.js # ...and the other test files 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 ``` diff --git a/files/translations/en-us.json b/files/translations/en-us.json index de5a27f..6223fd3 100644 --- a/files/translations/en-us.json +++ b/files/translations/en-us.json @@ -173,14 +173,24 @@ "lansync.phase.plan": "Comparing {0}", "lansync.phase.copy": "Sending {0}", "lansync.phase.registry": "Updating the library", + "lansync.phase.commit": "Removing extra files", + "lansync.phase.done": "Finishing up", "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.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.offline": "That device is no longer on the network.", "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.codeRerolled": "New sync code generated", "lansync.unknownError": "unknown error", diff --git a/files/translations/pl.json b/files/translations/pl.json index 21abef7..e3c2235 100644 --- a/files/translations/pl.json +++ b/files/translations/pl.json @@ -173,14 +173,24 @@ "lansync.phase.plan": "Porównywanie {0}", "lansync.phase.copy": "Wysyłanie {0}", "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.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.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.offline": "To urządzenie zniknęło z sieci.", "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.codeRerolled": "Wygenerowano nowy kod synchronizacji", "lansync.unknownError": "nieznany błąd", diff --git a/renderer/app.js b/renderer/app.js index ec34680..7091330 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -15,6 +15,10 @@ const state = { games: [], currentGame: 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' } @@ -114,8 +118,13 @@ function toast(message, kind) { // cancels any pending teardown and clears the closing state. let modalTeardownTimer = 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) { clearTimeout(modalTeardownTimer) modalTeardownTimer = null @@ -189,6 +198,7 @@ function closeModal() { modal.classList.remove('closing', 'swapping', 'resizing') modal.style.height = '' modal.innerHTML = '' + modalLocked = false }, 160) } @@ -1137,6 +1147,41 @@ const LAN_CATEGORIES = [ { 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() { const info = await window.plwl.lansyncGet() state.lansync = info @@ -1243,17 +1288,23 @@ async function openLanSyncModal(device, errorKey) { } async function runLanSync(device, code, categories, force) { + // locked: a running sync is not cancellable by a stray click on the backdrop const modal = showModal(`

- `) +

+

+ `, { locked: true }) modal.querySelector('#ls-run-title').textContent = t('lansync.progress', device.name) const bar = modal.querySelector('progress') const phase = modal.querySelector('#ls-run-phase') const count = modal.querySelector('#ls-run-count') + const stats = modal.querySelector('#ls-run-stats') + const bytes = modal.querySelector('#ls-run-bytes') let active = true + state.lanPushing = true window.plwl.onLanSyncProgress(data => { if (!active) return const key = 'lansync.phase.' + data.phase @@ -1266,13 +1317,22 @@ async function runLanSync(device, code, categories, force) { bar.removeAttribute('value') 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 }) - active = false + let res = null + 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() if (res && res.ok) { 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() return } @@ -1287,6 +1347,107 @@ async function runLanSync(device, code, categories, force) { 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(` +

+
+

+

+

+

+ `, { 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 // list so the views show the merged library instead of stale state. async function reloadLocalData() { @@ -1308,8 +1469,9 @@ function wireLanSync() { if (state.lansync) state.lansync.devices = devices if (state.currentView === 'lansync') renderLanDevices(devices) }) + window.plwl.onLanSyncReceiving(info => onLanReceiving(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() }) qs('#ls-reroll-id').addEventListener('click', async () => { @@ -1422,10 +1584,11 @@ function wireEvents() { toast(t('config.playtimeCleared'), 'success') }) 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() }) document.addEventListener('keydown', e => { - if (e.key === 'Escape') closeModal() + if (e.key === 'Escape' && !modalLocked) closeModal() }) } diff --git a/renderer/style.css b/renderer/style.css index bfe739a..6249cce 100644 --- a/renderer/style.css +++ b/renderer/style.css @@ -464,6 +464,9 @@ select { white-space: nowrap; 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; } .check-group { display: flex; flex-direction: column; } .check-group .check-label { margin-top: 0.2rem; margin-bottom: 0.2rem; } diff --git a/src/lansync.js b/src/lansync.js index 21cbd12..64a5073 100644 --- a/src/lansync.js +++ b/src/lansync.js @@ -623,7 +623,7 @@ function writeStream(abs, stream) { try { if (st && st.isSymbolicLink()) removeAbs(abs) fs.renameSync(tmp, abs) - resolve() + resolve(out.bytesWritten || 0) } catch (err) { removeAbs(tmp) reject(err) @@ -701,6 +701,34 @@ function createServerState() { 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:", "wine:" or "prefix:"). +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) { if (!state.session) return null 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) const session = authorize(state, url.searchParams.get('token')) 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/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/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' }) } catch (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 || ''), expires: Date.now() + TOKEN_TTL_MS, 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.session = session + emitReceive(hooks, session, 'start') sendJson(res, 200, { ok: true, 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 label = url.searchParams.get('label') || '' let roots = [] try { roots = JSON.parse(url.searchParams.get('roots') || '[]') @@ -810,6 +848,7 @@ async function handlePlan(state, session, url, req, res) { const paths = new Set() const upload = [] const same = [] + let uploadBytes = 0 for (const entry of entries) { if (!withinRoots(entry.path, owned)) continue const abs = filesPath(entry.path) @@ -822,10 +861,26 @@ async function handlePlan(state, session, url, req, res) { } if (entry.type === 'dir') ensureDir(abs) 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 }) + // 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 }) + emitReceive(hooks, session, 'plan', { unit: label }) } 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' }) } try { - await writeStream(abs, req) + const written = await writeStream(abs, req) session.received = (session.received || 0) + 1 + session.bytes = (session.bytes || 0) + (written || 0) sendJson(res, 200, { ok: true }) + emitReceive(hooks, session, 'copy', { unit: session.unitLabel || '' }) } catch (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 unit = session.units.get(key) 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) } session.units.delete(key) + session.deleted = (session.deleted || 0) + removed sendJson(res, 200, { ok: true, deleted: removed }) + emitReceive(hooks, session, 'commit', { unit: session.unitLabel || '', deleted: removed }) } 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 prefixesDeleted = cats.has('prefixes') ? applyPrefixes(payload) : 0 const removed = appsDeleted + winesDeleted + prefixesDeleted + emitReceive(hooks, session, 'registry', { deleted: removed }) 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, { 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) if (state.session && state.session.token === session.token) state.session = null sendJson(res, 200, { ok: true }) + emitReceive(hooks, session, 'done', { deleted: session.deleted || 0 }) } // --- discovery -------------------------------------------------------------- diff --git a/src/lansyncpush.js b/src/lansyncpush.js index ac3a902..8fff75f 100644 --- a/src/lansyncpush.js +++ b/src/lansyncpush.js @@ -36,7 +36,7 @@ function createPusher(api) { // of thousands of entries from being one giant string in memory either side. function planUnit(device, token, unit, entries, force) { 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( { host: device.address, @@ -185,6 +185,10 @@ function createPusher(api) { skipped: 0, deleted: 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 }, wines: { copied: 0, skipped: 0, deleted: 0 }, prefixes: { copied: 0, skipped: 0, deleted: 0 } @@ -233,19 +237,6 @@ function createPusher(api) { events.push(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 try { @@ -272,6 +263,28 @@ function createPusher(api) { const units = buildUnits(cats) const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps') 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 () => { try { await httpJson(device, 'POST', `/plwl/end?token=${encodeURIComponent(token)}`, { json: {} }) @@ -281,6 +294,9 @@ function createPusher(api) { for (const unit of units) { const roots = unit.roots.filter(ownedRoot) if (!roots.length) continue + const bucket = summary[bucketOf(unit.kind)] + summary.items[bucketOf(unit.kind)] += 1 + itemsSeen += 1 emit('scan', unit, 0, 0) const entries = await buildManifest(roots, !!force, (done, total) => emit('hash', unit, done, total)) 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}`) const upload = new Set(plan.data.upload || []) const same = new Set(plan.data.same || []) - const bucket = summary[bucketOf(unit.kind)] // per category every manifest entry counts: created/overwritten vs // already identical (the plan reports both) bucket.copied += entries.length - 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 for (const entry of entries) { if (entry.type !== 'file') continue diff --git a/src/main.js b/src/main.js index a14560c..fb38c9b 100644 --- a/src/main.js +++ b/src/main.js @@ -93,10 +93,16 @@ function trayIconPath() { function createTray() { const { Tray, Menu } = require('electron') + const icon = trayIconPath() try { - tray = new Tray(trayIconPath()) - } catch { - return // no icon available; window still works normally + tray = new Tray(icon) + } catch (err) { + // 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') rebuildTrayMenu() @@ -233,16 +239,25 @@ app.whenReady().then(async () => { const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu }) gamesService = games - // LAN sync runs whenever the launcher runs - that is what makes this - // device show up on the other machines' client lists - lansync.start({ - onDevices: devices => sendToWindow(win, 'lansync:devices', devices), - onApplied: info => sendToWindow(win, 'lansync:applied', info), - isBusy: () => !!(gamesService && gamesService.listRunning().length) - }) - + // the tray comes first: nothing (least of all the network) may keep it from + // coming up - a launcher without a tray icon looks simply broken 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) { // shortcut boot: run the game without opening the launcher window launchGame(autoLaunchId) @@ -259,5 +274,7 @@ app.whenReady().then(async () => { app.on('before-quit', () => { quitting = true - lansync.stop() + try { + lansync.stop() + } catch {} }) diff --git a/src/paths.js b/src/paths.js index 24656ae..598cf6e 100644 --- a/src/paths.js +++ b/src/paths.js @@ -115,21 +115,47 @@ function resolvePortablePath(p) { } // 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 -// runtime. +// icon and as the .desktop shortcut fallback. +// +// 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 function launcherIconPath() { - if (launcherIcon === null) { - const p = path.join(APP_ROOT, 'renderer', 'plwl.png') - try { - launcherIcon = fs.existsSync(p) ? p : '' - } catch { - launcherIcon = '' - } - } + if (launcherIcon === null) launcherIcon = firstExisting(launcherIconCandidates()) || '' 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) { try { return JSON.parse(fs.readFileSync(file, 'utf8')) @@ -145,4 +171,4 @@ function writeJson(file, data) { 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 } diff --git a/src/preload.js b/src/preload.js index 2b2d3e4..b7b3190 100644 --- a/src/preload.js +++ b/src/preload.js @@ -95,5 +95,6 @@ contextBridge.exposeInMainWorld('plwl', { onLanSyncProgress: 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)) }) diff --git a/src/shortcuts.js b/src/shortcuts.js index aa69016..831e556 100644 --- a/src/shortcuts.js +++ b/src/shortcuts.js @@ -7,7 +7,7 @@ const fs = require('fs') 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 @@ -62,8 +62,9 @@ function buildDesktopEntry(game) { if (found) icon = path.join(iconDir, found) } catch {} if (!icon) { - // fall back to the launcher icon so the shortcut is never naked - icon = require('./paths').launcherIconPath() || '' + // fall back to the launcher icon so the shortcut is never naked (the + // on-disk copy: the desktop environment cannot read inside app.asar) + icon = launcherIconDiskPath() || '' } const lines = [ '[Desktop Entry]',