cleanup comments, update translations, bump to 0.4.1
This commit is contained in:
parent
9edcc9841b
commit
7d8ab87eee
28 changed files with 412 additions and 1114 deletions
232
renderer/app.js
232
renderer/app.js
|
|
@ -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'],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue