add importing from prefix
This commit is contained in:
parent
29a20324a6
commit
b83e530392
11 changed files with 287 additions and 22 deletions
154
renderer/app.js
154
renderer/app.js
|
|
@ -104,8 +104,20 @@ function toast(message, kind) {
|
|||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
function showModal(html) {
|
||||
if (modalTeardownTimer) {
|
||||
clearTimeout(modalTeardownTimer)
|
||||
modalTeardownTimer = null
|
||||
}
|
||||
const overlay = qs('#modal-overlay')
|
||||
const modal = qs('#modal')
|
||||
modal.classList.remove('closing')
|
||||
qs('#modal').innerHTML = html
|
||||
overlay.style.display = 'flex'
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => overlay.classList.add('show')))
|
||||
|
|
@ -118,7 +130,9 @@ function closeModal() {
|
|||
if (overlay.style.display === 'none') return
|
||||
modal.classList.add('closing')
|
||||
overlay.classList.remove('show')
|
||||
setTimeout(() => {
|
||||
if (modalTeardownTimer) clearTimeout(modalTeardownTimer)
|
||||
modalTeardownTimer = setTimeout(() => {
|
||||
modalTeardownTimer = null
|
||||
overlay.style.display = 'none'
|
||||
modal.classList.remove('closing')
|
||||
modal.innerHTML = ''
|
||||
|
|
@ -310,6 +324,29 @@ function absPathInput(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)
|
||||
|
|
@ -341,11 +378,15 @@ function renderLibrary() {
|
|||
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">
|
||||
<span class="badge ${portable ? 'primary' : ''}">${portable ? t('library.portable') : t('library.external')}</span>
|
||||
${sourceBadge}
|
||||
<span class="badge">${wineLabel(game.wine)}</span>
|
||||
</div>
|
||||
<div class="meta">${t('library.lastPlayed', last)}</div>
|
||||
|
|
@ -382,7 +423,9 @@ function renderLibrary() {
|
|||
else toast(t('library.viewLogs'))
|
||||
}
|
||||
card.querySelector('[data-act=remove]').onclick = async () => {
|
||||
const note = game.mode === 'copy' || game.mode === 'move' ? t('library.removeFilesNote') : ''
|
||||
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)
|
||||
|
|
@ -446,6 +489,7 @@ async function startImportFlow() {
|
|||
<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>
|
||||
|
|
@ -456,6 +500,7 @@ async function startImportFlow() {
|
|||
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)
|
||||
|
|
@ -463,6 +508,48 @@ async function startImportFlow() {
|
|||
})
|
||||
}
|
||||
|
||||
// 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(`
|
||||
|
|
@ -495,6 +582,36 @@ async function doImport(srcDir, mode) {
|
|||
}
|
||||
}
|
||||
|
||||
// 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 = ''
|
||||
|
|
@ -654,6 +771,9 @@ function renderPrefixOptions(selectId, selected) {
|
|||
{ 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 || '')
|
||||
}
|
||||
|
|
@ -691,8 +811,8 @@ async function openGameConfig(id) {
|
|||
qs('#gc-title').textContent = game.name
|
||||
qs('#gc-name').value = game.name
|
||||
refreshConfigIcon()
|
||||
qs('#gc-exe').value = displayPath(game.exePath)
|
||||
qs('#gc-workdir').value = displayPath(game.workdir)
|
||||
qs('#gc-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)
|
||||
|
|
@ -764,19 +884,33 @@ async function saveGameConfig() {
|
|||
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: absPathInput(qs('#gc-exe').value.trim()),
|
||||
workdir: absPathInput(qs('#gc-workdir').value.trim()),
|
||||
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: selects['gc-prefix'].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
|
||||
|
|
@ -862,12 +996,12 @@ function wireEvents() {
|
|||
qs('#gc-exe-browse').addEventListener('click', async () => {
|
||||
const cur = absPathInput(qs('#gc-workdir').value.trim())
|
||||
const p = await window.plwl.dialogPickFile({ properties: ['openFile'], defaultPath: cur || window.plwl.FILES_DIR })
|
||||
if (p) qs('#gc-exe').value = displayPath(p)
|
||||
if (p) qs('#gc-exe').value = displayGamePath(p, state.currentGame)
|
||||
})
|
||||
qs('#gc-workdir-browse').addEventListener('click', async () => {
|
||||
const cur = absPathInput(qs('#gc-exe').value.trim())
|
||||
const p = await window.plwl.dialogPickFile({ properties: ['openDirectory'], defaultPath: cur || window.plwl.FILES_DIR })
|
||||
if (p) qs('#gc-workdir').value = displayPath(p)
|
||||
if (p) qs('#gc-workdir').value = displayGamePath(p, state.currentGame)
|
||||
})
|
||||
qs('#gc-tool-run').addEventListener('click', runCustomTool)
|
||||
async function applyIconResult(res) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue