First build
This commit is contained in:
parent
511556737e
commit
fd773f5832
954 changed files with 305115 additions and 2 deletions
54
src/i18n.js
Normal file
54
src/i18n.js
Normal 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
99
src/importer.js
Normal 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
87
src/library.js
Normal 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
252
src/main.js
Normal 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
60
src/paths.js
Normal 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
78
src/prefixes.js
Normal 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
39
src/preload.js
Normal 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
151
src/runner.js
Normal 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
28
src/window.js
Normal 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
192
src/wines.js
Normal 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 }
|
||||
Loading…
Add table
Add a link
Reference in a new issue