fix (i hope) all bugs

This commit is contained in:
olcxja 2026-09-26 11:40:04 +02:00
commit 201f9d7a7e
11 changed files with 195 additions and 37 deletions

View file

@ -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)

View file

@ -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
}

View file

@ -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 }

View file

@ -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 }

View file

@ -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 })