fix (i hope) all bugs
This commit is contained in:
parent
e8cd664965
commit
201f9d7a7e
11 changed files with 195 additions and 37 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.2 KiB |
|
|
@ -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,
|
||||
|
|
|
|||
53
src/icons.js
53
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)
|
||||
|
|
|
|||
|
|
@ -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/<id> - 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
18
src/paths.js
18
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/<id>/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 }
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue