add importing from prefix
This commit is contained in:
parent
29a20324a6
commit
b83e530392
11 changed files with 287 additions and 22 deletions
|
|
@ -5,6 +5,7 @@ const path = require('path')
|
|||
const { DIRS, resolvePortablePath } = require('./paths')
|
||||
const { newGame, newGameId, addGame } = require('./library')
|
||||
const { extractIconFromExe } = require('./icons')
|
||||
const { prefixPath } = require('./prefixes')
|
||||
|
||||
// Directories that typically hold installers/redistributables, not the game.
|
||||
const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc'])
|
||||
|
|
@ -62,6 +63,35 @@ async function importGame(srcDir, mode, onProgress) {
|
|||
return addGame(game)
|
||||
}
|
||||
|
||||
// Import from a wine prefix: the folder is picked under the prefix's
|
||||
// drive_c and left in place - the game paths are stored relative to the
|
||||
// prefix, so wine sees the app exactly as if it had been installed
|
||||
// normally into C:\...
|
||||
async function importGameFromPrefix(prefix, srcDir, onProgress) {
|
||||
const prefixes = require('./prefixes')
|
||||
if (!prefixes.listPrefixes().prefixes.some(p => p.name === prefix)) return { ok: false, error: 'prefix-not-found' }
|
||||
// the folder must live inside the prefix's C: drive so the C:\ mapping
|
||||
// holds - drive_c sits at the prefix top for plain wine, under pfx/ for
|
||||
// Proton-managed prefixes
|
||||
const driveRoot = prefixes.prefixDriveRoot(prefix)
|
||||
if (!driveRoot) return { ok: false, error: 'no-drive-c' }
|
||||
const rel = path.relative(driveRoot, srcDir)
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return { ok: false, error: 'outside-drive-c' }
|
||||
const preExe = await findDefaultExe(srcDir)
|
||||
if (!preExe) return { ok: false, error: 'no-exe-found' }
|
||||
const id = newGameId()
|
||||
const game = newGame({ id, name: path.basename(srcDir), exePath: preExe, workdir: srcDir, mode: 'prefix' })
|
||||
game.importSource = 'prefix'
|
||||
game.prefix = prefix
|
||||
// exe 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(preExe), icon).ok) {
|
||||
game.iconSource = icon
|
||||
}
|
||||
return addGame(game)
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
|
@ -103,4 +133,4 @@ async function findDefaultExe(dir) {
|
|||
return best
|
||||
}
|
||||
|
||||
module.exports = { importGame, findDefaultExe, copyRecursive, moveFolder }
|
||||
module.exports = { importGame, importGameFromPrefix, findDefaultExe, copyRecursive, moveFolder }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use strict'
|
||||
|
||||
const { runGame, runTool } = require('../runner')
|
||||
const { importGame } = require('../importer')
|
||||
const { importGame, importGameFromPrefix } = require('../importer')
|
||||
const library = require('../library')
|
||||
|
||||
function killProcessGroup(pid) {
|
||||
|
|
@ -114,7 +114,13 @@ function createGamesService({ getWindow, onGamesChanged }) {
|
|||
return importGame(srcDir, mode, data => sendToWindow(win, 'import:progress', data))
|
||||
}
|
||||
|
||||
return { isRunning, listRunning, start, kill, runToolFor, import: importTo, setOnGameExit }
|
||||
// prefix import: nothing is copied, the game just gets registered with
|
||||
// paths relative to the prefix (wine sees it as a normal C:\ install)
|
||||
async function importFromPrefix(win, prefix, srcDir) {
|
||||
return importGameFromPrefix(prefix, srcDir)
|
||||
}
|
||||
|
||||
return { isRunning, listRunning, start, kill, runToolFor, import: importTo, importFromPrefix, setOnGameExit }
|
||||
}
|
||||
|
||||
module.exports = { createGamesService, killProcessGroup, sendToWindow }
|
||||
|
|
|
|||
|
|
@ -180,6 +180,27 @@ function registerIpc({ getWindow, onGamesChanged }) {
|
|||
return errorResult(err)
|
||||
}
|
||||
})
|
||||
// import from an existing wine prefix: pick the prefix first, then the app
|
||||
// folder inside it (drive_c) - paths are stored prefix-relative so the app
|
||||
// shows up for wine as a regular C:\ install
|
||||
ipcMain.handle('games:pickPrefixFolder', async (e, prefix) => {
|
||||
// open the picker straight in the prefix's C: drive (plain wine: top
|
||||
// level, Proton: under pfx/)
|
||||
const driveC = prefixes.prefixDriveRoot(prefix)
|
||||
const res = await dialog.showOpenDialog(getWindow(), {
|
||||
title: i18n.translate(library.loadSettings().language, 'import.pickPrefixFolder'),
|
||||
defaultPath: driveC || prefixes.prefixPath(prefix),
|
||||
properties: ['openDirectory']
|
||||
})
|
||||
return res.canceled || !res.filePaths[0] ? null : res.filePaths[0]
|
||||
})
|
||||
ipcMain.handle('games:importFromPrefix', async (e, prefix, srcDir) => {
|
||||
try {
|
||||
return await games.importFromPrefix(getWindow(), prefix, srcDir)
|
||||
} catch (err) {
|
||||
return errorResult(err)
|
||||
}
|
||||
})
|
||||
|
||||
// dialogs & shell ---------------------------------------------------------------------------
|
||||
ipcMain.handle('dialog:pickFile', async (e, opts) => {
|
||||
|
|
|
|||
|
|
@ -29,13 +29,23 @@ function writePlaySeconds(id, seconds) {
|
|||
fs.writeFileSync(file, String(Math.max(0, Math.round(seconds))))
|
||||
}
|
||||
|
||||
// Prefix imports keep exe/workdir paths relative to the PREFIX ROOT
|
||||
// ("drive_c/..." for plain wine, "pfx/drive_c/..." for Proton-managed
|
||||
// prefixes) so wine sees them under C:\... - everything else is
|
||||
// FILES-relative/absolute.
|
||||
const isPrefixRel = v => typeof v === 'string' && /^(pfx[/\\])?drive_c([/\\]|$)/i.test(v)
|
||||
|
||||
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)
|
||||
// stored relative to FILES (portable) -> resolve to absolute for use.
|
||||
// Prefix imports resolve against their own prefix instead (never the
|
||||
// launcher-wide default).
|
||||
const usePrefix = g.importSource === 'prefix' && g.prefix ? prefixPath(g.prefix) : null
|
||||
for (const k of ['path', 'exePath', 'workdir']) {
|
||||
if (!g[k]) continue
|
||||
g[k] = usePrefix && isPrefixRel(g[k]) ? path.join(usePrefix, g[k]) : resolvePortablePath(g[k])
|
||||
}
|
||||
if (g.iconSource) g.iconSource = resolvePortablePath(g.iconSource)
|
||||
const ps = readPlaySeconds(g.id)
|
||||
if (ps != null) g.playSeconds = ps
|
||||
|
|
@ -48,8 +58,18 @@ function loadGames() {
|
|||
// FILES (apps left in place) are kept as-is.
|
||||
function portableGame(g) {
|
||||
const out = { ...g }
|
||||
const pfx = out.importSource === 'prefix' && out.prefix ? prefixPath(out.prefix) : null
|
||||
for (const k of ['path', 'exePath', 'workdir', 'iconSource']) {
|
||||
if (out[k]) out[k] = toPortablePath(out[k])
|
||||
const v = out[k]
|
||||
if (!v) continue
|
||||
if (pfx && k !== 'iconSource' && !isPrefixRel(v)) {
|
||||
// prefix game: store relative to the PREFIX ROOT (keeps the drive_c
|
||||
// segment, e.g. "drive_c/Games/..." or "pfx/drive_c/Games/...")
|
||||
const rel = path.relative(pfx, resolvePortablePath(v))
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) out[k] = rel
|
||||
} else if (!(pfx && isPrefixRel(v))) {
|
||||
out[k] = toPortablePath(v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -95,6 +115,9 @@ function removeGame(id) {
|
|||
require('fs').rmSync(g.path, { recursive: true, force: true })
|
||||
} catch {}
|
||||
}
|
||||
// prefix imports live inside the prefix: deleting launcher files would
|
||||
// nuke the user's prefix (installed DLLs, saves of every other app), so
|
||||
// only the registry entry is dropped
|
||||
games.splice(idx, 1)
|
||||
saveGames(games)
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -42,6 +42,24 @@ function prefixExists(name) {
|
|||
}
|
||||
}
|
||||
|
||||
// Where the Windows C: drive of a prefix actually lives: plain wine keeps
|
||||
// it at the top (WINEPREFIX=<prefix>), Proton-managed prefixes nest it
|
||||
// under pfx/ (WINEPREFIX=<prefix>/pfx). system.reg marks the real prefix
|
||||
// root; a fresh prefix may not have it yet, so fall back to drive_c itself.
|
||||
function prefixDriveRoot(name) {
|
||||
const root = prefixPath(name)
|
||||
for (const pass of [true, false]) {
|
||||
for (const base of [root, path.join(root, 'pfx')]) {
|
||||
try {
|
||||
const driveC = path.join(base, 'drive_c')
|
||||
if (!fs.statSync(driveC).isDirectory()) continue
|
||||
if (!pass || fs.existsSync(path.join(base, 'system.reg'))) return driveC
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function createPrefix(name) {
|
||||
const clean = normalizeName(name)
|
||||
if (!clean) return { ok: false, error: 'invalidName' }
|
||||
|
|
@ -75,4 +93,4 @@ function setDefaultPrefix(name) {
|
|||
return { ok: true }
|
||||
}
|
||||
|
||||
module.exports = { listPrefixes, createPrefix, deletePrefix, setDefaultPrefix, prefixPath, prefixExists, normalizeName, MAIN }
|
||||
module.exports = { listPrefixes, createPrefix, deletePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoot, normalizeName, MAIN }
|
||||
|
|
|
|||
|
|
@ -3,6 +3,13 @@
|
|||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
const { DIRS } = require('./paths')
|
||||
|
||||
// single-slot progress: every import modal used to register ANOTHER listener
|
||||
// and old ones never unregistered, so the file counter sped up per import
|
||||
let importProgressCb = null
|
||||
ipcRenderer.on('import:progress', (e, data) => {
|
||||
if (importProgressCb) importProgressCb(data)
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('plwl', {
|
||||
FILES_DIR: DIRS.FILES,
|
||||
i18nList: () => ipcRenderer.invoke('i18n:list'),
|
||||
|
|
@ -32,7 +39,9 @@ contextBridge.exposeInMainWorld('plwl', {
|
|||
gamesCreateShortcut: id => ipcRenderer.invoke('games:createShortcut', id),
|
||||
gamesShortcutPaths: id => ipcRenderer.invoke('games:shortcutPaths', id),
|
||||
gamesPickFolder: () => ipcRenderer.invoke('games:pickFolder'),
|
||||
gamesPickPrefixFolder: prefix => ipcRenderer.invoke('games:pickPrefixFolder', prefix),
|
||||
gamesImport: (src, mode) => ipcRenderer.invoke('games:import', src, mode),
|
||||
gamesImportFromPrefix: (prefix, src) => ipcRenderer.invoke('games:importFromPrefix', prefix, src),
|
||||
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),
|
||||
|
|
@ -42,6 +51,8 @@ contextBridge.exposeInMainWorld('plwl', {
|
|||
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)),
|
||||
onImportProgress: cb => {
|
||||
importProgressCb = cb
|
||||
},
|
||||
onGamesChanged: cb => ipcRenderer.on('games:changed', () => cb())
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue