First build

This commit is contained in:
olcxja 2026-09-25 17:09:38 +02:00
commit fd773f5832
954 changed files with 305115 additions and 2 deletions

54
src/i18n.js Normal file
View file

@ -0,0 +1,54 @@
'use strict'
const fsp = require('fs').promises
const path = require('path')
const { DIRS } = require('./paths')
const FALLBACK = 'en-us'
let cache = {}
async function loadLang(code) {
const file = path.join(DIRS.TRANSLATIONS, `${code}.json`)
try {
const raw = await fsp.readFile(file, 'utf8')
const parsed = JSON.parse(raw)
cache[code] = parsed
return parsed
} catch {
return null
}
}
async function init() {
const files = await fsp.readdir(DIRS.TRANSLATIONS).catch(() => [])
const langs = []
for (const f of files) {
if (!f.endsWith('.json')) continue
const lang = await loadLang(f.replace(/\.json$/, ''))
if (lang && lang.data && lang.texts) langs.push(lang)
}
return langs
}
function listLangs() {
return Object.entries(cache).map(([code, lang]) => ({
code,
nameEn: (lang.data && lang.data.nameEn) || code,
nameSelf: (lang.data && lang.data.nameSelf) || code
}))
}
function getLang(code) {
return cache[code] || cache[FALLBACK] || { texts: {}, data: {} }
}
function translate(code, key, ...args) {
const lang = getLang(code)
let text = (lang.texts && lang.texts[key]) ?? (cache[FALLBACK] && cache[FALLBACK].texts && cache[FALLBACK].texts[key]) ?? key
args.forEach((v, i) => {
text = String(text).split(`{${i}}`).join(String(v))
})
return text
}
module.exports = { init, listLangs, getLang, translate, loadLang, FALLBACK }

99
src/importer.js Normal file
View file

@ -0,0 +1,99 @@
'use strict'
const fsp = require('fs').promises
const path = require('path')
const fs = require('fs')
const { DIRS } = require('./paths')
const { newGame } = require('./library')
function walk(dir, base) {
return fsp.readdir(dir, { withFileTypes: true }).then(async entries => {
let total = 0
for (const e of entries) {
const full = path.join(dir, e.name)
if (e.isDirectory()) total += await walk(full, base)
else if (e.isFile()) {
const st = await fsp.stat(full)
total += st.size
}
}
return total
}).catch(() => 0)
}
function copyRecursive(src, dst, onProgress) {
return fsp.readdir(src, { withFileTypes: true }).then(async entries => {
await fsp.mkdir(dst, { recursive: true })
for (const e of entries) {
const s = path.join(src, e.name)
const d = path.join(dst, e.name)
if (e.isDirectory()) {
await copyRecursive(s, d, onProgress)
} else if (e.isFile()) {
await fsp.copyFile(s, d)
if (onProgress) onProgress(s)
}
}
})
}
function moveFolder(src, dst, onProgress) {
if (path.parse(src).root !== path.parse(dst).root) {
return copyRecursive(src, dst, onProgress).then(() => fsp.rm(src, { recursive: true, force: true }))
}
return fsp.rename(src, dst).catch(() => copyRecursive(src, dst, onProgress).then(() => fsp.rm(src, { recursive: true, force: true })))
}
function dedupeName(base, name) {
let candidate = path.join(base, name)
let n = 2
while (fs.existsSync(candidate)) {
candidate = path.join(base, `${name} (${n})`)
n++
}
return candidate
}
async function importGame(srcDir, mode, onProgress) {
const name = path.basename(srcDir)
let workdir = srcDir
if (mode === 'copy') {
const dst = dedupeName(DIRS.GAMES, name)
await copyRecursive(srcDir, dst, onProgress)
workdir = dst
} else if (mode === 'move') {
const dst = dedupeName(DIRS.GAMES, name)
await moveFolder(srcDir, dst, onProgress)
workdir = dst
}
const exePath = await findDefaultExe(workdir)
const game = newGame({ name, exePath, workdir, mode })
return game
}
async function findDefaultExe(dir) {
let best = ''
const walk = async d => {
let entries = []
try {
entries = await fsp.readdir(d, { withFileTypes: true })
} catch {
return
}
for (const e of entries) {
const full = path.join(d, e.name)
if (e.isDirectory()) {
const lower = e.name.toLowerCase()
if (lower === 'redist' || lower === 'directx' || lower === '_commonredist' || lower === 'support' || lower === 'dotnet' || lower === 'vc') continue
await walk(full)
} else if (e.name.toLowerCase().endsWith('.exe')) {
const st = await fsp.stat(full)
if (!best || st.size > (await fsp.stat(best)).size) best = full
}
}
}
await walk(dir)
return best
}
module.exports = { importGame, findDefaultExe, copyRecursive, moveFolder, walk }

87
src/library.js Normal file
View file

@ -0,0 +1,87 @@
'use strict'
const path = require('path')
const { DIRS, readJson, writeJson } = require('./paths')
const { prefixPath } = require('./prefixes')
const REGISTRY = path.join(DIRS.DATA, 'games.json')
const SETTINGS = path.join(DIRS.DATA, 'settings.json')
function loadGames() {
return readJson(REGISTRY, [])
}
function saveGames(games) {
writeJson(REGISTRY, games)
}
function getGame(id) {
return loadGames().find(g => g.id === id) || null
}
function addGame(game) {
const games = loadGames()
games.push(game)
saveGames(games)
return game
}
function updateGame(id, patch) {
const games = loadGames()
const g = games.find(x => x.id === id)
if (!g) return null
Object.assign(g, patch)
saveGames(games)
return g
}
function removeGame(id) {
const games = loadGames()
const idx = games.findIndex(g => g.id === id)
if (idx === -1) return false
const g = games[idx]
if (g.imported && g.path && g.path.startsWith(DIRS.GAMES)) {
try {
require('fs').rmSync(g.path, { recursive: true, force: true })
} catch {}
}
games.splice(idx, 1)
saveGames(games)
return true
}
function loadSettings() {
return readJson(SETTINGS, { language: 'en-us', defaultWine: 'system', theme: 'dark' })
}
function saveSettings(patch) {
const s = { ...loadSettings(), ...patch }
writeJson(SETTINGS, s)
return s
}
function newGame({ name, exePath, workdir, mode }) {
return {
id: 'g' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
name,
mode,
path: workdir,
exePath,
workdir,
wine: 'system',
prefix: '',
args: '',
dllOverrides: [],
env: {},
created: Date.now(),
lastPlayed: 0
}
}
function resolvePrefixDir(game) {
if (game.prefix) return prefixPath(game.prefix)
const defs = readJson(path.join(DIRS.DATA, 'prefixes.json'), { default: 'default', prefixes: [] })
return prefixPath(defs.default || 'default')
}
module.exports = { loadGames, saveGames, getGame, addGame, updateGame, removeGame, loadSettings, saveSettings, newGame, resolvePrefixDir }

252
src/main.js Normal file
View file

@ -0,0 +1,252 @@
'use strict'
const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron')
const path = require('path')
const { ensureDirs, DIRS, readJson, writeJson } = require('./paths')
const wines = require('./wines')
const prefixes = require('./prefixes')
const library = require('./library')
const runner = require('./runner')
const importer = require('./importer')
const i18n = require('./i18n')
const { createMainWindow } = require('./window')
app.setName('PLGL')
let win = null
function t(code, key, ...args) {
return i18n.translate(code, key, ...args)
}
function wineFor(id) {
if (!id || id === 'system') {
return { id: 'system', type: 'system', label: 'System Wine' }
}
if (id === 'default') {
id = library.loadSettings().defaultWine || 'system'
}
const w = wines.getWine(id)
if (!w) {
const fallback = library.loadSettings().defaultWine || 'system'
return wineFor(fallback)
}
return w
}
function scanGamesFolder(dir, depth, out) {
if (depth <= 0 || !out) return
let entries = []
try {
entries = require('fs').readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const e of entries) {
if (!e.isDirectory()) continue
const full = path.join(dir, e.name)
if (require('fs').existsSync(path.join(full, '.plgl-imported'))) {
out.push(full)
} else {
scanGamesFolder(full, depth - 1, out)
}
}
}
ipcMain.handle('i18n:list', async () => {
await i18n.init()
return i18n.listLangs()
})
ipcMain.handle('i18n:get', async (e, code) => {
const lang = await i18n.loadLang(code)
if (lang) return lang
return (await i18n.loadLang(i18n.FALLBACK)) || { data: {}, texts: {} }
})
ipcMain.handle('settings:get', () => library.loadSettings())
ipcMain.handle('settings:set', (e, patch) => library.saveSettings(patch))
ipcMain.handle('wines:list', async () => {
await wines.refreshWines()
return [
{ id: 'system', type: 'system', label: 'System Wine', version: detectSystemWineVersion() },
...wines.getWines()
]
})
function detectSystemWineVersion() {
try {
const { execFileSync } = require('child_process')
return String(execFileSync('wine', ['--version'], { timeout: 15000 })).trim().replace(/^wine-?/i, '')
} catch {
return ''
}
}
ipcMain.handle('wines:refresh', async () => {
const list = await wines.refreshWines()
return list.filter(w => !w.disabled)
})
ipcMain.handle('wines:setDisabled', (e, id, disabled) => wines.setWineDisabled(id, disabled))
ipcMain.handle('wines:setDefault', (e, id) => wines.setDefaultWine(id))
ipcMain.handle('wines:delete', (e, id) => wines.deleteWine(id))
ipcMain.handle('wines:import', async (e, srcDir) => {
try {
return await wines.importWineFolder(srcDir)
} catch (err) {
return { ok: false, error: String(err && err.message || err) }
}
})
ipcMain.handle('prefixes:list', () => prefixes.listPrefixes())
ipcMain.handle('prefixes:create', (e, name) => prefixes.createPrefix(name))
ipcMain.handle('prefixes:delete', (e, name) => prefixes.deletePrefix(name))
ipcMain.handle('prefixes:setDefault', (e, name) => prefixes.setDefaultPrefix(name))
ipcMain.handle('games:list', () => library.loadGames())
ipcMain.handle('games:remove', (e, id) => library.removeGame(id))
ipcMain.handle('games:get', (e, id) => library.getGame(id))
ipcMain.handle('games:update', (e, id, patch) => library.updateGame(id, patch))
ipcMain.handle('games:pickFolder', async e => {
const res = await dialog.showOpenDialog(win, {
title: t(library.loadSettings().language, 'import.pickFolder'),
properties: ['openDirectory']
})
if (res.canceled || !res.filePaths[0]) return null
return res.filePaths[0]
})
ipcMain.handle('games:import', async (e, srcDir, mode) => {
const onProgress = data => {
if (win && !win.isDestroyed()) win.webContents.send('import:progress', data)
}
const game = await importer.importGame(srcDir, mode, onProgress)
library.addGame(game)
require('fs').writeFileSync(path.join(game.workdir, '.plgl-imported'), '')
return game
})
const running = new Map()
const killRequests = new Set()
ipcMain.handle('games:run', async (e, id) => {
const game = library.getGame(id)
if (!game) return { ok: false, error: 'game-not-found' }
if (running.has(id)) return { ok: false, error: 'already-running' }
running.set(id, null)
let wine
try {
wine = wineFor(game.wine)
} catch {
running.delete(id)
return { ok: false, error: 'wine-not-found' }
}
const systemWine = wine.type === 'system'
try {
const res = await runner.runGame({
game,
wine,
systemWine,
onExit: () => {
running.delete(id)
if (win && !win.isDestroyed()) win.webContents.send('games:changed')
}
})
if (res.pid) running.set(id, res.pid)
if (killRequests.has(id)) {
killRequests.delete(id)
if (res.pid) {
try {
process.kill(-res.pid, 'SIGKILL')
} catch {}
try {
process.kill(res.pid, 'SIGKILL')
} catch {}
}
}
if (win && !win.isDestroyed()) win.webContents.send('games:changed')
return { ok: true }
} catch (err) {
running.delete(id)
return { ok: false, error: String(err && err.message || err) }
}
})
ipcMain.handle('games:runTool', async (e, id, tool, args) => {
const game = library.getGame(id)
if (!game) return { ok: false, error: 'game-not-found' }
let wine
try {
wine = wineFor(game.wine)
} catch {
return { ok: false, error: 'wine-not-found' }
}
const systemWine = wine.type === 'system'
try {
await runner.runTool({ game, wine, tool, args, systemWine })
return { ok: true }
} catch (err) {
return { ok: false, error: String(err && err.message || err) }
}
})
ipcMain.handle('dialog:pickFile', async (e, opts) => {
const res = await dialog.showOpenDialog(win, {
properties: ['openFile', 'openDirectory'],
...(opts || {})
})
if (res.canceled || !res.filePaths[0]) return null
return res.filePaths[0]
})
ipcMain.handle('shell:openPath', (e, p) => shell.openPath(p))
ipcMain.handle('shell:showItemInFolder', (e, p) => {
shell.showItemInFolder(p)
return true
})
ipcMain.handle('games:getLogs', (e, id) => {
const dir = path.join(DIRS.DATA, 'logs')
let files = []
try {
files = require('fs').readdirSync(dir).filter(f => f.startsWith(id + '-')).sort()
} catch {}
return files.map(f => path.join(dir, f))
})
ipcMain.handle('games:kill', (e, id) => {
const pid = running.get(id)
if (pid) {
try {
process.kill(-pid, 'SIGKILL')
} catch {}
try {
process.kill(pid, 'SIGKILL')
} catch {}
running.delete(id)
if (win && !win.isDestroyed()) win.webContents.send('games:changed')
} else if (running.has(id)) {
killRequests.add(id)
}
return { ok: true }
})
ipcMain.handle('games:isRunning', (e, id) => running.has(id))
ipcMain.handle('games:listRunning', () => [...running.keys()])
app.whenReady().then(async () => {
ensureDirs()
await i18n.init()
await wines.refreshWines()
win = createMainWindow()
win.on('closed', () => {
win = null
})
})
app.on('window-all-closed', () => {
app.quit()
})

60
src/paths.js Normal file
View file

@ -0,0 +1,60 @@
'use strict'
const path = require('path')
const fs = require('fs')
const APP_ROOT = path.resolve(__dirname, '..')
const BUILD = process.env.PLGL_BUILD_DIR || path.join(APP_ROOT, 'build')
const BUNDLED = path.join(APP_ROOT, 'files')
const FILES = path.join(BUILD, 'files')
const DATA = path.join(FILES, 'data')
const DIRS = {
BUILD,
APP_ROOT,
FILES,
DATA,
WINES: path.join(FILES, 'wines'),
PREFIXES: path.join(FILES, 'wineprefixes'),
GAMES: path.join(FILES, 'games'),
TRANSLATIONS: path.join(FILES, 'translations'),
BUNDLED_TRANSLATIONS: path.join(BUNDLED, 'translations'),
FAKE_STEAM: path.join(DATA, 'fake-steam')
}
function ensureDirs() {
const legacy = path.join(FILES, 'wine prefixes')
try {
if (fs.existsSync(legacy) && !fs.existsSync(DIRS.PREFIXES)) fs.renameSync(legacy, DIRS.PREFIXES)
} catch {}
for (const dir of [FILES, DATA, DIRS.WINES, DIRS.PREFIXES, DIRS.GAMES, DIRS.TRANSLATIONS, DIRS.FAKE_STEAM]) {
fs.mkdirSync(dir, { recursive: true })
}
if (DIRS.TRANSLATIONS !== DIRS.BUNDLED_TRANSLATIONS) {
try {
for (const f of fs.readdirSync(DIRS.BUNDLED_TRANSLATIONS)) {
const dst = path.join(DIRS.TRANSLATIONS, f)
if (!fs.existsSync(dst)) fs.copyFileSync(path.join(DIRS.BUNDLED_TRANSLATIONS, f), dst)
}
} catch {}
}
}
function readJson(file, fallback) {
try {
return JSON.parse(fs.readFileSync(file, 'utf8'))
} catch {
return fallback
}
}
function writeJson(file, data) {
fs.mkdirSync(path.dirname(file), { recursive: true })
const tmp = file + '.tmp'
fs.writeFileSync(tmp, JSON.stringify(data, null, 2))
fs.renameSync(tmp, file)
}
module.exports = { APP_ROOT, BUILD, FILES, DATA, DIRS, ensureDirs, readJson, writeJson }

78
src/prefixes.js Normal file
View file

@ -0,0 +1,78 @@
'use strict'
const path = require('path')
const fs = require('fs')
const { DIRS, readJson, writeJson } = require('./paths')
const REGISTRY = path.join(DIRS.DATA, 'prefixes.json')
const MAIN = 'Main'
function seed() {
const defs = readJson(REGISTRY, null)
if (!defs || !Array.isArray(defs.prefixes) || !defs.prefixes.length) {
const seeded = { default: MAIN, prefixes: [{ name: MAIN, created: Date.now(), locked: true }] }
if (!fs.existsSync(prefixPath(MAIN))) fs.mkdirSync(prefixPath(MAIN), { recursive: true })
writeJson(REGISTRY, seeded)
}
}
function listPrefixes() {
seed()
return readJson(REGISTRY, { default: MAIN, prefixes: [] })
}
function savePrefixes(defs) {
writeJson(REGISTRY, defs)
}
function normalizeName(name) {
return String(name || '').trim().replace(/[^\w .-]/g, '').replace(/\s+/g, ' ')
}
function prefixPath(name) {
return path.join(DIRS.PREFIXES, name)
}
function prefixExists(name) {
try {
fs.accessSync(prefixPath(name))
return true
} catch {
return false
}
}
function createPrefix(name) {
const clean = normalizeName(name)
if (!clean) return { ok: false, error: 'invalidName' }
if (clean.toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'alreadyExists' }
if (fs.existsSync(prefixPath(clean))) return { ok: false, error: 'alreadyExists' }
fs.mkdirSync(prefixPath(clean), { recursive: true })
const defs = listPrefixes()
defs.prefixes.push({ name: clean, created: Date.now() })
if (!defs.prefixes.some(p => p.name === defs.default)) defs.default = clean
savePrefixes(defs)
return { ok: true, name: clean }
}
function deletePrefix(name) {
if (String(name).toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'isMain' }
const defs = listPrefixes()
defs.prefixes = defs.prefixes.filter(p => p.name !== name)
if (defs.default === name) defs.default = MAIN
savePrefixes(defs)
try {
fs.rmSync(prefixPath(name), { recursive: true, force: true })
} catch {}
return { ok: true }
}
function setDefaultPrefix(name) {
const defs = listPrefixes()
if (name && !defs.prefixes.some(p => p.name === name)) return { ok: false, error: 'notFound' }
defs.default = name || ''
savePrefixes(defs)
return { ok: true }
}
module.exports = { listPrefixes, createPrefix, deletePrefix, setDefaultPrefix, prefixPath, prefixExists, normalizeName, MAIN }

39
src/preload.js Normal file
View file

@ -0,0 +1,39 @@
'use strict'
const { contextBridge, ipcRenderer } = require('electron')
const { DIRS } = require('./paths')
contextBridge.exposeInMainWorld('plgl', {
FILES_DIR: DIRS.FILES,
i18nList: () => ipcRenderer.invoke('i18n:list'),
i18nGet: code => ipcRenderer.invoke('i18n:get', code),
settingsGet: () => ipcRenderer.invoke('settings:get'),
settingsSet: patch => ipcRenderer.invoke('settings:set', patch),
winesList: () => ipcRenderer.invoke('wines:list'),
winesRefresh: () => ipcRenderer.invoke('wines:refresh'),
winesSetDisabled: (id, disabled) => ipcRenderer.invoke('wines:setDisabled', id, disabled),
winesSetDefault: id => ipcRenderer.invoke('wines:setDefault', id),
winesDelete: id => ipcRenderer.invoke('wines:delete', id),
winesImport: srcDir => ipcRenderer.invoke('wines:import', srcDir),
prefixesList: () => ipcRenderer.invoke('prefixes:list'),
prefixesCreate: name => ipcRenderer.invoke('prefixes:create', name),
prefixesDelete: name => ipcRenderer.invoke('prefixes:delete', name),
prefixesSetDefault: name => ipcRenderer.invoke('prefixes:setDefault', name),
gamesList: () => ipcRenderer.invoke('games:list'),
gamesGet: id => ipcRenderer.invoke('games:get', id),
gamesUpdate: (id, patch) => ipcRenderer.invoke('games:update', id, patch),
gamesRemove: id => ipcRenderer.invoke('games:remove', id),
gamesPickFolder: () => ipcRenderer.invoke('games:pickFolder'),
gamesImport: (src, mode) => ipcRenderer.invoke('games:import', src, mode),
gamesRun: id => ipcRenderer.invoke('games:run', id),
gamesRunTool: (id, tool, args) => ipcRenderer.invoke('games:runTool', id, tool, args),
gamesGetLogs: id => ipcRenderer.invoke('games:getLogs', id),
gamesKill: id => ipcRenderer.invoke('games:kill', id),
gamesIsRunning: id => ipcRenderer.invoke('games:isRunning', id),
gamesListRunning: () => ipcRenderer.invoke('games:listRunning'),
dialogPickFile: opts => ipcRenderer.invoke('dialog:pickFile', opts),
shellOpenPath: p => ipcRenderer.invoke('shell:openPath', p),
shellShowItemInFolder: p => ipcRenderer.invoke('shell:showItemInFolder', p),
onImportProgress: cb => ipcRenderer.on('import:progress', (e, data) => cb(data)),
onGamesChanged: cb => ipcRenderer.on('games:changed', () => cb())
})

151
src/runner.js Normal file
View file

