1730 lines
68 KiB
JavaScript
1730 lines
68 KiB
JavaScript
'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,
|
|
lansync: null,
|
|
// true while this device pushes a sync (and while it shows the progress
|
|
// popup): only one such popup exists, the pushing side wins over an
|
|
// incoming sync
|
|
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)" 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>',
|
|
logs: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 3h9l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM9 12h6M9 16h6"/></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
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
})
|
|
}
|
|
|
|
// The teardown of a closing modal is delayed (for the fade-out) - an
|
|
// eagerly armed timer must never kill a modal opened in the meantime (e.g.
|
|
// import mode -> prefix picker happens within milliseconds), so showModal
|
|
// cancels any pending teardown and clears the closing state.
|
|
let modalTeardownTimer = null
|
|
let modalSwapTimer = null
|
|
// A running sync must not be thrown away by a stray click: a locked modal
|
|
// ignores the backdrop and Escape, only its own buttons (or the end of the
|
|
// operation) close it.
|
|
let modalLocked = false
|
|
|
|
function showModal(html, opts) {
|
|
modalLocked = !!(opts && opts.locked)
|
|
if (modalTeardownTimer) {
|
|
clearTimeout(modalTeardownTimer)
|
|
modalTeardownTimer = null
|
|
}
|
|
const overlay = qs('#modal-overlay')
|
|
const modal = qs('#modal')
|
|
modal.classList.remove('closing')
|
|
// Opening over an already-visible modal (import mode -> prefix picker):
|
|
// morph instead of close-then-open - the box glides to the new height
|
|
// while old content dissolves into the new one.
|
|
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 while the modal stays open. The new HTML is put in
|
|
// SYNCHRONOUSLY (callers wire their buttons right after showModal returns);
|
|
// an opaque snapshot of the old content is laid on top and fades out while
|
|
// the box animates to the new content's height. Same feel as switching tabs.
|
|
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
|
|
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')
|
|
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>
|
|
`)
|
|
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.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')
|
|
}
|
|
|
|
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()
|
|
if (name === 'lansync') renderLanSync()
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Prefix-imported apps: show paths the way wine sees them - relative to the
|
|
// prefix ("pfx/drive_c/..." or "drive_c/...") instead of the long
|
|
// files/wineprefixes/<prefix>/... form.
|
|
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: accept a prefix-relative (drive_c/... or
|
|
// pfx/drive_c/...) path and resolve it under the game's prefix; anything
|
|
// else falls through to the normal FILES/absolute handling.
|
|
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)
|
|
}
|
|
|
|
async function attachCardIcon(card, game) {
|
|
// placeholder first so every card has a stable icon slot; swapped for the
|
|
// real icon once it loads - games without one just keep the placeholder
|
|
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 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}
|
|
<span class="badge">${wineLabel(game.wine)}</span>
|
|
</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" data-act="logs" title="${t('library.viewLogs')}">${ICONS.logs()}</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)
|
|
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')
|
|
: (inPrefix ? t('library.removePrefixNote') : '')
|
|
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(`
|
|
<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>
|
|
<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)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Import from a wine prefix, step 1: pick WHICH prefix - the app folder must
|
|
// live inside one so its paths can stay prefix-relative (wine then sees it as
|
|
// a regular C:\ install).
|
|
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)
|
|
}
|
|
}
|
|
|
|
async function doImport(srcDir, mode) {
|
|
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></div>
|
|
<p id="import-status"></p>
|
|
`)
|
|
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')
|
|
}
|
|
}
|
|
|
|
// --- "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 || '')
|
|
}
|
|
|
|
// The popup: wine version + prefix selects defaulting to the LAUNCHER's
|
|
// defaults (settings.defaultWine / prefixes.default), like the per-app
|
|
// config's "Default (...)" options - just without importing anything.
|
|
async function showOpenWithModal(openPath) {
|
|
// always fresh: wines/prefixes/default 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 (live handoff and the boot queue) converge on
|
|
// openwithTake(): the main-process slot is drained exactly when a popup
|
|
// shows, so a reloaded window can never resurrect an old popup and two
|
|
// quick handoffs collapse into one (the newest file wins).
|
|
window.plwl.onOpenWith(() => window.plwl.openwithTake().then(handleOpenWith))
|
|
// file queued before the window existed (boot handoff): pick it up once
|
|
window.plwl.openwithTake().then(handleOpenWith)
|
|
}
|
|
|
|
// Import from a prefix, step 2: register the app in place - nothing is
|
|
// copied, paths stay prefix-relative.
|
|
async function doPrefixImport(prefix, srcDir) {
|
|
const name = srcDir.split('/').filter(Boolean).pop()
|
|
const modal = showModal(`
|
|
<h2>${t('import.progress', name)}</h2>
|
|
<div class="progress-row"><progress></progress></div>
|
|
`)
|
|
try {
|
|
const game = await window.plwl.gamesImportFromPrefix(prefix, srcDir)
|
|
closeModal()
|
|
if (!game || game.ok === false) {
|
|
const raw = (game && game.error) || ''
|
|
const errText =
|
|
raw === 'no-exe-found' ? t('import.noExe')
|
|
: raw === 'no-drive-c' ? t('import.prefix.noDriveC')
|
|
: t('import.prefix.outside')
|
|
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 = `
|
|
<div class="grow">
|
|
<div class="title"></div>
|
|
</div>
|
|
${p.name === defs.default ? `<span class="badge primary">${t('prefixes.default')}</span>` : ''}
|
|
<div class="actions">
|
|
<button class="button small" data-act="default" ${p.name === defs.default ? 'style="display:none"' : ''}></button>
|
|
<button class="button small danger" data-act="delete" ${locked ? 'style="display:none"' : ''}></button>
|
|
</div>
|
|
`
|
|
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()}<span>${t('prefixes.delete')}</span>`
|
|
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 = `
|
|
<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 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 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 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 }))
|
|
]
|
|
// prefix imports pin their prefix even when it IS the default (which the
|
|
// list above omits) - keep it visible instead of a blank select
|
|
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)
|
|
// apps imported from a prefix are pinned to it (their paths are prefix-
|
|
// relative) - the select is locked, not just 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 || [])
|
|
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 = `
|
|
<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')
|
|
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()
|
|
})
|
|
// prefix imports keep their paths relative to the prefix they were
|
|
// imported from - pointing them at another prefix would silently break
|
|
// exePath/workdir, so the original prefix stays locked. Other edits in
|
|
// the same save are kept; only the prefix snaps back (with a hint).
|
|
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()
|
|
}
|
|
await window.plwl.gamesUpdate(game.id, patch)
|
|
state.games = await window.plwl.gamesList()
|
|
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' },
|
|
// opt-in: without it a sync never touches the other machine's defaults
|
|
{ id: 'defaults', label: 'lansync.defaults' }
|
|
]
|
|
|
|
// Byte sizes for the progress popups. Decimal units, the way a file manager
|
|
// or a download shows them, because that is what a "1.2 GB" on screen means
|
|
// to the user.
|
|
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 · Prefixes: 1/2 · Wine versions: 0/1" - how much of each category
|
|
// is done out of how much the sync carries, so the line says what is still
|
|
// coming instead of only listing what has already been walked over. A peer
|
|
// that does not announce its totals falls back to the plain count.
|
|
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, not file counts: a sync spends its time on the big
|
|
// files, and a file-count bar sits at 99% while the last gigabyte crawls in.
|
|
// It also follows the PHASE it is in (barBytes/barTotalBytes): a phase starts
|
|
// from an empty bar instead of inheriting a full one from the phase before, so
|
|
// hashing never looks finished while it has just begun.
|
|
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)
|
|
}
|
|
|
|
// Older peers only send the overall byte totals - then those drive the bar.
|
|
function barValues(info) {
|
|
const bytes = info.barBytes == null ? info.bytes : info.barBytes
|
|
const total = info.barTotalBytes == null ? info.totalBytes : info.barTotalBytes
|
|
return [bytes, total]
|
|
}
|
|
|
|
// "340 MB / 1.2 GB" once the total is known (the sending side learns it as it
|
|
// plans the units, the receiving side as the manifests arrive), otherwise
|
|
// just what has moved so far - so the line is never a lying zero.
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Modal that collects the other device's sync code plus what to push. Shown
|
|
// again with a cleared code after a rejected one, so the user can retype it.
|
|
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 box = modal.querySelector('#ls-categories')
|
|
for (const category of LAN_CATEGORIES) {
|
|
const label = document.createElement('label')
|
|
label.className = 'check-label'
|
|
label.innerHTML = '<input type="checkbox" checked><span></span>'
|
|
label.querySelector('input').id = 'ls-cat-' + category.id
|
|
label.querySelector('span').textContent = t(category.label)
|
|
box.appendChild(label)
|
|
}
|
|
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 categories = LAN_CATEGORIES.map(c => c.id).filter(id => modal.querySelector('#ls-cat-' + id).checked)
|
|
if (!categories.length) {
|
|
toast(t('lansync.noneSelected'), 'error')
|
|
return
|
|
}
|
|
await runLanSync(device, value, categories, modal.querySelector('#ls-force').checked)
|
|
}
|
|
}
|
|
|
|
async function runLanSync(device, code, categories, force) {
|
|
// 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
|
|
// Every line keeps its value while the sync moves between phases: the
|
|
// file counter is CUMULATIVE across units (it never resets to 0/N with
|
|
// the next app) and phases that move no files simply do not touch it -
|
|
// only the byte bar follows the phase being worked on
|
|
phase.textContent = t('lansync.phase.' + data.phase, data.label || '').trim()
|
|
renderProgressBar(bar, ...barValues(data))
|
|
if (data.total > 0) count.textContent = t('lansync.progressFiles', data.done, data.total)
|
|
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 })
|
|
} catch (err) {
|
|
res = { ok: false, error: (err && err.message) || '' }
|
|
} finally {
|
|
active = false
|
|
state.lanPushing = false
|
|
}
|
|
closeModal()
|
|
if (res && res.ok) {
|
|
const summary = res.summary || {}
|
|
toast(t('lansync.done', device.name, summary.uploaded || 0, summary.skipped || 0, summary.deleted || 0, formatBytes(summary.bytes || 0)), 'success')
|
|
await renderLanSync()
|
|
return
|
|
}
|
|
const err = (res && res.error) || ''
|
|
if (err === 'bad-code') {
|
|
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' ? t('lansync.offline')
|
|
: err === 'rejected' ? t('lansync.rejected')
|
|
: err === 'incoming-timeout' ? t('lansync.incomingTimedOut')
|
|
: (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 must also be visible here - otherwise the
|
|
// library changes under the user's hands with no explanation. Same popup, but
|
|
// driven by the events the receiving server emits, and equally not closable
|
|
// by a click.
|
|
// The sending machine does its hashing before anything is written here, which
|
|
// can be minutes of complete silence - so the popup switches to "waiting"
|
|
// instead of closing, and only a really long silence drops it. Events after
|
|
// that reopen it (the sync is still running, the user just cannot see it).
|
|
const RECEIVE_WATCHDOG_MS = 15 * 60 * 1000
|
|
const RECEIVE_STALL_MS = 6 * 1000
|
|
// phases whose wording differs on this side (everything else is neutral:
|
|
// "Comparing X", "Updating the library", "Finishing up")
|
|
const RECV_PHASE_KEYS = { copy: 'lansync.recvPhase.copy' }
|
|
// A device wants to push to us: the popup asks for consent before anything
|
|
// moves. One pending request at a time (the server enforces that too).
|
|
let incomingRequest = null
|
|
let incomingTimer = null
|
|
|
|
function onLanIncoming(info) {
|
|
if (!info || !info.requestId) return
|
|
if (state.lanPushing || receiving) {
|
|
// our own transfer owns the modal slot - answer so the sender fails
|
|
// fast instead of waiting out its timeout
|
|
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))
|
|
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')
|
|
// the SENDER is the source of truth: the warning is phrased from this
|
|
// (receiving) side's point of view, not the send modal's mirrorWarning
|
|
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
|
|
let receiveClosed = 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
|
|
receiveClosed = true
|
|
if (!wasReceiving) return
|
|
if (message) toast(message, 'error')
|
|
closeModal()
|
|
}
|
|
|
|
// A sender that crashed halfway through must not leave this window locked: the
|
|
// popup closes itself if nothing at all arrives for a long time (the server
|
|
// drops the session after the same idle time). While it waits, the phase line
|
|
// says so rather than the popup disappearing mid-transfer.
|
|
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) parts.phase.textContent = t('lansync.recvStalled')
|
|
}, 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') receiveClosed = false
|
|
if (!receiving) {
|
|
// The popup also opens mid-sync: the sender may have been hashing for
|
|
// minutes when this window starts listening, and a popup that the safety
|
|
// timeout closed has to come back for the rest of the transfer. A sync
|
|
// that already ended stays closed.
|
|
if (receiveClosed && info.stage !== 'start') return
|
|
if (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
|
|
// The same phases read from this side's point of view: a file arriving here
|
|
// is being received, not sent.
|
|
const phaseKey = RECV_PHASE_KEYS[info.stage] || 'lansync.phase.' + info.stage
|
|
parts.phase.textContent = (info.stage === 'start' ? t('lansync.recvWaiting') : t(phaseKey, info.label || '')).trim()
|
|
renderProgressBar(parts.bar, ...barValues(info))
|
|
if (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') {
|
|
// let the totals be read, then get out of the way (the library reloads
|
|
// itself through lansync:applied)
|
|
if (receiveCloseTimer) clearTimeout(receiveCloseTimer)
|
|
receiveCloseTimer = setTimeout(() => {
|
|
receiveCloseTimer = null
|
|
endReceiving('')
|
|
}, 4000)
|
|
}
|
|
}
|
|
|
|
// Data received from another device changed the local files: reload every
|
|
// list so the views show the merged library instead of stale state. Settings
|
|
// too - a sync with "Default settings" rewrote the default wine/prefix on
|
|
// disk, and the settings view would keep showing the values from before.
|
|
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.onLanSyncApplied(async info => {
|
|
toast(t('lansync.applied', info.from || '', info.files || 0, formatBytes(info.bytes || 0)), 'success')
|
|
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)
|
|
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(`
|
|
<h2>${t('wines.importing')}</h2>
|
|
<div class="progress-row"><progress></progress></div>
|
|
`)
|
|
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 () => {
|
|
// start from the field being edited (exe picker from the exe field) and
|
|
// resolve the SHOWN path (prefix apps display pfx/drive_c/...) to an
|
|
// absolute one - absPathInput alone would pass the prefix-relative form
|
|
// through and the picker would open at the process cwd ('/')
|
|
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 () => {
|
|
applyIconResult(await window.plwl.gamesAutoIcon(state.currentGame.id))
|
|
})
|
|
qs('#gc-icon-browse').addEventListener('click', async () => {
|
|
// start in the app's own folder (prefix apps included) - that is where
|
|
// the exe with the embedded icon most likely lives
|
|
const start = 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()
|
|
wireLanSync()
|
|
qs('#loading').remove()
|
|
qs('#main').style.display = 'flex'
|
|
showView('library')
|
|
}
|
|
|
|
boot()
|