cleanup & sync summary

This commit is contained in:
olcxja 2026-10-19 09:26:02 +02:00
commit be4f67fb60
17 changed files with 637 additions and 207 deletions

View file

@ -818,6 +818,17 @@ async function startPrefixImportFlow() {
}
}
// One place for the technical error tokens the import handlers answer with.
function importErrorText(raw) {
if (raw === 'no-exe-found') return t('import.noExe')
if (raw === 'no-linux-exe') return t('import.noLinuxExe')
if (raw === 'prefix-root') return t('import.prefixRoot')
if (raw === 'no-drive-c') return t('import.prefix.noDriveC')
if (raw === 'outside-drive-c') return t('import.prefix.outside')
if (raw === 'copy-incomplete') return t('import.copyIncomplete')
return raw || ''
}
async function doImport(srcDir, mode, kind) {
const name = srcDir.split('/').filter(Boolean).pop()
const modal = showModal(`
@ -842,13 +853,7 @@ async function doImport(srcDir, mode, kind) {
const game = await window.plwl.gamesImport(srcDir, mode, kind)
closeModal()
if (!game || game.ok === false) {
const raw = (game && game.error) || ''
const errText =
raw === 'no-exe-found' ? t('import.noExe')
: raw === 'no-linux-exe' ? t('import.noLinuxExe')
: raw === 'prefix-root' ? t('import.prefixRoot')
: (raw || 'unknown error')
toast(t('import.failed', errText), 'error')
toast(t('import.failed', importErrorText((game && game.error) || '')), 'error')
return
}
state.games = await window.plwl.gamesList()
@ -1005,13 +1010,7 @@ async function doPrefixImport(prefix, srcDir) {
const game = await window.plwl.gamesImportFromPrefix(prefix, srcDir)
closeModal()
if (!game || game.ok === false) {
const raw = (game && game.error) || ''
const errText =
raw === 'no-exe-found' ? t('import.noExe')
: raw === 'no-drive-c' ? t('import.prefix.noDriveC')
: raw === 'outside-drive-c' ? t('import.prefix.outside')
: (raw || 'unknown error')
toast(t('import.failed', errText), 'error')
toast(t('import.failed', importErrorText((game && game.error) || '')), 'error')
return
}
state.games = await window.plwl.gamesList()
@ -1633,6 +1632,52 @@ async function runWithProgress({ title, action }) {
}
}
// End-of-sync popup: what moved, what was already there, and how much space
// the synced trees take. A toast is too easy to miss for a transfer that can
// run for minutes.
function showSyncSummary({ from, received, categories, summary }) {
const items = (summary && summary.items) || {}
const cats = new Set(categories || [])
const apps = Math.max(0, Number(items.apps) || 0)
const wines = Math.max(0, Number(items.wines) || 0)
const prefixes = Math.max(0, Number(items.prefixes) || 0)
const files = Math.max(0, Number(summary && summary.uploaded) || 0)
const skipped = Math.max(0, Number(summary && summary.skipped) || 0)
const deleted = Math.max(0, Number(summary && summary.deleted) || 0)
const bytes = Math.max(0, Number(summary && summary.bytes) || 0)
const sizeBytes = Math.max(0, Number(summary && summary.sizeBytes) || 0)
const rows = []
const row = (label, value) => rows.push(
`<div class="sync-summary-row"><span>${label}</span><strong>${value}</strong></div>`
)
// a count alone also shows for a peer that did not send its categories
if (apps || cats.has('apps-portable') || cats.has('apps-prefix')) row(t('lansync.summaryApps'), apps)
if (wines || cats.has('wines')) row(t('lansync.wines'), wines)
if (prefixes || cats.has('prefixes')) row(t('lansync.prefixes'), prefixes)
if (cats.has('settings')) row(t('lansync.summarySettings'), t('lansync.summaryYes'))
if (files || skipped || deleted) {
row(t(received ? 'lansync.summaryReceived' : 'lansync.summarySent'), files)
if (skipped) row(t('lansync.summarySkipped'), skipped)
if (deleted) row(t('lansync.summaryDeleted'), deleted)
}
if (bytes) row(t('lansync.summaryTransferred'), formatBytes(bytes))
if (sizeBytes) row(t('lansync.summarySize'), formatBytes(sizeBytes))
const modal = showModal(`
<h2>${t('lansync.summaryTitle')}</h2>
<p class="subtitle" id="ls-summary-from"></p>
<div class="sync-summary">${rows.join('')}</div>
<div class="modal-actions">
<button class="button primary" id="ls-summary-ok">${t('dialog.ok')}</button>
</div>
`)
// the device name comes off the network: never interpolate it into HTML
modal.querySelector('#ls-summary-from').textContent = t(
received ? 'lansync.summaryReceivedFrom' : 'lansync.summarySentTo',
from || ''
)
modal.querySelector('#ls-summary-ok').onclick = closeModal
}
// "340 MB / 1.2 GB" once the total is known, otherwise just the moved bytes.
function lanBytesLine(bytes, totalBytes) {
const sent = formatBytes(bytes)
@ -1785,15 +1830,16 @@ async function runLanSync(device, code, categories, force) {
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, formatBytes(summary.bytes || 0)), 'success')
// swap the locked progress popup into the result, not a toast
showSyncSummary({ from: device.name, received: false, categories, summary: res.summary || {} })
await renderLanSync()
return
}
closeModal()
const err = (res && res.error) || ''
if (err === 'bad-code') {
// the server answers a wrong code as 'auth-failed'; both mean "ask again"
if (err === 'bad-code' || err === 'auth-failed') {
await window.plwl.lansyncForget(device.id)
toast(t('lansync.badCode'), 'error')
openLanSyncModal({ ...device, code: '' }, 'lansync.badCode')
@ -1801,7 +1847,7 @@ async function runLanSync(device, code, categories, force) {
}
const message =
err === 'busy' ? t('lansync.busy')
: err === 'offline' ? t('lansync.offline')
: err === 'offline' || err === 'receiver-lost' ? t('lansync.offline')
: err === 'rejected' ? t('lansync.rejected')
: err === 'incoming-timeout' ? t('lansync.incomingTimedOut')
: (err || t('lansync.unknownError'))
@ -1877,6 +1923,9 @@ function onLanIncoming(info) {
}
let receiving = false
// The last apply payload, for the end-of-sync summary (it carries files,
// bytes, skipped files and the total size of the synced trees).
let lastApplied = null
// Only the sender's 'done' ends this; a watchdog-closed popup may reopen.
let receiveEnded = false
let receiveWatchdog = null
@ -1935,12 +1984,25 @@ function armReceiveWatchdog() {
function onLanReceiving(info) {
if (!info || !info.stage) return
if (state.lanPushing) return // our own push owns the (single) popup
if (info.stage === 'start') receiveEnded = false
if (info.stage === 'done') receiveEnded = true
if (info.stage === 'start') {
receiveEnded = false
lastApplied = null
}
if (info.stage === 'done') {
receiveEnded = true
// the popup may have been closed by the watchdog: still show the result
// if nothing else owns the modal slot
if (!receiving) {
if (lastApplied && qs('#modal-overlay').style.display === 'none') showReceiveSummary(info)
return
}
}
if (!receiving) {
// reopen a watchdog-closed popup unless the sync already ended
if (receiveEnded) return
if (qs('#modal-overlay').style.display !== 'none') return
// a completion summary underneath gives way to the next sync's progress
const summaryOpen = !!qs('#modal').querySelector('#ls-summary-ok')
if (!summaryOpen && qs('#modal-overlay').style.display !== 'none') return
receiving = true
const modal = showModal(`
<h2 id="ls-recv-title"></h2>
@ -1979,15 +2041,47 @@ function renderReceiving(info) {
parts.stats.textContent = lanStatsLine(info.items, info.itemsDone, info.totals)
parts.bytes.textContent = lanBytesLine(info.bytes, info.totalBytes)
if (info.stage === 'done') {
// let the totals be read, then close (the library reloads itself)
// hold the finished bar for a moment, then swap it for the summary
if (receiveCloseTimer) clearTimeout(receiveCloseTimer)
receiveCloseTimer = setTimeout(() => {
receiveCloseTimer = null
endReceiving('')
}, 4000)
showReceiveSummary(info)
}, 1200)
}
}
// Swap the finished progress popup for the result; the library has already
// been reloaded by the apply event.
function showReceiveSummary(info) {
if (receiveWatchdog) {
clearTimeout(receiveWatchdog)
receiveWatchdog = null
}
if (receiveStall) {
clearTimeout(receiveStall)
receiveStall = null
}
if (receiveCloseTimer) {
clearTimeout(receiveCloseTimer)
receiveCloseTimer = null
}
receiving = false
const applied = lastApplied || {}
showSyncSummary({
from: applied.from || info.from || '',
received: true,
categories: applied.categories || [],
summary: {
uploaded: applied.files ?? info.done,
skipped: applied.skipped || 0,
deleted: applied.deleted ?? info.deleted,
bytes: applied.bytes ?? info.bytes,
sizeBytes: applied.sizeBytes ?? info.sizeBytes,
items: applied.items || info.items || {}
}
})
}
// A received sync changed local files: reload every list (settings included).
async function reloadLocalData() {
const [wines, prefixes, games, settings] = await Promise.all([
@ -2014,7 +2108,8 @@ function wireLanSync() {
window.plwl.onLanSyncReceiving(info => onLanReceiving(info))
window.plwl.onLanSyncIncoming(info => onLanIncoming(info))
window.plwl.onLanSyncApplied(async info => {
toast(t('lansync.applied', info.from || '', info.files || 0, formatBytes(info.bytes || 0)), 'success')
// the summary popup replaces the toast; reload behind it
lastApplied = info
await reloadLocalData()
})
qs('#ls-reroll-id').addEventListener('click', async () => {
@ -2079,7 +2174,7 @@ function wireEvents() {
toast(t('wines.imported', res.path), 'success')
state.wines = await window.plwl.winesList()
} else {
toast(t('toast.importFailed') + ': ' + (res.error || ''), 'error')
toast(t('import.failed', importErrorText((res && res.error) || '')), 'error')
}
renderWines()
})

View file

@ -195,6 +195,20 @@ content {
user has to notice, so it stands out from the subtitles around it. */
.sync-warning { color: var(--big-red); margin-top: 0.6rem; }
/* End-of-sync summary: label left, count/size right, one row per fact. */
.sync-summary { display: flex; flex-direction: column; }
.sync-summary-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
padding: 0.45rem 0;
border-bottom: var(--border-width) solid var(--light-border-color);
}
.sync-summary-row:last-child { border-bottom: none; padding-bottom: 0; }
.sync-summary-row span { color: var(--darker-text-color); }
.sync-summary-row strong { font-weight: 700; font-variant-numeric: tabular-nums; }
.button {
height: var(--button-height);
padding: 0 1rem;