'use strict' // The CSS scales the root font size with the window width (max(15px, 100vw/90)), // which is fractional at almost every width - every rem value in the UI then // lands on a fractional pixel and text/border rounding drifts a pixel up or // down while resizing (badges were the most visible case). Snap it to whole // pixels so the whole layout steps in 1px root increments instead. function snapRootFontSize() { const px = Math.max(15, Math.round(window.innerWidth / 90)) document.documentElement.style.fontSize = px + 'px' } snapRootFontSize() window.addEventListener('resize', snapRootFontSize, { passive: true }) const qs = s => document.querySelector(s) const qsa = s => [...document.querySelectorAll(s)] const runningCards = new Map() const state = { lang: 'en-us', texts: {}, langs: [], settings: null, wines: [], prefixes: { default: '', prefixes: [] }, games: [], currentGame: null, sandbox: null, lansync: null, // true while this device pushes a sync: it owns the single popup lanPushing: false, currentView: 'library' } function t(key, ...args) { let text = state.texts[key] ?? key args.forEach((v, i) => { text = String(text).split(`{${i}}`).join(String(v)) }) return text } const ICONS = { // app grid - the library view (games AND other Windows apps) library: () => '', // stacked layers - wine prefixes stacked on top of each other prefixes: () => '', // wine glass - wine/proton runners wines: () => '', settings: () => '', // nodes connected to each other - devices on the local network lansync: () => '', // arrow into a tray - import config: () => '', folder: () => '', trash: () => '', terminal: () => '', star: () => '', download: () => '', chevronDown: () => '' } function applyI18n() { qsa('[data-t]').forEach(el => { el.textContent = t(el.dataset.t) }) qsa('[data-ph]').forEach(el => { el.placeholder = t(el.dataset.ph) }) qsa('custom-select').forEach(el => { if (el.refresh) el.refresh() }) document.title = t('app.title') } function setButtonIcons() { const map = { 'nav-library': ICONS.library(), 'nav-prefixes': ICONS.prefixes(), 'nav-wines': ICONS.wines(), 'nav-settings': ICONS.settings(), 'nav-lansync': ICONS.lansync() } for (const [id, svg] of Object.entries(map)) { const el = document.getElementById(id) const btn = el && el.querySelector('button') if (btn) btn.innerHTML = svg } } // FLIP: animate elements between their previous and new layout positions. function flipShift(container, mutate) { const entries = [...container.children].map(el => ({ el, top: el.getBoundingClientRect().top })) mutate() for (const { el, top } of entries) { if (!el.isConnected) continue const delta = top - el.getBoundingClientRect().top if (!delta) continue el.style.transition = 'none' el.style.transform = `translateY(${delta}px)` void el.offsetHeight // flush styles so the shift back animates el.style.transition = '' el.style.transform = '' } } function toast(message, kind) { const container = qs('#toasts') flipShift(container, () => { const el = document.createElement('div') el.className = 'toast' + (kind ? ' ' + kind : '') el.textContent = message container.appendChild(el) requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('show'))) setTimeout(() => { el.classList.add('hide') // after the fade-out, free the slot with an animated shift instead of a jump setTimeout(() => flipShift(container, () => el.remove()), 300) }, 4000) }) } // A delayed teardown must never kill a modal opened in the meantime. let modalTeardownTimer = null let modalSwapTimer = null // A locked modal ignores backdrop/Escape; only its own buttons close it. let modalLocked = false // Settles the promise-based modal (confirm/prompt) so callers never hang. let modalSettle = null function settleModal(value) { const settle = modalSettle modalSettle = null if (settle) settle(value) } function showModal(html, opts) { modalLocked = !!(opts && opts.locked) // a review list needs the room a narrow form does not qs('#modal').classList.toggle('wide', !!(opts && opts.wide)) settleModal(null) if (modalTeardownTimer) { clearTimeout(modalTeardownTimer) modalTeardownTimer = null } const overlay = qs('#modal-overlay') const modal = qs('#modal') modal.classList.remove('closing') // morph instead of close-then-open when a modal opens over a visible one const swapping = overlay.style.display !== 'none' && !!modal.innerHTML.trim() if (swapping) swapModalContent(modal, html) else modal.innerHTML = html overlay.style.display = 'flex' requestAnimationFrame(() => requestAnimationFrame(() => overlay.classList.add('show'))) return qs('#modal') } // Animated content swap: new HTML goes in synchronously, the old one fades // out on top while the box glides to the new height. function swapModalContent(modal, html) { if (modalSwapTimer) { clearTimeout(modalSwapTimer) modalSwapTimer = null } const prevGhost = modal.querySelector('.modal-ghost') if (prevGhost) prevGhost.remove() // rapid double swap: snapshot clean state modal.classList.remove('swapping') const oldH = modal.offsetHeight const ghost = document.createElement('div') ghost.className = 'modal-ghost' ghost.innerHTML = modal.innerHTML modal.classList.add('resizing') // clip content while the box glides modal.style.height = oldH + 'px' modal.innerHTML = html // measure the new natural height without an intermediate paint modal.style.height = 'auto' const newH = modal.offsetHeight modal.style.height = oldH + 'px' void modal.offsetHeight // flush styles so the height transition starts here modal.appendChild(ghost) void ghost.offsetHeight // commit the ghost's initial opacity:1 so the fade transition runs requestAnimationFrame(() => { modal.style.height = newH + 'px' modal.classList.add('swapping') // fades the ghost out over the new content modalSwapTimer = setTimeout(() => { modalSwapTimer = null ghost.remove() modal.classList.remove('swapping', 'resizing') modal.style.height = '' // back to auto (dropdowns may overflow again) }, 240) }) } function closeModal() { const overlay = qs('#modal-overlay') const modal = qs('#modal') if (overlay.style.display === 'none') return settleModal(null) modal.classList.add('closing') overlay.classList.remove('show') if (modalTeardownTimer) clearTimeout(modalTeardownTimer) modalTeardownTimer = setTimeout(() => { modalTeardownTimer = null if (modalSwapTimer) { clearTimeout(modalSwapTimer) modalSwapTimer = null } overlay.style.display = 'none' modal.classList.remove('closing', 'swapping', 'resizing', 'wide') modal.style.height = '' modal.innerHTML = '' modalLocked = false }, 160) } function confirmModal(message, okLabel) { return new Promise(resolve => { const modal = showModal(`

${t('dialog.confirm')}

${message}

`) const finish = ok => { settleModal(ok) closeModal() } modal.querySelector('#modal-cancel').onclick = () => finish(false) modal.querySelector('#modal-ok').onclick = () => finish(true) modalSettle = v => resolve(!!v) }) } // Shared "type a name" modal; Cancel/Escape/backdrop resolve null. function promptModal({ title, label, value, confirm, hint }) { return new Promise(resolve => { const modal = showModal(`

${title}

${hint ? `

${hint}

` : ''} `) const input = modal.querySelector('#prompt-input') input.value = value || '' input.focus() input.select() const finish = v => { settleModal(v) closeModal() } const submit = () => finish(input.value.trim() || null) input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault() submit() } }) modal.querySelector('#modal-cancel').onclick = () => finish(null) modal.querySelector('#modal-ok').onclick = submit modalSettle = v => resolve(v) }) } class CustomSelect { constructor(host, options, value, onChange) { this.host = host this.options = options this.value = value this.onChange = onChange this.disabled = false this.render() if (!this.host.dataset.wired) { this.wire() this.host.dataset.wired = '1' } } setDisabled(v) { this.disabled = !!v this.host.classList.toggle('disabled', this.disabled) } render() { const selected = this.options.find(o => o.value === this.value) this.host.classList.add('custom-select') this.host.dataset.value = this.value ?? '' this.host.innerHTML = `
${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() { if (this.disabled) return const wasOpen = this.host.classList.contains('open') CustomSelect.closeAll() if (!wasOpen) { this.host.classList.add('open') this.lift() } } // the transform on every element (see style.css) makes each one its own // stacking context, so an ancestor wrapper would still paint the open list // under the rows that follow it; positioned ancestors already stack right lift() { for (let el = this.host.parentElement; el && el !== document.body; el = el.parentElement) { if (getComputedStyle(el).position === 'static') el.classList.add('select-lift') } } close() { this.host.classList.remove('open') CustomSelect.unlift() } setValue(v) { this.value = v this.host.dataset.value = v ?? '' this.render() if (this.onChange) this.onChange(v) } update(options, value) { this.options = options if (value !== undefined) this.value = value this.host.dataset.value = this.value ?? '' this.render() } static closeAll() { qsa('.custom-select.open').forEach(el => el.classList.remove('open')) CustomSelect.unlift() } static unlift() { qsa('.select-lift').forEach(el => el.classList.remove('select-lift')) } } const selects = {} const NAV_PARENT = { gameconfig: 'library' } function showView(name) { state.currentView = name qsa('.view').forEach(v => v.classList.remove('active')) const view = qs('#view-' + name) if (view) view.classList.add('active') const navName = NAV_PARENT[name] || name qsa('sidebarelement').forEach(el => el.classList.remove('active')) const nav = qs('#nav-' + navName) if (nav) nav.classList.add('active') if (name === 'library') renderLibrary() if (name === 'prefixes') renderPrefixes() if (name === 'wines') renderWines() if (name === 'settings') renderSettings() if (name === 'lansync') renderLanSync() } async function loadAll() { const [langs, settings, wines, prefixes, games, sandbox] = await Promise.all([ window.plwl.i18nList(), window.plwl.settingsGet(), window.plwl.winesList(), window.plwl.prefixesList(), window.plwl.gamesList(), window.plwl.sandboxStatus() ]) state.langs = langs state.settings = settings state.wines = wines state.prefixes = prefixes state.games = games state.sandbox = sandbox state.lang = settings.language || 'en-us' await loadLangTexts(state.lang) } async function loadLangTexts(code) { const lang = await window.plwl.i18nGet(code) if (lang && lang.texts) { state.texts = lang.texts state.lang = code } } function wineLabel(id) { const w = state.wines.find(w => w.id === id) return w ? w.label : (id === 'system' ? t('wines.system') : id) } function iconUrl(id) { return `plwl-icon://${encodeURIComponent(id)}/icon` } // --- native Linux apps ------------------------------------------------------ const isLinuxGame = game => !!game && game.kind === 'linux' function sandboxStrong() { return !!(state.sandbox && state.sandbox.backend === 'bwrap') } // "bwrap 0.12.0 (strong)" / "no sandbox: not installed" function isolationStatusText(status) { if (!status) return '' if (status.backend === 'bwrap') return t('config.isolation.bwrap', status.version || '') return t('config.isolation.env', t('sandbox.reason.' + (status.reason || 'failed'))) } // The badge names the level the app would really run at, not the policy: // automatic is a choice, and what it resolves to depends on this machine. function isolationLevel(game) { const policy = (game && game.isolation) || 'auto' if (policy === 'none') return 'none' if (policy === 'auto') return sandboxStrong() ? 'relaxed' : 'none' return sandboxStrong() ? policy : 'unavailable' } function isolationBadge(game) { const level = isolationLevel(game) if (level === 'none') return `${t('sandbox.badgeNone')}` if (level === 'unavailable') return `${t('sandbox.badgeRequired')}` if (level === 'isolated') return `${t('sandbox.badgeIsolated')}` return `${t('sandbox.badgeRelaxed')}` } // Portable apps show files/-relative paths; external apps keep absolute ones. function displayPath(p) { if (!p) return '' const files = window.plwl.FILES_DIR if (files && p.startsWith(files + '/')) return 'files/' + p.slice(files.length + 1) return p } // Inverse of displayPath: files/-prefixed or bare path -> absolute. function absPathInput(v) { if (!v) return v const files = window.plwl.FILES_DIR if (!files) return v if (v.startsWith('files/')) return files + '/' + v.slice(6) if (!v.includes('/') && !v.startsWith('.')) return files + '/' + v return v } // Prefix-imported apps: show paths as wine sees them (prefix-relative). function displayGamePath(p, game) { if (game && game.importSource === 'prefix' && game.prefix) { const files = window.plwl.FILES_DIR const base = files ? files + '/wineprefixes/' + game.prefix + '/' : '' if (base && p && p.startsWith(base)) return p.slice(base.length) } return displayPath(p) } // Inverse of displayGamePath: prefix-relative path -> absolute under the prefix. function absGamePathInput(v, game) { if (game && game.importSource === 'prefix' && game.prefix && /^(pfx\/)?drive_c([\/]|$)/i.test(v || '')) { const files = window.plwl.FILES_DIR if (files) return files + '/wineprefixes/' + game.prefix + '/' + v } return absPathInput(v) } function fmtPlaytime(seconds) { const s = Math.round(seconds || 0) if (s < 60) return t('library.playtimeSeconds', s) const h = Math.floor(s / 3600) const m = Math.floor((s % 3600) / 60) if (h > 0) return t('library.playtimeHM', h, m) return t('library.playtimeMin', m) } // --- reordering the library (drag & drop) ---------------------------------- // The registry array is the display order; a drop reorders state.games and // persists the whole id list. let dragId = null function clearDropMarks(except) { qsa('.game-card.drop-before, .game-card.drop-after').forEach(card => { if (card !== except) card.classList.remove('drop-before', 'drop-after') }) } function dropSide(card, x) { const rect = card.getBoundingClientRect() return x < rect.left + rect.width / 2 ? 'before' : 'after' } function commitOrder(ids) { const byId = new Map(state.games.map(g => [g.id, g])) state.games = ids.map(id => byId.get(id)).filter(Boolean) renderLibrary() window.plwl.gamesReorder(ids).then(games => { if (!Array.isArray(games)) return const same = games.length === state.games.length && games.every((g, i) => g.id === state.games[i].id) if (same) return // the registry also held entries this render did not know about - show them state.games = games renderLibrary() }) } function wireCardDrag(card, game) { card.draggable = true card.title = t('library.reorderHint') card.addEventListener('dragstart', e => { dragId = game.id e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', game.id) // a drag needs data to start card.classList.add('dragging') }) card.addEventListener('dragend', () => { dragId = null // a drop in place never re-renders the list, so drop the fade here too card.classList.remove('dragging') clearDropMarks() }) card.addEventListener('dragover', e => { if (!dragId || dragId === game.id) return e.preventDefault() e.dataTransfer.dropEffect = 'move' const side = dropSide(card, e.clientX) clearDropMarks(card) card.classList.toggle('drop-before', side === 'before') card.classList.toggle('drop-after', side === 'after') }) card.addEventListener('dragleave', e => { // dragleave also fires when the pointer crosses a child element if (!card.contains(e.relatedTarget)) card.classList.remove('drop-before', 'drop-after') }) card.addEventListener('drop', e => { if (!dragId || dragId === game.id) return e.preventDefault() e.stopPropagation() // the list's own drop handler moves cards to the end const side = dropSide(card, e.clientX) const id = dragId dragId = null const ids = state.games.map(g => g.id).filter(x => x !== id) const at = ids.indexOf(game.id) if (at === -1) return ids.splice(side === 'before' ? at : at + 1, 0, id) commitOrder(ids) }) } async function attachCardIcon(card, game) { // placeholder first, swapped for the real icon once it loads const ph = document.createElement('div') ph.className = 'game-icon placeholder' card.prepend(ph) const p = await window.plwl.gamesIconPath(game.id) if (!p) return const img = document.createElement('img') img.className = 'game-icon' img.alt = '' img.onerror = () => img.remove() // placeholder stays visible img.onload = () => { ph.remove() card.prepend(img) } img.src = iconUrl(game.id) if (img.complete && img.naturalWidth > 0) img.onload() } function renderLibrary() { const list = qs('#game-list') list.innerHTML = '' runningCards.clear() qs('#library-empty').style.display = state.games.length ? 'none' : 'flex' state.games.forEach((game, idx) => { const card = document.createElement('div') card.className = 'game-card' card.style.animationDelay = `${idx * 0.04}s` const portable = game.mode === 'copy' || game.mode === 'move' const inPrefix = game.importSource === 'prefix' const isLinux = isLinuxGame(game) const sourceBadge = inPrefix ? `${t('library.inPrefix')}${game.prefix ? ' · ' + game.prefix : ''}` : `${portable ? t('library.portable') : t('library.external')}` const last = game.lastPlayed ? new Date(game.lastPlayed).toLocaleDateString() : t('library.never') card.innerHTML = `

${sourceBadge} ${isLinux ? `${t('config.linuxKindValue')}` : `${wineLabel(game.wine)}`} ${isLinux ? isolationBadge(game) : ''}
${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) // the app's own folder, not its working directory card.querySelector('[data-act=folder]').onclick = () => window.plwl.shellOpenPath(game.path || game.workdir) card.querySelector('[data-act=remove]').onclick = async () => { const note = game.mode === 'copy' || game.mode === 'move' ? t('library.removeFilesNote') : (inPrefix ? t('library.removePrefixNote') : (isLinux ? t('library.removeLinuxDataNote') : '')) const ok = await confirmModal(t('library.removeConfirm', game.name, note), t('library.remove')) if (!ok) return // portable apps delete their folder too - show how far that got await runWithProgress({ title: t('library.removing', game.name), action: () => window.plwl.gamesRemove(game.id) }) toast(t('toast.gameRemoved', game.name), 'success') state.games = await window.plwl.gamesList() renderLibrary() } list.appendChild(card) wireCardDrag(card, game) attachCardIcon(card, game) }) } async function playGame(game) { const res = await window.plwl.gamesRun(game.id) if (res.ok) { const entry = runningCards.get(game.id) if (entry) entry.applyRunning(true) toast(t('toast.started', game.name), 'success') } else if (res.error === 'already-running') { const entry = runningCards.get(game.id) if (entry) entry.applyRunning(true) toast(t('library.running'), 'error') } else if (res.error === 'sandbox-required') { const reason = t('sandbox.reason.' + ((state.sandbox && state.sandbox.reason) || 'failed')) toast(t('sandbox.required', reason), 'error') } else { toast(t('toast.startFailed', game.name, res.error || ''), 'error') } } async function refreshLibraryData() { state.games = await window.plwl.gamesList() const runningList = await window.plwl.gamesListRunning() const runningSet = new Set(runningList) for (const [id, entry] of runningCards) { entry.applyRunning(runningSet.has(id)) const fresh = state.games.find(g => g.id === id) if (!fresh) continue if (entry.lastMeta) { const last = fresh.lastPlayed ? new Date(fresh.lastPlayed).toLocaleDateString() : t('library.never') entry.lastMeta.textContent = t('library.lastPlayed', last) } if (entry.playtimeMeta) { entry.playtimeMeta.textContent = t('library.playtime', fmtPlaytime(fresh.playSeconds || 0)) } } } function wireRealtime() { window.plwl.onGamesChanged(() => { if (state.currentView === 'library') refreshLibraryData() }) // pick up live playtime/lastPlayed updates while a game is running setInterval(() => { if (state.currentView === 'library' && runningCards.size) refreshLibraryData() }, 15000) } // Step 0: Windows or native Linux? Everything after this is the same flow. async function startImportFlow() { const modal = showModal(`

${t('library.import')}

${t('import.kind.title')}

${t('import.kind.windowsHint')}

${t('import.kind.linuxHint')}

`) modal.querySelector('#modal-cancel').onclick = closeModal modal.querySelectorAll('[data-kind]').forEach(btn => { btn.onclick = () => { const kind = btn.dataset.kind closeModal() startModeFlow(kind) } }) } // Step 1: same question as before; only Windows apps can come from a prefix. async function startModeFlow(kind) { const fromPrefix = kind !== 'linux' const modal = showModal(`

${t('library.import')}

${t('import.question')}

${fromPrefix ? `` : ''}
`) modal.querySelector('#modal-cancel').onclick = closeModal modal.querySelectorAll('[data-mode]').forEach(btn => { btn.onclick = async () => { const mode = btn.dataset.mode closeModal() if (mode === 'prefix') return startPrefixImportFlow() const srcDir = await window.plwl.gamesPickFolder() if (!srcDir) return await doImport(srcDir, mode, kind) } }) } // Import step 1: pick the prefix the app folder lives in. async function startPrefixImportFlow() { try { state.prefixes = await window.plwl.prefixesList() } catch (err) { toast(t('import.failed', (err && err.message) || String(err)), 'error') return } const modal = showModal(`

${t('import.prefix.title')}

${t('import.prefix.hint')}

`) modal.querySelector('#modal-cancel').onclick = closeModal const choices = modal.querySelector('#prefix-choices') if (!state.prefixes.prefixes.length) { const p = document.createElement('p') p.className = 'subtitle' p.textContent = t('prefixes.empty') choices.replaceWith(p) return } for (const p of state.prefixes.prefixes) { const btn = document.createElement('button') btn.className = 'button' btn.textContent = p.name + (p.name === state.prefixes.default ? ` (${t('prefixes.default')})` : '') btn.onclick = async () => { const prefix = p.name closeModal() const srcDir = await window.plwl.gamesPickPrefixFolder(prefix) if (!srcDir) return await doPrefixImport(prefix, srcDir) } choices.appendChild(btn) } } // One place for the technical error tokens the import handlers answer with. function importErrorText(raw) { if (raw === 'no-exe-found') return t('import.noExe') if (raw === 'no-linux-exe') return t('import.noLinuxExe') if (raw === 'prefix-root') return t('import.prefixRoot') if (raw === 'no-drive-c') return t('import.prefix.noDriveC') if (raw === 'outside-drive-c') return t('import.prefix.outside') if (raw === 'copy-incomplete') return t('import.copyIncomplete') return raw || '' } async function doImport(srcDir, mode, kind) { const name = srcDir.split('/').filter(Boolean).pop() const modal = showModal(`

${t('import.progress', name)}

`) let count = 0 window.plwl.onImportProgress(data => { count++ const bar = modal.querySelector('#import-progress') const pct = modal.querySelector('.progress-pct') const status = modal.querySelector('#import-status') // byte totals drive the bar when the copy knows them if (bar && data && data.totalBytes) { renderProgressBar(bar, data.bytes, data.totalBytes) if (pct) pct.textContent = Math.round(((data.bytes || 0) / data.totalBytes) * 100) + '%' } if (status) status.textContent = t('import.fileDone', (data && data.files) || count) }) try { const game = await window.plwl.gamesImport(srcDir, mode, kind) closeModal() if (!game || game.ok === false) { toast(t('import.failed', importErrorText((game && game.error) || '')), 'error') return } state.games = await window.plwl.gamesList() renderLibrary() toast(t('toast.importDone', game.name), 'success') await openGameConfig(game.id) } catch (err) { closeModal() toast(t('import.failed', (err && err.message) || String(err)), 'error') } } // --- "Open with PLWL" (file manager -> Open With -> PLWL) ----------------------- const openwithTexts = {} function ot(key, ...args) { let text = openwithTexts[key] ?? t(key) args.forEach((v, i) => { text = String(text).split(`{${i}}`).join(String(v)) }) return text } async function loadOpenwithTexts() { try { Object.assign(openwithTexts, await window.plwl.openwithTexts(state.lang)) } catch {} } function isWindowsExecutable(p) { return /\.(exe|msi|bat|cmd|lnk)$/i.test(p || '') } // "Open with" popup: wine + prefix selects defaulting to the launcher defaults. async function showOpenWithModal(openPath) { // always fresh: defaults can change while the launcher sits in the tray try { const [wines, prefixes, settings] = await Promise.all([ window.plwl.winesList(), window.plwl.prefixesList(), window.plwl.settingsGet() ]) state.wines = wines state.prefixes = prefixes state.settings = settings } catch {} const modal = showModal(`

${ot('openwith.title')}

${ot('openwith.file')}:

`) modal.querySelector('#ow-file').textContent = openPath modal.querySelector('#ow-file').title = openPath const missing = !window.plwl.fileExists(openPath) || !isWindowsExecutable(openPath) modal.querySelector('#ow-missing').style.display = missing ? '' : 'none' const defWine = (state.settings && state.settings.defaultWine) || 'system' const defPrefix = state.prefixes.default || '' const wineOpts = [ { value: 'default', label: ot('openwith.wineDefault', wineLabel(defWine)) }, ...state.wines.map(w => ({ value: w.id, label: w.label })) ] const prefixOpts = [ { value: '', label: ot('openwith.prefixDefault', defPrefix || 'Main') }, ...state.prefixes.prefixes.filter(p => p.name !== defPrefix).map(p => ({ value: p.name, label: p.name })) ] new CustomSelect(modal.querySelector('#ow-wine'), wineOpts, 'default') new CustomSelect(modal.querySelector('#ow-prefix'), prefixOpts, '') modal.querySelector('#ow-cancel').onclick = closeModal modal.querySelector('#ow-run').onclick = async () => { if (missing) { closeModal() return } const runBtn = modal.querySelector('#ow-run') runBtn.disabled = true const res = await window.plwl.openwithRun({ openPath, wine: modal.querySelector('#ow-wine').dataset.value || 'default', prefix: modal.querySelector('#ow-prefix').dataset.value || '' }) closeModal() if (res.ok) { toast(t('toast.started', window.plwl.basename(openPath)), 'success') } else { const raw = res.error === 'file-missing' ? ot('openwith.fileMissing') : (res.error === 'not-windows' ? ot('openwith.notWindows') : (res.error || '')) toast(ot('openwith.startFailed', window.plwl.basename(openPath), raw), 'error') } } } // Entry point: the main process hands over a file opened through PLWL // (boot argv or a second-instance handoff). async function handleOpenWith(openPath) { if (!openPath) return await loadOpenwithTexts() showOpenWithModal(openPath) } function wireOpenWith() { // both delivery paths converge on openwithTake(); newest file wins window.plwl.onOpenWith(() => window.plwl.openwithTake().then(handleOpenWith)) // file queued before the window existed (boot handoff): pick it up once window.plwl.openwithTake().then(handleOpenWith) } // --- isolated hand-off ------------------------------------------------------- // A document handed to an isolated app is copied in first. Only copies big // enough to be noticed arrive here (main decides, and puts a window up so this // popup can be seen at all). function wireHandoffProgress() { window.plwl.onHandoffProgress(data => { if (!data || !data.notice) return if (data.phase === 'copy') return showHandoffCopy(data) if (data.phase !== 'done' && data.phase !== 'error') return // only ever close our own modal: another one may have opened over it if (qs('#modal').querySelector('#ho-progress')) closeModal() if (data.phase === 'error') toast(t('handoff.failed', data.name, data.error || ''), 'error') }) } function showHandoffCopy(data) { const modal = qs('#modal').querySelector('#ho-progress') ? qs('#modal') : showModal(`

${t('handoff.copying', data.name)}

`) renderProgressBar(modal.querySelector('#ho-progress'), data.bytes, data.totalBytes) const pct = modal.querySelector('.progress-pct') pct.textContent = data.totalBytes ? Math.round(((data.bytes || 0) / data.totalBytes) * 100) + '%' : '' modal.querySelector('#ho-status').textContent = t('handoff.copyNote', formatBytes(data.bytes), formatBytes(data.totalBytes)) } // Import step 2: register the app in place, nothing is copied. async function doPrefixImport(prefix, srcDir) { const name = srcDir.split('/').filter(Boolean).pop() // nothing copied: no progress to show showModal(`

${t('import.adding', name)}

${t('import.prefix.registering')}

`) try { const game = await window.plwl.gamesImportFromPrefix(prefix, srcDir) closeModal() if (!game || game.ok === false) { toast(t('import.failed', importErrorText((game && game.error) || '')), 'error') return } state.games = await window.plwl.gamesList() renderLibrary() toast(t('toast.importDone', game.name), 'success') await openGameConfig(game.id) } catch (err) { closeModal() toast(t('import.failed', (err && err.message) || String(err)), 'error') } } // Apps blocking a prefix rename/delete, fetched fresh for the warning. async function prefixUsers(name) { const [games, running] = await Promise.all([window.plwl.gamesList(), window.plwl.gamesListRunning()]) const runningSet = new Set(running || []) const users = (games || []).filter(g => (g.prefix || state.prefixes.default || 'Main') === name) return { all: users, running: users.filter(g => runningSet.has(g.id)) } } function renderPrefixes() { const list = qs('#prefix-list') list.innerHTML = '' const defs = state.prefixes if (!defs.prefixes.length) { const empty = document.createElement('p') empty.className = 'subtitle' empty.textContent = t('prefixes.empty') list.appendChild(empty) return } for (const p of defs.prefixes) { const locked = !!p.locked || p.name === 'Main' const row = document.createElement('div') row.className = 'list-row' row.innerHTML = `
${p.name === defs.default ? `${t('prefixes.default')}` : ''}
${locked ? '' : ''}
` row.querySelector('.title').textContent = p.name const renBtn = row.querySelector('[data-act=rename]') if (renBtn) { renBtn.textContent = t('prefixes.rename') renBtn.onclick = async () => { const users = await prefixUsers(p.name) const name = await promptModal({ title: t('prefixes.renameTitle'), label: t('prefixes.namePlaceholder'), hint: users.running.length ? t('prefixes.inUseHint') : t('prefixes.renameHint'), value: p.name, confirm: t('prefixes.rename') }) if (!name || name === p.name) return const res = await window.plwl.prefixesRename(p.name, name) if (!res || !res.ok) { const err = (res && res.error) || '' if (err === 'inUse') toast(t('prefixes.inUse', p.name, (res.apps || []).join(', ')), 'error') else if (err === 'hasApps') toast(t('prefixes.hasApps', p.name, (res.apps || []).join(', ')), 'error') else { const known = { alreadyExists: 'prefixes.exists', invalidName: 'prefixes.invalidName', isMain: 'prefixes.mainLocked' } toast(known[err] ? t(known[err]) : t('prefixes.renameFailed', err), 'error') } return } toast(t('prefixes.renamed', p.name, res.name), 'success') state.prefixes = await window.plwl.prefixesList() // the apps that run in this prefix point at the new name now state.games = await window.plwl.gamesList() renderPrefixes() } } const defBtn = row.querySelector('[data-act=default]') defBtn.textContent = t('prefixes.makeDefault') defBtn.onclick = async () => { const res = await window.plwl.prefixesSetDefault(p.name) if (res && res.ok !== false) toast(t('toast.prefixDefault', p.name), 'success') else if (res && res.error === 'inUse') toast(t('prefixes.inUse', p.name, (res.apps || []).join(', ')), 'error') else if (res && res.error === 'notFound') toast(t('toast.prefixDeleteFailed', p.name), 'error') state.prefixes = await window.plwl.prefixesList() renderPrefixes() } const resetBtn = row.querySelector('[data-act=reset]') resetBtn.textContent = t('prefixes.resetConfig') resetBtn.title = t('prefixes.resetConfigHint') resetBtn.onclick = async () => { const ok = await confirmModal(t('prefixes.resetConfigConfirm', p.name), t('prefixes.resetConfig')) if (!ok) return const res = await window.plwl.prefixesResetLinuxConfig(p.name) if (!res || !res.ok) { if (res && res.error === 'inUse') toast(t('prefixes.resetConfigBusy', p.name, (res.apps || []).join(', ')), 'error') else toast(t('prefixes.resetConfigFailed', p.name), 'error') return } if (res.removed && res.removed.length) toast(t('prefixes.resetConfigDone', p.name), 'success') else toast(t('prefixes.resetConfigNone', p.name), 'success') } const delBtn = row.querySelector('[data-act=delete]') delBtn.innerHTML = `${ICONS.trash()}${t('prefixes.delete')}` delBtn.onclick = async () => { // refused up front: a running app holds the folder, or apps reference it const users = await prefixUsers(p.name) if (users.all.length) return toast(t('prefixes.hasApps', p.name, users.all.map(g => g.name).join(', ')), 'error') const ok = await confirmModal(t('prefixes.deleteConfirm', p.name), t('prefixes.delete')) if (!ok) return // removing a whole windows drive takes a while: measured progress modal const res = await runWithProgress({ title: t('prefixes.deleting', p.name), action: () => window.plwl.prefixesDelete(p.name) }) if (res.ok) toast(t('toast.prefixDeleted', p.name), 'success') else if (res.error === 'isMain') toast(t('prefixes.mainLocked'), 'error') else if (res.error === 'inUse' || res.error === 'hasApps') toast(t('prefixes.hasApps', p.name, (res.apps || []).join(', ')), 'error') else toast(t('toast.prefixDeleteFailed', p.name), 'error') state.prefixes = await window.plwl.prefixesList() renderPrefixes() } list.appendChild(row) } } function renderWines() { const list = qs('#wine-list') list.innerHTML = '' const defaultWine = (state.settings && state.settings.defaultWine) || 'system' if (!state.wines.length) { const empty = document.createElement('p') empty.className = 'subtitle' empty.textContent = t('wines.empty') list.appendChild(empty) } for (const w of state.wines) { const isDefault = w.id === defaultWine const row = document.createElement('div') row.className = 'list-row' const typeBadge = w.type === 'proton' ? t('wines.type.proton') : (w.type === 'system' ? t('wines.type.system') : t('wines.type.wine')) row.innerHTML = `
${isDefault ? `${t('wines.default')}` : ''} ${typeBadge} ${w.version ? `${w.version}` : ''}
${w.id !== 'system' ? `` : ''} ${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 renBtn = row.querySelector('[data-act=rename]') if (renBtn) { renBtn.textContent = t('wines.rename') renBtn.onclick = async () => { const name = await promptModal({ title: t('wines.renameTitle'), label: t('common.name'), hint: t('wines.renameHint'), value: w.displayName || w.label, confirm: t('wines.rename') }) if (!name || name === (w.displayName || w.label)) return const res = await window.plwl.winesSetDisplayName(w.id, name) if (!res || !res.ok) { const err = (res && res.error) || '' toast(err === 'invalidName' ? t('wines.invalidName') : t('wines.renameFailed', err), 'error') return } toast(t('wines.renamed', w.label, res.displayName), 'success') state.wines = await window.plwl.winesList() renderWines() } } const defBtn = row.querySelector('[data-act=default]') defBtn.textContent = t('wines.makeDefault') defBtn.onclick = async () => { const res = await window.plwl.winesSetDefault(w.id) if (res && res.ok) { state.settings = await window.plwl.settingsGet() toast(t('wines.defaultSet', w.label), 'success') } renderWines() } const delBtn = row.querySelector('[data-act=delete]') if (delBtn) { delBtn.innerHTML = `${ICONS.trash()}${t('wines.delete')}` delBtn.onclick = async () => { const ok = await confirmModal(t('wines.deleteConfirm', w.label), t('wines.delete')) if (!ok) return const res = await runWithProgress({ title: t('wines.deleting', w.label), action: () => window.plwl.winesDelete(w.id) }) if (res.ok) { toast(t('wines.deleted', w.label), 'success') state.wines = await window.plwl.winesList() state.settings = await window.plwl.settingsGet() } else if (res.error === 'isSystem') { toast(t('wines.systemLocked'), 'error') } else if (res.error === 'inUse') { toast(t('wines.inUse', (res.apps || []).join(', ')), 'error') } renderWines() } } list.appendChild(row) } } function renderSettings() { if (!selects['set-language']) { selects['set-language'] = new CustomSelect(qs('#set-language'), [], state.lang) selects['set-wine'] = new CustomSelect(qs('#set-wine'), [], 'system') selects['set-prefix'] = new CustomSelect(qs('#set-prefix'), [], '') } selects['set-language'].update(state.langs.map(l => ({ value: l.code, label: l.nameSelf })), state.lang) selects['set-wine'].update(state.wines.map(w => ({ value: w.id, label: w.label })), state.settings.defaultWine || 'system') selects['set-prefix'].update(state.prefixes.prefixes.map(p => ({ value: p.name, label: p.name })), state.prefixes.default) qs('#set-closetotray').checked = state.settings.closeToTray !== false qs('#set-sandbox-status').textContent = isolationStatusText(state.sandbox) || '…' } async function recheckSandbox() { state.sandbox = await window.plwl.sandboxProbe() if (state.currentView === 'settings') renderSettings() if (state.currentView === 'library') renderLibrary() toast(t('sandbox.rechecked'), 'success') } // A silent downgrade is the worst outcome, so say it once per install. function warnSandboxOnce() { const status = state.sandbox if (!status || status.backend === 'bwrap') return if (state.settings && state.settings.sandboxNotifySeen) return const modal = showModal(`

${t('sandbox.warnTitle')}

${t('sandbox.warnBody', t('sandbox.reason.' + (status.reason || 'failed')))}

`) modal.querySelector('#sandbox-warn-ok').onclick = async () => { closeModal() state.settings = await window.plwl.settingsSet({ sandboxNotifySeen: true }) } } async function saveSettings() { state.settings = await window.plwl.settingsSet({ language: selects['set-language'].value, defaultWine: selects['set-wine'].value, closeToTray: qs('#set-closetotray').checked }) await window.plwl.prefixesSetDefault(selects['set-prefix'].value || state.prefixes.default) state.prefixes = await window.plwl.prefixesList() const lang = selects['set-language'].value if (lang !== state.lang) { await loadLangTexts(lang) applyI18n() } toast(t('settings.saved'), 'success') if (state.currentView === 'settings') renderSettings() } function renderWineOptions(selectId, selected) { const defaultWine = (state.settings && state.settings.defaultWine) || 'system' const defLabel = wineLabel(defaultWine) const opts = [ { value: 'default', label: t('config.wineDefault', defLabel) }, ...state.wines.map(w => ({ value: w.id, label: w.label })) ] const value = !selected || selected === 'default' ? 'default' : selected if (!selects[selectId]) selects[selectId] = new CustomSelect(qs('#' + selectId), opts, value) else selects[selectId].update(opts, value) } function renderPrefixOptions(selectId, selected) { const defs = state.prefixes const opts = [ { value: '', label: t('config.prefixDefault', defs.default) }, ...defs.prefixes.filter(p => p.name !== defs.default).map(p => ({ value: p.name, label: p.name })) ] // prefix imports pin their prefix even when it is the default if (selected && !opts.some(o => o.value === selected)) opts.push({ value: selected, label: selected }) if (!selects[selectId]) selects[selectId] = new CustomSelect(qs('#' + selectId), opts, selected || '') else selects[selectId].update(opts, selected || '') } async function refreshConfigIcon() { const game = state.currentGame if (!game) return const p = await window.plwl.gamesIconPath(game.id) const img = qs('#gc-icon') const ph = qs('#gc-icon-placeholder') img.onerror = () => { img.style.display = 'none' img.removeAttribute('src') ph.style.display = 'flex' } if (p) { img.onload = () => { img.style.display = '' ph.style.display = 'none' } img.src = iconUrl(game.id) + '?t=' + Date.now() if (img.complete && img.naturalWidth > 0) img.onload() } else { img.style.display = 'none' img.removeAttribute('src') ph.style.display = 'flex' } } async function openGameConfig(id) { const game = state.games.find(g => g.id === id) if (!game) return state.currentGame = JSON.parse(JSON.stringify(game)) showView('gameconfig') qs('#gc-title').textContent = game.name qs('#gc-name').value = game.name refreshConfigIcon() qs('#gc-exe').value = displayGamePath(game.exePath, game) qs('#gc-workdir').value = displayGamePath(game.workdir, game) qs('#gc-args').value = game.args || '' qs('#gc-env').value = Object.entries(game.env || {}).map(([k, v]) => `${k}=${v}`).join('\n') renderWineOptions('gc-wine', game.wine) renderPrefixOptions('gc-prefix', game.prefix) // prefix-imported apps are pinned: the select is locked, not corrected on save const prefixLocked = game.importSource === 'prefix' selects['gc-prefix'].setDisabled(prefixLocked) const lockHint = qs('#gc-prefix-lock-hint') if (lockHint) lockHint.style.display = prefixLocked ? '' : 'none' renderOverrides(game.dllOverrides || []) qs('#gc-disable-umu').checked = !!game.disableUmu renderTools() // a native app has no wine, no proton launcher and no Wine tools const isLinux = isLinuxGame(game) qsa('.win-only').forEach(el => { el.style.display = isLinux ? 'none' : '' }) qsa('.linux-only').forEach(el => { el.style.display = isLinux ? '' : 'none' }) qs('#gc-icon-auto').textContent = t(isLinux ? 'config.icon.autoLinux' : 'config.icon.auto') if (isLinux) { renderIsolationOptions(game.isolation || 'auto') qs('#gc-isolation-status').textContent = isolationStatusText(state.sandbox) qs('#gc-force-x11').checked = !!game.forceX11 } } const isolationOptions = () => [ { value: 'auto', label: t('config.isolation.auto') }, { value: 'none', label: t('config.isolation.none') }, { value: 'relaxed', label: t('config.isolation.relaxed') }, { value: 'isolated', label: t('config.isolation.isolated') } ] // The level names are one word each - what they mean has to be spelled out // right under the select, because the price of relaxed is not visible anywhere // else in the UI. function renderIsolationHint(value) { qs('#gc-isolation-hint').textContent = t(`config.isolationHint.${value || 'auto'}`) } function renderIsolationOptions(selected) { const opts = isolationOptions() if (!selects['gc-isolation']) { selects['gc-isolation'] = new CustomSelect(qs('#gc-isolation'), opts, selected, renderIsolationHint) } else selects['gc-isolation'].update(opts, selected) renderIsolationHint(selected) } async function createAppShortcut() { const game = state.currentGame if (!game) return const res = await window.plwl.gamesCreateShortcut(game.id) if (res && res.ok) { toast(t('config.shortcutCreated', game.name), 'success') } else { toast(t('config.shortcutFailed') + ': ' + ((res && res.error) || ''), 'error') } } // Shows the newest run log in the file manager. async function showAppLogs() { const game = state.currentGame if (!game) return const logs = await window.plwl.gamesGetLogs(game.id) if (logs.length) window.plwl.shellShowItemInFolder(logs[logs.length - 1]) else toast(t('config.noLogs'), 'error') } function renderOverrides(overrides) { const box = qs('#gc-overrides') box.innerHTML = '' for (const o of overrides) { box.appendChild(buildOverrideRow(o)) } } // native,builtin is the default a new override row starts on const OVERRIDE_OPTIONS = [ { value: 'native,builtin', label: 'native,builtin' }, { value: 'native', label: 'native' }, { value: 'builtin', label: 'builtin' }, { value: 'builtin,native', label: 'builtin,native' }, { value: '', label: 'disabled' } ] function buildOverrideRow(o) { const row = document.createElement('div') row.className = 'override-row' row.innerHTML = ` ` row.querySelector('.dll').value = o.dll || '' const selectHost = document.createElement('span') // an empty setting IS the "disabled" choice - no || fallback here new CustomSelect(selectHost, OVERRIDE_OPTIONS, o.setting == null ? 'native,builtin' : o.setting) row.insertBefore(selectHost, row.querySelector('[data-act=del]')) row.querySelector('[data-act=del]').onclick = () => row.remove() return row } function addOverrideRow() { qs('#gc-overrides').appendChild(buildOverrideRow({ dll: '', setting: 'native,builtin' })) const rows = qsa('#gc-overrides .override-row') rows[rows.length - 1].querySelector('.dll').focus() } // Guide-style settings ("n", "native,builtin", "d"...) mapped to select values. function normalizeOverrideSetting(raw) { const v = String(raw || '').trim().toLowerCase().replace(/\s+/g, '') if (!v || v === 'd' || v === 'disabled') return '' if (v === 'n' || v === 'native') return 'native' if (v === 'b' || v === 'builtin') return 'builtin' if (v === 'n,b' || v === 'native,builtin') return 'native,builtin' if (v === 'b,n' || v === 'builtin,native') return 'builtin,native' return null } function readOverrides() { return qsa('#gc-overrides .override-row').map(row => { let dll = row.querySelector('.dll').value.trim() let setting = row.querySelector('.custom-select').dataset.value ?? 'native,builtin' // split pasted "dinput8=native" into name + setting const eq = dll.indexOf('=') if (eq > 0) { const pasted = normalizeOverrideSetting(dll.slice(eq + 1)) if (pasted !== null) setting = pasted dll = dll.slice(0, eq).trim() } dll = dll.replace(/\.dll$/i, '') return { dll, setting } }).filter(o => o.dll) } async function saveGameConfig() { const game = state.currentGame if (!game) return const env = {} qs('#gc-env').value.split('\n').forEach(line => { const idx = line.indexOf('=') if (idx > 0) env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim() }) // prefix imports keep their prefix; only the prefix snaps back (with a hint) let prefixLockedHint = false let prefix = selects['gc-prefix'].value if (game.importSource === 'prefix') { if (prefix !== game.prefix) { prefixLockedHint = true renderPrefixOptions('gc-prefix', game.prefix) } prefix = game.prefix } const patch = { name: qs('#gc-name').value.trim() || game.name, exePath: absGamePathInput(qs('#gc-exe').value.trim(), game), workdir: absGamePathInput(qs('#gc-workdir').value.trim(), game), args: qs('#gc-args').value, wine: selects['gc-wine'].value, prefix, env, dllOverrides: readOverrides(), disableUmu: qs('#gc-disable-umu').checked } if (isLinuxGame(game)) { patch.isolation = selects['gc-isolation'].value patch.forceX11 = qs('#gc-force-x11').checked } await window.plwl.gamesUpdate(game.id, patch) state.games = await window.plwl.gamesList() // show what was actually stored (split/stripped) renderOverrides(patch.dllOverrides) toast(t('config.saved'), 'success') if (prefixLockedHint) toast(t('config.prefixLocked'), 'error') if (state.currentView === 'gameconfig') { state.currentGame = { ...state.currentGame, ...patch } qs('#gc-title').textContent = patch.name } } function renderTools() { const grid = qs('#gc-tools') grid.innerHTML = '' const tools = ['winecfg', 'regedit', 'taskmgr', 'cmd', 'control', 'eject'] for (const tool of tools) { const btn = document.createElement('button') btn.className = 'button small' btn.innerHTML = `${ICONS.terminal()}${tool}` btn.onclick = async () => { const game = state.currentGame const res = await window.plwl.gamesRunTool(game.id, tool, '') if (res.ok) toast(t('toast.started', tool)) else toast(t('toast.startFailed', tool, res.error || ''), 'error') } grid.appendChild(btn) } } async function runCustomTool() { const input = qs('#gc-custom-tool').value.trim() if (!input) return const [tool, ...rest] = input.split(/\s+/) const game = state.currentGame const res = await window.plwl.gamesRunTool(game.id, tool, rest.join(' ')) if (res.ok) toast(t('toast.started', tool)) else toast(t('toast.startFailed', tool, res.error || ''), 'error') } // --- LAN syncing ------------------------------------------------------------ const LAN_CATEGORIES = [ { id: 'apps-portable', label: 'lansync.appsPortable' }, { id: 'apps-prefix', label: 'lansync.appsPrefix' }, { id: 'wines', label: 'lansync.wines' }, { id: 'prefixes', label: 'lansync.prefixes' }, // registry-level settings only; no files move with this category { id: 'settings', label: 'lansync.settings' } ] // Byte sizes in decimal units, as a file manager shows them. function formatBytes(bytes) { const value = Math.max(0, Number(bytes) || 0) if (value < 1000) return `${Math.round(value)} B` if (value < 1000 * 1000) return `${(value / 1000).toFixed(1)} kB` if (value < 1000 * 1000 * 1000) return `${(value / 1000 / 1000).toFixed(1)} MB` return `${(value / 1000 / 1000 / 1000).toFixed(2)} GB` } // "Apps: 2/5 · ..." per-category progress; no totals -> plain count. function lanStatsLine(items, itemsDone, totals) { const rows = [ ['apps', 'lansync.statsApps'], ['prefixes', 'lansync.statsPrefixes'], ['wines', 'lansync.statsWines'] ] const out = [] for (const [kind, key] of rows) { const listed = Number(items && items[kind]) || 0 const total = Number(totals && totals[kind]) || 0 const known = itemsDone && Number.isFinite(Number(itemsDone[kind])) const done = known ? Number(itemsDone[kind]) : listed if (total > 0) out.push(t(key, `${Math.min(done, total)}/${total}`)) else if (listed > 0) out.push(t(key, listed)) } return out.join(' · ') } // The bar follows bytes and the current phase (a phase starts empty). function renderProgressBar(bar, bytes, totalBytes) { const sent = Math.max(0, Number(bytes) || 0) const total = Math.max(0, Number(totalBytes) || 0) bar.max = Math.max(total, 1) bar.value = Math.min(sent, bar.max) } // The bar follows the current phase's bytes (a phase starts empty). function barValues(info) { let bytes = Math.max(0, Number(info.barBytes) || 0) let total = Math.max(0, Number(info.barTotalBytes) || 0) // a provisional total can be smaller than what already moved (it grows as // entries arrive): the bar reads full, never over 100% if (total > 0 && bytes > total) bytes = total return [bytes, total] } // Progress modal for byte-measured work; `action` is the ipc call, the modal // closes itself when it settles. async function runWithProgress({ title, action }) { const modal = showModal(`