@ -0,0 +1,151 @@
'use strict'
const path = require('path')
const fs = require('fs')
const { spawn } = require('child_process')
const { DIRS } = require('./paths')
const { prefixPath } = require('./prefixes')
function ensureFakeSteam() {
const fake = DIRS.FAKE_STEAM
fs.mkdirSync(path.join(fake, 'steamapps'), { recursive: true })
return fake
}
function linkPrefix(compatData, prefixDir) {
try {
const st = fs.lstatSync(compatData)
if (st.isSymbolicLink()) {
if (fs.readlinkSync(compatData) === prefixDir) return
fs.unlinkSync(compatData)
} else {
return
}
} catch {}
try {
fs.symlinkSync(prefixDir, compatData, 'dir')
} catch {}
}
function buildEnv({ game, wine, workdir, systemWine }) {
const env = { ...process.env }
const isProton = wine && wine.type === 'proton'
let prefixDir = game.prefix ? prefixPath(game.prefix) : null
if (!prefixDir) {
const defs = require('./prefixes').listPrefixes()
prefixDir = prefixPath(defs.default || 'default')
}
if (isProton) {
const fakeSteam = ensureFakeSteam()
const buildRoot = path.resolve(wine.path, '..')
env.STEAM_COMPAT_CLIENT_INSTALL_PATH = fakeSteam
env.STEAM_COMPAT_DATA_PATH = path.join(DIRS.DATA, 'compat', game.id)
env.STEAM_COMPAT_APP_ID = '0'
env.SteamAppId = '0'
env.SteamGameId = '0'
env.WINEPREFIX = path.join(env.STEAM_COMPAT_DATA_PATH, 'pfx')
env.PROTON_LOG_DIR = path.join(DIRS.DATA, 'logs')
fs.mkdirSync(path.join(env.PROTON_LOG_DIR), { recursive: true })
linkPrefix(env.STEAM_COMPAT_DATA_PATH, prefixDir)
if (!fs.existsSync(env.STEAM_COMPAT_DATA_PATH)) {
fs.mkdirSync(env.STEAM_COMPAT_DATA_PATH, { recursive: true })
}
} else if (!systemWine) {
env.WINEPREFIX = prefixDir
} else {
env.WINEPREFIX = prefixDir
}
env.WINEDLLOVERRIDES = (game.dllOverrides || [])
.filter(o => o && o.dll && o.setting)
.map(o => `${o.dll}=${o.setting}`)
.join(';')
if (!env.WINEDLLOVERRIDES) delete env.WINEDLLOVERRIDES
for (const [k, v] of Object.entries(game.env || {})) env[k] = v
return { env, prefixDir }
}
function parseArgs(args) {
const out = []
const re = /"([^"]*)"|'([^']*)'|(\S+)/g
let m
while ((m = re.exec(String(args || '')))) out.push(m[1] ?? m[2] ?? m[3])
return out
}
function runGame({ game, wine, systemWine, onExit }) {
const isProton = wine && wine.type === 'proton'
const { env } = buildEnv({ game, wine })
const exe = game.exePath
const workdir = game.workdir || path.dirname(exe)
const argList = parseArgs(game.args)
let cmd
let base
if (isProton) {
cmd = wine.wineBin || path.join(wine.path, 'proton')
base = ['run', exe, ...argList]
} else {
cmd = systemWine ? 'wine' : wine.wineBin
base = [exe, ...argList]
}
fs.mkdirSync(DIRS.DATA, { recursive: true })
return new Promise((resolve, reject) => {
let child
try {
child = spawn(cmd, base, {
cwd: workdir,
env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: true
})
} catch (err) {
return reject(err)
}
const logFile = path.join(DIRS.DATA, 'logs', `${game.id}-${Date.now()}.log`)
fs.mkdirSync(path.dirname(logFile), { recursive: true })
const ws = fs.createWriteStream(logFile)
child.stdout.pipe(ws)
child.stderr.pipe(ws)
child.on('error', reject)
child.unref()
child.on('exit', (code, signal) => {
ws.end()
require('./library').updateGame(game.id, { lastPlayed: Date.now() })
if (onExit) onExit(code, signal)
})
resolve({ pid: child.pid, logFile })
})
}
function runTool({ game, wine, tool, args, systemWine }) {
const isProton = wine && wine.type === 'proton'
const { env } = buildEnv({ game, wine })
const exe = game.exePath
const workdir = game.workdir || path.dirname(exe)
const argList = parseArgs(args)
let cmd
let base
if (isProton) {
cmd = wine.wineBin || path.join(wine.path, 'proton')
base = ['run', tool, ...argList]
} else if (systemWine) {
cmd = tool
base = argList
} else {
cmd = wine.wineBin
base = [tool, ...argList]
}
return new Promise((resolve, reject) => {
const child = spawn(cmd, base, { cwd: workdir, env, stdio: 'ignore', detached: true })
child.on('error', reject)
child.unref()
resolve({ pid: child.pid })
})
}
module.exports = { buildEnv, runGame, runTool, parseArgs }

28
src/window.js Normal file
View file

@ -0,0 +1,28 @@
'use strict'
const path = require('path')
const fs = require('fs')
const DIST = path.resolve(__dirname, '..', 'renderer')
function createMainWindow() {
const { BrowserWindow } = require('electron')
const win = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 940,
minHeight: 600,
backgroundColor: '#141414',
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false
}
})
win.loadFile(path.join(DIST, 'index.html'))
return win
}
module.exports = { createMainWindow }

192
src/wines.js Normal file
View file

@ -0,0 +1,192 @@
'use strict'
const fs = require('fs')
const fsp = fs.promises
const path = require('path')
const { DIRS, readJson, writeJson } = require('./paths')
const WINES_DIR = DIRS.WINES
const REGISTRY = path.join(DIRS.DATA, 'wines.json')
const DEFAULT_KEY = 'defaultWine'
function exists(p) {
try {
fs.accessSync(p)
return true
} catch {
return false
}
}
function detectType(dir) {
if (exists(path.join(dir, 'proton'))) return 'proton'
for (const rel of ['files/bin/wine', 'bin/wine', 'dist/bin/wine']) {
if (exists(path.join(dir, rel))) return 'wine'
}
return null
}
function findWineBinary(dir) {
for (const rel of ['files/bin/wine', 'bin/wine', 'dist/bin/wine']) {
const p = path.join(dir, rel)
if (exists(p)) return p
}
return null
}
function versionOf(wineBin, env) {
try {
const { execFileSync } = require('child_process')
const out = execFileSync(wineBin, ['--version'], {
timeout: 15000,
env: { ...process.env, ...env, WINEPREFIX: path.join(DIRS.DATA, 'probe-prefix') }
})
return String(out).trim().replace(/^wine-?/i, '')
} catch {
return ''
}
}
function labelFor(type, dir, version) {
const name = path.basename(dir)
if (type === 'proton') return /GE|CachyOS|TKG/i.test(name) ? name : (version ? `Proton ${version}` : name)
return version ? `Wine ${version}` : name
}
async function scanBuild(dir, depth) {
const type = detectType(dir)
if (type) {
const entry = {
id: path.basename(dir).toLowerCase().replace(/[^a-z0-9._-]+/g, '-'),
name: path.basename(dir),
path: dir,
type
}
if (type === 'proton') {
entry.wineBin = path.join(dir, 'proton')
} else if (type === 'wine') {
const bin = findWineBinary(dir)
if (bin) {
entry.wineBin = bin
const ver = versionOf(bin, {})
if (ver) entry.version = ver
}
}
entry.label = labelFor(type, dir, entry.version)
return [entry]
}
if (depth <= 0) return []
const out = []
let names = []
try {
names = await fsp.readdir(dir)
} catch {
return out
}
for (const name of names) {
const sub = path.join(dir, name)
let st
try {
st = await fsp.stat(sub)
} catch {
continue
}
if (st.isDirectory() && name !== 'wineprefixes' && name !== 'share') {
out.push(...(await scanBuild(sub, depth - 1)))
}
}
return out
}
async function refreshWines() {
let found = []
try {
found = await scanBuild(WINES_DIR, 2)
} catch {
found = []
}
const prev = readJson(REGISTRY, [])
const disabled = new Set(prev.filter(w => w.disabled).map(w => w.id))
for (const w of found) if (disabled.has(w.id)) w.disabled = true
const merged = [...found]
for (const p of prev) {
if (p.system) merged.push(p)
else if (!found.some(f => f.id === p.id) && exists(p.path)) merged.push(p)
}
writeJson(REGISTRY, merged)
return merged
}
function getWines() {
return readJson(REGISTRY, [])
}
function getWine(id) {
return getWines().find(w => w.id === id) || null
}
function setWineDisabled(id, disabled) {
const wines = getWines()
const w = wines.find(x => x.id === id)
if (w) {
w.disabled = !!disabled
writeJson(REGISTRY, wines)
}
return wines
}
function getDefaultWine() {
return require('./library').loadSettings().defaultWine || 'system'
}
function setDefaultWine(id) {
const wines = getWines()
if (id !== 'system' && !wines.some(w => w.id === id)) return { ok: false, error: 'notFound' }
require('./library').saveSettings({ defaultWine: id })
return { ok: true, defaultWine: id }
}
function deleteWine(id) {
const wines = getWines()
const w = wines.find(x => x.id === id)
if (!w || w.system) return { ok: false, error: w ? 'isSystem' : 'notFound' }
try {
fs.rmSync(w.path, { recursive: true, force: true })
} catch {}
writeJson(REGISTRY, wines.filter(x => x.id !== id))
if (getDefaultWine() === id) require('./library').saveSettings({ defaultWine: 'system' })
return { ok: true }
}
function dedupeDir(base, name) {
let candidate = path.join(base, name)
let n = 2
while (fs.existsSync(candidate)) {
candidate = path.join(base, `${name} (${n})`)
n++
}
return candidate
}
async function importWineFolder(srcDir) {
const type = detectType(srcDir)
let copySrc = srcDir
if (!type) {
let subs = []
try {
subs = (await fsp.readdir(srcDir, { withFileTypes: true })).filter(e => e.isDirectory())
} catch {}
for (const s of subs) {
if (detectType(path.join(srcDir, s.name))) {
copySrc = path.join(srcDir, s.name)
break
}
}
}
const dst = dedupeDir(WINES_DIR, path.basename(copySrc))
await fsp.cp(copySrc, dst, { recursive: true })
await refreshWines()
return { ok: true, path: dst }
}
module.exports = { refreshWines, getWines, getWine, setWineDisabled, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType }