more accurate sync progress report
This commit is contained in:
parent
8e3512b5cd
commit
c0f6e802ed
12 changed files with 390 additions and 63 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
177
renderer/app.js
177
renderer/app.js
|
|
@ -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()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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 --------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
41
src/main.js
41
src/main.js
|
|
@ -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 {}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
48
src/paths.js
48
src/paths.js
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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]',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue