PLWL/renderer/app.js
2026-10-19 11:02:52 +02:00

2653 lines
104 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="3.5" width="7.5" height="7.5" rx="2"/><rect x="13" y="3.5" width="7.5" height="7.5" rx="2"/><rect x="3.5" y="13" width="7.5" height="7.5" rx="2"/><rect x="13" y="13" width="7.5" height="7.5" rx="2"/></svg>',
// stacked layers - wine prefixes stacked on top of each other
prefixes: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 3.5 7.25 12 11.5l8.5-4.25L12 3Z"/><path d="m3.5 12.25 8.5 4.25 8.5-4.25"/><path d="m3.5 16.75 8.5 4.25 8.5-4.25"/></svg>',
// wine glass - wine/proton runners
wines: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 22h8"/><path d="M12 15v7"/><path d="M7 10h10"/><path d="M12 15a5 5 0 0 0 5-5c0-2-.5-4-2-8H9c-1.5 4-2 6-2 8a5 5 0 0 0 5 5Z"/></svg>',
settings: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2-1.2L14 3h-4l-.5 2.6a7 7 0 0 0-2 1.2l-2.4-1-2 3.5 2 1.5a7 7 0 0 0 0 2.4l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2 1.2L10 21h4l.5-2.6a7 7 0 0 0 2-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z"/></svg>',
// nodes connected to each other - devices on the local network
lansync: () => '<svg viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd"><g transform="translate(-1.44,26.17) scale(0.028)"><path transform="translate(182.4,-402.4) scale(0.62)" stroke="currentColor" stroke-width="34" stroke-linejoin="round" stroke-linecap="round" d="M160-160v-80h110l-16-14q-52-46-73-105t-21-119q0-111 66.5-197.5T400-790v84q-72 26-116 88.5T240-478q0 45 17 87.5t53 78.5l10 10v-98h80v240H160Zm400-10v-84q72-26 116-88.5T720-482q0-45-17-87.5T650-648l-10-10v98h-80v-240h240v80H690l16 14q49 49 71.5 106.5T800-482q0 111-66.5 197.5T560-170Z"/><path d="M200-120q-33 0-56.5-23.5T120-200v-160q0-33 23.5-56.5T200-440h560q33 0 56.5 23.5T840-360v160q0 33-23.5 56.5T760-120H200Zm0-80h560v-160H200v160Z"/><circle cx="340" cy="-280" r="40"/><circle cx="480" cy="-280" r="40"/><circle cx="620" cy="-280" r="40"/></g></svg>',
// arrow into a tray - import
config: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6"/></svg>',
folder: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
trash: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14M10 11v6M14 11v6"/></svg>',
terminal: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17l6-5-6-5M12 19h8"/></svg>',
star: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 3l2.7 5.8 6.3.8-4.6 4.3 1.2 6.1-5.6-3.1-5.6 3.1 1.2-6.1L3 9.6l6.3-.8z"/></svg>',
download: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12M6 9l6 6 6-6"/><path d="M4 21h16"/></svg>',
chevronDown: () => '<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>'
}
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(`
<h2>${t('dialog.confirm')}</h2>
<p>${message}</p>
<div class="modal-actions">
<button class="button" id="modal-cancel">${t('dialog.cancel')}</button>
<button class="button primary" id="modal-ok">${okLabel || t('dialog.ok')}</button>
</div>
`)
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(`
<h2>${title}</h2>
${hint ? `<p class="subtitle">${hint}</p>` : ''}
<label class="field-label">${label}</label>
<input type="text" id="prompt-input">
<div class="modal-actions">
<button class="button" id="modal-cancel">${t('dialog.cancel')}</button>
<button class="button primary" id="modal-ok">${confirm || t('dialog.ok')}</button>
</div>
`)
const input = modal.querySelector('#prompt-input')
input.value = value || ''
input.focus()
input.select()
const finish = v => {
settleModal(v)
closeModal()
}
const submit = () => finish(input.value.trim() || null)
input.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault()
submit()
}
})
modal.querySelector('#modal-cancel').onclick = () => finish(null)
modal.querySelector('#modal-ok').onclick = submit
modalSettle = v => resolve(v)
})
}
class CustomSelect {
constructor(host, options, value, onChange) {
this.host = host
this.options = options
this.value = value
this.onChange = onChange
this.disabled = false
this.render()
if (!this.host.dataset.wired) {
this.wire()
this.host.dataset.wired = '1'
}
}
setDisabled(v) {
this.disabled = !!v
this.host.classList.toggle('disabled', this.disabled)
}
render() {
const selected = this.options.find(o => o.value === this.value)
this.host.classList.add('custom-select')
this.host.dataset.value = this.value ?? ''
this.host.innerHTML = `
<div class="custom-select-value">
<span></span>
${ICONS.chevronDown()}
</div>
<div class="custom-select-options"></div>
`
this.host.querySelector('.custom-select-value span').textContent = selected ? selected.label : ''
const box = this.host.querySelector('.custom-select-options')
for (const opt of this.options) {
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'custom-select-option' + (opt.value === this.value ? ' selected' : '')
btn.textContent = opt.label
btn.onclick = e => {
e.stopPropagation()
this.setValue(opt.value)
this.close()
}
box.appendChild(btn)
}
this.host.refresh = () => {
const sel = this.options.find(o => o.value === this.value)
this.host.querySelector('.custom-select-value span').textContent = sel ? sel.label : ''
const btns = box.querySelectorAll('.custom-select-option')
this.options.forEach((opt, i) => {
btns[i].textContent = opt.label
btns[i].classList.toggle('selected', opt.value === this.value)
})
}
}
wire() {
this.host.onclick = e => {
if (e.target.closest('.custom-select-option')) return
e.stopPropagation()
this.toggle()
}
document.addEventListener('click', () => this.close())
}
toggle() {
if (this.disabled) return
const wasOpen = this.host.classList.contains('open')
CustomSelect.closeAll()
if (!wasOpen) {
this.host.classList.add('open')
this.lift()
}
}
// the transform on every element (see style.css) makes each one its own
// stacking context, so an ancestor wrapper would still paint the open list
// under the rows that follow it; positioned ancestors already stack right
lift() {
for (let el = this.host.parentElement; el && el !== document.body; el = el.parentElement) {
if (getComputedStyle(el).position === 'static') el.classList.add('select-lift')
}
}
close() {
this.host.classList.remove('open')
CustomSelect.unlift()
}
setValue(v) {
this.value = v
this.host.dataset.value = v ?? ''
this.render()
if (this.onChange) this.onChange(v)
}
update(options, value) {
this.options = options
if (value !== undefined) this.value = value
this.host.dataset.value = this.value ?? ''
this.render()
}
static closeAll() {
qsa('.custom-select.open').forEach(el => el.classList.remove('open'))
CustomSelect.unlift()
}
static unlift() {
qsa('.select-lift').forEach(el => el.classList.remove('select-lift'))
}
}
const selects = {}
const NAV_PARENT = {
gameconfig: 'library'
}
function showView(name) {
state.currentView = name
qsa('.view').forEach(v => v.classList.remove('active'))
const view = qs('#view-' + name)
if (view) view.classList.add('active')
const navName = NAV_PARENT[name] || name
qsa('sidebarelement').forEach(el => el.classList.remove('active'))
const nav = qs('#nav-' + navName)
if (nav) nav.classList.add('active')
if (name === 'library') renderLibrary()
if (name === 'prefixes') renderPrefixes()
if (name === 'wines') renderWines()
if (name === 'settings') renderSettings()
if (name === 'lansync') renderLanSync()
}
async function loadAll() {
const [langs, settings, wines, prefixes, games, sandbox] = await Promise.all([
window.plwl.i18nList(),
window.plwl.settingsGet(),
window.plwl.winesList(),
window.plwl.prefixesList(),
window.plwl.gamesList(),
window.plwl.sandboxStatus()
])
state.langs = langs
state.settings = settings
state.wines = wines
state.prefixes = prefixes
state.games = games
state.sandbox = sandbox
state.lang = settings.language || 'en-us'
await loadLangTexts(state.lang)
}
async function loadLangTexts(code) {
const lang = await window.plwl.i18nGet(code)
if (lang && lang.texts) {
state.texts = lang.texts
state.lang = code
}
}
function wineLabel(id) {
const w = state.wines.find(w => w.id === id)
return w ? w.label : (id === 'system' ? t('wines.system') : id)
}
function iconUrl(id) {
return `plwl-icon://${encodeURIComponent(id)}/icon`
}
// --- native Linux apps ------------------------------------------------------
const isLinuxGame = game => !!game && game.kind === 'linux'
function sandboxStrong() {
return !!(state.sandbox && state.sandbox.backend === 'bwrap')
}
// "bwrap 0.12.0 (strong)" / "no sandbox: not installed"
function isolationStatusText(status) {
if (!status) return ''
if (status.backend === 'bwrap') return t('config.isolation.bwrap', status.version || '')
return t('config.isolation.env', t('sandbox.reason.' + (status.reason || 'failed')))
}
// The badge names the level the app would really run at, not the policy:
// automatic is a choice, and what it resolves to depends on this machine.
function isolationLevel(game) {
const policy = (game && game.isolation) || 'auto'
if (policy === 'none') return 'none'
if (policy === 'auto') return sandboxStrong() ? 'relaxed' : 'none'
return sandboxStrong() ? policy : 'unavailable'
}
function isolationBadge(game) {
const level = isolationLevel(game)
if (level === 'none') return `<span class="badge">${t('sandbox.badgeNone')}</span>`
if (level === 'unavailable') return `<span class="badge">${t('sandbox.badgeRequired')}</span>`
if (level === 'isolated') return `<span class="badge primary">${t('sandbox.badgeIsolated')}</span>`
return `<span class="badge primary">${t('sandbox.badgeRelaxed')}</span>`
}
// Portable apps show files/-relative paths; external apps keep absolute ones.
function displayPath(p) {
if (!p) return ''
const files = window.plwl.FILES_DIR
if (files && p.startsWith(files + '/')) return 'files/' + p.slice(files.length + 1)
return p
}
// Inverse of displayPath: files/-prefixed or bare path -> absolute.
function absPathInput(v) {
if (!v) return v
const files = window.plwl.FILES_DIR
if (!files) return v
if (v.startsWith('files/')) return files + '/' + v.slice(6)
if (!v.includes('/') && !v.startsWith('.')) return files + '/' + v
return v
}
// 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
const base = files ? files + '/wineprefixes/' + game.prefix + '/' : ''
if (base && p && p.startsWith(base)) return p.slice(base.length)
}
return displayPath(p)
}
// 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
if (files) return files + '/wineprefixes/' + game.prefix + '/' + v
}
return absPathInput(v)
}
function fmtPlaytime(seconds) {
const s = Math.round(seconds || 0)
if (s < 60) return t('library.playtimeSeconds', s)
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
if (h > 0) return t('library.playtimeHM', h, m)
return t('library.playtimeMin', m)
}
// --- reordering the library (drag & drop) ----------------------------------
// The registry array is the display order; a drop reorders state.games and
// persists the whole id list.
let dragId = null
function clearDropMarks(except) {
qsa('.game-card.drop-before, .game-card.drop-after').forEach(card => {
if (card !== except) card.classList.remove('drop-before', 'drop-after')
})
}
function dropSide(card, x) {
const rect = card.getBoundingClientRect()
return x < rect.left + rect.width / 2 ? 'before' : 'after'
}
function commitOrder(ids) {
const byId = new Map(state.games.map(g => [g.id, g]))
state.games = ids.map(id => byId.get(id)).filter(Boolean)
renderLibrary()
window.plwl.gamesReorder(ids).then(games => {
if (!Array.isArray(games)) return
const same = games.length === state.games.length && games.every((g, i) => g.id === state.games[i].id)
if (same) return
// the registry also held entries this render did not know about - show them
state.games = games
renderLibrary()
})
}
function wireCardDrag(card, game) {
card.draggable = true
card.title = t('library.reorderHint')
card.addEventListener('dragstart', e => {
dragId = game.id
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', game.id) // a drag needs data to start
card.classList.add('dragging')
})
card.addEventListener('dragend', () => {
dragId = null
// a drop in place never re-renders the list, so drop the fade here too
card.classList.remove('dragging')
clearDropMarks()
})
card.addEventListener('dragover', e => {
if (!dragId || dragId === game.id) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const side = dropSide(card, e.clientX)
clearDropMarks(card)
card.classList.toggle('drop-before', side === 'before')
card.classList.toggle('drop-after', side === 'after')
})
card.addEventListener('dragleave', e => {
// dragleave also fires when the pointer crosses a child element
if (!card.contains(e.relatedTarget)) card.classList.remove('drop-before', 'drop-after')
})
card.addEventListener('drop', e => {
if (!dragId || dragId === game.id) return
e.preventDefault()
e.stopPropagation() // the list's own drop handler moves cards to the end
const side = dropSide(card, e.clientX)
const id = dragId
dragId = null
const ids = state.games.map(g => g.id).filter(x => x !== id)
const at = ids.indexOf(game.id)
if (at === -1) return
ids.splice(side === 'before' ? at : at + 1, 0, id)
commitOrder(ids)
})
}
async function attachCardIcon(card, game) {
// placeholder first, swapped for the real icon once it loads
const ph = document.createElement('div')
ph.className = 'game-icon placeholder'
card.prepend(ph)
const p = await window.plwl.gamesIconPath(game.id)
if (!p) return
const img = document.createElement('img')
img.className = 'game-icon'
img.alt = ''
img.onerror = () => img.remove() // placeholder stays visible
img.onload = () => {
ph.remove()
card.prepend(img)
}
img.src = iconUrl(game.id)
if (img.complete && img.naturalWidth > 0) img.onload()
}
function renderLibrary() {
const list = qs('#game-list')
list.innerHTML = ''
runningCards.clear()
qs('#library-empty').style.display = state.games.length ? 'none' : 'flex'
state.games.forEach((game, idx) => {
const card = document.createElement('div')
card.className = 'game-card'
card.style.animationDelay = `${idx * 0.04}s`
const portable = game.mode === 'copy' || game.mode === 'move'
const inPrefix = game.importSource === 'prefix'
const isLinux = isLinuxGame(game)
const sourceBadge = inPrefix
? `<span class="badge primary">${t('library.inPrefix')}${game.prefix ? ' · ' + game.prefix : ''}</span>`
: `<span class="badge ${portable ? 'primary' : ''}">${portable ? t('library.portable') : t('library.external')}</span>`
const last = game.lastPlayed ? new Date(game.lastPlayed).toLocaleDateString() : t('library.never')
card.innerHTML = `
<h3 title=""></h3>
<div class="meta">
${sourceBadge}
${isLinux ? `<span class="badge">${t('config.linuxKindValue')}</span>` : `<span class="badge">${wineLabel(game.wine)}</span>`}
${isLinux ? isolationBadge(game) : ''}
</div>
<div class="meta">${t('library.lastPlayed', last)}</div>
<div class="meta">${t('library.playtime', fmtPlaytime(game.playSeconds || 0))}</div>
<div class="actions">
<button class="button primary small" data-act="play">${t('library.play')}</button>
<button class="button small danger" data-act="kill" style="display:none">${t('library.stop')}</button>
<button class="button small" data-act="config" title="${t('library.config')}">${ICONS.config()}</button>
<button class="button small" data-act="folder" title="${t('library.openFolder')}">${ICONS.folder()}</button>
<button class="button small danger" data-act="remove" title="${t('library.remove')}">${ICONS.trash()}</button>
</div>
`
card.querySelector('h3').textContent = game.name
const playBtn = card.querySelector('[data-act=play]')
const killBtn = card.querySelector('[data-act=kill]')
const metas = card.querySelectorAll('.meta')
const applyRunning = isRunning => {
playBtn.style.display = isRunning ? 'none' : ''
killBtn.style.display = isRunning ? '' : 'none'
}
runningCards.set(game.id, { applyRunning, lastMeta: metas[1], playtimeMeta: metas[2], game })
window.plwl.gamesIsRunning(game.id).then(applyRunning)
playBtn.onclick = () => playGame(game)
killBtn.onclick = async () => {
await window.plwl.gamesKill(game.id)
applyRunning(false)
}
card.querySelector('[data-act=config]').onclick = () => openGameConfig(game.id)
// the app's own folder, not its working directory
card.querySelector('[data-act=folder]').onclick = () => window.plwl.shellOpenPath(game.path || game.workdir)
card.querySelector('[data-act=remove]').onclick = async () => {
const note = game.mode === 'copy' || game.mode === 'move'
? t('library.removeFilesNote')
: (inPrefix ? t('library.removePrefixNote') : (isLinux ? t('library.removeLinuxDataNote') : ''))
const ok = await confirmModal(t('library.removeConfirm', game.name, note), t('library.remove'))
if (!ok) return
// portable apps delete their folder too - show how far that got
await runWithProgress({
title: t('library.removing', game.name),
action: () => window.plwl.gamesRemove(game.id)
})
toast(t('toast.gameRemoved', game.name), 'success')
state.games = await window.plwl.gamesList()
renderLibrary()
}
list.appendChild(card)
wireCardDrag(card, game)
attachCardIcon(card, game)
})
}
async function playGame(game) {
const res = await window.plwl.gamesRun(game.id)
if (res.ok) {
const entry = runningCards.get(game.id)
if (entry) entry.applyRunning(true)
toast(t('toast.started', game.name), 'success')
} else if (res.error === 'already-running') {
const entry = runningCards.get(game.id)
if (entry) entry.applyRunning(true)
toast(t('library.running'), 'error')
} else if (res.error === 'sandbox-required') {
const reason = t('sandbox.reason.' + ((state.sandbox && state.sandbox.reason) || 'failed'))
toast(t('sandbox.required', reason), 'error')
} else {
toast(t('toast.startFailed', game.name, res.error || ''), 'error')
}
}
async function refreshLibraryData() {
state.games = await window.plwl.gamesList()
const runningList = await window.plwl.gamesListRunning()
const runningSet = new Set(runningList)
for (const [id, entry] of runningCards) {
entry.applyRunning(runningSet.has(id))
const fresh = state.games.find(g => g.id === id)
if (!fresh) continue
if (entry.lastMeta) {
const last = fresh.lastPlayed ? new Date(fresh.lastPlayed).toLocaleDateString() : t('library.never')
entry.lastMeta.textContent = t('library.lastPlayed', last)
}
if (entry.playtimeMeta) {
entry.playtimeMeta.textContent = t('library.playtime', fmtPlaytime(fresh.playSeconds || 0))
}
}
}
function wireRealtime() {
window.plwl.onGamesChanged(() => {
if (state.currentView === 'library') refreshLibraryData()
})
// pick up live playtime/lastPlayed updates while a game is running
setInterval(() => {
if (state.currentView === 'library' && runningCards.size) refreshLibraryData()
}, 15000)
}
// Step 0: Windows or native Linux? Everything after this is the same flow.
async function startImportFlow() {
const modal = showModal(`
<h2>${t('library.import')}</h2>
<p>${t('import.kind.title')}</p>
<div class="mode-buttons">
<button class="button" data-kind="windows">${t('import.kind.windows')}</button>
<p class="subtitle">${t('import.kind.windowsHint')}</p>
<button class="button" data-kind="linux">${t('import.kind.linux')}</button>
<p class="subtitle">${t('import.kind.linuxHint')}</p>
</div>
<div class="modal-actions">
<button class="button" id="modal-cancel">${t('dialog.cancel')}</button>
</div>
`)
modal.querySelector('#modal-cancel').onclick = closeModal
modal.querySelectorAll('[data-kind]').forEach(btn => {
btn.onclick = () => {
const kind = btn.dataset.kind
closeModal()
startModeFlow(kind)
}
})
}
// Step 1: same question as before; only Windows apps can come from a prefix.
async function startModeFlow(kind) {
const fromPrefix = kind !== 'linux'
const modal = showModal(`
<h2>${t('library.import')}</h2>
<p>${t('import.question')}</p>
<div class="mode-buttons">
<button class="button" data-mode="copy">${t('import.mode.copy')}</button>
<button class="button" data-mode="move">${t('import.mode.move')}</button>
<button class="button" data-mode="leave">${t('import.mode.leave')}</button>
${fromPrefix ? `<button class="button" data-mode="prefix">${t('import.mode.prefix')}</button>` : ''}
</div>
<div class="modal-actions">
<button class="button" id="modal-cancel">${t('dialog.cancel')}</button>
</div>
`)
modal.querySelector('#modal-cancel').onclick = closeModal
modal.querySelectorAll('[data-mode]').forEach(btn => {
btn.onclick = async () => {
const mode = btn.dataset.mode
closeModal()
if (mode === 'prefix') return startPrefixImportFlow()
const srcDir = await window.plwl.gamesPickFolder()
if (!srcDir) return
await doImport(srcDir, mode, kind)
}
})
}
// Import step 1: pick the prefix the app folder lives in.
async function startPrefixImportFlow() {
try {
state.prefixes = await window.plwl.prefixesList()
} catch (err) {
toast(t('import.failed', (err && err.message) || String(err)), 'error')
return
}
const modal = showModal(`
<h2>${t('import.prefix.title')}</h2>
<p>${t('import.prefix.hint')}</p>
<div class="mode-buttons" id="prefix-choices"></div>
<div class="modal-actions">
<button class="button" id="modal-cancel">${t('dialog.cancel')}</button>
</div>
`)
modal.querySelector('#modal-cancel').onclick = closeModal
const choices = modal.querySelector('#prefix-choices')
if (!state.prefixes.prefixes.length) {
const p = document.createElement('p')
p.className = 'subtitle'
p.textContent = t('prefixes.empty')
choices.replaceWith(p)
return
}
for (const p of state.prefixes.prefixes) {
const btn = document.createElement('button')
btn.className = 'button'
btn.textContent = p.name + (p.name === state.prefixes.default ? ` (${t('prefixes.default')})` : '')
btn.onclick = async () => {
const prefix = p.name
closeModal()
const srcDir = await window.plwl.gamesPickPrefixFolder(prefix)
if (!srcDir) return
await doPrefixImport(prefix, srcDir)
}
choices.appendChild(btn)
}
}
// One place for the technical error tokens the import handlers answer with.
function importErrorText(raw) {
if (raw === 'no-exe-found') return t('import.noExe')
if (raw === 'no-linux-exe') return t('import.noLinuxExe')
if (raw === 'prefix-root') return t('import.prefixRoot')
if (raw === 'no-drive-c') return t('import.prefix.noDriveC')
if (raw === 'outside-drive-c') return t('import.prefix.outside')
if (raw === 'copy-incomplete') return t('import.copyIncomplete')
return raw || ''
}
async function doImport(srcDir, mode, kind) {
const name = srcDir.split('/').filter(Boolean).pop()
const modal = showModal(`
<h2>${t('import.progress', name)}</h2>
<div class="progress-row"><progress id="import-progress"></progress><span class="progress-pct"></span></div>
<p class="subtitle" id="import-status"></p>
`)
let count = 0
window.plwl.onImportProgress(data => {
count++
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
if (bar && data && data.totalBytes) {
renderProgressBar(bar, data.bytes, data.totalBytes)
if (pct) pct.textContent = Math.round(((data.bytes || 0) / data.totalBytes) * 100) + '%'
}
if (status) status.textContent = t('import.fileDone', (data && data.files) || count)
})
try {
const game = await window.plwl.gamesImport(srcDir, mode, kind)
closeModal()
if (!game || game.ok === false) {
toast(t('import.failed', importErrorText((game && game.error) || '')), 'error')
return
}
state.games = await window.plwl.gamesList()
renderLibrary()
toast(t('toast.importDone', game.name), 'success')
await openGameConfig(game.id)
} catch (err) {
closeModal()
toast(t('import.failed', (err && err.message) || String(err)), 'error')
}
}
// --- "Open with PLWL" (file manager -> Open With -> PLWL) -----------------------
const openwithTexts = {}
function ot(key, ...args) {
let text = openwithTexts[key] ?? t(key)
args.forEach((v, i) => {
text = String(text).split(`{${i}}`).join(String(v))
})
return text
}
async function loadOpenwithTexts() {
try {
Object.assign(openwithTexts, await window.plwl.openwithTexts(state.lang))
} catch {}
}
function isWindowsExecutable(p) {
return /\.(exe|msi|bat|cmd|lnk)$/i.test(p || '')
}
// "Open with" popup: wine + prefix selects defaulting to the launcher defaults.
async function showOpenWithModal(openPath) {
// always fresh: defaults can change while the launcher sits in the tray
try {
const [wines, prefixes, settings] = await Promise.all([
window.plwl.winesList(),
window.plwl.prefixesList(),
window.plwl.settingsGet()
])
state.wines = wines
state.prefixes = prefixes
state.settings = settings
} catch {}
const modal = showModal(`
<h2>${ot('openwith.title')}</h2>
<p class="subtitle openwith-file-row">${ot('openwith.file')}: <code id="ow-file"></code></p>
<p class="subtitle" id="ow-missing" style="display:none">${ot('openwith.fileMissing')}</p>
<label class="field-label">${ot('openwith.wine')}</label>
<span id="ow-wine"></span>
<label class="field-label">${ot('openwith.prefix')}</label>
<span id="ow-prefix"></span>
<div class="modal-actions">
<button class="button" id="ow-cancel">${t('dialog.cancel')}</button>
<button class="button primary" id="ow-run">${ot('openwith.run')}</button>
</div>
`)
modal.querySelector('#ow-file').textContent = openPath
modal.querySelector('#ow-file').title = openPath
const missing = !window.plwl.fileExists(openPath) || !isWindowsExecutable(openPath)
modal.querySelector('#ow-missing').style.display = missing ? '' : 'none'
const defWine = (state.settings && state.settings.defaultWine) || 'system'
const defPrefix = state.prefixes.default || ''
const wineOpts = [
{ value: 'default', label: ot('openwith.wineDefault', wineLabel(defWine)) },
...state.wines.map(w => ({ value: w.id, label: w.label }))
]
const prefixOpts = [
{ value: '', label: ot('openwith.prefixDefault', defPrefix || 'Main') },
...state.prefixes.prefixes.filter(p => p.name !== defPrefix).map(p => ({ value: p.name, label: p.name }))
]
new CustomSelect(modal.querySelector('#ow-wine'), wineOpts, 'default')
new CustomSelect(modal.querySelector('#ow-prefix'), prefixOpts, '')
modal.querySelector('#ow-cancel').onclick = closeModal
modal.querySelector('#ow-run').onclick = async () => {
if (missing) {
closeModal()
return
}
const runBtn = modal.querySelector('#ow-run')
runBtn.disabled = true
const res = await window.plwl.openwithRun({
openPath,
wine: modal.querySelector('#ow-wine').dataset.value || 'default',
prefix: modal.querySelector('#ow-prefix').dataset.value || ''
})
closeModal()
if (res.ok) {
toast(t('toast.started', window.plwl.basename(openPath)), 'success')
} else {
const raw = res.error === 'file-missing' ? ot('openwith.fileMissing') : (res.error === 'not-windows' ? ot('openwith.notWindows') : (res.error || ''))
toast(ot('openwith.startFailed', window.plwl.basename(openPath), raw), 'error')
}
}
}
// Entry point: the main process hands over a file opened through PLWL
// (boot argv or a second-instance handoff).
async function handleOpenWith(openPath) {
if (!openPath) return
await loadOpenwithTexts()
showOpenWithModal(openPath)
}
function wireOpenWith() {
// 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)
}
// --- isolated hand-off -------------------------------------------------------
// A document handed to an isolated app is copied in first. Only copies big
// enough to be noticed arrive here (main decides, and puts a window up so this
// popup can be seen at all).
function wireHandoffProgress() {
window.plwl.onHandoffProgress(data => {
if (!data || !data.notice) return
if (data.phase === 'copy') return showHandoffCopy(data)
if (data.phase !== 'done' && data.phase !== 'error') return
// only ever close our own modal: another one may have opened over it
if (qs('#modal').querySelector('#ho-progress')) closeModal()
if (data.phase === 'error') toast(t('handoff.failed', data.name, data.error || ''), 'error')
})
}
function showHandoffCopy(data) {
const modal = qs('#modal').querySelector('#ho-progress')
? qs('#modal')
: showModal(`
<h2>${t('handoff.copying', data.name)}</h2>
<div class="progress-row"><progress id="ho-progress"></progress><span class="progress-pct"></span></div>
<p class="subtitle" id="ho-status"></p>
`)
renderProgressBar(modal.querySelector('#ho-progress'), data.bytes, data.totalBytes)
const pct = modal.querySelector('.progress-pct')
pct.textContent = data.totalBytes ? Math.round(((data.bytes || 0) / data.totalBytes) * 100) + '%' : ''
modal.querySelector('#ho-status').textContent = t('handoff.copyNote', formatBytes(data.bytes), formatBytes(data.totalBytes))
}
// Import step 2: register the app in place, nothing is copied.
async function doPrefixImport(prefix, srcDir) {
const name = srcDir.split('/').filter(Boolean).pop()
// nothing copied: no progress to show
showModal(`
<h2>${t('import.adding', name)}</h2>
<p class="subtitle">${t('import.prefix.registering')}</p>
`)
try {
const game = await window.plwl.gamesImportFromPrefix(prefix, srcDir)
closeModal()
if (!game || game.ok === false) {
toast(t('import.failed', importErrorText((game && game.error) || '')), 'error')
return
}
state.games = await window.plwl.gamesList()
renderLibrary()
toast(t('toast.importDone', game.name), 'success')
await openGameConfig(game.id)
} catch (err) {
closeModal()
toast(t('import.failed', (err && err.message) || String(err)), 'error')
}
}
// 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 || [])
const users = (games || []).filter(g => (g.prefix || state.prefixes.default || 'Main') === name)
return { all: users, running: users.filter(g => runningSet.has(g.id)) }
}
function renderPrefixes() {
const list = qs('#prefix-list')
list.innerHTML = ''
const defs = state.prefixes
if (!defs.prefixes.length) {
const empty = document.createElement('p')
empty.className = 'subtitle'
empty.textContent = t('prefixes.empty')
list.appendChild(empty)
return
}
for (const p of defs.prefixes) {
const locked = !!p.locked || p.name === 'Main'
const row = document.createElement('div')
row.className = 'list-row'
row.innerHTML = `
<div class="grow">
<div class="title"></div>
</div>
${p.name === defs.default ? `<span class="badge primary">${t('prefixes.default')}</span>` : ''}
<div class="actions">
${locked ? '' : '<button class="button small" data-act="rename"></button>'}
<button class="button small" data-act="default" ${p.name === defs.default ? 'style="display:none"' : ''}></button>
<button class="button small" data-act="reset"></button>
<button class="button small danger" data-act="delete" ${locked ? 'style="display:none"' : ''}></button>
</div>
`
row.querySelector('.title').textContent = p.name
const renBtn = row.querySelector('[data-act=rename]')
if (renBtn) {
renBtn.textContent = t('prefixes.rename')
renBtn.onclick = async () => {
const users = await prefixUsers(p.name)
const name = await promptModal({
title: t('prefixes.renameTitle'),
label: t('prefixes.namePlaceholder'),
hint: users.running.length ? t('prefixes.inUseHint') : t('prefixes.renameHint'),
value: p.name,
confirm: t('prefixes.rename')
})
if (!name || name === p.name) return
const res = await window.plwl.prefixesRename(p.name, name)
if (!res || !res.ok) {
const err = (res && res.error) || ''
if (err === 'inUse') toast(t('prefixes.inUse', p.name, (res.apps || []).join(', ')), 'error')
else if (err === 'hasApps') toast(t('prefixes.hasApps', p.name, (res.apps || []).join(', ')), 'error')
else {
const known = { alreadyExists: 'prefixes.exists', invalidName: 'prefixes.invalidName', isMain: 'prefixes.mainLocked' }
toast(known[err] ? t(known[err]) : t('prefixes.renameFailed', err), 'error')
}
return
}
toast(t('prefixes.renamed', p.name, res.name), 'success')
state.prefixes = await window.plwl.prefixesList()
// the apps that run in this prefix point at the new name now
state.games = await window.plwl.gamesList()
renderPrefixes()
}
}
const defBtn = row.querySelector('[data-act=default]')
defBtn.textContent = t('prefixes.makeDefault')
defBtn.onclick = async () => {
const res = await window.plwl.prefixesSetDefault(p.name)
if (res && res.ok !== false) toast(t('toast.prefixDefault', p.name), 'success')
else if (res && res.error === 'inUse') toast(t('prefixes.inUse', p.name, (res.apps || []).join(', ')), 'error')
else if (res && res.error === 'notFound') toast(t('toast.prefixDeleteFailed', p.name), 'error')
state.prefixes = await window.plwl.prefixesList()
renderPrefixes()
}
const resetBtn = row.querySelector('[data-act=reset]')
resetBtn.textContent = t('prefixes.resetConfig')
resetBtn.title = t('prefixes.resetConfigHint')
resetBtn.onclick = async () => {
const ok = await confirmModal(t('prefixes.resetConfigConfirm', p.name), t('prefixes.resetConfig'))
if (!ok) return
const res = await window.plwl.prefixesResetLinuxConfig(p.name)
if (!res || !res.ok) {
if (res && res.error === 'inUse') toast(t('prefixes.resetConfigBusy', p.name, (res.apps || []).join(', ')), 'error')
else toast(t('prefixes.resetConfigFailed', p.name), 'error')
return
}
if (res.removed && res.removed.length) toast(t('prefixes.resetConfigDone', p.name), 'success')
else toast(t('prefixes.resetConfigNone', p.name), 'success')
}
const delBtn = row.querySelector('[data-act=delete]')
delBtn.innerHTML = `${ICONS.trash()}<span>${t('prefixes.delete')}</span>`
delBtn.onclick = async () => {
// 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'))
if (!ok) return
// removing a whole windows drive takes a while: measured progress modal
const res = await runWithProgress({
title: t('prefixes.deleting', p.name),
action: () => window.plwl.prefixesDelete(p.name)
})
if (res.ok) toast(t('toast.prefixDeleted', p.name), 'success')
else if (res.error === 'isMain') toast(t('prefixes.mainLocked'), 'error')
else if (res.error === 'inUse' || res.error === 'hasApps') toast(t('prefixes.hasApps', p.name, (res.apps || []).join(', ')), 'error')
else toast(t('toast.prefixDeleteFailed', p.name), 'error')
state.prefixes = await window.plwl.prefixesList()
renderPrefixes()
}
list.appendChild(row)
}
}
function renderWines() {
const list = qs('#wine-list')
list.innerHTML = ''
const defaultWine = (state.settings && state.settings.defaultWine) || 'system'
if (!state.wines.length) {
const empty = document.createElement('p')
empty.className = 'subtitle'
empty.textContent = t('wines.empty')
list.appendChild(empty)
}
for (const w of state.wines) {
const isDefault = w.id === defaultWine
const row = document.createElement('div')
row.className = 'list-row'
const typeBadge = w.type === 'proton' ? t('wines.type.proton') : (w.type === 'system' ? t('wines.type.system') : t('wines.type.wine'))
row.innerHTML = `
<div class="grow">
<div class="title"></div>
<div class="sub" title=""></div>
</div>
${isDefault ? `<span class="badge primary">${t('wines.default')}</span>` : ''}
<span class="badge">${typeBadge}</span>
${w.version ? `<span class="badge">${w.version}</span>` : ''}
<div class="actions">
<button class="button small" data-act="default" ${isDefault ? 'style="display:none"' : ''}></button>
${w.id !== 'system' ? `<button class="button small" data-act="rename"></button>` : ''}
${w.id !== 'system' ? `<button class="button small danger" data-act="delete"></button>` : ''}
</div>
`
row.querySelector('.title').textContent = w.label
row.querySelector('.sub').textContent = displayPath(w.path) || t('wines.systemHint')
row.querySelector('.sub').title = row.querySelector('.sub').textContent
const renBtn = row.querySelector('[data-act=rename]')
if (renBtn) {
renBtn.textContent = t('wines.rename')
renBtn.onclick = async () => {
const name = await promptModal({
title: t('wines.renameTitle'),
label: t('common.name'),
hint: t('wines.renameHint'),
value: w.displayName || w.label,
confirm: t('wines.rename')
})
if (!name || name === (w.displayName || w.label)) return
const res = await window.plwl.winesSetDisplayName(w.id, name)
if (!res || !res.ok) {
const err = (res && res.error) || ''
toast(err === 'invalidName' ? t('wines.invalidName') : t('wines.renameFailed', err), 'error')
return
}
toast(t('wines.renamed', w.label, res.displayName), 'success')
state.wines = await window.plwl.winesList()
renderWines()
}
}
const defBtn = row.querySelector('[data-act=default]')
defBtn.textContent = t('wines.makeDefault')
defBtn.onclick = async () => {
const res = await window.plwl.winesSetDefault(w.id)
if (res && res.ok) {
state.settings = await window.plwl.settingsGet()
toast(t('wines.defaultSet', w.label), 'success')
}
renderWines()
}
const delBtn = row.querySelector('[data-act=delete]')
if (delBtn) {
delBtn.innerHTML = `${ICONS.trash()}<span>${t('wines.delete')}</span>`
delBtn.onclick = async () => {
const ok = await confirmModal(t('wines.deleteConfirm', w.label), t('wines.delete'))
if (!ok) return
const res = await runWithProgress({
title: t('wines.deleting', w.label),
action: () => window.plwl.winesDelete(w.id)
})
if (res.ok) {
toast(t('wines.deleted', w.label), 'success')
state.wines = await window.plwl.winesList()
state.settings = await window.plwl.settingsGet()
} else if (res.error === 'isSystem') {
toast(t('wines.systemLocked'), 'error')
} else if (res.error === 'inUse') {
toast(t('wines.inUse', (res.apps || []).join(', ')), 'error')
}
renderWines()
}
}
list.appendChild(row)
}
}
function renderSettings() {
if (!selects['set-language']) {
selects['set-language'] = new CustomSelect(qs('#set-language'), [], state.lang)
selects['set-wine'] = new CustomSelect(qs('#set-wine'), [], 'system')
selects['set-prefix'] = new CustomSelect(qs('#set-prefix'), [], '')
}
selects['set-language'].update(state.langs.map(l => ({ value: l.code, label: l.nameSelf })), state.lang)
selects['set-wine'].update(state.wines.map(w => ({ value: w.id, label: w.label })), state.settings.defaultWine || 'system')
selects['set-prefix'].update(state.prefixes.prefixes.map(p => ({ value: p.name, label: p.name })), state.prefixes.default)
qs('#set-closetotray').checked = state.settings.closeToTray !== false
qs('#set-sandbox-status').textContent = isolationStatusText(state.sandbox) || '…'
}
async function recheckSandbox() {
state.sandbox = await window.plwl.sandboxProbe()
if (state.currentView === 'settings') renderSettings()
if (state.currentView === 'library') renderLibrary()
toast(t('sandbox.rechecked'), 'success')
}
// A silent downgrade is the worst outcome, so say it once per install.
function warnSandboxOnce() {
const status = state.sandbox
if (!status || status.backend === 'bwrap') return
if (state.settings && state.settings.sandboxNotifySeen) return
const modal = showModal(`
<h2>${t('sandbox.warnTitle')}</h2>
<p>${t('sandbox.warnBody', t('sandbox.reason.' + (status.reason || 'failed')))}</p>
<div class="modal-actions">
<button class="button primary" id="sandbox-warn-ok">${t('sandbox.dismiss')}</button>
</div>
`)
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 = `
<input class="dll" placeholder="dinput8">
<button class="button small danger" data-act="del">${ICONS.trash()}</button>
`
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()}<span>${tool}</span>`
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(`
<h2>${title}</h2>
<div class="progress-row"><progress></progress><span class="progress-pct"></span></div>
<p class="subtitle" id="op-status">${t('import.phase.count')}</p>
`)
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(
`<div class="sync-summary-row"><span>${label}</span><strong>${value}</strong></div>`
)
// a count alone also shows for a peer that did not send its categories
if (apps || cats.has('apps-portable') || cats.has('apps-prefix')) row(t('lansync.summaryApps'), apps)
if (wines || cats.has('wines')) row(t('lansync.wines'), wines)
if (prefixes || cats.has('prefixes')) row(t('lansync.prefixes'), prefixes)
if (cats.has('settings')) row(t('lansync.summarySettings'), t('lansync.summaryYes'))
if (files || skipped || deleted || 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(`
<h2>${t('lansync.summaryTitle')}</h2>
<p class="subtitle" id="ls-summary-from"></p>
<div class="sync-summary">${rows.join('')}</div>
<div class="modal-actions">
<button class="button primary" id="ls-summary-ok">${t('dialog.ok')}</button>
</div>
`)
// the device name comes off the network: never interpolate it into HTML
modal.querySelector('#ls-summary-from').textContent = t(
received ? 'lansync.summaryReceivedFrom' : 'lansync.summarySentTo',
from || ''
)
modal.querySelector('#ls-summary-ok').onclick = closeModal
}
// "340 MB / 1.2 GB" once the total is known, otherwise just the moved bytes.
function lanBytesLine(bytes, totalBytes) {
const sent = formatBytes(bytes)
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 = `
<div class="grow">
<div class="title"></div>
<div class="sub"></div>
</div>
${device.paired ? `<span class="badge primary">${t('lansync.paired')}</span>` : ''}
<div class="actions">
<button class="button small primary" data-act="sync"></button>
</div>
`
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(`
<h2 id="ls-modal-title"></h2>
<label class="field-label" id="ls-code-label"></label>
<input type="text" id="ls-code" maxlength="8" autocomplete="off" spellcheck="false">
<p class="subtitle" id="ls-code-error" style="display:none"></p>
<label class="field-label" id="ls-what-label"></label>
<div class="check-group" id="ls-categories"></div>
<label class="check-label">
<input type="checkbox" id="ls-force">
<span id="ls-force-label"></span>
</label>
<p class="subtitle" id="ls-warning"></p>
<div class="modal-actions">
<button class="button" id="ls-cancel"></button>
<button class="button primary" id="ls-start"></button>
</div>
`)
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 = '<input type="checkbox" checked><span></span>'
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 = '<input type="checkbox"><span></span>'
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(`
<h2 id="ls-run-title"></h2>
<div class="progress-row"><progress></progress></div>
<p class="subtitle" id="ls-run-phase"></p>
<p class="subtitle" id="ls-run-count"></p>
<p class="subtitle" id="ls-run-stats"></p>
<p class="subtitle" id="ls-run-bytes"></p>
<p class="subtitle sync-warning" id="ls-run-warning"></p>
`, { 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(`
<h2 id="ls-incoming-title"></h2>
<p id="ls-incoming-text"></p>
<p class="subtitle" id="ls-incoming-what"></p>
<p class="subtitle sync-warning" id="ls-incoming-warning"></p>
<div class="modal-actions">
<button class="button" id="ls-incoming-decline"></button>
<button class="button primary" id="ls-incoming-accept"></button>
</div>
`, { 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(`
<h2 id="ls-recv-title"></h2>
<div class="progress-row" id="ls-recv-modal"><progress></progress></div>
<p class="subtitle" id="ls-recv-phase"></p>
<p class="subtitle" id="ls-recv-count"></p>
<p class="subtitle" id="ls-recv-stats"></p>
<p class="subtitle" id="ls-recv-bytes"></p>
<p class="subtitle sync-warning" id="ls-recv-warning"></p>
`, { 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(`
<h2>${t('lansync.reviewTitle')}</h2>
<p class="subtitle" id="ls-review-from"></p>
<p class="subtitle" id="ls-review-text"></p>
<div class="lan-review" id="ls-review-list"></div>
<p class="subtitle sync-warning" id="ls-review-warning"></p>
<div class="lan-review-actions">
<button class="button" id="ls-review-keep"></button>
<button class="button primary" id="ls-review-go"></button>
</div>
`, { 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 = `
<label class="check-label"><input type="checkbox"><span class="lan-node-name"></span></label>
<span class="lan-node-meta"></span>
<button type="button" class="button small lan-node-open" hidden></button>
`
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 = `
<label class="check-label"><input type="checkbox" checked><span class="lan-review-name"></span></label>
<span class="lan-review-meta"></span>
`
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()