${title}

${t('import.phase.count')}

`) const bar = modal.querySelector('progress') const pct = modal.querySelector('.progress-pct') const status = modal.querySelector('#op-status') window.plwl.onImportProgress(data => { if (!data || !data.phase) return // the counting pass reports its own phase with the totals if (data.phase === 'count') return renderProgressBar(bar, data.bytes, data.totalBytes) pct.textContent = data.totalBytes ? Math.round(((data.bytes || 0) / data.totalBytes) * 100) + '%' : '' if (data.totalFiles) status.textContent = t('import.phase.' + data.phase, data.files || 0, data.totalFiles) }) try { return await action() } finally { closeModal() } } // End-of-sync popup: what moved, what was already there, and how much space // the synced trees take. A toast is too easy to miss for a transfer that can // run for minutes. function showSyncSummary({ from, received, categories, summary }) { const items = (summary && summary.items) || {} const cats = new Set(categories || []) const apps = Math.max(0, Number(items.apps) || 0) const wines = Math.max(0, Number(items.wines) || 0) const prefixes = Math.max(0, Number(items.prefixes) || 0) const files = Math.max(0, Number(summary && summary.uploaded) || 0) const skipped = Math.max(0, Number(summary && summary.skipped) || 0) const deleted = Math.max(0, Number(summary && summary.deleted) || 0) const kept = Math.max(0, Number(summary && summary.kept) || 0) const bytes = Math.max(0, Number(summary && summary.bytes) || 0) const sizeBytes = Math.max(0, Number(summary && summary.sizeBytes) || 0) const rows = [] const row = (label, value) => rows.push( `
${label}${value}
` ) // a count alone also shows for a peer that did not send its categories if (apps || cats.has('apps-portable') || cats.has('apps-prefix')) row(t('lansync.summaryApps'), apps) if (wines || cats.has('wines')) row(t('lansync.wines'), wines) if (prefixes || cats.has('prefixes')) row(t('lansync.prefixes'), prefixes) if (cats.has('settings')) row(t('lansync.summarySettings'), t('lansync.summaryYes')) if (files || skipped || deleted || kept) { row(t(received ? 'lansync.summaryReceived' : 'lansync.summarySent'), files) if (skipped) row(t('lansync.summarySkipped'), skipped) if (deleted) row(t('lansync.summaryDeleted'), deleted) if (kept) row(t('lansync.summaryKept'), kept) } if (bytes) row(t('lansync.summaryTransferred'), formatBytes(bytes)) if (sizeBytes) row(t('lansync.summarySize'), formatBytes(sizeBytes)) const modal = showModal(`

${t('lansync.summaryTitle')}

${rows.join('')}
`) // the device name comes off the network: never interpolate it into HTML modal.querySelector('#ls-summary-from').textContent = t( received ? 'lansync.summaryReceivedFrom' : 'lansync.summarySentTo', from || '' ) modal.querySelector('#ls-summary-ok').onclick = closeModal } // "340 MB / 1.2 GB" once the total is known, otherwise just the moved bytes. function lanBytesLine(bytes, totalBytes) { const sent = formatBytes(bytes) if (!totalBytes) return bytes > 0 ? t('lansync.progressSent', sent) : '' return t('lansync.progressBytes', sent, formatBytes(totalBytes)) } async function renderLanSync() { const info = await window.plwl.lansyncGet() state.lansync = info qs('#ls-device-id').textContent = info.deviceId qs('#ls-sync-code').textContent = info.syncCode renderLanDevices(info.devices || []) } function renderLanDevices(devices) { const list = qs('#ls-device-list') list.innerHTML = '' if (!devices.length) { const empty = document.createElement('p') empty.className = 'subtitle' empty.textContent = t('lansync.empty') list.appendChild(empty) return } for (const device of devices) { const row = document.createElement('div') row.className = 'list-row' row.innerHTML = `
${device.paired ? `${t('lansync.paired')}` : ''}
` row.querySelector('.title').textContent = device.name const sub = row.querySelector('.sub') sub.textContent = device.id sub.title = device.id const sync = row.querySelector('[data-act=sync]') sync.textContent = t('lansync.sync') sync.onclick = () => openLanSyncModal(device, '') list.appendChild(row) } } // Collects the other device's sync code plus what to push. async function openLanSyncModal(device, errorKey) { const modal = showModal(`

`) modal.querySelector('#ls-modal-title').textContent = t('lansync.syncTitle', device.name) modal.querySelector('#ls-code-label').textContent = t('lansync.enterCode', device.name) const code = modal.querySelector('#ls-code') code.value = device.code || '' const error = modal.querySelector('#ls-code-error') if (errorKey) { error.textContent = t(errorKey) error.style.display = '' code.focus() } modal.querySelector('#ls-what-label').textContent = t('lansync.what') const pickers = await wireLanPickers(modal) modal.querySelector('#ls-force-label').textContent = t('lansync.force') modal.querySelector('#ls-warning').textContent = t('lansync.mirrorWarning') modal.querySelector('#ls-cancel').textContent = t('dialog.cancel') modal.querySelector('#ls-cancel').onclick = closeModal const start = modal.querySelector('#ls-start') start.textContent = t('lansync.start') start.onclick = async () => { const value = code.value.trim().toUpperCase() if (value.length !== 8) { error.textContent = t('lansync.badCode') error.style.display = '' code.focus() return } const picked = pickers.collect() if (!picked.categories.length) { toast(t('lansync.noneSelected'), 'error') return } await runLanSync(device, value, picked.categories, modal.querySelector('#ls-force').checked, picked.selection) } } // A category's checkbox says "this category travels"; the picker says which of // its items. Everything starts selected, and a selection only ever narrows what // is sent - nothing outside it may be deleted on the other side. async function wireLanPickers(modal) { const box = modal.querySelector('#ls-categories') box.innerHTML = '' let info = { apps: [], wines: [], prefixes: [] } try { info = await window.plwl.lansyncItems() } catch {} const groups = new Map([ ['apps-portable', info.apps.filter(app => app.category === 'apps-portable')], ['apps-prefix', info.apps.filter(app => app.category === 'apps-prefix')], ['wines', info.wines || []], ['prefixes', info.prefixes || []], ['settings', []] ]) const chosen = new Map() for (const [id, list] of groups) chosen.set(id, new Set(list.map(item => item.key))) const rows = new Map() const refreshButton = id => { const row = rows.get(id) if (!row || !row.pick) return const list = groups.get(id) || [] row.pick.textContent = chosen.get(id).size === list.length ? t('lansync.pick') : t('lansync.pickCount', chosen.get(id).size, list.length) } for (const category of LAN_CATEGORIES) { const list = groups.get(category.id) || [] const wrapper = document.createElement('div') wrapper.className = 'lan-cat' const head = document.createElement('div') head.className = 'lan-cat-head' const label = document.createElement('label') label.className = 'check-label' label.innerHTML = '' const input = label.querySelector('input') input.id = 'ls-cat-' + category.id label.querySelector('span').textContent = t(category.label) head.appendChild(label) const row = { input, list: null, search: null, pick: null } rows.set(category.id, row) if (list.length) { const pick = document.createElement('button') pick.type = 'button' pick.className = 'button small' head.appendChild(pick) row.pick = pick const panel = document.createElement('div') panel.className = 'lan-pick' panel.hidden = true const tools = document.createElement('div') tools.className = 'lan-pick-tools' if (list.length > 6) { const search = document.createElement('input') search.type = 'search' search.className = 'lan-search' search.placeholder = t('lansync.pickSearch') tools.appendChild(search) row.search = search search.oninput = () => renderList(category.id) } const all = document.createElement('button') all.type = 'button' all.className = 'button small' all.textContent = t('lansync.pickAll') const none = document.createElement('button') none.type = 'button' none.className = 'button small' none.textContent = t('lansync.pickNone') tools.appendChild(all) tools.appendChild(none) const listEl = document.createElement('div') listEl.className = 'lan-pick-list' row.list = listEl panel.appendChild(tools) panel.appendChild(listEl) pick.onclick = () => { panel.hidden = !panel.hidden if (!panel.hidden) renderList(category.id) } all.onclick = () => { for (const item of list) chosen.get(category.id).add(item.key) renderList(category.id) } none.onclick = () => { chosen.get(category.id).clear() renderList(category.id) } wrapper.appendChild(panel) refreshButton(category.id) } wrapper.insertBefore(head, wrapper.firstChild) box.appendChild(wrapper) } function renderList(id) { const row = rows.get(id) if (!row || !row.list) return const list = groups.get(id) || [] const set = chosen.get(id) const query = row.search ? row.search.value.trim().toLowerCase() : '' row.list.innerHTML = '' for (const item of list) { if (query && !String(item.name || '').toLowerCase().includes(query)) continue const label = document.createElement('label') label.className = 'check-label lan-item' label.innerHTML = '' const input = label.querySelector('input') input.checked = set.has(item.key) label.querySelector('span').textContent = item.name input.onchange = () => { if (input.checked) set.add(item.key) else set.delete(item.key) refreshButton(id) // a prefix app's own folder travels with its prefix, not with the app if (id === 'apps-prefix') renderList('apps-prefix') } row.list.appendChild(label) if (id === 'apps-prefix' && input.checked && item.prefix) { const prefixKey = `prefix:${item.prefix}` const prefixesOn = rows.get('prefixes').input.checked if (prefixesOn && !chosen.get('prefixes').has(prefixKey)) { const hint = document.createElement('p') hint.className = 'subtitle lan-hint' hint.textContent = t('lansync.prefixLeftBehind', item.prefix) row.list.appendChild(hint) } } } } return { collect() { const categories = [] const units = [] let partial = false for (const category of LAN_CATEGORIES) { const input = rows.get(category.id).input if (!input.checked) continue const list = groups.get(category.id) || [] const set = chosen.get(category.id) // a checked category with nothing ticked inside it sends nothing if (list.length && !set.size) continue categories.push(category.id) for (const item of list) if (set.has(item.key)) units.push(item.key) if (set.size !== list.length) partial = true } return { categories, selection: partial ? { units } : undefined } } } } async function runLanSync(device, code, categories, force, selection) { // locked: a running sync is not cancellable by a stray click on the backdrop const modal = showModal(`

`, { locked: true }) modal.querySelector('#ls-run-title').textContent = t('lansync.progress', device.name) modal.querySelector('#ls-run-warning').textContent = t('lansync.keepOpen') const bar = modal.querySelector('progress') const phase = modal.querySelector('#ls-run-phase') const count = modal.querySelector('#ls-run-count') const stats = modal.querySelector('#ls-run-stats') const bytes = modal.querySelector('#ls-run-bytes') let active = true state.lanPushing = true window.plwl.onLanSyncProgress(data => { if (!active) return // waiting phases have no translation under lansync.phase.* const phaseKey = data.phase === 'wait' ? 'lansync.waiting' : 'lansync.phase.' + data.phase phase.textContent = t(phaseKey, data.label || '').trim() renderProgressBar(bar, ...barValues(data)) // phases without a file count clear it: a stale "5 / 10" must not linger if (data.total > 0) count.textContent = t('lansync.progressFiles', data.done, data.total) else count.textContent = '' stats.textContent = lanStatsLine(data.items, data.itemsDone, data.totals) bytes.textContent = lanBytesLine(data.bytes, data.totalBytes) }) let res = null try { res = await window.plwl.lansyncSync({ deviceId: device.id, code, categories, force, selection }) } catch (err) { res = { ok: false, error: (err && err.message) || '' } } finally { active = false state.lanPushing = false } if (res && res.ok) { // swap the locked progress popup into the result, not a toast showSyncSummary({ from: device.name, received: false, categories, summary: res.summary || {} }) await renderLanSync() return } closeModal() const err = (res && res.error) || '' // the server answers a wrong code as 'auth-failed'; both mean "ask again" if (err === 'bad-code' || err === 'auth-failed') { await window.plwl.lansyncForget(device.id) toast(t('lansync.badCode'), 'error') openLanSyncModal({ ...device, code: '' }, 'lansync.badCode') return } const message = err === 'busy' ? t('lansync.busy') : err === 'offline' || err === 'receiver-lost' ? t('lansync.offline') : err === 'rejected' ? t('lansync.rejected') : err === 'incoming-timeout' ? t('lansync.incomingTimedOut') : err === 'old-peer' ? t('lansync.oldPeer') : (err || t('lansync.unknownError')) toast(t('lansync.failed', message), 'error') } // --- receiving a sync (the other device is the source of truth) ------------- // A sync pushed onto this device shows the same popup, driven by the // receiving server's events. Hashing can be minutes of silence: the popup // switches to "waiting" and events reopen it if the watchdog closed it. const RECEIVE_WATCHDOG_MS = 15 * 60 * 1000 const RECEIVE_STALL_MS = 6 * 1000 // phase wordings that differ on this side; the rest are neutral const RECV_PHASE_KEYS = { copy: 'lansync.recvPhase.copy' } // Incoming push: consent popup before anything moves. let incomingRequest = null let incomingTimer = null function onLanIncoming(info) { if (!info || !info.requestId) return if (state.lanPushing || receiving) { // our own transfer owns the modal slot: decline so the sender fails fast window.plwl.lansyncRespondIncoming(info.requestId, false) return } incomingRequest = info const name = info.name || info.deviceId || t('lansync.unknownDevice') const totals = info.totals || {} const parts = [] if (totals.apps > 0) parts.push(t('lansync.statsApps', totals.apps)) if (totals.wines > 0) parts.push(t('lansync.statsWines', totals.wines)) if (totals.prefixes > 0) parts.push(t('lansync.statsPrefixes', totals.prefixes)) // a settings-only sync moves no units, but it is still a real request if (!parts.length && totals.settings > 0) parts.push(t('lansync.incomingSettings')) const modal = showModal(`

`, { locked: true }) modal.querySelector('#ls-incoming-title').textContent = t('lansync.incomingTitle', name) modal.querySelector('#ls-incoming-text').textContent = t('lansync.incomingText', name) modal.querySelector('#ls-incoming-what').textContent = parts.length ? parts.join(' · ') : t('lansync.incomingNothing') // phrased from the receiving side's point of view modal.querySelector('#ls-incoming-warning').textContent = t('lansync.incomingMirror') modal.querySelector('#ls-incoming-decline').textContent = t('lansync.decline') modal.querySelector('#ls-incoming-accept').textContent = t('lansync.accept') const answer = accept => { if (!incomingRequest) return const requestId = incomingRequest.requestId incomingRequest = null if (incomingTimer) { clearTimeout(incomingTimer) incomingTimer = null } closeModal() window.plwl.lansyncRespondIncoming(requestId, accept) } modal.querySelector('#ls-incoming-accept').onclick = () => answer(true) modal.querySelector('#ls-incoming-decline').onclick = () => answer(false) // mirror the server's own expiry so the popup never outlives its request incomingTimer = setTimeout(() => { incomingTimer = null incomingRequest = null closeModal() toast(t('lansync.incomingTimedOut'), 'error') }, 60000) } let receiving = false // The last apply payload, for the end-of-sync summary (it carries files, // bytes, skipped files and the total size of the synced trees). let lastApplied = null // Only the sender's 'done' ends this; a watchdog-closed popup may reopen. let receiveEnded = false let receiveWatchdog = null let receiveStall = null let receiveCloseTimer = null function receiveModalParts() { const modal = qs('#modal') if (!receiving || !modal) return null const title = modal.querySelector('#ls-recv-title') if (!title) return null return { title, bar: modal.querySelector('#ls-recv-modal progress'), phase: modal.querySelector('#ls-recv-phase'), count: modal.querySelector('#ls-recv-count'), stats: modal.querySelector('#ls-recv-stats'), bytes: modal.querySelector('#ls-recv-bytes') } } function endReceiving(message) { if (receiveWatchdog) { clearTimeout(receiveWatchdog) receiveWatchdog = null } if (receiveStall) { clearTimeout(receiveStall) receiveStall = null } if (receiveCloseTimer) { clearTimeout(receiveCloseTimer) receiveCloseTimer = null } const wasReceiving = receiving receiving = false if (!wasReceiving) return if (message) toast(message, 'error') closeModal() } // A crashed sender must not leave the window locked: close after a long silence. function armReceiveWatchdog() { if (receiveWatchdog) clearTimeout(receiveWatchdog) receiveWatchdog = setTimeout(() => endReceiving(t('lansync.recvIdle')), RECEIVE_WATCHDOG_MS) if (receiveStall) clearTimeout(receiveStall) receiveStall = setTimeout(() => { receiveStall = null const parts = receiveModalParts() if (!parts) return // silence also means no bytes: freeze the bar and ask for patience parts.phase.textContent = t('lansync.waiting') }, RECEIVE_STALL_MS) } function onLanReceiving(info) { if (!info || !info.stage) return if (state.lanPushing) return // our own push owns the (single) popup if (info.stage === 'start') { receiveEnded = false lastApplied = null } if (info.stage === 'done') { receiveEnded = true // the review's decision was the last thing on screen: this is its result if (reviewFlow) { reviewFlow = false closeModal() showReceiveSummary(info) return } // the popup may have been closed by the watchdog: still show the result // if nothing else owns the modal slot if (!receiving) { if (lastApplied && qs('#modal-overlay').style.display === 'none') showReceiveSummary(info) return } } if (!receiving) { // reopen a watchdog-closed popup unless the sync already ended if (receiveEnded) return // a completion summary underneath gives way to the next sync's progress const summaryOpen = !!qs('#modal').querySelector('#ls-summary-ok') if (!summaryOpen && qs('#modal-overlay').style.display !== 'none') return receiving = true const modal = showModal(`

`, { locked: true }) modal.querySelector('#ls-recv-title').textContent = t('lansync.receiving', info.from || info.deviceId || '') modal.querySelector('#ls-recv-warning').textContent = t('lansync.keepOpen') armReceiveWatchdog() return renderReceiving(info) } armReceiveWatchdog() renderReceiving(info) } function renderReceiving(info) { const parts = receiveModalParts() if (!parts) return // a ping keeps the bar and counters as they are; only the phase line moves if (info.stage === 'wait') { parts.phase.textContent = t('lansync.waiting') return } // the same phases, worded for receiving const phaseKey = RECV_PHASE_KEYS[info.stage] || 'lansync.phase.' + info.stage parts.phase.textContent = (info.stage === 'start' ? t('lansync.waiting') : t(phaseKey, info.label || '')).trim() renderProgressBar(parts.bar, ...barValues(info)) // the file counter belongs to the copy/done phases; receive events for other // phases carry cumulative numbers that would look stale mid-phase if ((info.stage === 'copy' || info.stage === 'done') && info.total > 0) parts.count.textContent = t('lansync.progressFiles', info.done, info.total) else parts.count.textContent = '' parts.stats.textContent = lanStatsLine(info.items, info.itemsDone, info.totals) parts.bytes.textContent = lanBytesLine(info.bytes, info.totalBytes) if (info.stage === 'done') { // hold the finished bar for a moment, then swap it for the summary if (receiveCloseTimer) clearTimeout(receiveCloseTimer) receiveCloseTimer = setTimeout(() => { receiveCloseTimer = null showReceiveSummary(info) }, 1200) } } // Swap the finished progress popup for the result; the library has already // been reloaded by the apply event. function showReceiveSummary(info) { if (receiveWatchdog) { clearTimeout(receiveWatchdog) receiveWatchdog = null } if (receiveStall) { clearTimeout(receiveStall) receiveStall = null } if (receiveCloseTimer) { clearTimeout(receiveCloseTimer) receiveCloseTimer = null } receiving = false const applied = lastApplied || {} showSyncSummary({ from: applied.from || info.from || '', received: true, categories: applied.categories || [], summary: { uploaded: applied.files ?? info.done, skipped: applied.skipped || 0, deleted: applied.deleted ?? info.deleted, kept: applied.kept ?? 0, bytes: applied.bytes ?? info.bytes, sizeBytes: applied.sizeBytes ?? info.sizeBytes, items: applied.items || info.items || {} } }) } // --- the receiver's deletion review ---------------------------------------- // Nothing the sender left out is removed before this: the list is planned, the // user decides, and only then does anything get deleted. let reviewFlow = false // matches the receiver's own review timeout: an unanswered list deletes nothing const REVIEW_WATCHDOG_MS = 10 * 60 * 1000 let reviewTimer = null function clearReviewTimer() { if (!reviewTimer) return clearTimeout(reviewTimer) reviewTimer = null } function onLanReview(info) { if (!info || !info.id) return clearReviewTimer() // the review replaces the receive progress popup and owns the modal slot if (receiveWatchdog) { clearTimeout(receiveWatchdog) receiveWatchdog = null } if (receiveStall) { clearTimeout(receiveStall) receiveStall = null } if (receiveCloseTimer) { clearTimeout(receiveCloseTimer) receiveCloseTimer = null } receiving = false // no progress popup may reopen between the decision and the sender's 'done' receiveEnded = true reviewFlow = true reviewTimer = setTimeout(() => { reviewTimer = null reviewFlow = false closeModal() toast(t('lansync.reviewExpired'), 'error') }, REVIEW_WATCHDOG_MS) renderReview(info) } function renderReview(info) { const units = Array.isArray(info.units) ? info.units : [] const files = Math.max(0, Number(info.files) || 0) const bytes = Math.max(0, Number(info.bytes) || 0) const kept = new Set() const keptUnits = new Set() const nodeInfo = new Map() const modal = showModal(`

${t('lansync.reviewTitle')}

