'use strict'
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,
currentView: 'library'
}
function t(key, ...args) {
let text = state.texts[key] ?? key
args.forEach((v, i) => {
text = String(text).split(`{${i}}`).join(String(v))
})
return text
}
const ICONS = {
// app grid - the library view (games AND other Windows apps)
library: () => '',
// stacked layers - wine prefixes stacked on top of each other
prefixes: () => '',
// wine glass - wine/proton runners
wines: () => '',
settings: () => '',
// arrow into a tray - import
config: () => '',
folder: () => '',
trash: () => '',
terminal: () => '',
logs: () => '',
star: () => '',
download: () => '',
chevronDown: () => ''
}
function applyI18n() {
qsa('[data-t]').forEach(el => {
el.textContent = t(el.dataset.t)
})
qsa('[data-ph]').forEach(el => {
el.placeholder = t(el.dataset.ph)
})
qsa('custom-select').forEach(el => {
if (el.refresh) el.refresh()
})
document.title = t('app.title')
}
function setButtonIcons() {
const map = {
'nav-library': ICONS.library(),
'nav-prefixes': ICONS.prefixes(),
'nav-wines': ICONS.wines(),
'nav-settings': ICONS.settings()
}
for (const [id, svg] of Object.entries(map)) {
const el = document.getElementById(id)
const btn = el && el.querySelector('button')
if (btn) btn.innerHTML = svg
}
}
// Smoothly animate elements of `container` between their previous and new
// positions after `mutate()` changes the layout (FLIP technique).
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)
})
}
function showModal(html) {
const overlay = qs('#modal-overlay')
qs('#modal').innerHTML = html
overlay.style.display = 'flex'
requestAnimationFrame(() => requestAnimationFrame(() => overlay.classList.add('show')))
return qs('#modal')
}
function closeModal() {
const overlay = qs('#modal-overlay')
const modal = qs('#modal')
if (overlay.style.display === 'none') return
modal.classList.add('closing')
overlay.classList.remove('show')
setTimeout(() => {
overlay.style.display = 'none'
modal.classList.remove('closing')
modal.innerHTML = ''
}, 160)
}
function confirmModal(message, okLabel) {
return new Promise(resolve => {
const modal = showModal(`
${t('dialog.confirm')}
${message}
`)
modal.querySelector('#modal-cancel').onclick = () => {
closeModal()
resolve(false)
}
modal.querySelector('#modal-ok').onclick = () => {
closeModal()
resolve(true)
}
})
}
class CustomSelect {
constructor(host, options, value, onChange) {
this.host = host
this.options = options
this.value = value
this.onChange = onChange
this.render()
if (!this.host.dataset.wired) {
this.wire()
this.host.dataset.wired = '1'
}
}
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 = `
${ICONS.chevronDown()}
`
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() {
const wasOpen = this.host.classList.contains('open')
CustomSelect.closeAll()
if (!wasOpen) this.host.classList.add('open')
}
close() {
this.host.classList.remove('open')
}
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'))
}
}
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()
}
async function loadAll() {
const [langs, settings, wines, prefixes, games] = await Promise.all([
window.plwl.i18nList(),
window.plwl.settingsGet(),
window.plwl.winesList(),
window.plwl.prefixesList(),
window.plwl.gamesList()
])
state.langs = langs
state.settings = settings
state.wines = wines
state.prefixes = prefixes
state.games = games
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`
}
// Paths of imported (copy/move) apps live inside FILES/ and are stored
// relative to it in the registry (portable mode) - show them prefixed with
// files/ instead of the absolute install location. External (leave) apps
// keep their absolute path.
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 for input fields: accept a files/-prefixed (or bare,
// FILES-relative) path and resolve it to an absolute one.
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
}
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)
}
async function attachCardIcon(card, game) {
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()
img.onload = () => card.prepend(img)
img.src = iconUrl(game.id)
if (img.complete && img.naturalWidth > 0) card.prepend(img)
}
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 last = game.lastPlayed ? new Date(game.lastPlayed).toLocaleDateString() : t('library.never')
card.innerHTML = `
${portable ? t('library.portable') : t('library.external')}
${wineLabel(game.wine)}
${t('library.lastPlayed', last)}
${t('library.playtime', fmtPlaytime(game.playSeconds || 0))}
`
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)
card.querySelector('[data-act=folder]').onclick = () => window.plwl.shellOpenPath(game.workdir)
card.querySelector('[data-act=logs]').onclick = async () => {
const logs = await window.plwl.gamesGetLogs(game.id)
if (logs.length) window.plwl.shellShowItemInFolder(logs[logs.length - 1])
else toast(t('library.viewLogs'))
}
card.querySelector('[data-act=remove]').onclick = async () => {
const note = game.mode === 'copy' || game.mode === 'move' ? t('library.removeFilesNote') : ''
const ok = await confirmModal(t('library.removeConfirm', game.name, note), t('library.remove'))
if (!ok) return
await window.plwl.gamesRemove(game.id)
toast(t('toast.gameRemoved', game.name), 'success')
state.games = await window.plwl.gamesList()
renderLibrary()
}
list.appendChild(card)
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 {
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)
}
async function startImportFlow() {
const modal = showModal(`
${t('library.import')}
${t('import.question')}
`)
modal.querySelector('#modal-cancel').onclick = closeModal
modal.querySelectorAll('[data-mode]').forEach(btn => {
btn.onclick = async () => {
const mode = btn.dataset.mode
closeModal()
const srcDir = await window.plwl.gamesPickFolder()
if (!srcDir) return
await doImport(srcDir, mode)
}
})
}
async function doImport(srcDir, mode) {
const name = srcDir.split('/').filter(Boolean).pop()
const modal = showModal(`
${t('import.progress', name)}
`)
let count = 0
window.plwl.onImportProgress(() => {
count++
const status = modal.querySelector('#import-status')
if (status) status.textContent = t('import.fileDone', count)
})
try {
const game = await window.plwl.gamesImport(srcDir, mode)
closeModal()
if (!game || game.ok === false) {
const raw = (game && game.error) || ''
const errText = raw === 'no-exe-found' ? t('import.noExe') : (raw || 'unknown error')
toast(t('import.failed', errText), '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')
}
}
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 = `
${p.name === defs.default ? `${t('prefixes.default')}` : ''}
`
row.querySelector('.title').textContent = p.name
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')
state.prefixes = await window.plwl.prefixesList()
renderPrefixes()
}
const delBtn = row.querySelector('[data-act=delete]')
delBtn.innerHTML = `${ICONS.trash()}${t('prefixes.delete')}`
delBtn.onclick = async () => {
const ok = await confirmModal(t('prefixes.deleteConfirm', p.name), t('prefixes.delete'))
if (!ok) return
const res = await 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 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 = `
${isDefault ? `${t('wines.default')}` : ''}
${typeBadge}
${w.version ? `${w.version}` : ''}
${w.id !== 'system' ? `` : ''}
`
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 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()}${t('wines.delete')}`
delBtn.onclick = async () => {
const ok = await confirmModal(t('wines.deleteConfirm', w.label), t('wines.delete'))
if (!ok) return
const res = await 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')
}
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
}
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 }))
]
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 = displayPath(game.exePath)
qs('#gc-workdir').value = displayPath(game.workdir)
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)
renderOverrides(game.dllOverrides || [])
renderTools()
}
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')
}
}
function renderOverrides(overrides) {
const box = qs('#gc-overrides')
box.innerHTML = ''
for (const o of overrides) {
box.appendChild(buildOverrideRow(o))
}
}
const OVERRIDE_OPTIONS = [
{ value: 'native', label: 'native' },
{ value: 'builtin', label: 'builtin' },
{ value: 'native,builtin', label: 'native,builtin' },
{ value: 'builtin,native', label: 'builtin,native' },
{ value: '', label: 'disabled' }
]
function buildOverrideRow(o) {
const row = document.createElement('div')
row.className = 'override-row'
row.innerHTML = `
`
row.querySelector('.dll').value = o.dll || ''
const selectHost = document.createElement('span')
new CustomSelect(selectHost, OVERRIDE_OPTIONS, o.setting || 'native')
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' }))
const rows = qsa('#gc-overrides .override-row')
rows[rows.length - 1].querySelector('.dll').focus()
}
function readOverrides() {
return qsa('#gc-overrides .override-row').map(row => ({
dll: row.querySelector('.dll').value.trim(),
setting: row.querySelector('.custom-select').dataset.value ?? 'native'
})).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()
})
const patch = {
name: qs('#gc-name').value.trim() || game.name,
exePath: absPathInput(qs('#gc-exe').value.trim()),
workdir: absPathInput(qs('#gc-workdir').value.trim()),
args: qs('#gc-args').value,
wine: selects['gc-wine'].value,
prefix: selects['gc-prefix'].value,
env,
dllOverrides: readOverrides()
}
await window.plwl.gamesUpdate(game.id, patch)
state.games = await window.plwl.gamesList()
toast(t('config.saved'), 'success')
if (state.currentView === 'gameconfig') {
state.currentGame = { ...state.currentGame, ...patch }
qs('#gc-title').textContent = patch.name
}
}
function renderTools() {
const grid = qs('#gc-tools')
grid.innerHTML = ''
const tools = ['winecfg', 'regedit', 'taskmgr', 'cmd', 'control', 'eject']
for (const tool of tools) {
const btn = document.createElement('button')
btn.className = 'button small'
btn.innerHTML = `${ICONS.terminal()}${tool}`
btn.onclick = async () => {
const game = state.currentGame
const res = await window.plwl.gamesRunTool(game.id, tool, '')
if (res.ok) toast(t('toast.started', tool))
else toast(t('toast.startFailed', tool, res.error || ''), 'error')
}
grid.appendChild(btn)
}
}
async function runCustomTool() {
const input = qs('#gc-custom-tool').value.trim()
if (!input) return
const [tool, ...rest] = input.split(/\s+/)
const game = state.currentGame
const res = await window.plwl.gamesRunTool(game.id, tool, rest.join(' '))
if (res.ok) toast(t('toast.started', tool))
else toast(t('toast.startFailed', tool, res.error || ''), 'error')
}
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)
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 modal = showModal(`
${t('wines.importing')}
`)
const res = await window.plwl.winesImport(srcDir)
closeModal()
if (res.ok) {
toast(t('wines.imported', res.path), 'success')
state.wines = await window.plwl.winesList()
} else {
toast(t('toast.importFailed') + ': ' + (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-gc-back').addEventListener('click', () => showView('library'))
qs('#btn-gc-save').addEventListener('click', saveGameConfig)
qs('#btn-gc-shortcut').addEventListener('click', createAppShortcut)
qs('#gc-override-add').addEventListener('click', addOverrideRow)
qs('#gc-exe-browse').addEventListener('click', async () => {
const cur = absPathInput(qs('#gc-workdir').value.trim())
const p = await window.plwl.dialogPickFile({ properties: ['openFile'], defaultPath: cur || window.plwl.FILES_DIR })
if (p) qs('#gc-exe').value = displayPath(p)
})
qs('#gc-workdir-browse').addEventListener('click', async () => {
const cur = absPathInput(qs('#gc-exe').value.trim())
const p = await window.plwl.dialogPickFile({ properties: ['openDirectory'], defaultPath: cur || window.plwl.FILES_DIR })
if (p) qs('#gc-workdir').value = displayPath(p)
})
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 () => {
applyIconResult(await window.plwl.gamesAutoIcon(state.currentGame.id))
})
qs('#gc-icon-browse').addEventListener('click', async () => {
const p = await window.plwl.dialogPickFile({
properties: ['openFile'],
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 (e.target === qs('#modal-overlay')) closeModal()
})
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeModal()
})
}
async function boot() {
setButtonIcons()
await loadAll()
applyI18n()
wireEvents()
wireRealtime()
qs('#loading').remove()
qs('#main').style.display = 'flex'
showView('library')
}
boot()