[EXPERIMENTAL] linux apps support

This commit is contained in:
olcxja 2026-10-06 20:54:16 +02:00
commit e7636afe3d
20 changed files with 1377 additions and 101 deletions

View file

@ -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()

View file

@ -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>