diff --git a/files/translations/en-us.json b/files/translations/en-us.json index b317109..c06afd5 100644 --- a/files/translations/en-us.json +++ b/files/translations/en-us.json @@ -70,6 +70,7 @@ "config.prefix": "Wine prefix", "config.prefixDefault": "Default prefix ({0})", "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.overrides.add": "Add override", "config.overrides.dll": "DLL name", diff --git a/files/translations/pl.json b/files/translations/pl.json index e740f9e..7c8568e 100644 --- a/files/translations/pl.json +++ b/files/translations/pl.json @@ -70,6 +70,7 @@ "config.prefix": "Prefiks Wine", "config.prefixDefault": "Prefiks domyślny ({0})", "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.overrides.add": "Dodaj nadpisanie", "config.overrides.dll": "Nazwa DLL", diff --git a/renderer/app.js b/renderer/app.js index e357551..f9ccae2 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -166,6 +166,7 @@ class CustomSelect { this.options = options this.value = value this.onChange = onChange + this.disabled = false this.render() if (!this.host.dataset.wired) { this.wire() @@ -173,6 +174,11 @@ class CustomSelect { } } + setDisabled(v) { + this.disabled = !!v + this.host.classList.toggle('disabled', this.disabled) + } + render() { const selected = this.options.find(o => o.value === this.value) this.host.classList.add('custom-select') @@ -219,6 +225,7 @@ class CustomSelect { } toggle() { + if (this.disabled) return const wasOpen = this.host.classList.contains('open') CustomSelect.closeAll() if (!wasOpen) this.host.classList.add('open') @@ -357,15 +364,23 @@ function fmtPlaytime(seconds) { } 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) if (!p) return const img = document.createElement('img') img.className = 'game-icon' img.alt = '' - img.onerror = () => img.remove() - img.onload = () => card.prepend(img) + img.onerror = () => img.remove() // placeholder stays visible + img.onload = () => { + ph.remove() + card.prepend(img) + } img.src = iconUrl(game.id) - if (img.complete && img.naturalWidth > 0) card.prepend(img) + if (img.complete && img.naturalWidth > 0) img.onload() } 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') renderWineOptions('gc-wine', game.wine) 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 || []) renderTools() } @@ -994,12 +1015,16 @@ function wireEvents() { qs('#btn-gc-shortcut').addEventListener('click', createAppShortcut) qs('#gc-override-add').addEventListener('click', addOverrideRow) 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 }) 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 cur = absGamePathInput(qs('#gc-workdir').value.trim(), state.currentGame) const p = await window.plwl.dialogPickFile({ properties: ['openDirectory'], defaultPath: cur || window.plwl.FILES_DIR }) if (p) qs('#gc-workdir').value = displayGamePath(p, state.currentGame) }) @@ -1012,8 +1037,12 @@ function wireEvents() { applyIconResult(await window.plwl.gamesAutoIcon(state.currentGame.id)) }) 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({ properties: ['openFile'], + defaultPath: start || window.plwl.FILES_DIR, title: t('config.icon.pick'), filters: [{ name: t('config.icon.pick'), extensions: ['png', 'jpg', 'jpeg', 'svg', 'ico', 'webp', 'gif', 'bmp', 'exe'] }] }) diff --git a/renderer/index.html b/renderer/index.html index 786a0b9..249661b 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -146,6 +146,7 @@ + diff --git a/renderer/style.css b/renderer/style.css index 6229f29..ab71bc6 100644 --- a/renderer/style.css +++ b/renderer/style.css @@ -476,6 +476,21 @@ select { background: rgba(255, 255, 255, 0.03); 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,") center / contain no-repeat; + mask: url("data:image/svg+xml;utf8,") center / contain no-repeat; +} .game-card:hover { border-color: rgba(255, 255, 255, 0.2); } .game-card h3 { font-size: 1.05rem; diff --git a/src/importer.js b/src/importer.js index d4acf47..ffe4167 100644 --- a/src/importer.js +++ b/src/importer.js @@ -5,7 +5,7 @@ const path = require('path') const { DIRS, resolvePortablePath } = require('./paths') const { newGame, newGameId, addGame } = require('./library') const { extractIconFromExe } = require('./icons') -const { prefixPath } = require('./prefixes') +const { listPrefixes, prefixDriveRoot } = require('./prefixes') // Directories that typically hold installers/redistributables, not the game. 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 // normally into C:\... async function importGameFromPrefix(prefix, srcDir, onProgress) { - const prefixes = require('./prefixes') - if (!prefixes.listPrefixes().prefixes.some(p => p.name === prefix)) return { ok: false, error: 'prefix-not-found' } + if (!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 // holds - drive_c sits at the prefix top for plain wine, under pfx/ for // Proton-managed prefixes - const driveRoot = prefixes.prefixDriveRoot(prefix) + const driveRoot = prefixDriveRoot(prefix) if (!driveRoot) return { ok: false, error: 'no-drive-c' } const rel = path.relative(driveRoot, srcDir) if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return { ok: false, error: 'outside-drive-c' } diff --git a/src/ipc/index.js b/src/ipc/index.js index 7c2b7ab..19f8211 100644 --- a/src/ipc/index.js +++ b/src/ipc/index.js @@ -93,6 +93,8 @@ function registerIpc({ getWindow, onGamesChanged }) { // games: library ----------------------------------------------------------------- ipcMain.handle('games:list', () => library.loadGames()) 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:remove', (e, id) => { shortcuts.removeShortcut(id) @@ -204,7 +206,15 @@ function registerIpc({ getWindow, onGamesChanged }) { // dialogs & shell --------------------------------------------------------------------------- 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] }) ipcMain.handle('shell:openPath', (e, p) => shell.openPath(p)) diff --git a/src/library.js b/src/library.js index 7a5ec29..a2b6efa 100644 --- a/src/library.js +++ b/src/library.js @@ -93,6 +93,14 @@ function updateGame(id, patch) { const games = loadGames() const g = games.find(x => x.id === id) 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 // anything reading apps.json directly (loadGames prefers the file anyway). if (patch.playSeconds !== undefined) writePlaySeconds(id, patch.playSeconds)