- make prefix imported games paths relative to prefix
- show placeholder icon as fallback
- fix crossed working/exe directories
- fix broken working dir in prefix imported games
This commit is contained in:
olcxja 2026-09-30 16:27:31 +02:00
commit 66f42f30cc
8 changed files with 74 additions and 10 deletions

View file

@ -70,6 +70,7 @@
"config.prefix": "Wine prefix", "config.prefix": "Wine prefix",
"config.prefixDefault": "Default prefix ({0})", "config.prefixDefault": "Default prefix ({0})",
"config.prefixLocked": "This app was imported from a prefix - its prefix cannot be changed", "config.prefixLocked": "This app was imported from a prefix - its prefix cannot be changed",
"config.prefixLockedHint": "Pinned to the prefix it was imported from (its paths are stored relative to it)",
"config.dllOverrides": "DLL overrides", "config.dllOverrides": "DLL overrides",
"config.overrides.add": "Add override", "config.overrides.add": "Add override",
"config.overrides.dll": "DLL name", "config.overrides.dll": "DLL name",

View file

@ -70,6 +70,7 @@
"config.prefix": "Prefiks Wine", "config.prefix": "Prefiks Wine",
"config.prefixDefault": "Prefiks domyślny ({0})", "config.prefixDefault": "Prefiks domyślny ({0})",
"config.prefixLocked": "Ta aplikacja została zaimportowana z prefiksu - nie można zmienić jej prefiksu", "config.prefixLocked": "Ta aplikacja została zaimportowana z prefiksu - nie można zmienić jej prefiksu",
"config.prefixLockedHint": "Przypięta do prefiksu, z którego została zaimportowana (ścieżki trzymane są względem niego)",
"config.dllOverrides": "Nadpisania DLL", "config.dllOverrides": "Nadpisania DLL",
"config.overrides.add": "Dodaj nadpisanie", "config.overrides.add": "Dodaj nadpisanie",
"config.overrides.dll": "Nazwa DLL", "config.overrides.dll": "Nazwa DLL",

View file

@ -166,6 +166,7 @@ class CustomSelect {
this.options = options this.options = options
this.value = value this.value = value
this.onChange = onChange this.onChange = onChange
this.disabled = false
this.render() this.render()
if (!this.host.dataset.wired) { if (!this.host.dataset.wired) {
this.wire() this.wire()
@ -173,6 +174,11 @@ class CustomSelect {
} }
} }
setDisabled(v) {
this.disabled = !!v
this.host.classList.toggle('disabled', this.disabled)
}
render() { render() {
const selected = this.options.find(o => o.value === this.value) const selected = this.options.find(o => o.value === this.value)
this.host.classList.add('custom-select') this.host.classList.add('custom-select')
@ -219,6 +225,7 @@ class CustomSelect {
} }
toggle() { toggle() {
if (this.disabled) return
const wasOpen = this.host.classList.contains('open') const wasOpen = this.host.classList.contains('open')
CustomSelect.closeAll() CustomSelect.closeAll()
if (!wasOpen) this.host.classList.add('open') if (!wasOpen) this.host.classList.add('open')
@ -357,15 +364,23 @@ function fmtPlaytime(seconds) {
} }
async function attachCardIcon(card, game) { async function attachCardIcon(card, game) {
// placeholder first so every card has a stable icon slot; swapped for the
// real icon once it loads - games without one just keep the placeholder
const ph = document.createElement('div')
ph.className = 'game-icon placeholder'
card.prepend(ph)
const p = await window.plwl.gamesIconPath(game.id) const p = await window.plwl.gamesIconPath(game.id)
if (!p) return if (!p) return
const img = document.createElement('img') const img = document.createElement('img')
img.className = 'game-icon' img.className = 'game-icon'
img.alt = '' img.alt = ''
img.onerror = () => img.remove() img.onerror = () => img.remove() // placeholder stays visible
img.onload = () => card.prepend(img) img.onload = () => {
ph.remove()
card.prepend(img)
}
img.src = iconUrl(game.id) img.src = iconUrl(game.id)
if (img.complete && img.naturalWidth > 0) card.prepend(img) if (img.complete && img.naturalWidth > 0) img.onload()
} }
function renderLibrary() { function renderLibrary() {
@ -817,6 +832,12 @@ async function openGameConfig(id) {
qs('#gc-env').value = Object.entries(game.env || {}).map(([k, v]) => `${k}=${v}`).join('\n') qs('#gc-env').value = Object.entries(game.env || {}).map(([k, v]) => `${k}=${v}`).join('\n')
renderWineOptions('gc-wine', game.wine) renderWineOptions('gc-wine', game.wine)
renderPrefixOptions('gc-prefix', game.prefix) renderPrefixOptions('gc-prefix', game.prefix)
// apps imported from a prefix are pinned to it (their paths are prefix-
// relative) - the select is locked, not just corrected on save
const prefixLocked = game.importSource === 'prefix'
selects['gc-prefix'].setDisabled(prefixLocked)
const lockHint = qs('#gc-prefix-lock-hint')
if (lockHint) lockHint.style.display = prefixLocked ? '' : 'none'
renderOverrides(game.dllOverrides || []) renderOverrides(game.dllOverrides || [])
renderTools() renderTools()
} }
@ -994,12 +1015,16 @@ function wireEvents() {
qs('#btn-gc-shortcut').addEventListener('click', createAppShortcut) qs('#btn-gc-shortcut').addEventListener('click', createAppShortcut)
qs('#gc-override-add').addEventListener('click', addOverrideRow) qs('#gc-override-add').addEventListener('click', addOverrideRow)
qs('#gc-exe-browse').addEventListener('click', async () => { qs('#gc-exe-browse').addEventListener('click', async () => {
const cur = absPathInput(qs('#gc-workdir').value.trim()) // start from the field being edited (exe picker from the exe field) and
// resolve the SHOWN path (prefix apps display pfx/drive_c/...) to an
// absolute one - absPathInput alone would pass the prefix-relative form
// through and the picker would open at the process cwd ('/')
const cur = absGamePathInput(qs('#gc-exe').value.trim(), state.currentGame)
const p = await window.plwl.dialogPickFile({ properties: ['openFile'], defaultPath: cur || window.plwl.FILES_DIR }) const p = await window.plwl.dialogPickFile({ properties: ['openFile'], defaultPath: cur || window.plwl.FILES_DIR })
if (p) qs('#gc-exe').value = displayGamePath(p, state.currentGame) if (p) qs('#gc-exe').value = displayGamePath(p, state.currentGame)
}) })
qs('#gc-workdir-browse').addEventListener('click', async () => { qs('#gc-workdir-browse').addEventListener('click', async () => {
const cur = absPathInput(qs('#gc-exe').value.trim()) const cur = absGamePathInput(qs('#gc-workdir').value.trim(), state.currentGame)
const p = await window.plwl.dialogPickFile({ properties: ['openDirectory'], defaultPath: cur || window.plwl.FILES_DIR }) const p = await window.plwl.dialogPickFile({ properties: ['openDirectory'], defaultPath: cur || window.plwl.FILES_DIR })
if (p) qs('#gc-workdir').value = displayGamePath(p, state.currentGame) if (p) qs('#gc-workdir').value = displayGamePath(p, state.currentGame)
}) })
@ -1012,8 +1037,12 @@ function wireEvents() {
applyIconResult(await window.plwl.gamesAutoIcon(state.currentGame.id)) applyIconResult(await window.plwl.gamesAutoIcon(state.currentGame.id))
}) })
qs('#gc-icon-browse').addEventListener('click', async () => { qs('#gc-icon-browse').addEventListener('click', async () => {
// start in the app's own folder (prefix apps included) - that is where
// the exe with the embedded icon most likely lives
const start = absGamePathInput(qs('#gc-workdir').value.trim(), state.currentGame)
const p = await window.plwl.dialogPickFile({ const p = await window.plwl.dialogPickFile({
properties: ['openFile'], properties: ['openFile'],
defaultPath: start || window.plwl.FILES_DIR,
title: t('config.icon.pick'), title: t('config.icon.pick'),
filters: [{ name: t('config.icon.pick'), extensions: ['png', 'jpg', 'jpeg', 'svg', 'ico', 'webp', 'gif', 'bmp', 'exe'] }] filters: [{ name: t('config.icon.pick'), extensions: ['png', 'jpg', 'jpeg', 'svg', 'ico', 'webp', 'gif', 'bmp', 'exe'] }]
}) })

View file

@ -146,6 +146,7 @@
<label class="field-label" data-t="config.prefix"></label> <label class="field-label" data-t="config.prefix"></label>
<span id="gc-prefix"></span> <span id="gc-prefix"></span>
<p class="subtitle" id="gc-prefix-lock-hint" data-t="config.prefixLockedHint" style="display:none"></p>
<label class="field-label" data-t="config.env"></label> <label class="field-label" data-t="config.env"></label>
<textarea id="gc-env" rows="3" data-ph="config.envHint"></textarea> <textarea id="gc-env" rows="3" data-ph="config.envHint"></textarea>

View file

@ -476,6 +476,21 @@ select {
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
align-self: flex-start; align-self: flex-start;
} }
.game-icon.placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--darker-text-color);
opacity: 0.45;
}
.game-icon.placeholder::before {
content: '';
width: 2rem;
height: 2rem;
background: currentColor;
-webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'><rect x='3' y='3' width='18' height='18' rx='2'/><circle cx='8.5' cy='8.5' r='1.5'/><path d='M21 15l-5-5L5 21'/></svg>") center / contain no-repeat;
mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'><rect x='3' y='3' width='18' height='18' rx='2'/><circle cx='8.5' cy='8.5' r='1.5'/><path d='M21 15l-5-5L5 21'/></svg>") center / contain no-repeat;
}
.game-card:hover { border-color: rgba(255, 255, 255, 0.2); } .game-card:hover { border-color: rgba(255, 255, 255, 0.2); }
.game-card h3 { .game-card h3 {
font-size: 1.05rem; font-size: 1.05rem;

View file

@ -5,7 +5,7 @@ const path = require('path')
const { DIRS, resolvePortablePath } = require('./paths') const { DIRS, resolvePortablePath } = require('./paths')
const { newGame, newGameId, addGame } = require('./library') const { newGame, newGameId, addGame } = require('./library')
const { extractIconFromExe } = require('./icons') const { extractIconFromExe } = require('./icons')
const { prefixPath } = require('./prefixes') const { listPrefixes, prefixDriveRoot } = require('./prefixes')
// Directories that typically hold installers/redistributables, not the game. // Directories that typically hold installers/redistributables, not the game.
const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc']) const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc'])
@ -68,12 +68,11 @@ async function importGame(srcDir, mode, onProgress) {
// prefix, so wine sees the app exactly as if it had been installed // prefix, so wine sees the app exactly as if it had been installed
// normally into C:\... // normally into C:\...
async function importGameFromPrefix(prefix, srcDir, onProgress) { async function importGameFromPrefix(prefix, srcDir, onProgress) {
const prefixes = require('./prefixes') if (!listPrefixes().prefixes.some(p => p.name === prefix)) return { ok: false, error: 'prefix-not-found' }
if (!prefixes.listPrefixes().prefixes.some(p => p.name === prefix)) return { ok: false, error: 'prefix-not-found' }
// the folder must live inside the prefix's C: drive so the C:\ mapping // the folder must live inside the prefix's C: drive so the C:\ mapping
// holds - drive_c sits at the prefix top for plain wine, under pfx/ for // holds - drive_c sits at the prefix top for plain wine, under pfx/ for
// Proton-managed prefixes // Proton-managed prefixes
const driveRoot = prefixes.prefixDriveRoot(prefix) const driveRoot = prefixDriveRoot(prefix)
if (!driveRoot) return { ok: false, error: 'no-drive-c' } if (!driveRoot) return { ok: false, error: 'no-drive-c' }
const rel = path.relative(driveRoot, srcDir) const rel = path.relative(driveRoot, srcDir)
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return { ok: false, error: 'outside-drive-c' } if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return { ok: false, error: 'outside-drive-c' }

View file

@ -93,6 +93,8 @@ function registerIpc({ getWindow, onGamesChanged }) {
// games: library ----------------------------------------------------------------- // games: library -----------------------------------------------------------------
ipcMain.handle('games:list', () => library.loadGames()) ipcMain.handle('games:list', () => library.loadGames())
ipcMain.handle('games:get', (e, id) => library.getGame(id)) ipcMain.handle('games:get', (e, id) => library.getGame(id))
// prefix pin is enforced inside library.updateGame (prefix/importSource
// fields are ignored for prefix imports), so nothing extra needed here
ipcMain.handle('games:update', (e, id, patch) => library.updateGame(id, patch)) ipcMain.handle('games:update', (e, id, patch) => library.updateGame(id, patch))
ipcMain.handle('games:remove', (e, id) => { ipcMain.handle('games:remove', (e, id) => {
shortcuts.removeShortcut(id) shortcuts.removeShortcut(id)
@ -204,7 +206,15 @@ function registerIpc({ getWindow, onGamesChanged }) {
// dialogs & shell --------------------------------------------------------------------------- // dialogs & shell ---------------------------------------------------------------------------
ipcMain.handle('dialog:pickFile', async (e, opts) => { ipcMain.handle('dialog:pickFile', async (e, opts) => {
const res = await dialog.showOpenDialog(getWindow(), { properties: ['openFile', 'openDirectory'], ...(opts || {}) }) const o = { ...(opts || {}) }
// Electron resolves a RELATIVE defaultPath against the process cwd (the
// install root in packaged mode), which lands the picker somewhere
// random like '/'. Renderer paths are FILES-relative by convention, so
// anchor them there instead.
if (o.defaultPath && typeof o.defaultPath === 'string' && !path.isAbsolute(o.defaultPath)) {
o.defaultPath = path.join(DIRS.FILES, o.defaultPath)
}
const res = await dialog.showOpenDialog(getWindow(), { properties: ['openFile', 'openDirectory'], ...o })
return res.canceled || !res.filePaths[0] ? null : res.filePaths[0] return res.canceled || !res.filePaths[0] ? null : res.filePaths[0]
}) })
ipcMain.handle('shell:openPath', (e, p) => shell.openPath(p)) ipcMain.handle('shell:openPath', (e, p) => shell.openPath(p))

View file

@ -93,6 +93,14 @@ function updateGame(id, patch) {
const games = loadGames() const games = loadGames()
const g = games.find(x => x.id === id) const g = games.find(x => x.id === id)
if (!g) return null if (!g) return null
// prefix imports are pinned: their paths are stored relative to their
// prefix, so NOTHING may move them to another prefix or flip their
// import source (the patch fields are simply ignored)
if (g.importSource === 'prefix' && patch) {
patch = { ...patch }
delete patch.prefix
delete patch.importSource
}
// playSeconds goes to its own tiny file; the registry just mirrors it for // playSeconds goes to its own tiny file; the registry just mirrors it for
// anything reading apps.json directly (loadGames prefers the file anyway). // anything reading apps.json directly (loadGames prefers the file anyway).
if (patch.playSeconds !== undefined) writePlaySeconds(id, patch.playSeconds) if (patch.playSeconds !== undefined) writePlaySeconds(id, patch.playSeconds)