fixes:
- 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:
parent
b83e530392
commit
66f42f30cc
8 changed files with 74 additions and 10 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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'] }]
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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' }
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue