'use strict'
// The CSS scales the root font size with the window width (max(15px, 100vw/90)),
// which is fractional at almost every width - every rem value in the UI then
// lands on a fractional pixel and text/border rounding drifts a pixel up or
// down while resizing (badges were the most visible case). Snap it to whole
// pixels so the whole layout steps in 1px root increments instead.
function snapRootFontSize() {
const px = Math.max(15, Math.round(window.innerWidth / 90))
document.documentElement.style.fontSize = px + 'px'
}
snapRootFontSize()
window.addEventListener('resize', snapRootFontSize, { passive: true })
const qs = s => document.querySelector(s)
const qsa = s => [...document.querySelectorAll(s)]
const runningCards = new Map()
const state = {
lang: 'en-us',
texts: {},
langs: [],
settings: null,
wines: [],
prefixes: { default: '', prefixes: [] },
games: [],
currentGame: null,
sandbox: null,
lansync: null,
// true while this device pushes a sync: it owns the single popup
lanPushing: false,
currentView: 'library'
}
function t(key, ...args) {
let text = state.texts[key] ?? key
args.forEach((v, i) => {
text = String(text).split(`{${i}}`).join(String(v))
})
return text
}
const ICONS = {
// app grid - the library view (games AND other Windows apps)
library: () => '',
// stacked layers - wine prefixes stacked on top of each other
prefixes: () => '',
// wine glass - wine/proton runners
wines: () => '',
settings: () => '',
// nodes connected to each other - devices on the local network
lansync: () => '',
// arrow into a tray - import
config: () => '',
folder: () => '',
trash: () => '',
terminal: () => '',
star: () => '',
download: () => '',
chevronDown: () => ''
}
function applyI18n() {
qsa('[data-t]').forEach(el => {
el.textContent = t(el.dataset.t)
})
qsa('[data-ph]').forEach(el => {
el.placeholder = t(el.dataset.ph)
})
qsa('custom-select').forEach(el => {
if (el.refresh) el.refresh()
})
document.title = t('app.title')
}
function setButtonIcons() {
const map = {
'nav-library': ICONS.library(),
'nav-prefixes': ICONS.prefixes(),
'nav-wines': ICONS.wines(),
'nav-settings': ICONS.settings(),
'nav-lansync': ICONS.lansync()
}
for (const [id, svg] of Object.entries(map)) {
const el = document.getElementById(id)
const btn = el && el.querySelector('button')
if (btn) btn.innerHTML = svg
}
}
// 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()
for (const { el, top } of entries) {
if (!el.isConnected) continue
const delta = top - el.getBoundingClientRect().top
if (!delta) continue
el.style.transition = 'none'
el.style.transform = `translateY(${delta}px)`
void el.offsetHeight // flush styles so the shift back animates
el.style.transition = ''
el.style.transform = ''
}
}
function toast(message, kind) {
const container = qs('#toasts')
flipShift(container, () => {
const el = document.createElement('div')
el.className = 'toast' + (kind ? ' ' + kind : '')
el.textContent = message
container.appendChild(el)
requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('show')))
setTimeout(() => {
el.classList.add('hide')
// after the fade-out, free the slot with an animated shift instead of a jump
setTimeout(() => flipShift(container, () => el.remove()), 300)
}, 4000)
})
}
// A delayed teardown must never kill a modal opened in the meantime.
let modalTeardownTimer = null
let modalSwapTimer = null
// A locked modal ignores backdrop/Escape; only its own buttons close it.
let modalLocked = false
// Settles the promise-based modal (confirm/prompt) so callers never hang.
let modalSettle = null
function settleModal(value) {
const settle = modalSettle
modalSettle = null
if (settle) settle(value)
}
function showModal(html, opts) {
modalLocked = !!(opts && opts.locked)
// a review list needs the room a narrow form does not
qs('#modal').classList.toggle('wide', !!(opts && opts.wide))
settleModal(null)
if (modalTeardownTimer) {
clearTimeout(modalTeardownTimer)
modalTeardownTimer = null
}
const overlay = qs('#modal-overlay')
const modal = qs('#modal')
modal.classList.remove('closing')
// 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
overlay.style.display = 'flex'
requestAnimationFrame(() => requestAnimationFrame(() => overlay.classList.add('show')))
return qs('#modal')
}
// 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)
modalSwapTimer = null
}
const prevGhost = modal.querySelector('.modal-ghost')
if (prevGhost) prevGhost.remove() // rapid double swap: snapshot clean state
modal.classList.remove('swapping')
const oldH = modal.offsetHeight
const ghost = document.createElement('div')
ghost.className = 'modal-ghost'
ghost.innerHTML = modal.innerHTML
modal.classList.add('resizing') // clip content while the box glides
modal.style.height = oldH + 'px'
modal.innerHTML = html
// measure the new natural height without an intermediate paint
modal.style.height = 'auto'
const newH = modal.offsetHeight
modal.style.height = oldH + 'px'
void modal.offsetHeight // flush styles so the height transition starts here
modal.appendChild(ghost)
void ghost.offsetHeight // commit the ghost's initial opacity:1 so the fade transition runs
requestAnimationFrame(() => {
modal.style.height = newH + 'px'
modal.classList.add('swapping') // fades the ghost out over the new content
modalSwapTimer = setTimeout(() => {
modalSwapTimer = null
ghost.remove()
modal.classList.remove('swapping', 'resizing')
modal.style.height = '' // back to auto (dropdowns may overflow again)
}, 240)
})
}
function closeModal() {
const overlay = qs('#modal-overlay')
const modal = qs('#modal')
if (overlay.style.display === 'none') return
settleModal(null)
modal.classList.add('closing')
overlay.classList.remove('show')
if (modalTeardownTimer) clearTimeout(modalTeardownTimer)
modalTeardownTimer = setTimeout(() => {
modalTeardownTimer = null
if (modalSwapTimer) {
clearTimeout(modalSwapTimer)
modalSwapTimer = null
}
overlay.style.display = 'none'
modal.classList.remove('closing', 'swapping', 'resizing', 'wide')
modal.style.height = ''
modal.innerHTML = ''
modalLocked = false
}, 160)
}
function confirmModal(message, okLabel) {
return new Promise(resolve => {
const modal = showModal(`
${t('dialog.confirm')}
${message}
`)
const finish = ok => {
settleModal(ok)
closeModal()
}
modal.querySelector('#modal-cancel').onclick = () => finish(false)
modal.querySelector('#modal-ok').onclick = () => finish(true)
modalSettle = v => resolve(!!v)
})
}
// Shared "type a name" modal; Cancel/Escape/backdrop resolve null.
function promptModal({ title, label, value, confirm, hint }) {
return new Promise(resolve => {
const modal = showModal(`
`)
modal.querySelector('#sandbox-warn-ok').onclick = async () => {
closeModal()
state.settings = await window.plwl.settingsSet({ sandboxNotifySeen: true })
}
}
async function saveSettings() {
state.settings = await window.plwl.settingsSet({
language: selects['set-language'].value,
defaultWine: selects['set-wine'].value,
closeToTray: qs('#set-closetotray').checked
})
await window.plwl.prefixesSetDefault(selects['set-prefix'].value || state.prefixes.default)
state.prefixes = await window.plwl.prefixesList()
const lang = selects['set-language'].value
if (lang !== state.lang) {
await loadLangTexts(lang)
applyI18n()
}
toast(t('settings.saved'), 'success')
if (state.currentView === 'settings') renderSettings()
}
function renderWineOptions(selectId, selected) {
const defaultWine = (state.settings && state.settings.defaultWine) || 'system'
const defLabel = wineLabel(defaultWine)
const opts = [
{ value: 'default', label: t('config.wineDefault', defLabel) },
...state.wines.map(w => ({ value: w.id, label: w.label }))
]
const value = !selected || selected === 'default' ? 'default' : selected
if (!selects[selectId]) selects[selectId] = new CustomSelect(qs('#' + selectId), opts, value)
else selects[selectId].update(opts, value)
}
function renderPrefixOptions(selectId, selected) {
const defs = state.prefixes
const opts = [
{ 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
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 || '')
}
async function refreshConfigIcon() {
const game = state.currentGame
if (!game) return
const p = await window.plwl.gamesIconPath(game.id)
const img = qs('#gc-icon')
const ph = qs('#gc-icon-placeholder')
img.onerror = () => {
img.style.display = 'none'
img.removeAttribute('src')
ph.style.display = 'flex'
}
if (p) {
img.onload = () => {
img.style.display = ''
ph.style.display = 'none'
}
img.src = iconUrl(game.id) + '?t=' + Date.now()
if (img.complete && img.naturalWidth > 0) img.onload()
} else {
img.style.display = 'none'
img.removeAttribute('src')
ph.style.display = 'flex'
}
}
async function openGameConfig(id) {
const game = state.games.find(g => g.id === id)
if (!game) return
state.currentGame = JSON.parse(JSON.stringify(game))
showView('gameconfig')
qs('#gc-title').textContent = game.name
qs('#gc-name').value = game.name
refreshConfigIcon()
qs('#gc-exe').value = displayGamePath(game.exePath, game)
qs('#gc-workdir').value = displayGamePath(game.workdir, game)
qs('#gc-args').value = game.args || ''
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)
// 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')
if (lockHint) lockHint.style.display = prefixLocked ? '' : 'none'
renderOverrides(game.dllOverrides || [])
qs('#gc-disable-umu').checked = !!game.disableUmu
renderTools()
// a native app has no wine, no proton launcher and no Wine tools
const isLinux = isLinuxGame(game)
qsa('.win-only').forEach(el => { el.style.display = isLinux ? 'none' : '' })
qsa('.linux-only').forEach(el => { el.style.display = isLinux ? '' : 'none' })
qs('#gc-icon-auto').textContent = t(isLinux ? 'config.icon.autoLinux' : 'config.icon.auto')
if (isLinux) {
renderIsolationOptions(game.isolation || 'auto')
qs('#gc-isolation-status').textContent = isolationStatusText(state.sandbox)
qs('#gc-force-x11').checked = !!game.forceX11
}
}
const isolationOptions = () => [
{ value: 'auto', label: t('config.isolation.auto') },
{ value: 'none', label: t('config.isolation.none') },
{ value: 'relaxed', label: t('config.isolation.relaxed') },
{ value: 'isolated', label: t('config.isolation.isolated') }
]
// The level names are one word each - what they mean has to be spelled out
// right under the select, because the price of relaxed is not visible anywhere
// else in the UI.
function renderIsolationHint(value) {
qs('#gc-isolation-hint').textContent = t(`config.isolationHint.${value || 'auto'}`)
}
function renderIsolationOptions(selected) {
const opts = isolationOptions()
if (!selects['gc-isolation']) {
selects['gc-isolation'] = new CustomSelect(qs('#gc-isolation'), opts, selected, renderIsolationHint)
} else selects['gc-isolation'].update(opts, selected)
renderIsolationHint(selected)
}
async function createAppShortcut() {
const game = state.currentGame
if (!game) return
const res = await window.plwl.gamesCreateShortcut(game.id)
if (res && res.ok) {
toast(t('config.shortcutCreated', game.name), 'success')
} else {
toast(t('config.shortcutFailed') + ': ' + ((res && res.error) || ''), 'error')
}
}
// Shows the newest run log in the file manager.
async function showAppLogs() {
const game = state.currentGame
if (!game) return
const logs = await window.plwl.gamesGetLogs(game.id)
if (logs.length) window.plwl.shellShowItemInFolder(logs[logs.length - 1])
else toast(t('config.noLogs'), 'error')
}
function renderOverrides(overrides) {
const box = qs('#gc-overrides')
box.innerHTML = ''
for (const o of overrides) {
box.appendChild(buildOverrideRow(o))
}
}
// native,builtin is the default a new override row starts on
const OVERRIDE_OPTIONS = [
{ value: 'native,builtin', label: 'native,builtin' },
{ value: 'native', label: 'native' },
{ value: 'builtin', label: 'builtin' },
{ value: 'builtin,native', label: 'builtin,native' },
{ value: '', label: 'disabled' }
]
function buildOverrideRow(o) {
const row = document.createElement('div')
row.className = 'override-row'
row.innerHTML = `
`
row.querySelector('.dll').value = o.dll || ''
const selectHost = document.createElement('span')
// 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()
return row
}
function addOverrideRow() {
qs('#gc-overrides').appendChild(buildOverrideRow({ dll: '', setting: 'native,builtin' }))
const rows = qsa('#gc-overrides .override-row')
rows[rows.length - 1].querySelector('.dll').focus()
}
// 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 ''
if (v === 'n' || v === 'native') return 'native'
if (v === 'b' || v === 'builtin') return 'builtin'
if (v === 'n,b' || v === 'native,builtin') return 'native,builtin'
if (v === 'b,n' || v === 'builtin,native') return 'builtin,native'
return null
}
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'
// split pasted "dinput8=native" into name + setting
const eq = dll.indexOf('=')
if (eq > 0) {
const pasted = normalizeOverrideSetting(dll.slice(eq + 1))
if (pasted !== null) setting = pasted
dll = dll.slice(0, eq).trim()
}
dll = dll.replace(/\.dll$/i, '')
return { dll, setting }
}).filter(o => o.dll)
}
async function saveGameConfig() {
const game = state.currentGame
if (!game) return
const env = {}
qs('#gc-env').value.split('\n').forEach(line => {
const idx = line.indexOf('=')
if (idx > 0) env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim()
})
// 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') {
if (prefix !== game.prefix) {
prefixLockedHint = true
renderPrefixOptions('gc-prefix', game.prefix)
}
prefix = game.prefix
}
const patch = {
name: qs('#gc-name').value.trim() || game.name,
exePath: absGamePathInput(qs('#gc-exe').value.trim(), game),
workdir: absGamePathInput(qs('#gc-workdir').value.trim(), game),
args: qs('#gc-args').value,
wine: selects['gc-wine'].value,
prefix,
env,
dllOverrides: readOverrides(),
disableUmu: qs('#gc-disable-umu').checked
}
if (isLinuxGame(game)) {
patch.isolation = selects['gc-isolation'].value
patch.forceX11 = qs('#gc-force-x11').checked
}
await window.plwl.gamesUpdate(game.id, patch)
state.games = await window.plwl.gamesList()
// show what was actually stored (split/stripped)
renderOverrides(patch.dllOverrides)
toast(t('config.saved'), 'success')
if (prefixLockedHint) toast(t('config.prefixLocked'), 'error')
if (state.currentView === 'gameconfig') {
state.currentGame = { ...state.currentGame, ...patch }
qs('#gc-title').textContent = patch.name
}
}
function renderTools() {
const grid = qs('#gc-tools')
grid.innerHTML = ''
const tools = ['winecfg', 'regedit', 'taskmgr', 'cmd', 'control', 'eject']
for (const tool of tools) {
const btn = document.createElement('button')
btn.className = 'button small'
btn.innerHTML = `${ICONS.terminal()}${tool}`
btn.onclick = async () => {
const game = state.currentGame
const res = await window.plwl.gamesRunTool(game.id, tool, '')
if (res.ok) toast(t('toast.started', tool))
else toast(t('toast.startFailed', tool, res.error || ''), 'error')
}
grid.appendChild(btn)
}
}
async function runCustomTool() {
const input = qs('#gc-custom-tool').value.trim()
if (!input) return
const [tool, ...rest] = input.split(/\s+/)
const game = state.currentGame
const res = await window.plwl.gamesRunTool(game.id, tool, rest.join(' '))
if (res.ok) toast(t('toast.started', tool))
else toast(t('toast.startFailed', tool, res.error || ''), 'error')
}
// --- LAN syncing ------------------------------------------------------------
const LAN_CATEGORIES = [
{ id: 'apps-portable', label: 'lansync.appsPortable' },
{ id: 'apps-prefix', label: 'lansync.appsPrefix' },
{ id: 'wines', label: 'lansync.wines' },
{ id: 'prefixes', label: 'lansync.prefixes' },
// registry-level settings only; no files move with this category
{ id: 'settings', label: 'lansync.settings' }
]
// 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`
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`
}
// "Apps: 2/5 · ..." per-category progress; no totals -> plain count.
function lanStatsLine(items, itemsDone, totals) {
const rows = [
['apps', 'lansync.statsApps'],
['prefixes', 'lansync.statsPrefixes'],
['wines', 'lansync.statsWines']
]
const out = []
for (const [kind, key] of rows) {
const listed = Number(items && items[kind]) || 0
const total = Number(totals && totals[kind]) || 0
const known = itemsDone && Number.isFinite(Number(itemsDone[kind]))
const done = known ? Number(itemsDone[kind]) : listed
if (total > 0) out.push(t(key, `${Math.min(done, total)}/${total}`))
else if (listed > 0) out.push(t(key, listed))
}
return out.join(' · ')
}
// 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)
bar.max = Math.max(total, 1)
bar.value = Math.min(sent, bar.max)
}
// The bar follows the current phase's bytes (a phase starts empty).
function barValues(info) {
let bytes = Math.max(0, Number(info.barBytes) || 0)
let total = Math.max(0, Number(info.barTotalBytes) || 0)
// a provisional total can be smaller than what already moved (it grows as
// entries arrive): the bar reads full, never over 100%
if (total > 0 && bytes > total) bytes = total
return [bytes, total]
}
// 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(`
${title}
${t('import.phase.count')}
`)
const bar = modal.querySelector('progress')
const pct = modal.querySelector('.progress-pct')
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
if (data.phase === 'count') return
renderProgressBar(bar, data.bytes, data.totalBytes)
pct.textContent = data.totalBytes ? Math.round(((data.bytes || 0) / data.totalBytes) * 100) + '%' : ''
if (data.totalFiles) status.textContent = t('import.phase.' + data.phase, data.files || 0, data.totalFiles)
})
try {
return await action()
} finally {
closeModal()
}
}
// 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 kept = Math.max(0, Number(summary && summary.kept) || 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(
`
${label}${value}
`
)
// 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 || kept) {
row(t(received ? 'lansync.summaryReceived' : 'lansync.summarySent'), files)
if (skipped) row(t('lansync.summarySkipped'), skipped)
if (deleted) row(t('lansync.summaryDeleted'), deleted)
if (kept) row(t('lansync.summaryKept'), kept)
}
if (bytes) row(t('lansync.summaryTransferred'), formatBytes(bytes))
if (sizeBytes) row(t('lansync.summarySize'), formatBytes(sizeBytes))
const modal = showModal(`
${t('lansync.summaryTitle')}
${rows.join('')}
`)
// 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)
if (!totalBytes) return bytes > 0 ? t('lansync.progressSent', sent) : ''
return t('lansync.progressBytes', sent, formatBytes(totalBytes))
}
async function renderLanSync() {
const info = await window.plwl.lansyncGet()
state.lansync = info
qs('#ls-device-id').textContent = info.deviceId
qs('#ls-sync-code').textContent = info.syncCode
renderLanDevices(info.devices || [])
}
function renderLanDevices(devices) {
const list = qs('#ls-device-list')
list.innerHTML = ''
if (!devices.length) {
const empty = document.createElement('p')
empty.className = 'subtitle'
empty.textContent = t('lansync.empty')
list.appendChild(empty)
return
}
for (const device of devices) {
const row = document.createElement('div')
row.className = 'list-row'
row.innerHTML = `
${device.paired ? `${t('lansync.paired')}` : ''}
`
row.querySelector('.title').textContent = device.name
const sub = row.querySelector('.sub')
sub.textContent = device.id
sub.title = device.id
const sync = row.querySelector('[data-act=sync]')
sync.textContent = t('lansync.sync')
sync.onclick = () => openLanSyncModal(device, '')
list.appendChild(row)
}
}
// Collects the other device's sync code plus what to push.
async function openLanSyncModal(device, errorKey) {
const modal = showModal(`
`)
modal.querySelector('#ls-modal-title').textContent = t('lansync.syncTitle', device.name)
modal.querySelector('#ls-code-label').textContent = t('lansync.enterCode', device.name)
const code = modal.querySelector('#ls-code')
code.value = device.code || ''
const error = modal.querySelector('#ls-code-error')
if (errorKey) {
error.textContent = t(errorKey)
error.style.display = ''
code.focus()
}
modal.querySelector('#ls-what-label').textContent = t('lansync.what')
const pickers = await wireLanPickers(modal)
modal.querySelector('#ls-force-label').textContent = t('lansync.force')
modal.querySelector('#ls-warning').textContent = t('lansync.mirrorWarning')
modal.querySelector('#ls-cancel').textContent = t('dialog.cancel')
modal.querySelector('#ls-cancel').onclick = closeModal
const start = modal.querySelector('#ls-start')
start.textContent = t('lansync.start')
start.onclick = async () => {
const value = code.value.trim().toUpperCase()
if (value.length !== 8) {
error.textContent = t('lansync.badCode')
error.style.display = ''
code.focus()
return
}
const picked = pickers.collect()
if (!picked.categories.length) {
toast(t('lansync.noneSelected'), 'error')
return
}
await runLanSync(device, value, picked.categories, modal.querySelector('#ls-force').checked, picked.selection)
}
}
// A category's checkbox says "this category travels"; the picker says which of
// its items. Everything starts selected, and a selection only ever narrows what
// is sent - nothing outside it may be deleted on the other side.
async function wireLanPickers(modal) {
const box = modal.querySelector('#ls-categories')
box.innerHTML = ''
let info = { apps: [], wines: [], prefixes: [] }
try {
info = await window.plwl.lansyncItems()
} catch {}
const groups = new Map([
['apps-portable', info.apps.filter(app => app.category === 'apps-portable')],
['apps-prefix', info.apps.filter(app => app.category === 'apps-prefix')],
['wines', info.wines || []],
['prefixes', info.prefixes || []],
['settings', []]
])
const chosen = new Map()
for (const [id, list] of groups) chosen.set(id, new Set(list.map(item => item.key)))
const rows = new Map()
const refreshButton = id => {
const row = rows.get(id)
if (!row || !row.pick) return
const list = groups.get(id) || []
row.pick.textContent = chosen.get(id).size === list.length
? t('lansync.pick')
: t('lansync.pickCount', chosen.get(id).size, list.length)
}
for (const category of LAN_CATEGORIES) {
const list = groups.get(category.id) || []
const wrapper = document.createElement('div')
wrapper.className = 'lan-cat'
const head = document.createElement('div')
head.className = 'lan-cat-head'
const label = document.createElement('label')
label.className = 'check-label'
label.innerHTML = ''
const input = label.querySelector('input')
input.id = 'ls-cat-' + category.id
label.querySelector('span').textContent = t(category.label)
head.appendChild(label)
const row = { input, list: null, search: null, pick: null }
rows.set(category.id, row)
if (list.length) {
const pick = document.createElement('button')
pick.type = 'button'
pick.className = 'button small'
head.appendChild(pick)
row.pick = pick
const panel = document.createElement('div')
panel.className = 'lan-pick'
panel.hidden = true
const tools = document.createElement('div')
tools.className = 'lan-pick-tools'
if (list.length > 6) {
const search = document.createElement('input')
search.type = 'search'
search.className = 'lan-search'
search.placeholder = t('lansync.pickSearch')
tools.appendChild(search)
row.search = search
search.oninput = () => renderList(category.id)
}
const all = document.createElement('button')
all.type = 'button'
all.className = 'button small'
all.textContent = t('lansync.pickAll')
const none = document.createElement('button')
none.type = 'button'
none.className = 'button small'
none.textContent = t('lansync.pickNone')
tools.appendChild(all)
tools.appendChild(none)
const listEl = document.createElement('div')
listEl.className = 'lan-pick-list'
row.list = listEl
panel.appendChild(tools)
panel.appendChild(listEl)
pick.onclick = () => {
panel.hidden = !panel.hidden
if (!panel.hidden) renderList(category.id)
}
all.onclick = () => {
for (const item of list) chosen.get(category.id).add(item.key)
renderList(category.id)
}
none.onclick = () => {
chosen.get(category.id).clear()
renderList(category.id)
}
wrapper.appendChild(panel)
refreshButton(category.id)
}
wrapper.insertBefore(head, wrapper.firstChild)
box.appendChild(wrapper)
}
function renderList(id) {
const row = rows.get(id)
if (!row || !row.list) return
const list = groups.get(id) || []
const set = chosen.get(id)
const query = row.search ? row.search.value.trim().toLowerCase() : ''
row.list.innerHTML = ''
for (const item of list) {
if (query && !String(item.name || '').toLowerCase().includes(query)) continue
const label = document.createElement('label')
label.className = 'check-label lan-item'
label.innerHTML = ''
const input = label.querySelector('input')
input.checked = set.has(item.key)
label.querySelector('span').textContent = item.name
input.onchange = () => {
if (input.checked) set.add(item.key)
else set.delete(item.key)
refreshButton(id)
// a prefix app's own folder travels with its prefix, not with the app
if (id === 'apps-prefix') renderList('apps-prefix')
}
row.list.appendChild(label)
if (id === 'apps-prefix' && input.checked && item.prefix) {
const prefixKey = `prefix:${item.prefix}`
const prefixesOn = rows.get('prefixes').input.checked
if (prefixesOn && !chosen.get('prefixes').has(prefixKey)) {
const hint = document.createElement('p')
hint.className = 'subtitle lan-hint'
hint.textContent = t('lansync.prefixLeftBehind', item.prefix)
row.list.appendChild(hint)
}
}
}
}
return {
collect() {
const categories = []
const units = []
let partial = false
for (const category of LAN_CATEGORIES) {
const input = rows.get(category.id).input
if (!input.checked) continue
const list = groups.get(category.id) || []
const set = chosen.get(category.id)
// a checked category with nothing ticked inside it sends nothing
if (list.length && !set.size) continue
categories.push(category.id)
for (const item of list) if (set.has(item.key)) units.push(item.key)
if (set.size !== list.length) partial = true
}
return { categories, selection: partial ? { units } : undefined }
}
}
}
async function runLanSync(device, code, categories, force, selection) {
// locked: a running sync is not cancellable by a stray click on the backdrop
const modal = showModal(`
`, { locked: true })
modal.querySelector('#ls-run-title').textContent = t('lansync.progress', device.name)
modal.querySelector('#ls-run-warning').textContent = t('lansync.keepOpen')
const bar = modal.querySelector('progress')
const phase = modal.querySelector('#ls-run-phase')
const count = modal.querySelector('#ls-run-count')
const stats = modal.querySelector('#ls-run-stats')
const bytes = modal.querySelector('#ls-run-bytes')
let active = true
state.lanPushing = true
window.plwl.onLanSyncProgress(data => {
if (!active) return
// waiting phases have no translation under lansync.phase.*
const phaseKey = data.phase === 'wait' ? 'lansync.waiting' : 'lansync.phase.' + data.phase
phase.textContent = t(phaseKey, data.label || '').trim()
renderProgressBar(bar, ...barValues(data))
// phases without a file count clear it: a stale "5 / 10" must not linger
if (data.total > 0) count.textContent = t('lansync.progressFiles', data.done, data.total)
else count.textContent = ''
stats.textContent = lanStatsLine(data.items, data.itemsDone, data.totals)
bytes.textContent = lanBytesLine(data.bytes, data.totalBytes)
})
let res = null
try {
res = await window.plwl.lansyncSync({ deviceId: device.id, code, categories, force, selection })
} catch (err) {
res = { ok: false, error: (err && err.message) || '' }
} finally {
active = false
state.lanPushing = false
}
if (res && res.ok) {
// 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) || ''
// 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')
return
}
const message =
err === 'busy' ? t('lansync.busy')
: err === 'offline' || err === 'receiver-lost' ? t('lansync.offline')
: err === 'rejected' ? t('lansync.rejected')
: err === 'incoming-timeout' ? t('lansync.incomingTimedOut')
: err === 'old-peer' ? t('lansync.oldPeer')
: (err || t('lansync.unknownError'))
toast(t('lansync.failed', message), 'error')
}
// --- receiving a sync (the other device is the source of truth) -------------
// 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
// phase wordings that differ on this side; the rest are neutral
const RECV_PHASE_KEYS = { copy: 'lansync.recvPhase.copy' }
// 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: decline so the sender fails fast
window.plwl.lansyncRespondIncoming(info.requestId, false)
return
}
incomingRequest = info
const name = info.name || info.deviceId || t('lansync.unknownDevice')
const totals = info.totals || {}
const parts = []
if (totals.apps > 0) parts.push(t('lansync.statsApps', totals.apps))
if (totals.wines > 0) parts.push(t('lansync.statsWines', totals.wines))
if (totals.prefixes > 0) parts.push(t('lansync.statsPrefixes', totals.prefixes))
// a settings-only sync moves no units, but it is still a real request
if (!parts.length && totals.settings > 0) parts.push(t('lansync.incomingSettings'))
const modal = showModal(`
`, { locked: true })
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')
// 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')
const answer = accept => {
if (!incomingRequest) return
const requestId = incomingRequest.requestId
incomingRequest = null
if (incomingTimer) {
clearTimeout(incomingTimer)
incomingTimer = null
}
closeModal()
window.plwl.lansyncRespondIncoming(requestId, accept)
}
modal.querySelector('#ls-incoming-accept').onclick = () => answer(true)
modal.querySelector('#ls-incoming-decline').onclick = () => answer(false)
// mirror the server's own expiry so the popup never outlives its request
incomingTimer = setTimeout(() => {
incomingTimer = null
incomingRequest = null
closeModal()
toast(t('lansync.incomingTimedOut'), 'error')
}, 60000)
}
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
let receiveStall = 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 (receiveStall) {
clearTimeout(receiveStall)
receiveStall = null
}
if (receiveCloseTimer) {
clearTimeout(receiveCloseTimer)
receiveCloseTimer = null
}
const wasReceiving = receiving
receiving = false
if (!wasReceiving) return
if (message) toast(message, 'error')
closeModal()
}
// 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)
if (receiveStall) clearTimeout(receiveStall)
receiveStall = setTimeout(() => {
receiveStall = null
const parts = receiveModalParts()
if (!parts) return
// silence also means no bytes: freeze the bar and ask for patience
parts.phase.textContent = t('lansync.waiting')
}, RECEIVE_STALL_MS)
}
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
lastApplied = null
}
if (info.stage === 'done') {
receiveEnded = true
// the review's decision was the last thing on screen: this is its result
if (reviewFlow) {
reviewFlow = false
closeModal()
showReceiveSummary(info)
return
}
// 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
// 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(`
`, { locked: true })
modal.querySelector('#ls-recv-title').textContent = t('lansync.receiving', info.from || info.deviceId || '')
modal.querySelector('#ls-recv-warning').textContent = t('lansync.keepOpen')
armReceiveWatchdog()
return renderReceiving(info)
}
armReceiveWatchdog()
renderReceiving(info)
}
function renderReceiving(info) {
const parts = receiveModalParts()
if (!parts) return
// a ping keeps the bar and counters as they are; only the phase line moves
if (info.stage === 'wait') {
parts.phase.textContent = t('lansync.waiting')
return
}
// 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.waiting') : t(phaseKey, info.label || '')).trim()
renderProgressBar(parts.bar, ...barValues(info))
// the file counter belongs to the copy/done phases; receive events for other
// phases carry cumulative numbers that would look stale mid-phase
if ((info.stage === 'copy' || info.stage === 'done') && info.total > 0) parts.count.textContent = t('lansync.progressFiles', info.done, info.total)
else parts.count.textContent = ''
parts.stats.textContent = lanStatsLine(info.items, info.itemsDone, info.totals)
parts.bytes.textContent = lanBytesLine(info.bytes, info.totalBytes)
if (info.stage === 'done') {
// hold the finished bar for a moment, then swap it for the summary
if (receiveCloseTimer) clearTimeout(receiveCloseTimer)
receiveCloseTimer = setTimeout(() => {
receiveCloseTimer = null
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,
kept: applied.kept ?? 0,
bytes: applied.bytes ?? info.bytes,
sizeBytes: applied.sizeBytes ?? info.sizeBytes,
items: applied.items || info.items || {}
}
})
}
// --- the receiver's deletion review ----------------------------------------
// Nothing the sender left out is removed before this: the list is planned, the
// user decides, and only then does anything get deleted.
let reviewFlow = false
// matches the receiver's own review timeout: an unanswered list deletes nothing
const REVIEW_WATCHDOG_MS = 10 * 60 * 1000
let reviewTimer = null
function clearReviewTimer() {
if (!reviewTimer) return
clearTimeout(reviewTimer)
reviewTimer = null
}
function onLanReview(info) {
if (!info || !info.id) return
clearReviewTimer()
// the review replaces the receive progress popup and owns the modal slot
if (receiveWatchdog) {
clearTimeout(receiveWatchdog)
receiveWatchdog = null
}
if (receiveStall) {
clearTimeout(receiveStall)
receiveStall = null
}
if (receiveCloseTimer) {
clearTimeout(receiveCloseTimer)
receiveCloseTimer = null
}
receiving = false
// no progress popup may reopen between the decision and the sender's 'done'
receiveEnded = true
reviewFlow = true
reviewTimer = setTimeout(() => {
reviewTimer = null
reviewFlow = false
closeModal()
toast(t('lansync.reviewExpired'), 'error')
}, REVIEW_WATCHDOG_MS)
renderReview(info)
}
function renderReview(info) {
const units = Array.isArray(info.units) ? info.units : []
const files = Math.max(0, Number(info.files) || 0)
const bytes = Math.max(0, Number(info.bytes) || 0)
const kept = new Set()
const keptUnits = new Set()
const nodeInfo = new Map()
const modal = showModal(`
${t('lansync.reviewTitle')}
`, { locked: true, wide: true })
// the device name comes off the network: never interpolate it into HTML
modal.querySelector('#ls-review-from').textContent = t('lansync.reviewFrom', info.name || info.deviceId || '')
modal.querySelector('#ls-review-text').textContent = t('lansync.reviewText', files, formatBytes(bytes))
modal.querySelector('#ls-review-warning').textContent = t('lansync.reviewOverwrite')
const list = modal.querySelector('#ls-review-list')
const go = modal.querySelector('#ls-review-go')
const keepAll = modal.querySelector('#ls-review-keep')
keepAll.textContent = t('lansync.reviewKeepAll')
const keptBy = path => [...kept].some(entry => path === entry || path.startsWith(entry + '/'))
const keptChild = path => [...kept].some(entry => entry !== path && entry.startsWith(path + '/'))
const keptTotals = () => {
let keptFiles = 0
let keptBytes = 0
for (const unit of units) {
if (!keptUnits.has(unit.key)) continue
keptFiles += Math.max(0, Number(unit.files) || 0)
keptBytes += Math.max(0, Number(unit.bytes) || 0)
}
for (const path of kept) {
if ([...kept].some(entry => entry !== path && path.startsWith(entry + '/'))) continue
const unit = units.find(entry => (nodeInfo.get(path) || {}).unit === entry.key)
if (unit && keptUnits.has(unit.key)) continue
const node = nodeInfo.get(path)
if (!node) continue
keptFiles += node.files
keptBytes += node.bytes
}
return { files: keptFiles, bytes: keptBytes }
}
const refresh = () => {
for (const row of modal.querySelectorAll('.lan-node')) {
const input = row.querySelector('input')
const path = row.dataset.path
const unitKept = keptUnits.has(row.dataset.unit)
input.checked = !unitKept && !keptBy(path)
input.indeterminate = input.checked && keptChild(path)
// kept through an ancestor: re-enable that ancestor instead
input.disabled = unitKept || (keptBy(path) && !kept.has(path))
}
for (const row of modal.querySelectorAll('.lan-review-unit')) {
const input = row.querySelector('input')
const key = row.dataset.key
const unit = units.find(entry => entry.key === key)
input.checked = !keptUnits.has(key)
input.indeterminate = input.checked && [...kept].some(entry => (nodeInfo.get(entry) || {}).unit === key)
row.querySelector('.lan-review-meta').textContent = t('lansync.reviewFiles', unit ? unit.files : 0, formatBytes(unit ? unit.bytes : 0))
}
const left = keptTotals()
go.textContent = t('lansync.reviewDelete', Math.max(0, files - left.files), formatBytes(Math.max(0, bytes - left.bytes)))
}
const nodeRow = (node, unitKey, depth) => {
nodeInfo.set(node.path, { files: node.files || 0, bytes: node.bytes || 0, unit: unitKey })
const row = document.createElement('div')
row.className = 'lan-node'
row.dataset.path = node.path
row.dataset.unit = unitKey
row.style.paddingLeft = `${depth * 18 + 6}px`
row.innerHTML = `
`
row.querySelector('.lan-node-name').textContent = node.name
row.querySelector('.lan-node-meta').textContent = t('lansync.reviewFiles', node.files || 0, formatBytes(node.bytes || 0))
const input = row.querySelector('input')
input.onchange = () => {
if (input.checked) {
kept.delete(node.path)
// keep the descendants' own entries from overriding an ancestor
for (const entry of [...kept]) if (entry.startsWith(node.path + '/')) kept.delete(entry)
} else {
for (const entry of [...kept]) if (entry.startsWith(node.path + '/')) kept.delete(entry)
kept.add(node.path)
}
refresh()
}
const childBox = document.createElement('div')
childBox.className = 'lan-nodes'
childBox.hidden = true
const open = row.querySelector('.lan-node-open')
if (node.hasChildren) {
open.hidden = false
open.textContent = '+'
open.onclick = async () => {
if (!childBox.dataset.loaded) {
childBox.dataset.loaded = '1'
const res = await window.plwl.lansyncReviewTree(info.id, node.path)
for (const child of (res && res.nodes) || []) childBox.appendChild(nodeRow(child, unitKey, depth + 1))
}
childBox.hidden = !childBox.hidden
open.textContent = childBox.hidden ? '+' : '−'
refresh()
}
}
const wrap = document.createElement('div')
wrap.className = 'lan-node-wrap'
wrap.appendChild(row)
wrap.appendChild(childBox)
return wrap
}
for (const unit of units) {
const row = document.createElement('div')
row.className = 'lan-review-unit'
row.dataset.key = unit.key
row.innerHTML = `
`
row.querySelector('.lan-review-name').textContent = unit.label || unit.key
row.querySelector('input').onchange = async event => {
if (event.target.checked) {
keptUnits.delete(unit.key)
for (const entry of [...kept]) if ((nodeInfo.get(entry) || {}).unit === unit.key) kept.delete(entry)
} else {
keptUnits.add(unit.key)
for (const entry of [...kept]) if ((nodeInfo.get(entry) || {}).unit === unit.key) kept.delete(entry)
}
refresh()
}
list.appendChild(row)
const children = document.createElement('div')
children.className = 'lan-nodes'
list.appendChild(children)
// top folders load on first paint; a unit's list is small (collapsed rows)
window.plwl.lansyncReviewTree(info.id, unit.key).then(res => {
for (const node of (res && res.nodes) || []) children.appendChild(nodeRow(node, unit.key, 1))
refresh()
})
}
keepAll.onclick = () => {
for (const unit of units) keptUnits.add(unit.key)
kept.clear()
refresh()
decide()
}
let deciding = false
const decide = async () => {
if (deciding) return
deciding = true
clearReviewTimer()
go.disabled = true
keepAll.disabled = true
go.textContent = t('lansync.reviewDeleting')
try {
await window.plwl.lansyncReviewDecide(info.id, { keep: [...kept], keepUnits: [...keptUnits] })
} catch {}
closeModal()
}
go.onclick = decide
refresh()
}
// 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(),
window.plwl.prefixesList(),
window.plwl.gamesList(),
window.plwl.settingsGet()
])
state.wines = wines
state.prefixes = prefixes
state.games = games
state.settings = settings
if (state.currentView === 'library') renderLibrary()
else if (state.currentView === 'wines') renderWines()
else if (state.currentView === 'prefixes') renderPrefixes()
else if (state.currentView === 'settings') renderSettings()
}
function wireLanSync() {
window.plwl.onLanSyncDevices(devices => {
if (state.lansync) state.lansync.devices = devices
if (state.currentView === 'lansync') renderLanDevices(devices)
})
window.plwl.onLanSyncReceiving(info => onLanReceiving(info))
window.plwl.onLanSyncIncoming(info => onLanIncoming(info))
window.plwl.onLanSyncReview(info => onLanReview(info))
window.plwl.onLanSyncApplied(async info => {
// the summary popup replaces the toast; reload behind it
lastApplied = info
await reloadLocalData()
})
qs('#ls-reroll-id').addEventListener('click', async () => {
const res = await window.plwl.lansyncRerollDeviceId()
qs('#ls-device-id').textContent = res.deviceId
if (state.lansync) state.lansync.deviceId = res.deviceId
toast(t('lansync.idRerolled'), 'success')
})
qs('#ls-reroll-code').addEventListener('click', async () => {
const res = await window.plwl.lansyncRerollSyncCode()
qs('#ls-sync-code').textContent = res.syncCode
if (state.lansync) state.lansync.syncCode = res.syncCode
toast(t('lansync.codeRerolled'), 'success')
})
}
function wireEvents() {
qsa('[data-nav]').forEach(btn => {
btn.addEventListener('click', () => showView(btn.dataset.nav))
})
qs('#btn-import-top').addEventListener('click', startImportFlow)
qs('#btn-import-empty').addEventListener('click', startImportFlow)
// 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()
})
gameList.addEventListener('drop', e => {
if (!dragId || e.target !== gameList) return
e.preventDefault()
const id = dragId
dragId = null
commitOrder([...state.games.map(g => g.id).filter(x => x !== id), id])
})
qs('#btn-prefix-create').addEventListener('click', async () => {
const input = qs('#prefix-name-input')
const name = input.value.trim()
if (!name) return
const res = await window.plwl.prefixesCreate(name)
if (!res.ok) {
toast(t(res.error === 'alreadyExists' ? 'prefixes.exists' : 'prefixes.invalidName'), 'error')
return
}
input.value = ''
toast(t('toast.prefixCreated', res.name), 'success')
state.prefixes = await window.plwl.prefixesList()
renderPrefixes()
})
qs('#btn-wines-rescan').addEventListener('click', async () => {
await window.plwl.winesRefresh()
state.wines = await window.plwl.winesList()
renderWines()
})
qs('#btn-wines-import').addEventListener('click', async () => {
const srcDir = await window.plwl.dialogPickFile({ properties: ['openDirectory'], title: t('wines.importPick') })
if (!srcDir) return
const res = await runWithProgress({
title: t('wines.importing'),
action: () => window.plwl.winesImport(srcDir)
})
if (res.ok) {
toast(t('wines.imported', res.path), 'success')
state.wines = await window.plwl.winesList()
} else {
toast(t('import.failed', importErrorText((res && res.error) || '')), 'error')
}
renderWines()
})
qs('#btn-settings-save').addEventListener('click', saveSettings)
qs('#btn-open-files').addEventListener('click', () => window.plwl.shellOpenPath(window.plwl.FILES_DIR))
qs('#btn-clear-hash-cache').addEventListener('click', async () => {
const ok = await confirmModal(t('settings.hashCacheClearConfirm'), t('settings.clearHashCache'))
if (!ok) return
await window.plwl.lansyncClearHashCache()
toast(t('settings.hashCacheCleared'), 'success')
})
qs('#btn-gc-back').addEventListener('click', () => showView('library'))
qs('#btn-gc-save').addEventListener('click', saveGameConfig)
qs('#btn-gc-data-folder').addEventListener('click', async () => {
const res = await window.plwl.gamesOpenDataFolder(state.currentGame.id)
if (!res || !res.ok) toast(String((res && res.error) || ''), 'error')
})
qs('#btn-sandbox-check').addEventListener('click', recheckSandbox)
qs('#btn-gc-shortcut').addEventListener('click', createAppShortcut)
qs('#btn-gc-logs').addEventListener('click', showAppLogs)
qs('#gc-override-add').addEventListener('click', addOverrideRow)
qs('#gc-exe-browse').addEventListener('click', async () => {
// 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)
})
qs('#gc-workdir-browse').addEventListener('click', async () => {
const cur = absGamePathInput(qs('#gc-workdir').value.trim(), state.currentGame)
const p = await window.plwl.dialogPickFile({ properties: ['openDirectory'], defaultPath: cur || window.plwl.FILES_DIR })
if (p) qs('#gc-workdir').value = displayGamePath(p, state.currentGame)
})
qs('#gc-tool-run').addEventListener('click', runCustomTool)
async function applyIconResult(res) {
toast(res.ok ? t('config.icon.set') : t('config.icon.noIcon'), res.ok ? 'success' : 'error')
if (res.ok) refreshConfigIcon()
}
qs('#gc-icon-auto').addEventListener('click', async () => {
// 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, where the icon exe most likely lives
const start = state.currentGame.path || absGamePathInput(qs('#gc-workdir').value.trim(), state.currentGame)
const p = await window.plwl.dialogPickFile({
properties: ['openFile'],
defaultPath: start || window.plwl.FILES_DIR,
title: t('config.icon.pick'),
filters: [{ name: t('config.icon.pick'), extensions: ['png', 'jpg', 'jpeg', 'svg', 'ico', 'webp', 'gif', 'bmp', 'exe'] }]
})
if (!p) return
applyIconResult(await window.plwl.gamesSetIconFile(state.currentGame.id, p))
})
qs('#gc-icon-reset').addEventListener('click', async () => {
await window.plwl.gamesResetIcon(state.currentGame.id)
refreshConfigIcon()
})
qs('#btn-gc-clear-playtime').addEventListener('click', async () => {
const ok = await confirmModal(t('config.clearPlaytimeConfirm'), t('config.clearPlaytime'))
if (!ok) return
await window.plwl.gamesClearPlaytime(state.currentGame.id)
state.games = await window.plwl.gamesList()
state.currentGame = state.games.find(g => g.id === state.currentGame.id)
toast(t('config.playtimeCleared'), 'success')
})
qs('#modal-overlay').addEventListener('click', e => {
if (modalLocked) return // a sync in progress: the backdrop is not a cancel button
if (e.target === qs('#modal-overlay')) closeModal()
})
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && !modalLocked) closeModal()
})
}
async function boot() {
setButtonIcons()
await loadAll()
applyI18n()
wireEvents()
wireRealtime()
wireOpenWith()
wireHandoffProgress()
wireLanSync()
qs('#loading').remove()
qs('#main').style.display = 'flex'
showView('library')
warnSandboxOnce()
}
boot()