[EXPERIMENTAL] linux apps support
This commit is contained in:
parent
d4996988d9
commit
e7636afe3d
20 changed files with 1377 additions and 101 deletions
150
renderer/app.js
150
renderer/app.js
|
|
@ -26,6 +26,7 @@ const state = {
|
|||
prefixes: { default: '', prefixes: [] },
|
||||
games: [],
|
||||
currentGame: null,
|
||||
sandbox: null,
|
||||
lansync: null,
|
||||
// true while this device pushes a sync: it owns the single popup
|
||||
lanPushing: false,
|
||||
|
|
@ -383,18 +384,20 @@ function showView(name) {
|
|||
}
|
||||
|
||||
async function loadAll() {
|
||||
const [langs, settings, wines, prefixes, games] = await Promise.all([
|
||||
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.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)
|
||||
}
|
||||
|
|
@ -416,6 +419,29 @@ 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')))
|
||||
}
|
||||
|
||||
function isolationBadge(game) {
|
||||
const policy = (game && game.isolation) || 'auto'
|
||||
if (policy === 'off') return `<span class="badge">${t('sandbox.badgeOff')}</span>`
|
||||
if (sandboxStrong()) return `<span class="badge primary">${t('sandbox.badgeStrong')}</span>`
|
||||
if (policy === 'require') return `<span class="badge">${t('sandbox.badgeRequired')}</span>`
|
||||
return `<span class="badge">${t('sandbox.badgeWeak')}</span>`
|
||||
}
|
||||
|
||||
// Portable apps show files/-relative paths; external apps keep absolute ones.
|
||||
function displayPath(p) {
|
||||
if (!p) return ''
|
||||
|
|
@ -565,6 +591,7 @@ function renderLibrary() {
|
|||
card.style.animationDelay = `${idx * 0.04}s`
|
||||
const portable = game.mode === 'copy' || game.mode === 'move'
|
||||
const inPrefix = game.importSource === 'prefix'
|
||||
const isLinux = isLinuxGame(game)
|
||||
const sourceBadge = inPrefix
|
||||
? `<span class="badge primary">${t('library.inPrefix')}${game.prefix ? ' · ' + game.prefix : ''}</span>`
|
||||
: `<span class="badge ${portable ? 'primary' : ''}">${portable ? t('library.portable') : t('library.external')}</span>`
|
||||
|
|
@ -573,7 +600,8 @@ function renderLibrary() {
|
|||
<h3 title=""></h3>
|
||||
<div class="meta">
|
||||
${sourceBadge}
|
||||
<span class="badge">${wineLabel(game.wine)}</span>
|
||||
${isLinux ? `<span class="badge">${t('config.linuxKindValue')}</span>` : `<span class="badge">${wineLabel(game.wine)}</span>`}
|
||||
${isLinux ? isolationBadge(game) : ''}
|
||||
</div>
|
||||
<div class="meta">${t('library.lastPlayed', last)}</div>
|
||||
<div class="meta">${t('library.playtime', fmtPlaytime(game.playSeconds || 0))}</div>
|
||||
|
|
@ -605,7 +633,7 @@ function renderLibrary() {
|
|||
card.querySelector('[data-act=remove]').onclick = async () => {
|
||||
const note = game.mode === 'copy' || game.mode === 'move'
|
||||
? t('library.removeFilesNote')
|
||||
: (inPrefix ? t('library.removePrefixNote') : '')
|
||||
: (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
|
||||
|
|
@ -633,6 +661,9 @@ async function playGame(game) {
|
|||
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')
|
||||
}
|
||||
|
|
@ -666,7 +697,34 @@ function wireRealtime() {
|
|||
}, 15000)
|
||||
}
|
||||
|
||||
// Step 0: Windows or native Linux? Everything after this is the same flow.
|
||||
async function startImportFlow() {
|
||||
const modal = showModal(`
|
||||
<h2>${t('library.import')}</h2>
|
||||
<p>${t('import.kind.title')}</p>
|
||||
<div class="mode-buttons">
|
||||
<button class="button" data-kind="windows">${t('import.kind.windows')}</button>
|
||||
<p class="subtitle">${t('import.kind.windowsHint')}</p>
|
||||
<button class="button" data-kind="linux">${t('import.kind.linux')}</button>
|
||||
<p class="subtitle">${t('import.kind.linuxHint')}</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="button" id="modal-cancel">${t('dialog.cancel')}</button>
|
||||
</div>
|
||||
`)
|
||||
modal.querySelector('#modal-cancel').onclick = closeModal
|
||||
modal.querySelectorAll('[data-kind]').forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
const kind = btn.dataset.kind
|
||||
closeModal()
|
||||
startModeFlow(kind)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Step 1: same question as before; only Windows apps can come from a prefix.
|
||||
async function startModeFlow(kind) {
|
||||
const fromPrefix = kind !== 'linux'
|
||||
const modal = showModal(`
|
||||
<h2>${t('library.import')}</h2>
|
||||
<p>${t('import.question')}</p>
|
||||
|
|
@ -674,7 +732,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>
|
||||
${fromPrefix ? `<button class="button" data-mode="prefix">${t('import.mode.prefix')}</button>` : ''}
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="button" id="modal-cancel">${t('dialog.cancel')}</button>
|
||||
|
|
@ -688,7 +746,7 @@ async function startImportFlow() {
|
|||
if (mode === 'prefix') return startPrefixImportFlow()
|
||||
const srcDir = await window.plwl.gamesPickFolder()
|
||||
if (!srcDir) return
|
||||
await doImport(srcDir, mode)
|
||||
await doImport(srcDir, mode, kind)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -733,7 +791,7 @@ async function startPrefixImportFlow() {
|
|||
}
|
||||
}
|
||||
|
||||
async function doImport(srcDir, mode) {
|
||||
async function doImport(srcDir, mode, kind) {
|
||||
const name = srcDir.split('/').filter(Boolean).pop()
|
||||
const modal = showModal(`
|
||||
<h2>${t('import.progress', name)}</h2>
|
||||
|
|
@ -754,12 +812,13 @@ async function doImport(srcDir, mode) {
|
|||
if (status) status.textContent = t('import.fileDone', (data && data.files) || count)
|
||||
})
|
||||
try {
|
||||
const game = await window.plwl.gamesImport(srcDir, mode)
|
||||
const game = await window.plwl.gamesImport(srcDir, mode, kind)
|
||||
closeModal()
|
||||
if (!game || game.ok === false) {
|
||||
const raw = (game && game.error) || ''
|
||||
const errText =
|
||||
raw === 'no-exe-found' ? t('import.noExe')
|
||||
: raw === 'no-linux-exe' ? t('import.noLinuxExe')
|
||||
: raw === 'prefix-root' ? t('import.prefixRoot')
|
||||
: (raw || 'unknown error')
|
||||
toast(t('import.failed', errText), 'error')
|
||||
|
|
@ -939,6 +998,7 @@ function renderPrefixes() {
|
|||
<div class="actions">
|
||||
${locked ? '' : '<button class="button small" data-act="rename"></button>'}
|
||||
<button class="button small" data-act="default" ${p.name === defs.default ? 'style="display:none"' : ''}></button>
|
||||
<button class="button small" data-act="reset"></button>
|
||||
<button class="button small danger" data-act="delete" ${locked ? 'style="display:none"' : ''}></button>
|
||||
</div>
|
||||
`
|
||||
|
|
@ -984,6 +1044,21 @@ function renderPrefixes() {
|
|||
state.prefixes = await window.plwl.prefixesList()
|
||||
renderPrefixes()
|
||||
}
|
||||
const resetBtn = row.querySelector('[data-act=reset]')
|
||||
resetBtn.textContent = t('prefixes.resetConfig')
|
||||
resetBtn.title = t('prefixes.resetConfigHint')
|
||||
resetBtn.onclick = async () => {
|
||||
const ok = await confirmModal(t('prefixes.resetConfigConfirm', p.name), t('prefixes.resetConfig'))
|
||||
if (!ok) return
|
||||
const res = await window.plwl.prefixesResetLinuxConfig(p.name)
|
||||
if (!res || !res.ok) {
|
||||
if (res && res.error === 'inUse') toast(t('prefixes.resetConfigBusy', p.name, (res.apps || []).join(', ')), 'error')
|
||||
else toast(t('prefixes.resetConfigFailed', p.name), 'error')
|
||||
return
|
||||
}
|
||||
if (res.removed && res.removed.length) toast(t('prefixes.resetConfigDone', p.name), 'success')
|
||||
else toast(t('prefixes.resetConfigNone', p.name), 'success')
|
||||
}
|
||||
const delBtn = row.querySelector('[data-act=delete]')
|
||||
delBtn.innerHTML = `${ICONS.trash()}<span>${t('prefixes.delete')}</span>`
|
||||
delBtn.onclick = async () => {
|
||||
|
|
@ -1109,6 +1184,32 @@ function renderSettings() {
|
|||
selects['set-wine'].update(state.wines.map(w => ({ value: w.id, label: w.label })), state.settings.defaultWine || 'system')
|
||||
selects['set-prefix'].update(state.prefixes.prefixes.map(p => ({ value: p.name, label: p.name })), state.prefixes.default)
|
||||
qs('#set-closetotray').checked = state.settings.closeToTray !== false
|
||||
qs('#set-sandbox-status').textContent = isolationStatusText(state.sandbox) || '…'
|
||||
}
|
||||
|
||||
async function recheckSandbox() {
|
||||
state.sandbox = await window.plwl.sandboxProbe()
|
||||
if (state.currentView === 'settings') renderSettings()
|
||||
if (state.currentView === 'library') renderLibrary()
|
||||
toast(t('sandbox.rechecked'), 'success')
|
||||
}
|
||||
|
||||
// A silent downgrade is the worst outcome, so say it once per install.
|
||||
function warnSandboxOnce() {
|
||||
const status = state.sandbox
|
||||
if (!status || status.backend === 'bwrap') return
|
||||
if (state.settings && state.settings.sandboxNotifySeen) return
|
||||
const modal = showModal(`
|
||||
<h2>${t('sandbox.warnTitle')}</h2>
|
||||
<p>${t('sandbox.warnBody', t('sandbox.reason.' + (status.reason || 'failed')))}</p>
|
||||
<div class="modal-actions">
|
||||
<button class="button primary" id="sandbox-warn-ok">${t('sandbox.dismiss')}</button>
|
||||
</div>
|
||||
`)
|
||||
modal.querySelector('#sandbox-warn-ok').onclick = async () => {
|
||||
closeModal()
|
||||
state.settings = await window.plwl.settingsSet({ sandboxNotifySeen: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
|
|
@ -1199,6 +1300,28 @@ async function openGameConfig(id) {
|
|||
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: 'off', label: t('config.isolation.off') },
|
||||
{ value: 'require', label: t('config.isolation.require') }
|
||||
]
|
||||
|
||||
function renderIsolationOptions(selected) {
|
||||
const opts = isolationOptions()
|
||||
if (!selects['gc-isolation']) selects['gc-isolation'] = new CustomSelect(qs('#gc-isolation'), opts, selected)
|
||||
else selects['gc-isolation'].update(opts, selected)
|
||||
}
|
||||
|
||||
async function createAppShortcut() {
|
||||
|
|
@ -1316,6 +1439,10 @@ async function saveGameConfig() {
|
|||
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)
|
||||
|
|
@ -1891,6 +2018,7 @@ function wireEvents() {
|
|||
})
|
||||
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
|
||||
|
|
@ -1899,6 +2027,11 @@ function wireEvents() {
|
|||
})
|
||||
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)
|
||||
|
|
@ -1967,6 +2100,7 @@ async function boot() {
|
|||
qs('#loading').remove()
|
||||
qs('#main').style.display = 'flex'
|
||||
showView('library')
|
||||
warnSandboxOnce()
|
||||
}
|
||||
|
||||
boot()
|
||||
|
|
|
|||
|
|
@ -110,6 +110,15 @@
|
|||
<button class="button danger" id="btn-clear-hash-cache" data-t="settings.clearHashCache"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-card">
|
||||
<h2 data-t="sandbox.title"></h2>
|
||||
<p class="subtitle" data-t="sandbox.hint"></p>
|
||||
<label class="field-label" data-t="sandbox.status"></label>
|
||||
<p class="subtitle" id="set-sandbox-status"></p>
|
||||
<div class="form-actions">
|
||||
<button class="button" id="btn-sandbox-check" data-t="sandbox.check"></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="view-lansync" class="view">
|
||||
|
|
@ -180,26 +189,48 @@
|
|||
<label class="field-label" data-t="config.args"></label>
|
||||
<input type="text" id="gc-args" data-ph="config.argsHint">
|
||||
|
||||
<label class="field-label" data-t="config.wine"></label>
|
||||
<span id="gc-wine"></span>
|
||||
<div class="win-only">
|
||||
<label class="field-label" data-t="config.wine"></label>
|
||||
<span id="gc-wine"></span>
|
||||
</div>
|
||||
|
||||
<label class="field-label" data-t="config.prefix"></label>
|
||||
<label class="field-label win-only" data-t="config.prefix"></label>
|
||||
<label class="field-label linux-only" data-t="config.linuxDataRoot" style="display:none"></label>
|
||||
<span id="gc-prefix"></span>
|
||||
<p class="subtitle" id="gc-prefix-lock-hint" data-t="config.prefixLockedHint" style="display:none"></p>
|
||||
<p class="subtitle win-only" id="gc-prefix-lock-hint" data-t="config.prefixLockedHint" style="display:none"></p>
|
||||
<p class="subtitle linux-only" data-t="config.linuxDataRootHint" style="display:none"></p>
|
||||
<div class="linux-only" style="display:none">
|
||||
<button class="button" id="btn-gc-data-folder" data-t="config.openData"></button>
|
||||
</div>
|
||||
|
||||
<label class="check-label">
|
||||
<input type="checkbox" id="gc-disable-umu">
|
||||
<span data-t="config.disableUmu"></span>
|
||||
</label>
|
||||
<p class="subtitle" data-t="config.disableUmuHint"></p>
|
||||
<div class="linux-only" style="display:none">
|
||||
<label class="field-label" data-t="config.isolation"></label>
|
||||
<span id="gc-isolation"></span>
|
||||
<p class="subtitle" id="gc-isolation-status"></p>
|
||||
<p class="subtitle" data-t="config.isolationHint"></p>
|
||||
|
||||
<label class="check-label">
|
||||
<input type="checkbox" id="gc-force-x11">
|
||||
<span data-t="config.forceX11"></span>
|
||||
</label>
|
||||
<p class="subtitle" data-t="config.forceX11Hint"></p>
|
||||
</div>
|
||||
|
||||
<label class="field-label" data-t="config.env"></label>
|
||||
<textarea id="gc-env" rows="3" data-ph="config.envHint"></textarea>
|
||||
|
||||
<label class="field-label" data-t="config.dllOverrides"></label>
|
||||
<div id="gc-overrides" class="overrides"></div>
|
||||
<button class="button" id="gc-override-add" data-t="config.overrides.add"></button>
|
||||
<p class="subtitle" data-t="config.overrides.hint"></p>
|
||||
<div class="win-only">
|
||||
<label class="check-label">
|
||||
<input type="checkbox" id="gc-disable-umu">
|
||||
<span data-t="config.disableUmu"></span>
|
||||
</label>
|
||||
<p class="subtitle" data-t="config.disableUmuHint"></p>
|
||||
|
||||
<label class="field-label" data-t="config.dllOverrides"></label>
|
||||
<div id="gc-overrides" class="overrides"></div>
|
||||
<button class="button" id="gc-override-add" data-t="config.overrides.add"></button>
|
||||
<p class="subtitle" data-t="config.overrides.hint"></p>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="button danger" id="btn-gc-clear-playtime" data-t="config.clearPlaytime"></button>
|
||||
|
|
@ -208,7 +239,7 @@
|
|||
<button class="button primary" id="btn-gc-save" data-t="config.save"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-card">
|
||||
<div class="form-card win-only">
|
||||
<h2 data-t="tools.title"></h2>
|
||||
<p class="subtitle" data-t="tools.subtitle"></p>
|
||||
<div class="tools-grid" id="gc-tools"></div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue