Compare commits

..

No commits in common. "66f42f30cca18d0c3fb158116b56c29d097dbe51" and "29a20324a6a0289b885d7a033899486859163541" have entirely different histories.

13 changed files with 28 additions and 357 deletions

View file

@ -39,10 +39,6 @@ cd PLWL
./olcxja.plwl/olcxja.plwl
```
## Importing from a wine prefix
Besides copy/move/leave, an app can be imported **from a wine prefix**: pick the prefix, then the app folder inside its `drive_c`. Nothing is copied — paths are stored relative to the prefix, so wine sees the app as if it were normally installed on `C:\` (registry entries pointing to `C:\...` keep working). Both layouts work: plain wine prefixes (`drive_c` at the top) and Proton-managed ones (`drive_c` under `pfx/`). The app stays tied to that prefix; removing the launcher entry never deletes files from the prefix.
## Wine & Proton support
- **System Wine** — uses the `wine` binary from PATH.

View file

@ -28,22 +28,14 @@
"library.never": "Never",
"library.portable": "Portable",
"library.external": "External",
"library.inPrefix": "In prefix",
"library.removeConfirm": "Remove {0} from the launcher?{1}",
"library.removeFilesNote": "\nIts imported files in files/apps will also be deleted.",
"library.removePrefixNote": "\nFiles stay in the prefix - only the launcher entry is removed.",
"library.tools": "Wine tools",
"library.viewLogs": "View logs",
"import.question": "Should the app files be brought into the launcher so it stays portable, or left where they are?",
"import.mode.copy": "Copy into launcher (portable)",
"import.mode.move": "Move into launcher (portable)",
"import.mode.leave": "Leave in place",
"import.mode.prefix": "Import from a wine prefix",
"import.prefix.title": "Import from prefix",
"import.prefix.hint": "Pick the wine prefix the app lives in. Nothing is copied - the app is launched from the prefix, so Windows paths keep working (C:\\...).",
"import.pickPrefixFolder": "Choose the app folder inside the prefix (drive_c)",
"import.prefix.outside": "the selected folder is outside the prefix's drive_c",
"import.prefix.noDriveC": "this prefix has no drive_c yet - run any app in it once so wine creates it",
"import.pickFolder": "Choose the app folder",
"import.progress": "Importing {0}…",
"import.fileDone": "{0} files copied",
@ -69,8 +61,6 @@
"config.wineDefault": "Default ({0})",
"config.prefix": "Wine prefix",
"config.prefixDefault": "Default prefix ({0})",
"config.prefixLocked": "This app was imported from a prefix - its prefix cannot be changed",
"config.prefixLockedHint": "Pinned to the prefix it was imported from (its paths are stored relative to it)",
"config.dllOverrides": "DLL overrides",
"config.overrides.add": "Add override",
"config.overrides.dll": "DLL name",

View file

@ -28,22 +28,14 @@
"library.never": "Nigdy",
"library.portable": "Przenośna",
"library.external": "Zewnętrzna",
"library.inPrefix": "W prefiksie",
"library.removeConfirm": "Usunąć {0} z launchera?{1}",
"library.removeFilesNote": "\nJej zaimportowane pliki w files/apps również zostaną usunięte.",
"library.removePrefixNote": "\nPliki zostają w prefiksie - usuwany jest tylko wpis w launcherze.",
"library.tools": "Narzędzia Wine",
"library.viewLogs": "Podejrzyj logi",
"import.question": "Czy pliki aplikacji mają trafić do launchera, aby był przenośny, czy mają zostać tam, gdzie są?",
"import.mode.copy": "Skopiuj do launchera (przenośne)",
"import.mode.move": "Przenieś do launchera (przenośne)",
"import.mode.leave": "Zostaw w miejscu",
"import.mode.prefix": "Importuj z prefiksu wine",
"import.prefix.title": "Import z prefiksu",
"import.prefix.hint": "Wybierz prefiks wine, w którym jest aplikacja. Nic nie jest kopiowane - aplikacja odpalana jest z prefiksu, więc ścieżki windowsowe (C:\\...) dalej działają.",
"import.pickPrefixFolder": "Wybierz folder aplikacji wewnątrz prefiksu (drive_c)",
"import.prefix.outside": "wybrany folder jest poza drive_c prefiksu",
"import.prefix.noDriveC": "ten prefiks nie ma jeszcze drive_c - uruchom w nim dowolną apkę raz, żeby wine je utworzył",
"import.pickFolder": "Wybierz folder aplikacji",
"import.progress": "Importowanie {0}…",
"import.fileDone": "Skopiowane pliki: {0}",
@ -69,8 +61,6 @@
"config.wineDefault": "Domyślna ({0})",
"config.prefix": "Prefiks Wine",
"config.prefixDefault": "Prefiks domyślny ({0})",
"config.prefixLocked": "Ta aplikacja została zaimportowana z prefiksu - nie można zmienić jej prefiksu",
"config.prefixLockedHint": "Przypięta do prefiksu, z którego została zaimportowana (ścieżki trzymane są względem niego)",
"config.dllOverrides": "Nadpisania DLL",
"config.overrides.add": "Dodaj nadpisanie",
"config.overrides.dll": "Nazwa DLL",

View file

@ -6,8 +6,8 @@
"main": "src/main.js",
"scripts": {
"start": "electron .",
"test": "node test/import.test.js && node test/prefix-import.test.js && node test/icons.test.js && node test/shortcuts.test.js && node test/wines-portable.test.js",
"smoke": "node test/import.test.js && node test/prefix-import.test.js && node test/icons.test.js && node test/shortcuts.test.js && node test/wines-portable.test.js"
"test": "node test/import.test.js && node test/icons.test.js && node test/shortcuts.test.js && node test/wines-portable.test.js",
"smoke": "node test/import.test.js && node test/icons.test.js && node test/shortcuts.test.js && node test/wines-portable.test.js"
},
"devDependencies": {
"@electron/asar": "^3.4.1",

View file

@ -104,20 +104,8 @@ function toast(message, kind) {
})
}
// The teardown of a closing modal is delayed (for the fade-out) - an
// eagerly armed timer must never kill a modal opened in the meantime (e.g.
// import mode -> prefix picker happens within milliseconds), so showModal
// cancels any pending teardown and clears the closing state.
let modalTeardownTimer = null
function showModal(html) {
if (modalTeardownTimer) {
clearTimeout(modalTeardownTimer)
modalTeardownTimer = null
}
const overlay = qs('#modal-overlay')
const modal = qs('#modal')
modal.classList.remove('closing')
qs('#modal').innerHTML = html
overlay.style.display = 'flex'
requestAnimationFrame(() => requestAnimationFrame(() => overlay.classList.add('show')))
@ -130,9 +118,7 @@ function closeModal() {
if (overlay.style.display === 'none') return
modal.classList.add('closing')
overlay.classList.remove('show')
if (modalTeardownTimer) clearTimeout(modalTeardownTimer)
modalTeardownTimer = setTimeout(() => {
modalTeardownTimer = null
setTimeout(() => {
overlay.style.display = 'none'
modal.classList.remove('closing')
modal.innerHTML = ''
@ -166,7 +152,6 @@ class CustomSelect {
this.options = options
this.value = value
this.onChange = onChange
this.disabled = false
this.render()
if (!this.host.dataset.wired) {
this.wire()
@ -174,11 +159,6 @@ class CustomSelect {
}
}
setDisabled(v) {
this.disabled = !!v
this.host.classList.toggle('disabled', this.disabled)
}
render() {
const selected = this.options.find(o => o.value === this.value)
this.host.classList.add('custom-select')
@ -225,7 +205,6 @@ class CustomSelect {
}
toggle() {
if (this.disabled) return
const wasOpen = this.host.classList.contains('open')
CustomSelect.closeAll()
if (!wasOpen) this.host.classList.add('open')
@ -331,29 +310,6 @@ function absPathInput(v) {
return v
}
// Prefix-imported apps: show paths the way wine sees them - relative to the
// prefix ("pfx/drive_c/..." or "drive_c/...") instead of the long
// files/wineprefixes/<prefix>/... form.
function displayGamePath(p, game) {
if (game && game.importSource === 'prefix' && game.prefix) {
const files = window.plwl.FILES_DIR
const base = files ? files + '/wineprefixes/' + game.prefix + '/' : ''
if (base && p && p.startsWith(base)) return p.slice(base.length)
}
return displayPath(p)
}
// Inverse of displayGamePath: accept a prefix-relative (drive_c/... or
// pfx/drive_c/...) path and resolve it under the game's prefix; anything
// else falls through to the normal FILES/absolute handling.
function absGamePathInput(v, game) {
if (game && game.importSource === 'prefix' && game.prefix && /^(pfx\/)?drive_c([\/]|$)/i.test(v || '')) {
const files = window.plwl.FILES_DIR
if (files) return files + '/wineprefixes/' + game.prefix + '/' + v
}
return absPathInput(v)
}
function fmtPlaytime(seconds) {
const s = Math.round(seconds || 0)
if (s < 60) return t('library.playtimeSeconds', s)
@ -364,23 +320,15 @@ function fmtPlaytime(seconds) {
}
async function attachCardIcon(card, game) {
// placeholder first so every card has a stable icon slot; swapped for the
// real icon once it loads - games without one just keep the placeholder
const ph = document.createElement('div')
ph.className = 'game-icon placeholder'
card.prepend(ph)
const p = await window.plwl.gamesIconPath(game.id)
if (!p) return
const img = document.createElement('img')
img.className = 'game-icon'
img.alt = ''
img.onerror = () => img.remove() // placeholder stays visible
img.onload = () => {
ph.remove()
card.prepend(img)
}
img.onerror = () => img.remove()
img.onload = () => card.prepend(img)
img.src = iconUrl(game.id)
if (img.complete && img.naturalWidth > 0) img.onload()
if (img.complete && img.naturalWidth > 0) card.prepend(img)
}
function renderLibrary() {
@ -393,15 +341,11 @@ function renderLibrary() {
card.className = 'game-card'
card.style.animationDelay = `${idx * 0.04}s`
const portable = game.mode === 'copy' || game.mode === 'move'
const inPrefix = game.importSource === 'prefix'
const sourceBadge = inPrefix
? `<span class="badge primary">${t('library.inPrefix')}${game.prefix ? ' · ' + game.prefix : ''}</span>`
: `<span class="badge ${portable ? 'primary' : ''}">${portable ? t('library.portable') : t('library.external')}</span>`
const last = game.lastPlayed ? new Date(game.lastPlayed).toLocaleDateString() : t('library.never')
card.innerHTML = `
<h3 title=""></h3>
<div class="meta">
${sourceBadge}
<span class="badge ${portable ? 'primary' : ''}">${portable ? t('library.portable') : t('library.external')}</span>
<span class="badge">${wineLabel(game.wine)}</span>
</div>
<div class="meta">${t('library.lastPlayed', last)}</div>
@ -438,9 +382,7 @@ function renderLibrary() {
else toast(t('library.viewLogs'))
}
card.querySelector('[data-act=remove]').onclick = async () => {
const note = game.mode === 'copy' || game.mode === 'move'
? t('library.removeFilesNote')
: (inPrefix ? t('library.removePrefixNote') : '')
const note = game.mode === 'copy' || game.mode === 'move' ? t('library.removeFilesNote') : ''
const ok = await confirmModal(t('library.removeConfirm', game.name, note), t('library.remove'))
if (!ok) return
await window.plwl.gamesRemove(game.id)
@ -504,7 +446,6 @@ async function startImportFlow() {
<button class="button" data-mode="copy">${t('import.mode.copy')}</button>
<button class="button" data-mode="move">${t('import.mode.move')}</button>
<button class="button" data-mode="leave">${t('import.mode.leave')}</button>
<button class="button" data-mode="prefix">${t('import.mode.prefix')}</button>
</div>
<div class="modal-actions">
<button class="button" id="modal-cancel">${t('dialog.cancel')}</button>
@ -515,7 +456,6 @@ async function startImportFlow() {
btn.onclick = async () => {
const mode = btn.dataset.mode
closeModal()
if (mode === 'prefix') return startPrefixImportFlow()
const srcDir = await window.plwl.gamesPickFolder()
if (!srcDir) return
await doImport(srcDir, mode)
@ -523,48 +463,6 @@ async function startImportFlow() {
})
}
// Import from a wine prefix, step 1: pick WHICH prefix - the app folder must
// live inside one so its paths can stay prefix-relative (wine then sees it as
// a regular C:\ install).
async function startPrefixImportFlow() {
try {
state.prefixes = await window.plwl.prefixesList()
} catch (err) {
toast(t('import.failed', (err && err.message) || String(err)), 'error')
return
}
const modal = showModal(`
<h2>${t('import.prefix.title')}</h2>
<p>${t('import.prefix.hint')}</p>
<div class="mode-buttons" id="prefix-choices"></div>
<div class="modal-actions">
<button class="button" id="modal-cancel">${t('dialog.cancel')}</button>
</div>
`)
modal.querySelector('#modal-cancel').onclick = closeModal
const choices = modal.querySelector('#prefix-choices')
if (!state.prefixes.prefixes.length) {
const p = document.createElement('p')
p.className = 'subtitle'
p.textContent = t('prefixes.empty')
choices.replaceWith(p)
return
}
for (const p of state.prefixes.prefixes) {
const btn = document.createElement('button')
btn.className = 'button'
btn.textContent = p.name + (p.name === state.prefixes.default ? ` (${t('prefixes.default')})` : '')
btn.onclick = async () => {
const prefix = p.name
closeModal()
const srcDir = await window.plwl.gamesPickPrefixFolder(prefix)
if (!srcDir) return
await doPrefixImport(prefix, srcDir)
}
choices.appendChild(btn)
}
}
async function doImport(srcDir, mode) {
const name = srcDir.split('/').filter(Boolean).pop()
const modal = showModal(`
@ -597,36 +495,6 @@ async function doImport(srcDir, mode) {
}
}
// Import from a prefix, step 2: register the app in place - nothing is
// copied, paths stay prefix-relative.
async function doPrefixImport(prefix, srcDir) {
const name = srcDir.split('/').filter(Boolean).pop()
const modal = showModal(`
<h2>${t('import.progress', name)}</h2>
<div class="progress-row"><progress></progress></div>
`)
try {
const game = await window.plwl.gamesImportFromPrefix(prefix, srcDir)
closeModal()
if (!game || game.ok === false) {
const raw = (game && game.error) || ''
const errText =
raw === 'no-exe-found' ? t('import.noExe')
: raw === 'no-drive-c' ? t('import.prefix.noDriveC')
: t('import.prefix.outside')
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('import.failed', (err && err.message) || String(err)), 'error')
}
}
function renderPrefixes() {
const list = qs('#prefix-list')
list.innerHTML = ''
@ -786,9 +654,6 @@ function renderPrefixOptions(selectId, selected) {
{ value: '', label: t('config.prefixDefault', defs.default) },
...defs.prefixes.filter(p => p.name !== defs.default).map(p => ({ value: p.name, label: p.name }))
]
// prefix imports pin their prefix even when it IS the default (which the
// list above omits) - keep it visible instead of a blank select
if (selected && !opts.some(o => o.value === selected)) opts.push({ value: selected, label: selected })
if (!selects[selectId]) selects[selectId] = new CustomSelect(qs('#' + selectId), opts, selected || '')
else selects[selectId].update(opts, selected || '')
}
@ -826,18 +691,12 @@ async function openGameConfig(id) {
qs('#gc-title').textContent = game.name
qs('#gc-name').value = game.name
refreshConfigIcon()
qs('#gc-exe').value = displayGamePath(game.exePath, game)
qs('#gc-workdir').value = displayGamePath(game.workdir, game)
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)
renderPrefixOptions('gc-prefix', game.prefix)
// apps imported from a prefix are pinned to it (their paths are prefix-
// relative) - the select is locked, not just corrected on save
const prefixLocked = game.importSource === 'prefix'
selects['gc-prefix'].setDisabled(prefixLocked)
const lockHint = qs('#gc-prefix-lock-hint')
if (lockHint) lockHint.style.display = prefixLocked ? '' : 'none'
renderOverrides(game.dllOverrides || [])
renderTools()
}
@ -905,33 +764,19 @@ async function saveGameConfig() {
const idx = line.indexOf('=')
if (idx > 0) env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim()
})
// prefix imports keep their paths relative to the prefix they were
// imported from - pointing them at another prefix would silently break
// exePath/workdir, so the original prefix stays locked. Other edits in
// the same save are kept; only the prefix snaps back (with a hint).
let prefixLockedHint = false
let prefix = selects['gc-prefix'].value
if (game.importSource === 'prefix') {
if (prefix !== game.prefix) {
prefixLockedHint = true
renderPrefixOptions('gc-prefix', game.prefix)
}
prefix = game.prefix
}
const patch = {
name: qs('#gc-name').value.trim() || game.name,
exePath: absGamePathInput(qs('#gc-exe').value.trim(), game),
workdir: absGamePathInput(qs('#gc-workdir').value.trim(), game),
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,
prefix: selects['gc-prefix'].value,
env,
dllOverrides: readOverrides()
}
await window.plwl.gamesUpdate(game.id, patch)
state.games = await window.plwl.gamesList()
toast(t('config.saved'), 'success')
if (prefixLockedHint) toast(t('config.prefixLocked'), 'error')
if (state.currentView === 'gameconfig') {
state.currentGame = { ...state.currentGame, ...patch }
qs('#gc-title').textContent = patch.name
@ -1015,18 +860,14 @@ function wireEvents() {
qs('#btn-gc-shortcut').addEventListener('click', createAppShortcut)
qs('#gc-override-add').addEventListener('click', addOverrideRow)
qs('#gc-exe-browse').addEventListener('click', async () => {
// start from the field being edited (exe picker from the exe field) and
// resolve the SHOWN path (prefix apps display pfx/drive_c/...) to an
// absolute one - absPathInput alone would pass the prefix-relative form
// through and the picker would open at the process cwd ('/')
const cur = absGamePathInput(qs('#gc-exe').value.trim(), state.currentGame)
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 = displayGamePath(p, state.currentGame)
if (p) qs('#gc-exe').value = displayPath(p)
})
qs('#gc-workdir-browse').addEventListener('click', async () => {
const cur = absGamePathInput(qs('#gc-workdir').value.trim(), state.currentGame)
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 = displayGamePath(p, state.currentGame)
if (p) qs('#gc-workdir').value = displayPath(p)
})
qs('#gc-tool-run').addEventListener('click', runCustomTool)
async function applyIconResult(res) {
@ -1037,12 +878,8 @@ function wireEvents() {
applyIconResult(await window.plwl.gamesAutoIcon(state.currentGame.id))
})
qs('#gc-icon-browse').addEventListener('click', async () => {
// start in the app's own folder (prefix apps included) - that is where
// the exe with the embedded icon most likely lives
const start = absGamePathInput(qs('#gc-workdir').value.trim(), state.currentGame)
const p = await window.plwl.dialogPickFile({
properties: ['openFile'],
defaultPath: start || window.plwl.FILES_DIR,
title: t('config.icon.pick'),
filters: [{ name: t('config.icon.pick'), extensions: ['png', 'jpg', 'jpeg', 'svg', 'ico', 'webp', 'gif', 'bmp', 'exe'] }]
})

View file

@ -146,7 +146,6 @@
<label class="field-label" data-t="config.prefix"></label>
<span id="gc-prefix"></span>
<p class="subtitle" id="gc-prefix-lock-hint" data-t="config.prefixLockedHint" style="display:none"></p>
<label class="field-label" data-t="config.env"></label>
<textarea id="gc-env" rows="3" data-ph="config.envHint"></textarea>

View file

@ -476,21 +476,6 @@ select {
background: rgba(255, 255, 255, 0.03);
align-self: flex-start;
}
.game-icon.placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--darker-text-color);
opacity: 0.45;
}
.game-icon.placeholder::before {
content: '';
width: 2rem;
height: 2rem;
background: currentColor;
-webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'><rect x='3' y='3' width='18' height='18' rx='2'/><circle cx='8.5' cy='8.5' r='1.5'/><path d='M21 15l-5-5L5 21'/></svg>") center / contain no-repeat;
mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'><rect x='3' y='3' width='18' height='18' rx='2'/><circle cx='8.5' cy='8.5' r='1.5'/><path d='M21 15l-5-5L5 21'/></svg>") center / contain no-repeat;
}
.game-card:hover { border-color: rgba(255, 255, 255, 0.2); }
.game-card h3 {
font-size: 1.05rem;

View file

@ -5,7 +5,6 @@ const path = require('path')
const { DIRS, resolvePortablePath } = require('./paths')
const { newGame, newGameId, addGame } = require('./library')
const { extractIconFromExe } = require('./icons')
const { listPrefixes, prefixDriveRoot } = require('./prefixes')
// Directories that typically hold installers/redistributables, not the game.
const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc'])
@ -63,34 +62,6 @@ 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) {
if (!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 = 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.
@ -132,4 +103,4 @@ async function findDefaultExe(dir) {
return best
}
module.exports = { importGame, importGameFromPrefix, findDefaultExe, copyRecursive, moveFolder }
module.exports = { importGame, findDefaultExe, copyRecursive, moveFolder }

View file

@ -1,7 +1,7 @@
'use strict'
const { runGame, runTool } = require('../runner')
const { importGame, importGameFromPrefix } = require('../importer')
const { importGame } = require('../importer')
const library = require('../library')
function killProcessGroup(pid) {
@ -114,13 +114,7 @@ function createGamesService({ getWindow, onGamesChanged }) {
return importGame(srcDir, mode, data => sendToWindow(win, 'import:progress', data))
}
// 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 }
return { isRunning, listRunning, start, kill, runToolFor, import: importTo, setOnGameExit }
}
module.exports = { createGamesService, killProcessGroup, sendToWindow }

View file

@ -93,8 +93,6 @@ function registerIpc({ getWindow, onGamesChanged }) {
// games: library -----------------------------------------------------------------
ipcMain.handle('games:list', () => library.loadGames())
ipcMain.handle('games:get', (e, id) => library.getGame(id))
// prefix pin is enforced inside library.updateGame (prefix/importSource
// fields are ignored for prefix imports), so nothing extra needed here
ipcMain.handle('games:update', (e, id, patch) => library.updateGame(id, patch))
ipcMain.handle('games:remove', (e, id) => {
shortcuts.removeShortcut(id)
@ -182,39 +180,10 @@ 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) => {
const o = { ...(opts || {}) }
// Electron resolves a RELATIVE defaultPath against the process cwd (the
// install root in packaged mode), which lands the picker somewhere
// random like '/'. Renderer paths are FILES-relative by convention, so
// anchor them there instead.
if (o.defaultPath && typeof o.defaultPath === 'string' && !path.isAbsolute(o.defaultPath)) {
o.defaultPath = path.join(DIRS.FILES, o.defaultPath)
}
const res = await dialog.showOpenDialog(getWindow(), { properties: ['openFile', 'openDirectory'], ...o })
const res = await dialog.showOpenDialog(getWindow(), { properties: ['openFile', 'openDirectory'], ...(opts || {}) })
return res.canceled || !res.filePaths[0] ? null : res.filePaths[0]
})
ipcMain.handle('shell:openPath', (e, p) => shell.openPath(p))

View file

@ -29,23 +29,13 @@ 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.
// 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])
}
// 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
@ -58,18 +48,8 @@ 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']) {
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)
}
if (out[k]) out[k] = toPortablePath(out[k])
}
return out
}
@ -93,14 +73,6 @@ function updateGame(id, patch) {
const games = loadGames()
const g = games.find(x => x.id === id)
if (!g) return null
// prefix imports are pinned: their paths are stored relative to their
// prefix, so NOTHING may move them to another prefix or flip their
// import source (the patch fields are simply ignored)
if (g.importSource === 'prefix' && patch) {
patch = { ...patch }
delete patch.prefix
delete patch.importSource
}
// playSeconds goes to its own tiny file; the registry just mirrors it for
// anything reading apps.json directly (loadGames prefers the file anyway).
if (patch.playSeconds !== undefined) writePlaySeconds(id, patch.playSeconds)
@ -123,9 +95,6 @@ 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

View file

@ -42,24 +42,6 @@ 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' }
@ -93,4 +75,4 @@ function setDefaultPrefix(name) {
return { ok: true }
}
module.exports = { listPrefixes, createPrefix, deletePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoot, normalizeName, MAIN }
module.exports = { listPrefixes, createPrefix, deletePrefix, setDefaultPrefix, prefixPath, prefixExists, normalizeName, MAIN }

View file

@ -3,13 +3,6 @@
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'),
@ -39,9 +32,7 @@ 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),
@ -51,8 +42,6 @@ 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 => {
importProgressCb = cb
},
onImportProgress: cb => ipcRenderer.on('import:progress', (e, data) => cb(data)),
onGamesChanged: cb => ipcRenderer.on('games:changed', () => cb())
})