diff --git a/files/translations/en-us.json b/files/translations/en-us.json index aace39a..70b6c97 100644 --- a/files/translations/en-us.json +++ b/files/translations/en-us.json @@ -44,6 +44,7 @@ "import.fileDone": "{0} files copied", "import.done": "Import finished", "import.failed": "Import failed: {0}", + "import.noExe": "no .exe file found in the selected folder", "config.title": "App configuration", "config.icon.auto": "Extract from exe", diff --git a/files/translations/pl.json b/files/translations/pl.json index 7889aac..5e5b022 100644 --- a/files/translations/pl.json +++ b/files/translations/pl.json @@ -44,6 +44,7 @@ "import.fileDone": "Skopiowane pliki: {0}", "import.done": "Import zakończony", "import.failed": "Import nie powiódł się: {0}", + "import.noExe": "nie znaleziono pliku .exe w wybranym folderze", "config.title": "Konfiguracja aplikacji", "config.icon.auto": "Wyciągnij z exe", diff --git a/package.json b/package.json index a11ab1a..d68c601 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "main": "src/main.js", "scripts": { "start": "electron .", - "smoke": "node test/smoke.js" + "test": "node test/import.test.js && node test/icons.test.js", + "smoke": "node test/import.test.js && node test/icons.test.js" }, "devDependencies": { "@electron/asar": "^3.4.1", diff --git a/renderer/app.js b/renderer/app.js index dc82d52..9c35176 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -287,6 +287,28 @@ function iconUrl(id) { return `plwl-icon://${encodeURIComponent(id)}/icon` } +// Paths of imported (copy/move) apps live inside FILES/ and are stored +// relative to it in the registry (portable mode) - show them prefixed with +// files/ instead of the absolute install location. External (leave) apps +// keep their absolute path. +function displayPath(p) { + if (!p) return '' + const files = window.plwl.FILES_DIR + if (files && p.startsWith(files + '/')) return 'files/' + p.slice(files.length + 1) + return p +} + +// Inverse of displayPath for input fields: accept a files/-prefixed (or bare, +// FILES-relative) path and resolve it to an absolute one. +function absPathInput(v) { + if (!v) return v + const files = window.plwl.FILES_DIR + if (!files) return v + if (v.startsWith('files/')) return files + '/' + v.slice(6) + if (!v.includes('/') && !v.startsWith('.')) return files + '/' + v + return v +} + function fmtPlaytime(seconds) { const s = Math.round(seconds || 0) if (s < 60) return t('library.playtimeSeconds', s) @@ -456,13 +478,19 @@ async function doImport(srcDir, mode) { try { const game = await window.plwl.gamesImport(srcDir, mode) closeModal() - toast(t('toast.importDone', game.name), 'success') + if (!game || game.ok === false) { + const raw = (game && game.error) || '' + const errText = raw === 'no-exe-found' ? t('import.noExe') : (raw || 'unknown error') + toast(t('import.failed', errText), 'error') + return + } state.games = await window.plwl.gamesList() renderLibrary() + toast(t('toast.importDone', game.name), 'success') await openGameConfig(game.id) } catch (err) { closeModal() - toast(t('toast.importFailed') + ': ' + (err.message || err), 'error') + toast(t('import.failed', (err && err.message) || String(err)), 'error') } } @@ -660,8 +688,8 @@ async function openGameConfig(id) { qs('#gc-title').textContent = game.name qs('#gc-name').value = game.name refreshConfigIcon() - qs('#gc-exe').value = game.exePath - qs('#gc-workdir').value = game.workdir + qs('#gc-exe').value = displayPath(game.exePath) + qs('#gc-workdir').value = displayPath(game.workdir) qs('#gc-args').value = game.args || '' qs('#gc-env').value = Object.entries(game.env || {}).map(([k, v]) => `${k}=${v}`).join('\n') renderWineOptions('gc-wine', game.wine) @@ -724,8 +752,8 @@ async function saveGameConfig() { }) const patch = { name: qs('#gc-name').value.trim() || game.name, - exePath: qs('#gc-exe').value.trim(), - workdir: qs('#gc-workdir').value.trim(), + exePath: absPathInput(qs('#gc-exe').value.trim()), + workdir: absPathInput(qs('#gc-workdir').value.trim()), args: qs('#gc-args').value, wine: selects['gc-wine'].value, prefix: selects['gc-prefix'].value, @@ -818,12 +846,14 @@ function wireEvents() { qs('#btn-gc-save').addEventListener('click', saveGameConfig) qs('#gc-override-add').addEventListener('click', addOverrideRow) qs('#gc-exe-browse').addEventListener('click', async () => { - const p = await window.plwl.dialogPickFile({ properties: ['openFile'], defaultPath: qs('#gc-workdir').value || window.plwl.FILES_DIR }) - if (p) qs('#gc-exe').value = p + const cur = absPathInput(qs('#gc-workdir').value.trim()) + const p = await window.plwl.dialogPickFile({ properties: ['openFile'], defaultPath: cur || window.plwl.FILES_DIR }) + if (p) qs('#gc-exe').value = displayPath(p) }) qs('#gc-workdir-browse').addEventListener('click', async () => { - const p = await window.plwl.dialogPickFile({ properties: ['openDirectory'], defaultPath: qs('#gc-exe').value || window.plwl.FILES_DIR }) - if (p) qs('#gc-workdir').value = p + const cur = absPathInput(qs('#gc-exe').value.trim()) + const p = await window.plwl.dialogPickFile({ properties: ['openDirectory'], defaultPath: cur || window.plwl.FILES_DIR }) + if (p) qs('#gc-workdir').value = displayPath(p) }) qs('#gc-tool-run').addEventListener('click', runCustomTool) async function applyIconResult(res) { diff --git a/renderer/plwl.png b/renderer/plwl.png index cfab4e6..ee0c558 100644 Binary files a/renderer/plwl.png and b/renderer/plwl.png differ diff --git a/renderer/style.css b/renderer/style.css index e8801c5..2cb5fb1 100644 --- a/renderer/style.css +++ b/renderer/style.css @@ -598,10 +598,14 @@ select { border: var(--border-width) solid var(--light-border-color); border-radius: 0.8rem; padding: 0.6rem 1rem; - max-width: 24rem; + max-width: min(24rem, calc(100vw - 2rem)); box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.4); opacity: 0; transform: translateX(1.5rem); + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; + min-width: 0; transition: opacity 0.2s ease, transform 0.3s ease-out, diff --git a/src/icons.js b/src/icons.js index 0907101..94a7265 100644 --- a/src/icons.js +++ b/src/icons.js @@ -120,6 +120,36 @@ function pngSize(img) { return null } +// Raw RT_ICON payloads are BITMAPINFOHEADER-based bitmaps: biSize, biWidth, +// biHeight (2x the visible height - XOR + AND mask), biPlanes, biBitCount, +// ..., biClrUsed. Read the real dimensions from there instead of treating the +// first bytes as an ICONDIRENTRY (which yielded garbage like h=0, planes=48 +// and made Chromium decode an empty image). +function bmpDims(img) { + if (img.length < 12) return null + const biSize = img.readUInt32LE(0) + if (biSize === 12) { + // BITMAPCOREHEADER (Win 2.x/3.x era icons): DWORD size, then WORD width, + // height, planes, bitcount + const w = img.readUInt16LE(4) + const h = Math.floor(img.readUInt16LE(6) / 2) + const planes = img.readUInt16LE(8) || 1 + const bpp = img.readUInt16LE(10) || 32 + const colors = bpp <= 8 ? 1 << bpp : 0 + if (w > 0 && h > 0 && w <= 1024 && h <= 1024) return { w, h, planes, bpp, colors } + return null + } + if (biSize >= 40 && biSize <= 124 && img.length > 40) { + const w = img.readUInt32LE(4) + const h = Math.floor(img.readUInt32LE(8) / 2) + const planes = img.readUInt16LE(12) || 1 + const bpp = img.readUInt16LE(14) || 32 + const colors = img.readUInt32LE(32) || (bpp <= 8 ? 1 << bpp : 0) + if (w > 0 && h > 0 && w <= 1024 && h <= 1024) return { w, h, planes, bpp, colors } + } + return null +} + function buildIco(images) { const header = Buffer.alloc(6) header.writeUInt16LE(0, 0) @@ -135,16 +165,27 @@ function buildIco(images) { w = dim.w h = dim.h } else { - w = img.readUInt8(0) || 256 - h = img.readUInt8(1) || 256 - colors = img.readUInt8(2) - planes = img.length > 4 ? img.readUInt16LE(4) || 1 : 1 - bpp = img.length > 6 ? img.readUInt16LE(6) || 32 : 32 + const dims = bmpDims(img) + if (dims) { + w = dims.w + h = dims.h + colors = dims.colors + planes = dims.planes + bpp = dims.bpp + } else { + // legacy fallback: ICONDIRENTRY-style first bytes (PNG-era heuristic) + w = img.readUInt8(0) || 256 + h = img.readUInt8(1) || 256 + colors = img.readUInt8(2) + planes = img.length > 4 ? img.readUInt16LE(4) || 1 : 1 + bpp = img.length > 6 ? img.readUInt16LE(6) || 32 : 32 + } } const meta = Buffer.alloc(16) meta.writeUInt8(w >= 256 ? 0 : w, 0) meta.writeUInt8(h >= 256 ? 0 : h, 1) - meta.writeUInt8(colors, 2) + // 0 in the ICO dir entry means "256+ colors" (palette implied by bpp) + meta.writeUInt8(colors >= 256 ? 0 : colors, 2) meta.writeUInt8(0, 3) meta.writeUInt16LE(planes, 4) meta.writeUInt16LE(bpp, 6) diff --git a/src/importer.js b/src/importer.js index 4f5d8ce..d4763e5 100644 --- a/src/importer.js +++ b/src/importer.js @@ -2,9 +2,9 @@ const fsp = require('fs').promises const path = require('path') -const { DIRS } = require('./paths') -const { newGame } = require('./library') -const { dedupeDir } = require('./fsutil') +const { DIRS, resolvePortablePath } = require('./paths') +const { newGame, newGameId, addGame } = require('./library') +const { extractIconFromExe } = require('./icons') // Directories that typically hold installers/redistributables, not the game. const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc']) @@ -33,21 +33,53 @@ function moveFolder(src, dst, onProgress) { async function importGame(srcDir, mode, onProgress) { const name = path.basename(srcDir) + // Validate before touching the user's files: refuse an import without any + // .exe (and don't half-move a folder we'd then have to roll back). + const preExe = await findDefaultExe(srcDir) + if (!preExe) return { ok: false, error: 'no-exe-found' } + // The id doubles as the imported folder name, so generate it first and + // create files/apps/ - no dedupe suffixes, ids can never collide. + const id = newGameId() let workdir = srcDir if (mode === 'copy' || mode === 'move') { - const dst = dedupeDir(DIRS.APPS, name) + const dst = path.join(DIRS.APPS, id) if (mode === 'copy') await copyRecursive(srcDir, dst, onProgress) else await moveFolder(srcDir, dst, onProgress) workdir = dst } - const exePath = await findDefaultExe(workdir) - return newGame({ name, exePath, workdir, mode }) + const exePath = (await findDefaultExe(workdir)) || preExe + if (!exePath) { + return { ok: false, error: 'no-exe-found' } + } + const game = newGame({ id, name, exePath, workdir, mode }) + game.imported = mode === 'copy' || mode === 'move' + // the exe was picked automatically -> grab its icon right away; a missing + // icon is not an import failure (game can still get one from config) + const icon = path.join(DIRS.DATA, 'icons', id, 'icon.ico') + if (extractIconFromExe(resolvePortablePath(exePath), icon).ok) { + game.iconSource = icon + } + return addGame(game) } -// Largest .exe in the tree, skipping redistributable folders. +// Largest .exe in the tree, skipping redistributable folders. Files are +// stat'ed in parallel and folders walked breadth-first so big games don't +// turn the picker into a sequential crawl. async function findDefaultExe(dir) { let best = '' let bestSize = -1 + const pending = [] + const consider = full => { + const size = fsp.stat(full).then(st => st.size).catch(() => -1) + pending.push( + size.then(sz => { + if (sz > bestSize) { + best = full + bestSize = sz + } + }) + ) + } const walk = async d => { let entries = [] try { @@ -55,20 +87,19 @@ async function findDefaultExe(dir) { } catch { return } + const subdirs = [] for (const e of entries) { const full = path.join(d, e.name) if (e.isDirectory()) { - if (!SKIPPED_DIRS.has(e.name.toLowerCase())) await walk(full) + if (!SKIPPED_DIRS.has(e.name.toLowerCase())) subdirs.push(full) } else if (e.name.toLowerCase().endsWith('.exe')) { - const size = await fsp.stat(full).then(st => st.size).catch(() => -1) - if (size > bestSize) { - best = full - bestSize = size - } + consider(full) } } + await Promise.all(subdirs.map(walk)) } await walk(dir) + await Promise.all(pending) return best } diff --git a/src/library.js b/src/library.js index e80a5a4..04192dc 100644 --- a/src/library.js +++ b/src/library.js @@ -2,7 +2,7 @@ const path = require('path') const fs = require('fs') -const { DIRS, readJson, writeJson } = require('./paths') +const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths') const { prefixPath } = require('./prefixes') const REGISTRY = path.join(DIRS.DATA, 'apps.json') @@ -32,14 +32,30 @@ function writePlaySeconds(id, seconds) { function loadGames() { const games = readJson(REGISTRY, []) for (const g of games) { + // stored relative to FILES (portable) -> resolve to absolute for use + g.path = resolvePortablePath(g.path) + g.exePath = resolvePortablePath(g.exePath) + if (g.workdir) g.workdir = resolvePortablePath(g.workdir) + if (g.iconSource) g.iconSource = resolvePortablePath(g.iconSource) const ps = readPlaySeconds(g.id) if (ps != null) g.playSeconds = ps } return games } +// Paths of anything living inside FILES/ are stored RELATIVE to FILES so the +// whole launcher folder can be moved (portable mode). Absolute paths outside +// FILES (apps left in place) are kept as-is. +function portableGame(g) { + const out = { ...g } + for (const k of ['path', 'exePath', 'workdir', 'iconSource']) { + if (out[k]) out[k] = toPortablePath(out[k]) + } + return out +} + function saveGames(games) { - writeJson(REGISTRY, games) + writeJson(REGISTRY, games.map(portableGame)) } function getGame(id) { @@ -94,9 +110,19 @@ function saveSettings(patch) { return s } -function newGame({ name, exePath, workdir, mode }) { +// ID is the current timestamp (ms); while a game with that id or a folder +// with that name exists, keep incrementing - so ids and import folder names +// can never collide. +function newGameId() { + let id = Date.now().toString() + const taken = () => getGame(id) || fs.existsSync(path.join(DIRS.APPS, id)) + while (taken()) id = String(Number(id) + 1) + return id +} + +function newGame({ id, name, exePath, workdir, mode }) { return { - id: 'g' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), + id: id || newGameId(), name, mode, path: workdir, @@ -120,4 +146,4 @@ function resolvePrefixDir(game) { return prefixPath(defs.default || 'default') } -module.exports = { loadGames, saveGames, getGame, addGame, updateGame, removeGame, loadSettings, saveSettings, newGame, resolvePrefixDir, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile } +module.exports = { loadGames, saveGames, getGame, addGame, updateGame, removeGame, loadSettings, saveSettings, newGame, newGameId, resolvePrefixDir, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile } diff --git a/src/paths.js b/src/paths.js index 2b72054..71310c1 100644 --- a/src/paths.js +++ b/src/paths.js @@ -64,6 +64,22 @@ function ensureDirs() { } } +// Portable paths: anything stored under FILES/ goes into the registry as a +// path RELATIVE to FILES (e.g. apps//game.exe), so moving the whole +// launcher folder keeps the library working. Absolute paths outside FILES +// (games left in place) are stored as-is. Resolve on read, normalize on write. +function toPortablePath(p) { + if (typeof p !== 'string' || !p || !path.isAbsolute(p)) return p + const rel = path.relative(FILES, p) + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return p + return rel +} + +function resolvePortablePath(p) { + if (typeof p !== 'string' || !p || path.isAbsolute(p)) return p + return path.join(FILES, p) +} + function readJson(file, fallback) { try { return JSON.parse(fs.readFileSync(file, 'utf8')) @@ -79,4 +95,4 @@ function writeJson(file, data) { fs.renameSync(tmp, file) } -module.exports = { RESOURCES, APP_ROOT, BUILD, FILES, DATA, DIRS, ensureDirs, readJson, writeJson } +module.exports = { RESOURCES, APP_ROOT, BUILD, FILES, DATA, DIRS, ensureDirs, readJson, writeJson, toPortablePath, resolvePortablePath } diff --git a/src/runner.js b/src/runner.js index d9fd927..24cf499 100644 --- a/src/runner.js +++ b/src/runner.js @@ -44,6 +44,13 @@ function buildEnv({ game, wine }) { env.STEAM_COMPAT_APP_ID = '0' env.SteamAppId = '0' env.SteamGameId = '0' + // Launch like the UMU protocol does (UMU/PortProton/Heroic): with UMU_ID + // set, newer Protons (incl. DW Proton 11, GE-Proton 10+) run the game via + // their built-in umu.exe instead of the steam.exe helper, which would + // otherwise try to load Steam's steamclient.so and crash with an + // "Assertion failed \"!status\"" dialog (steamclient_main.c) when no + // Steam client is installed. + if (!env.UMU_ID) env.UMU_ID = 'umu-0' env.WINEPREFIX = path.join(env.STEAM_COMPAT_DATA_PATH, 'pfx') env.PROTON_LOG_DIR = path.join(DIRS.DATA, 'logs') fs.mkdirSync(env.PROTON_LOG_DIR, { recursive: true })