`, { locked: true, wide: true }) // the device name comes off the network: never interpolate it into HTML modal.querySelector('#ls-review-from').textContent = t('lansync.reviewFrom', info.name || info.deviceId || '') modal.querySelector('#ls-review-text').textContent = t('lansync.reviewText', files, formatBytes(bytes)) modal.querySelector('#ls-review-warning').textContent = t('lansync.reviewOverwrite') const list = modal.querySelector('#ls-review-list') const go = modal.querySelector('#ls-review-go') const keepAll = modal.querySelector('#ls-review-keep') keepAll.textContent = t('lansync.reviewKeepAll') const keptBy = path => [...kept].some(entry => path === entry || path.startsWith(entry + '/')) const keptChild = path => [...kept].some(entry => entry !== path && entry.startsWith(path + '/')) const keptTotals = () => { let keptFiles = 0 let keptBytes = 0 for (const unit of units) { if (!keptUnits.has(unit.key)) continue keptFiles += Math.max(0, Number(unit.files) || 0) keptBytes += Math.max(0, Number(unit.bytes) || 0) } for (const path of kept) { if ([...kept].some(entry => entry !== path && path.startsWith(entry + '/'))) continue const unit = units.find(entry => (nodeInfo.get(path) || {}).unit === entry.key) if (unit && keptUnits.has(unit.key)) continue const node = nodeInfo.get(path) if (!node) continue keptFiles += node.files keptBytes += node.bytes } return { files: keptFiles, bytes: keptBytes } } const refresh = () => { for (const row of modal.querySelectorAll('.lan-node')) { const input = row.querySelector('input') const path = row.dataset.path const unitKept = keptUnits.has(row.dataset.unit) input.checked = !unitKept && !keptBy(path) input.indeterminate = input.checked && keptChild(path) // kept through an ancestor: re-enable that ancestor instead input.disabled = unitKept || (keptBy(path) && !kept.has(path)) } for (const row of modal.querySelectorAll('.lan-review-unit')) { const input = row.querySelector('input') const key = row.dataset.key const unit = units.find(entry => entry.key === key) input.checked = !keptUnits.has(key) input.indeterminate = input.checked && [...kept].some(entry => (nodeInfo.get(entry) || {}).unit === key) row.querySelector('.lan-review-meta').textContent = t('lansync.reviewFiles', unit ? unit.files : 0, formatBytes(unit ? unit.bytes : 0)) } const left = keptTotals() go.textContent = t('lansync.reviewDelete', Math.max(0, files - left.files), formatBytes(Math.max(0, bytes - left.bytes))) } const nodeRow = (node, unitKey, depth) => { nodeInfo.set(node.path, { files: node.files || 0, bytes: node.bytes || 0, unit: unitKey }) const row = document.createElement('div') row.className = 'lan-node' row.dataset.path = node.path row.dataset.unit = unitKey row.style.paddingLeft = `${depth * 18 + 6}px` row.innerHTML = ` ` row.querySelector('.lan-node-name').textContent = node.name row.querySelector('.lan-node-meta').textContent = t('lansync.reviewFiles', node.files || 0, formatBytes(node.bytes || 0)) const input = row.querySelector('input') input.onchange = () => { if (input.checked) { kept.delete(node.path) // keep the descendants' own entries from overriding an ancestor for (const entry of [...kept]) if (entry.startsWith(node.path + '/')) kept.delete(entry) } else { for (const entry of [...kept]) if (entry.startsWith(node.path + '/')) kept.delete(entry) kept.add(node.path) } refresh() } const childBox = document.createElement('div') childBox.className = 'lan-nodes' childBox.hidden = true const open = row.querySelector('.lan-node-open') if (node.hasChildren) { open.hidden = false open.textContent = '+' open.onclick = async () => { if (!childBox.dataset.loaded) { childBox.dataset.loaded = '1' const res = await window.plwl.lansyncReviewTree(info.id, node.path) for (const child of (res && res.nodes) || []) childBox.appendChild(nodeRow(child, unitKey, depth + 1)) } childBox.hidden = !childBox.hidden open.textContent = childBox.hidden ? '+' : '−' refresh() } } const wrap = document.createElement('div') wrap.className = 'lan-node-wrap' wrap.appendChild(row) wrap.appendChild(childBox) return wrap } for (const unit of units) { const row = document.createElement('div') row.className = 'lan-review-unit' row.dataset.key = unit.key row.innerHTML = ` ` row.querySelector('.lan-review-name').textContent = unit.label || unit.key row.querySelector('input').onchange = async event => { if (event.target.checked) { keptUnits.delete(unit.key) for (const entry of [...kept]) if ((nodeInfo.get(entry) || {}).unit === unit.key) kept.delete(entry) } else { keptUnits.add(unit.key) for (const entry of [...kept]) if ((nodeInfo.get(entry) || {}).unit === unit.key) kept.delete(entry) } refresh() } list.appendChild(row) const children = document.createElement('div') children.className = 'lan-nodes' list.appendChild(children) // top folders load on first paint; a unit's list is small (collapsed rows) window.plwl.lansyncReviewTree(info.id, unit.key).then(res => { for (const node of (res && res.nodes) || []) children.appendChild(nodeRow(node, unit.key, 1)) refresh() }) } keepAll.onclick = () => { for (const unit of units) keptUnits.add(unit.key) kept.clear() refresh() decide() } let deciding = false const decide = async () => { if (deciding) return deciding = true clearReviewTimer() go.disabled = true keepAll.disabled = true go.textContent = t('lansync.reviewDeleting') try { await window.plwl.lansyncReviewDecide(info.id, { keep: [...kept], keepUnits: [...keptUnits] }) } catch {} closeModal() } go.onclick = decide refresh() } // A received sync changed local files: reload every list (settings included). async function reloadLocalData() { const [wines, prefixes, games, settings] = await Promise.all([ window.plwl.winesList(), window.plwl.prefixesList(), window.plwl.gamesList(), window.plwl.settingsGet() ]) state.wines = wines state.prefixes = prefixes state.games = games state.settings = settings if (state.currentView === 'library') renderLibrary() else if (state.currentView === 'wines') renderWines() else if (state.currentView === 'prefixes') renderPrefixes() else if (state.currentView === 'settings') renderSettings() } function wireLanSync() { window.plwl.onLanSyncDevices(devices => { if (state.lansync) state.lansync.devices = devices if (state.currentView === 'lansync') renderLanDevices(devices) }) window.plwl.onLanSyncReceiving(info => onLanReceiving(info)) window.plwl.onLanSyncIncoming(info => onLanIncoming(info)) window.plwl.onLanSyncReview(info => onLanReview(info)) window.plwl.onLanSyncApplied(async info => { // the summary popup replaces the toast; reload behind it lastApplied = info await reloadLocalData() }) qs('#ls-reroll-id').addEventListener('click', async () => { const res = await window.plwl.lansyncRerollDeviceId() qs('#ls-device-id').textContent = res.deviceId if (state.lansync) state.lansync.deviceId = res.deviceId toast(t('lansync.idRerolled'), 'success') }) qs('#ls-reroll-code').addEventListener('click', async () => { const res = await window.plwl.lansyncRerollSyncCode() qs('#ls-sync-code').textContent = res.syncCode if (state.lansync) state.lansync.syncCode = res.syncCode toast(t('lansync.codeRerolled'), 'success') }) } function wireEvents() { qsa('[data-nav]').forEach(btn => { btn.addEventListener('click', () => showView(btn.dataset.nav)) }) qs('#btn-import-top').addEventListener('click', startImportFlow) qs('#btn-import-empty').addEventListener('click', startImportFlow) // a drop on the empty grid area moves the card to the end const gameList = qs('#game-list') gameList.addEventListener('dragover', e => { if (dragId) e.preventDefault() }) gameList.addEventListener('drop', e => { if (!dragId || e.target !== gameList) return e.preventDefault() const id = dragId dragId = null commitOrder([...state.games.map(g => g.id).filter(x => x !== id), id]) }) qs('#btn-prefix-create').addEventListener('click', async () => { const input = qs('#prefix-name-input') const name = input.value.trim() if (!name) return const res = await window.plwl.prefixesCreate(name) if (!res.ok) { toast(t(res.error === 'alreadyExists' ? 'prefixes.exists' : 'prefixes.invalidName'), 'error') return } input.value = '' toast(t('toast.prefixCreated', res.name), 'success') state.prefixes = await window.plwl.prefixesList() renderPrefixes() }) qs('#btn-wines-rescan').addEventListener('click', async () => { await window.plwl.winesRefresh() state.wines = await window.plwl.winesList() renderWines() }) qs('#btn-wines-import').addEventListener('click', async () => { const srcDir = await window.plwl.dialogPickFile({ properties: ['openDirectory'], title: t('wines.importPick') }) if (!srcDir) return const res = await runWithProgress({ title: t('wines.importing'), action: () => window.plwl.winesImport(srcDir) }) if (res.ok) { toast(t('wines.imported', res.path), 'success') state.wines = await window.plwl.winesList() } else { toast(t('import.failed', importErrorText((res && res.error) || '')), 'error') } renderWines() }) qs('#btn-settings-save').addEventListener('click', saveSettings) qs('#btn-open-files').addEventListener('click', () => window.plwl.shellOpenPath(window.plwl.FILES_DIR)) qs('#btn-clear-hash-cache').addEventListener('click', async () => { const ok = await confirmModal(t('settings.hashCacheClearConfirm'), t('settings.clearHashCache')) if (!ok) return await window.plwl.lansyncClearHashCache() toast(t('settings.hashCacheCleared'), 'success') }) qs('#btn-gc-back').addEventListener('click', () => showView('library')) qs('#btn-gc-save').addEventListener('click', saveGameConfig) qs('#btn-gc-data-folder').addEventListener('click', async () => { const res = await window.plwl.gamesOpenDataFolder(state.currentGame.id) if (!res || !res.ok) toast(String((res && res.error) || ''), 'error') }) qs('#btn-sandbox-check').addEventListener('click', recheckSandbox) qs('#btn-gc-shortcut').addEventListener('click', createAppShortcut) qs('#btn-gc-logs').addEventListener('click', showAppLogs) qs('#gc-override-add').addEventListener('click', addOverrideRow) qs('#gc-exe-browse').addEventListener('click', async () => { // resolve the SHOWN path to an absolute one, or the picker opens at cwd const cur = absGamePathInput(qs('#gc-exe').value.trim(), state.currentGame) const p = await window.plwl.dialogPickFile({ properties: ['openFile'], defaultPath: cur || window.plwl.FILES_DIR }) if (p) qs('#gc-exe').value = displayGamePath(p, state.currentGame) }) qs('#gc-workdir-browse').addEventListener('click', async () => { const cur = absGamePathInput(qs('#gc-workdir').value.trim(), state.currentGame) const p = await window.plwl.dialogPickFile({ properties: ['openDirectory'], defaultPath: cur || window.plwl.FILES_DIR }) if (p) qs('#gc-workdir').value = displayGamePath(p, state.currentGame) }) qs('#gc-tool-run').addEventListener('click', runCustomTool) async function applyIconResult(res) { toast(res.ok ? t('config.icon.set') : t('config.icon.noIcon'), res.ok ? 'success' : 'error') if (res.ok) refreshConfigIcon() } qs('#gc-icon-auto').addEventListener('click', async () => { // use the exe the field shows, not the saved one const exe = absGamePathInput(qs('#gc-exe').value.trim(), state.currentGame) applyIconResult(await window.plwl.gamesAutoIcon(state.currentGame.id, exe)) }) qs('#gc-icon-browse').addEventListener('click', async () => { // start in the app's own folder, where the icon exe most likely lives const start = state.currentGame.path || absGamePathInput(qs('#gc-workdir').value.trim(), state.currentGame) const p = await window.plwl.dialogPickFile({ properties: ['openFile'], defaultPath: start || window.plwl.FILES_DIR, title: t('config.icon.pick'), filters: [{ name: t('config.icon.pick'), extensions: ['png', 'jpg', 'jpeg', 'svg', 'ico', 'webp', 'gif', 'bmp', 'exe'] }] }) if (!p) return applyIconResult(await window.plwl.gamesSetIconFile(state.currentGame.id, p)) }) qs('#gc-icon-reset').addEventListener('click', async () => { await window.plwl.gamesResetIcon(state.currentGame.id) refreshConfigIcon() }) qs('#btn-gc-clear-playtime').addEventListener('click', async () => { const ok = await confirmModal(t('config.clearPlaytimeConfirm'), t('config.clearPlaytime')) if (!ok) return await window.plwl.gamesClearPlaytime(state.currentGame.id) state.games = await window.plwl.gamesList() state.currentGame = state.games.find(g => g.id === state.currentGame.id) toast(t('config.playtimeCleared'), 'success') }) qs('#modal-overlay').addEventListener('click', e => { if (modalLocked) return // a sync in progress: the backdrop is not a cancel button if (e.target === qs('#modal-overlay')) closeModal() }) document.addEventListener('keydown', e => { if (e.key === 'Escape' && !modalLocked) closeModal() }) } async function boot() { setButtonIcons() await loadAll() applyI18n() wireEvents() wireRealtime() wireOpenWith() wireHandoffProgress() wireLanSync() qs('#loading').remove() qs('#main').style.display = 'flex' showView('library') warnSandboxOnce() } boot()