cleanup comments, update translations, bump to 0.4.1

This commit is contained in:
olcxja 2026-10-05 15:42:44 +02:00
commit 7d8ab87eee
28 changed files with 412 additions and 1114 deletions

View file

@ -15,9 +15,7 @@ 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
// true while this device pushes a sync: it owns the single popup
lanPushing: false,
currentView: 'library'
}
@ -78,8 +76,7 @@ function setButtonIcons() {
}
}
// Smoothly animate elements of `container` between their previous and new
// positions after `mutate()` changes the layout (FLIP technique).
// FLIP: animate elements between their previous and new layout positions.
function flipShift(container, mutate) {
const entries = [...container.children].map(el => ({ el, top: el.getBoundingClientRect().top }))
mutate()
@ -111,20 +108,13 @@ function toast(message, kind) {
})
}
// The teardown of a closing modal is delayed (for the fade-out) - an
// eagerly armed timer must never kill a modal opened in the meantime (e.g.
// import mode -> prefix picker happens within milliseconds), so showModal
// cancels any pending teardown and clears the closing state.
// A delayed teardown must never kill a modal opened in the meantime.
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.
// A locked modal ignores backdrop/Escape; only its own buttons close it.
let modalLocked = false
// Resolver of the current promise-based modal (confirm/prompt). Escape, the
// backdrop or another modal opening must settle it, or the awaiting caller
// would never continue.
// Settles the promise-based modal (confirm/prompt) so callers never hang.
let modalSettle = null
function settleModal(value) {
@ -143,9 +133,7 @@ function showModal(html, opts) {
const overlay = qs('#modal-overlay')
const modal = qs('#modal')
modal.classList.remove('closing')
// Opening over an already-visible modal (import mode -> prefix picker):
// morph instead of close-then-open - the box glides to the new height
// while old content dissolves into the new one.
// morph instead of close-then-open when a modal opens over a visible one
const swapping = overlay.style.display !== 'none' && !!modal.innerHTML.trim()
if (swapping) swapModalContent(modal, html)
else modal.innerHTML = html
@ -154,10 +142,8 @@ function showModal(html, opts) {
return qs('#modal')
}
// Animated content swap while the modal stays open. The new HTML is put in
// SYNCHRONOUSLY (callers wire their buttons right after showModal returns);
// an opaque snapshot of the old content is laid on top and fades out while
// the box animates to the new content's height. Same feel as switching tabs.
// Animated content swap: new HTML goes in synchronously, the old one fades
// out on top while the box glides to the new height.
function swapModalContent(modal, html) {
if (modalSwapTimer) {
clearTimeout(modalSwapTimer)
@ -234,8 +220,7 @@ function confirmModal(message, okLabel) {
})
}
// Shared "type a name" modal (prefix rename, wine display name): Enter
// submits, Cancel/Escape/backdrop resolve null.
// Shared "type a name" modal; Cancel/Escape/backdrop resolve null.
function promptModal({ title, label, value, confirm, hint }) {
return new Promise(resolve => {
const modal = showModal(`
@ -419,10 +404,7 @@ function iconUrl(id) {
return `plwl-icon://${encodeURIComponent(id)}/icon`
}
// Paths of imported (copy/move) apps live inside FILES/ and are stored
// relative to it in the registry (portable mode) - show them prefixed with
// files/ instead of the absolute install location. External (leave) apps
// keep their absolute path.
// Portable apps show files/-relative paths; external apps keep absolute ones.
function displayPath(p) {
if (!p) return ''
const files = window.plwl.FILES_DIR
@ -430,8 +412,7 @@ function displayPath(p) {
return p
}
// Inverse of displayPath for input fields: accept a files/-prefixed (or bare,
// FILES-relative) path and resolve it to an absolute one.
// Inverse of displayPath: files/-prefixed or bare path -> absolute.
function absPathInput(v) {
if (!v) return v
const files = window.plwl.FILES_DIR
@ -441,9 +422,7 @@ function absPathInput(v) {
return v
}
// Prefix-imported apps: show paths the way wine sees them - relative to the
// prefix ("pfx/drive_c/..." or "drive_c/...") instead of the long
// files/wineprefixes/<prefix>/... form.
// Prefix-imported apps: show paths as wine sees them (prefix-relative).
function displayGamePath(p, game) {
if (game && game.importSource === 'prefix' && game.prefix) {
const files = window.plwl.FILES_DIR
@ -453,9 +432,7 @@ function displayGamePath(p, game) {
return displayPath(p)
}
// Inverse of displayGamePath: accept a prefix-relative (drive_c/... or
// pfx/drive_c/...) path and resolve it under the game's prefix; anything
// else falls through to the normal FILES/absolute handling.
// Inverse of displayGamePath: prefix-relative path -> absolute under the prefix.
function absGamePathInput(v, game) {
if (game && game.importSource === 'prefix' && game.prefix && /^(pfx\/)?drive_c([\/]|$)/i.test(v || '')) {
const files = window.plwl.FILES_DIR
@ -474,11 +451,8 @@ function fmtPlaytime(seconds) {
}
// --- reordering the library (drag & drop) ----------------------------------
// The registry array IS the display order, so a drop reorders state.games and
// persists the whole id list; the main process appends any id the renderer did
// not know about, so a stale card list can never drop an entry. Cards are
// plain flex items, so the insertion point is the half of the hovered card the
// pointer is over.
// The registry array is the display order; a drop reorders state.games and
// persists the whole id list.
let dragId = null
function clearDropMarks(except) {
@ -550,8 +524,7 @@ function wireCardDrag(card, game) {
}
async function attachCardIcon(card, game) {
// placeholder first so every card has a stable icon slot; swapped for the
// real icon once it loads - games without one just keep the placeholder
// placeholder first, swapped for the real icon once it loads
const ph = document.createElement('div')
ph.className = 'game-icon placeholder'
card.prepend(ph)
@ -708,9 +681,7 @@ async function startImportFlow() {
})
}
// Import from a wine prefix, step 1: pick WHICH prefix - the app folder must
// live inside one so its paths can stay prefix-relative (wine then sees it as
// a regular C:\ install).
// Import step 1: pick the prefix the app folder lives in.
async function startPrefixImportFlow() {
try {
state.prefixes = await window.plwl.prefixesList()
@ -763,8 +734,7 @@ async function doImport(srcDir, mode) {
const bar = modal.querySelector('#import-progress')
const pct = modal.querySelector('.progress-pct')
const status = modal.querySelector('#import-status')
// byte totals drive the bar when the copy knows them; a rename or a
// symlink-only run sends plain path events and only bumps the count
// byte totals drive the bar when the copy knows them
if (bar && data && data.totalBytes) {
renderProgressBar(bar, data.bytes, data.totalBytes)
if (pct) pct.textContent = Math.round(((data.bytes || 0) / data.totalBytes) * 100) + '%'
@ -815,12 +785,9 @@ function isWindowsExecutable(p) {
return /\.(exe|msi|bat|cmd|lnk)$/i.test(p || '')
}
// The popup: wine version + prefix selects defaulting to the LAUNCHER's
// defaults (settings.defaultWine / prefixes.default), like the per-app
// config's "Default (...)" options - just without importing anything.
// "Open with" popup: wine + prefix selects defaulting to the launcher defaults.
async function showOpenWithModal(openPath) {
// always fresh: wines/prefixes/default can change while the launcher sits
// in the tray
// always fresh: defaults can change while the launcher sits in the tray
try {
const [wines, prefixes, settings] = await Promise.all([
window.plwl.winesList(),
@ -892,21 +859,16 @@ async function handleOpenWith(openPath) {
}
function wireOpenWith() {
// Both delivery paths (live handoff and the boot queue) converge on
// openwithTake(): the main-process slot is drained exactly when a popup
// shows, so a reloaded window can never resurrect an old popup and two
// quick handoffs collapse into one (the newest file wins).
// both delivery paths converge on openwithTake(); newest file wins
window.plwl.onOpenWith(() => window.plwl.openwithTake().then(handleOpenWith))
// file queued before the window existed (boot handoff): pick it up once
window.plwl.openwithTake().then(handleOpenWith)
}
// Import from a prefix, step 2: register the app in place - nothing is
// copied, paths stay prefix-relative.
// Import step 2: register the app in place, nothing is copied.
async function doPrefixImport(prefix, srcDir) {
const name = srcDir.split('/').filter(Boolean).pop()
// nothing is copied here - the app is just registered in place, so there is
// no progress to show (no bar that would sit at zero)
// nothing copied: no progress to show
showModal(`
<h2>${t('import.adding', name)}</h2>
<p class="subtitle">${t('import.prefix.registering')}</p>
@ -934,9 +896,7 @@ async function doPrefixImport(prefix, srcDir) {
}
}
// Apps the launcher would name when it refuses a prefix rename/delete: a
// running app (blocking immediately) or a registered one (orphaned by a
// delete). Fetched fresh - the Prefixes view does not keep the library hot.
// Apps blocking a prefix rename/delete, fetched fresh for the warning.
async function prefixUsers(name) {
const [games, running] = await Promise.all([window.plwl.gamesList(), window.plwl.gamesListRunning()])
const runningSet = new Set(running || [])
@ -1015,8 +975,7 @@ function renderPrefixes() {
const delBtn = row.querySelector('[data-act=delete]')
delBtn.innerHTML = `${ICONS.trash()}<span>${t('prefixes.delete')}</span>`
delBtn.onclick = async () => {
// refused before the confirm prompt, with the reason: the folder is
// either held by a running app or still referenced by registered ones
// refused up front: a running app holds the folder, or apps reference it
const users = await prefixUsers(p.name)
if (users.all.length) return toast(t('prefixes.hasApps', p.name, users.all.map(g => g.name).join(', ')), 'error')
const ok = await confirmModal(t('prefixes.deleteConfirm', p.name), t('prefixes.delete'))
@ -1175,8 +1134,7 @@ function renderPrefixOptions(selectId, selected) {
{ value: '', label: t('config.prefixDefault', defs.default) },
...defs.prefixes.filter(p => p.name !== defs.default).map(p => ({ value: p.name, label: p.name }))
]
// prefix imports pin their prefix even when it IS the default (which the
// list above omits) - keep it visible instead of a blank select
// prefix imports pin their prefix even when it is the default
if (selected && !opts.some(o => o.value === selected)) opts.push({ value: selected, label: selected })
if (!selects[selectId]) selects[selectId] = new CustomSelect(qs('#' + selectId), opts, selected || '')
else selects[selectId].update(opts, selected || '')
@ -1221,8 +1179,7 @@ async function openGameConfig(id) {
qs('#gc-env').value = Object.entries(game.env || {}).map(([k, v]) => `${k}=${v}`).join('\n')
renderWineOptions('gc-wine', game.wine)
renderPrefixOptions('gc-prefix', game.prefix)
// apps imported from a prefix are pinned to it (their paths are prefix-
// relative) - the select is locked, not just corrected on save
// prefix-imported apps are pinned: the select is locked, not corrected on save
const prefixLocked = game.importSource === 'prefix'
selects['gc-prefix'].setDisabled(prefixLocked)
const lockHint = qs('#gc-prefix-lock-hint')
@ -1243,8 +1200,7 @@ async function createAppShortcut() {
}
}
// Shows the newest run log in the file manager - it lives next to the app
// configuration, where the user is when something needs debugging.
// Shows the newest run log in the file manager.
async function showAppLogs() {
const game = state.currentGame
if (!game) return
@ -1261,8 +1217,7 @@ function renderOverrides(overrides) {
}
}
// native,builtin first: that is the default a new override row starts on
// (load a native DLL when the game has one, fall back to Wine's builtin)
// native,builtin is the default a new override row starts on
const OVERRIDE_OPTIONS = [
{ value: 'native,builtin', label: 'native,builtin' },
{ value: 'native', label: 'native' },
@ -1280,8 +1235,7 @@ function buildOverrideRow(o) {
`
row.querySelector('.dll').value = o.dll || ''
const selectHost = document.createElement('span')
// a stored empty setting IS the "disabled" choice - a `||` fallback would
// turn every disabled override back into the default on reopen
// an empty setting IS the "disabled" choice - no || fallback here
new CustomSelect(selectHost, OVERRIDE_OPTIONS, o.setting == null ? 'native,builtin' : o.setting)
row.insertBefore(selectHost, row.querySelector('[data-act=del]'))
row.querySelector('[data-act=del]').onclick = () => row.remove()
@ -1294,9 +1248,7 @@ function addOverrideRow() {
rows[rows.length - 1].querySelector('.dll').focus()
}
// The setting a guide or a launch option uses ("n", "native,builtin", "d"...)
// mapped onto the values this select offers. Unknown text keeps the select's
// choice instead of inventing one.
// Guide-style settings ("n", "native,builtin", "d"...) mapped to select values.
function normalizeOverrideSetting(raw) {
const v = String(raw || '').trim().toLowerCase().replace(/\s+/g, '')
if (!v || v === 'd' || v === 'disabled') return ''
@ -1311,8 +1263,7 @@ function readOverrides() {
return qsa('#gc-overrides .override-row').map(row => {
let dll = row.querySelector('.dll').value.trim()
let setting = row.querySelector('.custom-select').dataset.value ?? 'native,builtin'
// "dinput8=native" pasted into the name box is the form every guide uses -
// split it here instead of storing a name with '=' that Wine cannot parse
// split pasted "dinput8=native" into name + setting
const eq = dll.indexOf('=')
if (eq > 0) {
const pasted = normalizeOverrideSetting(dll.slice(eq + 1))
@ -1332,10 +1283,7 @@ async function saveGameConfig() {
const idx = line.indexOf('=')
if (idx > 0) env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim()
})
// prefix imports keep their paths relative to the prefix they were
// imported from - pointing them at another prefix would silently break
// exePath/workdir, so the original prefix stays locked. Other edits in
// the same save are kept; only the prefix snaps back (with a hint).
// prefix imports keep their prefix; only the prefix snaps back (with a hint)
let prefixLockedHint = false
let prefix = selects['gc-prefix'].value
if (game.importSource === 'prefix') {
@ -1358,8 +1306,7 @@ async function saveGameConfig() {
}
await window.plwl.gamesUpdate(game.id, patch)
state.games = await window.plwl.gamesList()
// show what was actually stored: a pasted "name=setting" was split and a
// .dll suffix stripped, so the rows would otherwise disagree with the file
// show what was actually stored (split/stripped)
renderOverrides(patch.dllOverrides)
toast(t('config.saved'), 'success')
if (prefixLockedHint) toast(t('config.prefixLocked'), 'error')
@ -1404,15 +1351,11 @@ const LAN_CATEGORIES = [
{ id: 'apps-prefix', label: 'lansync.appsPrefix' },
{ id: 'wines', label: 'lansync.wines' },
{ id: 'prefixes', label: 'lansync.prefixes' },
// registry-level settings: default wine/prefix, custom names of wine builds
// and prefixes, and each app's configuration plus its library order. No
// files move with this category - it only patches what already exists.
// registry-level settings only; no files move with this category
{ id: 'settings', label: 'lansync.settings' }
]
// 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.
// Byte sizes in decimal units, as a file manager shows them.
function formatBytes(bytes) {
const value = Math.max(0, Number(bytes) || 0)
if (value < 1000) return `${Math.round(value)} B`
@ -1421,10 +1364,7 @@ function formatBytes(bytes) {
return `${(value / 1000 / 1000 / 1000).toFixed(2)} GB`
}
// "Apps: 2/5 · Prefixes: 1/2 · Wine versions: 0/1" - how much of each category
// is done out of how much the sync carries, so the line says what is still
// coming instead of only listing what has already been walked over. A peer
// that does not announce its totals falls back to the plain count.
// "Apps: 2/5 · ..." per-category progress; no totals -> plain count.
function lanStatsLine(items, itemsDone, totals) {
const rows = [
['apps', 'lansync.statsApps'],
@ -1443,11 +1383,7 @@ function lanStatsLine(items, itemsDone, totals) {
return out.join(' · ')
}
// The bar follows BYTES, not file counts: a sync spends its time on the big
// files, and a file-count bar sits at 99% while the last gigabyte crawls in.
// It also follows the PHASE it is in (barBytes/barTotalBytes): a phase starts
// from an empty bar instead of inheriting a full one from the phase before, so
// hashing never looks finished while it has just begun.
// The bar follows bytes and the current phase (a phase starts empty).
function renderProgressBar(bar, bytes, totalBytes) {
const sent = Math.max(0, Number(bytes) || 0)
const total = Math.max(0, Number(totalBytes) || 0)
@ -1462,12 +1398,8 @@ function barValues(info) {
return [bytes, total]
}
// One modal for operations with a measurable size (import, delete, long
// copies): the bar follows BYTES for the same reason the sync bar does, and a
// percent label sits next to it. Main-process work streams through the single
// import:progress slot (preload keeps only the newest listener), so the modal
// is the only thing showing progress at a time. `action` is the ipc call;
// the modal closes itself when it settles.
// Progress modal for byte-measured work; `action` is the ipc call, the modal
// closes itself when it settles.
async function runWithProgress({ title, action }) {
const modal = showModal(`
<h2>${title}</h2>
@ -1479,8 +1411,7 @@ async function runWithProgress({ title, action }) {
const status = modal.querySelector('#op-status')
window.plwl.onImportProgress(data => {
if (!data || !data.phase) return
// the counting pass reports its own phase with the totals; the bar starts
// moving once the real units arrive
// the counting pass reports its own phase with the totals
if (data.phase === 'count') return
renderProgressBar(bar, data.bytes, data.totalBytes)
pct.textContent = data.totalBytes ? Math.round(((data.bytes || 0) / data.totalBytes) * 100) + '%' : ''
@ -1493,9 +1424,7 @@ async function runWithProgress({ title, action }) {
}
}
// "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.
// "340 MB / 1.2 GB" once the total is known, otherwise just the moved bytes.
function lanBytesLine(bytes, totalBytes) {
const sent = formatBytes(bytes)
if (!totalBytes) return bytes > 0 ? t('lansync.progressSent', sent) : ''
@ -1544,8 +1473,7 @@ function renderLanDevices(devices) {
}
}
// Modal that collects the other device's sync code plus what to push. Shown
// again with a cleared code after a rejected one, so the user can retype it.
// Collects the other device's sync code plus what to push.
async function openLanSyncModal(device, errorKey) {
const modal = showModal(`
<h2 id="ls-modal-title"></h2>
@ -1629,10 +1557,7 @@ async function runLanSync(device, code, categories, force) {
state.lanPushing = true
window.plwl.onLanSyncProgress(data => {
if (!active) return
// Every line keeps its value while the sync moves between phases: the
// file counter is CUMULATIVE across units (it never resets to 0/N with
// the next app) and phases that move no files simply do not touch it -
// only the byte bar follows the phase being worked on
// the file counter is cumulative across units; only the bar follows phases
phase.textContent = t('lansync.phase.' + data.phase, data.label || '').trim()
renderProgressBar(bar, ...barValues(data))
if (data.total > 0) count.textContent = t('lansync.progressFiles', data.done, data.total)
@ -1673,29 +1598,21 @@ async function runLanSync(device, code, categories, force) {
// --- 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.
// The sending machine does its hashing before anything is written here, which
// can be minutes of complete silence - so the popup switches to "waiting"
// instead of closing, and only a really long silence drops it. Events after
// that reopen it (the sync is still running, the user just cannot see it).
// A sync pushed onto this device shows the same popup, driven by the
// receiving server's events. Hashing can be minutes of silence: the popup
// switches to "waiting" and events reopen it if the watchdog closed it.
const RECEIVE_WATCHDOG_MS = 15 * 60 * 1000
const RECEIVE_STALL_MS = 6 * 1000
// phases whose wording differs on this side (everything else is neutral:
// "Comparing X", "Updating the library", "Finishing up")
// phase wordings that differ on this side; the rest are neutral
const RECV_PHASE_KEYS = { copy: 'lansync.recvPhase.copy' }
// A device wants to push to us: the popup asks for consent before anything
// moves. One pending request at a time (the server enforces that too).
// Incoming push: consent popup before anything moves.
let incomingRequest = null
let incomingTimer = null
function onLanIncoming(info) {
if (!info || !info.requestId) return
if (state.lanPushing || receiving) {
// our own transfer owns the modal slot - answer so the sender fails
// fast instead of waiting out its timeout
// our own transfer owns the modal slot: decline so the sender fails fast
window.plwl.lansyncRespondIncoming(info.requestId, false)
return
}
@ -1719,8 +1636,7 @@ function onLanIncoming(info) {
modal.querySelector('#ls-incoming-title').textContent = t('lansync.incomingTitle', name)
modal.querySelector('#ls-incoming-text').textContent = t('lansync.incomingText', name)
modal.querySelector('#ls-incoming-what').textContent = parts.length ? parts.join(' · ') : t('lansync.incomingNothing')
// the SENDER is the source of truth: the warning is phrased from this
// (receiving) side's point of view, not the send modal's mirrorWarning
// phrased from the receiving side's point of view
modal.querySelector('#ls-incoming-warning').textContent = t('lansync.incomingMirror')
modal.querySelector('#ls-incoming-decline').textContent = t('lansync.decline')
modal.querySelector('#ls-incoming-accept').textContent = t('lansync.accept')
@ -1747,10 +1663,7 @@ function onLanIncoming(info) {
}
let receiving = false
// true only once the sender's 'done' event ended the transfer. A popup the
// safety watchdog closed while the sender was still hashing must be allowed to
// reopen when events resume - permanently closing it made a running sync
// invisible even though the transfer continued.
// Only the sender's 'done' ends this; a watchdog-closed popup may reopen.
let receiveEnded = false
let receiveWatchdog = null
let receiveStall = null
@ -1791,10 +1704,7 @@ function endReceiving(message) {
closeModal()
}
// A sender that crashed halfway through must not leave this window locked: the
// popup closes itself if nothing at all arrives for a long time (the server
// drops the session after the same idle time). While it waits, the phase line
// says so rather than the popup disappearing mid-transfer.
// A crashed sender must not leave the window locked: close after a long silence.
function armReceiveWatchdog() {
if (receiveWatchdog) clearTimeout(receiveWatchdog)
receiveWatchdog = setTimeout(() => endReceiving(t('lansync.recvIdle')), RECEIVE_WATCHDOG_MS)
@ -1812,10 +1722,7 @@ function onLanReceiving(info) {
if (info.stage === 'start') receiveEnded = false
if (info.stage === 'done') receiveEnded = true
if (!receiving) {
// The popup also opens mid-sync: the sender may have been hashing for
// minutes when this window starts listening, and a popup that the safety
// timeout closed comes back for the rest of the transfer. Only a sync
// that already ended stays closed.
// reopen a watchdog-closed popup unless the sync already ended
if (receiveEnded) return
if (qs('#modal-overlay').style.display !== 'none') return
receiving = true
@ -1840,17 +1747,12 @@ function onLanReceiving(info) {
function renderReceiving(info) {
const parts = receiveModalParts()
if (!parts) return
// A liveness ping from the sender (it is busy hashing a unit): the watchdog
// was re-armed by the caller, but what is on screen stays as it is - the
// sync is not stalled, just quiet. The exception is the popup this very ping
// just reopened (the safety timeout had closed it): every line would sit
// blank, so the phase says what is actually happening.
// a ping keeps the screen as it is; only a reopened popup needs a phase line
if (info.stage === 'wait') {
if (!parts.phase.textContent) parts.phase.textContent = t('lansync.recvWaiting')
return
}
// The same phases read from this side's point of view: a file arriving here
// is being received, not sent.
// the same phases, worded for receiving
const phaseKey = RECV_PHASE_KEYS[info.stage] || 'lansync.phase.' + info.stage
parts.phase.textContent = (info.stage === 'start' ? t('lansync.recvWaiting') : t(phaseKey, info.label || '')).trim()
renderProgressBar(parts.bar, ...barValues(info))
@ -1859,8 +1761,7 @@ 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 get out of the way (the library reloads
// itself through lansync:applied)
// let the totals be read, then close (the library reloads itself)
if (receiveCloseTimer) clearTimeout(receiveCloseTimer)
receiveCloseTimer = setTimeout(() => {
receiveCloseTimer = null
@ -1869,10 +1770,7 @@ function renderReceiving(info) {
}
}
// Data received from another device changed the local files: reload every
// list so the views show the merged library instead of stale state. Settings
// too - a sync with "Default settings" rewrote the default wine/prefix on
// disk, and the settings view would keep showing the values from before.
// A received sync changed local files: reload every list (settings included).
async function reloadLocalData() {
const [wines, prefixes, games, settings] = await Promise.all([
window.plwl.winesList(),
@ -1921,8 +1819,7 @@ function wireEvents() {
})
qs('#btn-import-top').addEventListener('click', startImportFlow)
qs('#btn-import-empty').addEventListener('click', startImportFlow)
// a drop on the empty area of the grid moves the card to the end; drops on a
// card are handled by the card itself (and stop there)
// a drop on the empty grid area moves the card to the end
const gameList = qs('#game-list')
gameList.addEventListener('dragover', e => {
if (dragId) e.preventDefault()
@ -1982,10 +1879,7 @@ function wireEvents() {
qs('#btn-gc-logs').addEventListener('click', showAppLogs)
qs('#gc-override-add').addEventListener('click', addOverrideRow)
qs('#gc-exe-browse').addEventListener('click', async () => {
// start from the field being edited (exe picker from the exe field) and
// resolve the SHOWN path (prefix apps display pfx/drive_c/...) to an
// absolute one - absPathInput alone would pass the prefix-relative form
// through and the picker would open at the process cwd ('/')
// resolve the SHOWN path to an absolute one, or the picker opens at cwd
const cur = absGamePathInput(qs('#gc-exe').value.trim(), state.currentGame)
const p = await window.plwl.dialogPickFile({ properties: ['openFile'], defaultPath: cur || window.plwl.FILES_DIR })
if (p) qs('#gc-exe').value = displayGamePath(p, state.currentGame)
@ -2001,14 +1895,12 @@ function wireEvents() {
if (res.ok) refreshConfigIcon()
}
qs('#gc-icon-auto').addEventListener('click', async () => {
// extract from the exe the field shows, not the saved one - the user may
// have just pointed the app at another exe and expects its icon
// use the exe the field shows, not the saved one
const exe = absGamePathInput(qs('#gc-exe').value.trim(), state.currentGame)
applyIconResult(await window.plwl.gamesAutoIcon(state.currentGame.id, exe))
})
qs('#gc-icon-browse').addEventListener('click', async () => {
// start in the app's own folder (prefix apps included) - that is where
// the exe with the embedded icon most likely lives
// start in the app's own folder, where the icon exe most likely lives
const start = absGamePathInput(qs('#gc-workdir').value.trim(), state.currentGame)
const p = await window.plwl.dialogPickFile({
properties: ['openFile'],