Compare commits

...

7 commits

Author SHA1 Message Date
3e054dd9de bump to 0.3.0 2026-10-02 16:39:37 +02:00
02b56b6534 center badge labels 2026-10-02 16:38:41 +02:00
c8dd0919be clear the drag fade when a card is dropped in place 2026-10-02 16:38:41 +02:00
5e342b6fe5 show progress for imports and deletions 2026-10-02 15:57:40 +02:00
38eea05789 guard prefix and wine edits while their apps run 2026-10-02 15:57:00 +02:00
aca9f839e3 added library reordering & prefix rename 2026-10-02 14:05:49 +02:00
f8ce9a9945 fixes:
- fixed icon saving
- slightly improved library layout
2026-10-02 08:59:27 +02:00
20 changed files with 919 additions and 179 deletions

View file

@ -41,7 +41,7 @@ cd 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. Imports keep the folder as it was: symlinks are recreated (rewritten to relative whenever the target is part of the launcher) and file permissions are carried over, so an imported game or build runs straight away.
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. A prefix can be renamed later (Wine prefixes): the folder moves on disk and everything running in it follows the new name, because those paths are stored relative to the prefix. Edits wait for running apps: while anything from a prefix is playing, renaming or deleting it (and switching the default prefix away from an app that has none of its own) is refused with the name of the app to close first — wine keeps its registry and user files open in that folder. Deleting is also refused while a prefix still has apps registered in it, so an app is never silently orphaned into the default prefix. Imports keep the folder as it was: symlinks are recreated (rewritten to relative whenever the target is part of the launcher) and file permissions are carried over, so an imported game or build runs straight away.
## "Open with PLWL" in the file manager
@ -68,7 +68,7 @@ A sync also carries **how the apps are set up**: the Wine/Proton build and prefi
## Wine & Proton support
- **System Wine** — uses the `wine` binary from PATH.
- **Imported builds** — drop any wine or Proton build into `files/wines/`. The folder may contain the build directly, or one level of nesting (zip-extracted layouts are detected automatically). After the copy, PLWL rewires the build's absolute symlinks to relative ones and restores the executable bit on the binaries it needs to run (`proton`, `wine`, `wineserver`, `winetricks`), so a build that arrived from a zip or another machine works without further fiddling.
- **Imported builds** — drop any wine or Proton build into `files/wines/`. The folder may contain the build directly, or one level of nesting (zip-extracted layouts are detected automatically). After the copy, PLWL rewires the build's absolute symlinks to relative ones and restores the executable bit on the binaries it needs to run (`proton`, `wine`, `wineserver`, `winetricks`), so a build that arrived from a zip or another machine works without further fiddling. A build can also be given a **displayed name** of its own (Wine versions): it is used everywhere the build is offered — library config, settings and the *Open with PLWL* popup — while the folder in `files/wines/` keeps its name and the symlinks inside prefixes stay valid.
- **Proton without Steam** — fully supported. Proton is invoked like Steam does it: `STEAM_COMPAT_CLIENT_INSTALL_PATH` points to a fake steam client folder, `STEAM_COMPAT_DATA_PATH` is a per-build compat folder, and the prefix lives at `<compat>/pfx` which PLWL links to its own prefix folder. No Steam installation required (GE builds work out of the box; for a few Valve builds that refuse to run without a client, PLWL fakes a minimal steam folder in `files/data/fake-steam`).
## Translations

View file

@ -14,12 +14,17 @@ src/ main process (Node/Electron backend)
games.js games service: run/stop state, wine resolution, open-with handoff
lansync.js LAN sync handlers (identity, devices, re-roll, sync + progress)
runner.js spawns wine/proton (env, prefix, playtime, logs)
library.js apps registry (apps.json) + settings + playtime files
library.js apps registry (apps.json) + settings + playtime files, plus
the "which prefix does this app use" / in-use guards
wines.js wine/proton build scan, registry, portable symlink healing
prefixes.js prefix registry (files/wineprefixes)
symlinks.js relative symlink rewiring + executable-bit restoration
(shared by imports, prefixes and LAN sync)
fsutil.js tree copy/remove with byte progress + rate-gated progress
events (imports, prefix/wine/app deletion, LAN sync)
importer.js app import (copy/move/leave/prefix)
names.js user-typed name sanitizing (prefix folder names, wine
display names)
openwith.js "Open with PLWL": ad-hoc runs, no library entry
shortcuts.js .desktop shortcut generation
paths.js portable layout, FILES-relative path helpers, translation sync
@ -35,7 +40,8 @@ renderer/ UI (plain HTML/CSS/JS, no framework)
style.css the whole look; CSS variables at the top
files/ portable data the launcher ships/creates at runtime
translations/ en-us.json, pl.json (copied to the data dir on start)
test/ plain node scripts, run them with `node test/<name>.test.js`
test/ plain node scripts, run them with `node test/<name>.test.js`;
electron test/<name>.test.js for ones that need the real IPC/Electron
tools/ install.sh / createshortcut.sh / build helpers
```
@ -64,6 +70,16 @@ handlers in src/ipc/. Keep new APIs in that pattern.
try/catch-and-ignore with a comment saying why it is safe to ignore.
- Windows apps run through runGame()/buildEnv() - never spawn wine directly
somewhere else (prefix handling, Proton env and playtime live there).
- A prefix or wine build that a running app uses cannot be renamed/deleted
(wine holds its registry and user files open in that folder): the guards sit
next to the handlers in src/ipc/, use the games service's running list plus
the helpers in library.js (prefixNameForGame/gamesUsingPrefix/is*InUse) and
answer { ok:false, error:'inUse'|'hasApps', apps:[names] } for the toast.
- Long operations stream progress through the single import:progress slot as
{ phase: 'count'|'copy'|'remove', files, bytes, totalFiles, totalBytes } and
the renderer drives a bar from it (runWithProgress for deletes/imports).
Emit the 'count' phase first (a treeSize walk) and copy/remove through the
fsutil helpers; never ship a progress bar no data can move.
- CSS: reuse the existing variables/classes (.button, .field-label,
.custom-select, form-card); spacing rhythm comes from --button-margin and
the flex gap of the container, not per-element magic numbers.

View file

@ -20,6 +20,7 @@
"library.running": "Running",
"library.config": "Configure",
"library.remove": "Remove",
"library.removing": "Removing {0}…",
"library.openFolder": "Open folder",
"library.lastPlayed": "Last played: {0}",
"library.playtime": "Playtime: {0}",
@ -35,6 +36,7 @@
"library.removePrefixNote": "\nFiles stay in the prefix, only the launcher entry is removed.",
"library.tools": "Wine tools",
"library.viewLogs": "View logs",
"library.reorderHint": "Drag to change the order",
"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)",
@ -47,7 +49,13 @@
"import.prefix.noDriveC": "this prefix has no drive_c yet, run any app in it so wine creates one",
"import.pickFolder": "Choose the app folder",
"import.progress": "Importing {0}…",
"import.adding": "Adding {0}…",
"import.prefix.registering": "Registering the app from the prefix - nothing is copied, it keeps running where it is.",
"import.fileDone": "{0} files copied",
"import.phase.count": "Counting files…",
"import.phase.copy": "Copying files: {0} / {1}",
"import.phase.remove": "Deleting: {0} / {1}",
"import.bytes": "{0} / {1}",
"import.done": "Import finished",
"import.failed": "Import failed: {0}",
"import.noExe": "no .exe file found in the selected folder",
@ -106,6 +114,7 @@
"prefixes.default": "Default",
"prefixes.makeDefault": "Set as default",
"prefixes.delete": "Delete",
"prefixes.deleting": "Deleting prefix {0}…",
"prefixes.deleteConfirm": "Delete prefix {0} and all of its data?",
"prefixes.empty": "No prefixes yet. Create one, or let wine create the default on first run.",
"prefixes.created": "Prefix {0} created",
@ -113,7 +122,15 @@
"prefixes.defaultSet": "{0} is now the default prefix",
"prefixes.exists": "A prefix with this name already exists",
"prefixes.invalidName": "Invalid prefix name",
"prefixes.mainLocked": "The Main prefix cannot be deleted",
"prefixes.mainLocked": "The Main prefix cannot be renamed or deleted",
"prefixes.rename": "Rename",
"prefixes.renameTitle": "Rename prefix",
"prefixes.renameHint": "The prefix folder and every app using it move to the new name.",
"prefixes.renamed": "Prefix {0} renamed to {1}",
"prefixes.renameFailed": "Could not rename the prefix: {0}",
"prefixes.inUse": "Cannot change prefix {0} while an app from it is running: {1}. Close it first.",
"prefixes.inUseHint": "An app is running from this prefix right now - close it before renaming.",
"prefixes.hasApps": "Cannot delete prefix {0} - these apps still use it: {1}. Point them at another prefix or remove them first.",
"wines.title": "Wine versions",
"wines.subtitle": "You can import your wine/proton builds here. Proton runs fully without Steam.",
"wines.system": "System Wine",
@ -130,9 +147,17 @@
"wines.makeDefault": "Set as default",
"wines.defaultSet": "{0} is now the default Wine version",
"wines.delete": "Delete",
"wines.deleting": "Deleting {0}…",
"wines.deleteConfirm": "Delete {0} from files/wines?\nThe build folder will be permanently removed.",
"wines.deleted": "{0} deleted",
"wines.systemLocked": "System Wine cannot be deleted",
"wines.systemLocked": "System Wine cannot be renamed or deleted",
"wines.rename": "Rename",
"wines.renameTitle": "Displayed name",
"wines.renameHint": "Only the name shown in the launcher changes - the build folder in files/wines stays as it is.",
"wines.renamed": "{0} is now shown as {1}",
"wines.renameFailed": "Could not rename: {0}",
"wines.inUse": "Cannot delete this Wine version while an app using it is running: {0}. Close it first.",
"wines.invalidName": "Invalid name",
"wines.import": "Import",
"wines.importPick": "Choose the Wine/Proton build folder",
"wines.importing": "Importing Wine build…",

View file

@ -20,6 +20,7 @@
"library.running": "Uruchomiona",
"library.config": "Konfiguracja",
"library.remove": "Usuń",
"library.removing": "Usuwanie {0}…",
"library.openFolder": "Otwórz folder",
"library.lastPlayed": "Ostatnio grane: {0}",
"library.playtime": "Czas gry: {0}",
@ -35,6 +36,7 @@
"library.removePrefixNote": "\nPliki zostają w prefiksie, usuwany jest tylko wpis w launcherze.",
"library.tools": "Narzędzia Wine",
"library.viewLogs": "Podejrzyj logi",
"library.reorderHint": "Przeciągnij, aby zmienić kolejność",
"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)",
@ -47,7 +49,13 @@
"import.prefix.noDriveC": "ten prefiks nie ma jeszcze drive_c, uruchom w nim dowolną apkę, żeby wine go utworzył",
"import.pickFolder": "Wybierz folder aplikacji",
"import.progress": "Importowanie {0}…",
"import.adding": "Dodawanie {0}…",
"import.prefix.registering": "Aplikacja jest rejestrowana z prefiksu - nic nie jest kopiowane, działa dalej tam, gdzie jest.",
"import.fileDone": "Skopiowane pliki: {0}",
"import.phase.count": "Liczenie plików…",
"import.phase.copy": "Kopiowanie plików: {0} / {1}",
"import.phase.remove": "Usuwanie: {0} / {1}",
"import.bytes": "{0} / {1}",
"import.done": "Import zakończony",
"import.failed": "Import nie powiódł się: {0}",
"import.noExe": "nie znaleziono pliku .exe w wybranym folderze",
@ -106,6 +114,7 @@
"prefixes.default": "Domyślny",
"prefixes.makeDefault": "Ustaw jako domyślny",
"prefixes.delete": "Usuń",
"prefixes.deleting": "Usuwanie prefiksu {0}…",
"prefixes.deleteConfirm": "Usunąć prefiks {0} wraz z całą jego zawartością?",
"prefixes.empty": "Brak prefiksów. Utwórz jeden, albo pozwól wine utworzyć domyślny przy pierwszym uruchomieniu.",
"prefixes.created": "Utworzono prefiks {0}",
@ -113,7 +122,15 @@
"prefixes.defaultSet": "{0} jest teraz prefiksem domyślnym",
"prefixes.exists": "Prefiks o tej nazwie już istnieje",
"prefixes.invalidName": "Nieprawidłowa nazwa prefiksu",
"prefixes.mainLocked": "Prefiks Main nie może być usunięty",
"prefixes.mainLocked": "Prefiks Main nie może być zmieniony ani usunięty",
"prefixes.rename": "Zmień nazwę",
"prefixes.renameTitle": "Zmień nazwę prefiksu",
"prefixes.renameHint": "Folder prefiksu i wszystkie używające go aplikacje przechodzą na nową nazwę.",
"prefixes.renamed": "Zmieniono nazwę prefiksu {0} na {1}",
"prefixes.renameFailed": "Nie udało się zmienić nazwy prefiksu: {0}",
"prefixes.inUse": "Nie można zmienić prefiksu {0}, gdy działa z niego aplikacja: {1}. Najpierw ją zamknij.",
"prefixes.inUseHint": "Właśnie działa aplikacja z tego prefiksu - zamknij ją przed zmianą nazwy.",
"prefixes.hasApps": "Nie można usunąć prefiksu {0} - używają go jeszcze aplikacje: {1}. Wskaż im inny prefiks albo usuń je najpierw.",
"wines.title": "Wersje Wine",
"wines.subtitle": "Tutaj możesz zaimportować swoje buildy wine/proton. Proton działa w pełni bez Steama.",
"wines.system": "Wine systemowe",
@ -130,9 +147,17 @@
"wines.makeDefault": "Ustaw jako domyślną",
"wines.defaultSet": "{0} jest teraz domyślną wersją Wine",
"wines.delete": "Usuń",
"wines.deleting": "Usuwanie {0}…",
"wines.deleteConfirm": "Usunąć {0} z files/wines?\nFolder z buildem zostanie trwale usunięty.",
"wines.deleted": "Usunięto {0}",
"wines.systemLocked": "Wine systemowego nie można usunąć",
"wines.systemLocked": "Wine systemowego nie można zmienić ani usunąć",
"wines.rename": "Zmień nazwę",
"wines.renameTitle": "Wyświetlana nazwa",
"wines.renameHint": "Zmienia się tylko nazwa pokazywana w launcherze - folder builda w files/wines zostaje bez zmian.",
"wines.renamed": "{0} wyświetla się teraz jako {1}",
"wines.renameFailed": "Nie udało się zmienić nazwy: {0}",
"wines.inUse": "Nie można usunąć tej wersji Wine, gdy działa aplikacja, która jej używa: {0}. Najpierw ją zamknij.",
"wines.invalidName": "Nieprawidłowa nazwa",
"wines.import": "Importuj",
"wines.importPick": "Wybierz folder z buildem Wine/Proton",
"wines.importing": "Importowanie builda Wine…",

View file

@ -1,7 +1,7 @@
{
"name": "PLWL",
"productName": "PLWL",
"version": "0.1.3",
"version": "0.3.0",
"description": "Portable Linux Wine Launcher",
"main": "src/main.js",
"scripts": {

View file

@ -123,8 +123,20 @@ let modalSwapTimer = null
// operation) close it.
let modalLocked = false
// Resolver of the current promise-based modal (confirm/prompt). Escape, the
// backdrop or another modal opening must settle it, or the awaiting caller
// would never continue.
let modalSettle = null
function settleModal(value) {
const settle = modalSettle
modalSettle = null
if (settle) settle(value)
}
function showModal(html, opts) {
modalLocked = !!(opts && opts.locked)
settleModal(null)
if (modalTeardownTimer) {
clearTimeout(modalTeardownTimer)
modalTeardownTimer = null
@ -185,6 +197,7 @@ function closeModal() {
const overlay = qs('#modal-overlay')
const modal = qs('#modal')
if (overlay.style.display === 'none') return
settleModal(null)
modal.classList.add('closing')
overlay.classList.remove('show')
if (modalTeardownTimer) clearTimeout(modalTeardownTimer)
@ -212,14 +225,48 @@ function confirmModal(message, okLabel) {
<button class="button primary" id="modal-ok">${okLabel || t('dialog.ok')}</button>
</div>
`)
modal.querySelector('#modal-cancel').onclick = () => {
const finish = ok => {
settleModal(ok)
closeModal()
resolve(false)
}
modal.querySelector('#modal-ok').onclick = () => {
modal.querySelector('#modal-cancel').onclick = () => finish(false)
modal.querySelector('#modal-ok').onclick = () => finish(true)
modalSettle = v => resolve(!!v)
})
}
// Shared "type a name" modal (prefix rename, wine display name): Enter
// submits, Cancel/Escape/backdrop resolve null.
function promptModal({ title, label, value, confirm, hint }) {
return new Promise(resolve => {
const modal = showModal(`
<h2>${title}</h2>
${hint ? `<p class="subtitle">${hint}</p>` : ''}
<label class="field-label">${label}</label>
<input type="text" id="prompt-input">
<div class="modal-actions">
<button class="button" id="modal-cancel">${t('dialog.cancel')}</button>
<button class="button primary" id="modal-ok">${confirm || t('dialog.ok')}</button>
</div>
`)
const input = modal.querySelector('#prompt-input')
input.value = value || ''
input.focus()
input.select()
const finish = v => {
settleModal(v)
closeModal()
resolve(true)
}
const submit = () => finish(input.value.trim() || null)
input.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault()
submit()
}
})
modal.querySelector('#modal-cancel').onclick = () => finish(null)
modal.querySelector('#modal-ok').onclick = submit
modalSettle = v => resolve(v)
})
}
@ -427,6 +474,82 @@ function fmtPlaytime(seconds) {
return t('library.playtimeMin', m)
}
// --- reordering the library (drag & drop) ----------------------------------
// The registry array IS the display order, so a drop reorders state.games and
// persists the whole id list; the main process appends any id the renderer did
// not know about, so a stale card list can never drop an entry. Cards are
// plain flex items, so the insertion point is the half of the hovered card the
// pointer is over.
let dragId = null
function clearDropMarks(except) {
qsa('.game-card.drop-before, .game-card.drop-after').forEach(card => {
if (card !== except) card.classList.remove('drop-before', 'drop-after')
})
}
function dropSide(card, x) {
const rect = card.getBoundingClientRect()
return x < rect.left + rect.width / 2 ? 'before' : 'after'
}
function commitOrder(ids) {
const byId = new Map(state.games.map(g => [g.id, g]))
state.games = ids.map(id => byId.get(id)).filter(Boolean)
renderLibrary()
window.plwl.gamesReorder(ids).then(games => {
if (!Array.isArray(games)) return
const same = games.length === state.games.length && games.every((g, i) => g.id === state.games[i].id)
if (same) return
// the registry also held entries this render did not know about - show them
state.games = games
renderLibrary()
})
}
function wireCardDrag(card, game) {
card.draggable = true
card.title = t('library.reorderHint')
card.addEventListener('dragstart', e => {
dragId = game.id
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', game.id) // a drag needs data to start
card.classList.add('dragging')
})
card.addEventListener('dragend', () => {
dragId = null
// a drop in place never re-renders the list, so drop the fade here too
card.classList.remove('dragging')
clearDropMarks()
})
card.addEventListener('dragover', e => {
if (!dragId || dragId === game.id) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const side = dropSide(card, e.clientX)
clearDropMarks(card)
card.classList.toggle('drop-before', side === 'before')
card.classList.toggle('drop-after', side === 'after')
})
card.addEventListener('dragleave', e => {
// dragleave also fires when the pointer crosses a child element
if (!card.contains(e.relatedTarget)) card.classList.remove('drop-before', 'drop-after')
})
card.addEventListener('drop', e => {
if (!dragId || dragId === game.id) return
e.preventDefault()
e.stopPropagation() // the list's own drop handler moves cards to the end
const side = dropSide(card, e.clientX)
const id = dragId
dragId = null
const ids = state.games.map(g => g.id).filter(x => x !== id)
const at = ids.indexOf(game.id)
if (at === -1) return
ids.splice(side === 'before' ? at : at + 1, 0, id)
commitOrder(ids)
})
}
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
@ -507,12 +630,17 @@ function renderLibrary() {
: (inPrefix ? t('library.removePrefixNote') : '')
const ok = await confirmModal(t('library.removeConfirm', game.name, note), t('library.remove'))
if (!ok) return
await window.plwl.gamesRemove(game.id)
// portable apps delete their folder too - show how far that got
await runWithProgress({
title: t('library.removing', game.name),
action: () => window.plwl.gamesRemove(game.id)
})
toast(t('toast.gameRemoved', game.name), 'success')
state.games = await window.plwl.gamesList()
renderLibrary()
}
list.appendChild(card)
wireCardDrag(card, game)
attachCardIcon(card, game)
})
}
@ -633,14 +761,22 @@ async function doImport(srcDir, mode) {
const name = srcDir.split('/').filter(Boolean).pop()
const modal = showModal(`
<h2>${t('import.progress', name)}</h2>
<div class="progress-row"><progress id="import-progress"></progress></div>
<p id="import-status"></p>
<div class="progress-row"><progress id="import-progress"></progress><span class="progress-pct"></span></div>
<p class="subtitle" id="import-status"></p>
`)
let count = 0
window.plwl.onImportProgress(() => {
window.plwl.onImportProgress(data => {
count++
const bar = modal.querySelector('#import-progress')
const pct = modal.querySelector('.progress-pct')
const status = modal.querySelector('#import-status')
if (status) status.textContent = t('import.fileDone', count)
// byte totals drive the bar when the copy knows them; a rename or a
// symlink-only run sends plain path events and only bumps the count
if (bar && data && data.totalBytes) {
renderProgressBar(bar, data.bytes, data.totalBytes)
if (pct) pct.textContent = Math.round(((data.bytes || 0) / data.totalBytes) * 100) + '%'
}
if (status) status.textContent = t('import.fileDone', (data && data.files) || count)
})
try {
const game = await window.plwl.gamesImport(srcDir, mode)
@ -773,9 +909,11 @@ function wireOpenWith() {
// 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>
// nothing is copied here - the app is just registered in place, so there is
// no progress to show (no bar that would sit at zero)
showModal(`
<h2>${t('import.adding', name)}</h2>
<p class="subtitle">${t('import.prefix.registering')}</p>
`)
try {
const game = await window.plwl.gamesImportFromPrefix(prefix, srcDir)
@ -799,6 +937,16 @@ async function doPrefixImport(prefix, srcDir) {
}
}
// Apps the launcher would name when it refuses a prefix rename/delete: a
// running app (blocking immediately) or a registered one (orphaned by a
// delete). Fetched fresh - the Prefixes view does not keep the library hot.
async function prefixUsers(name) {
const [games, running] = await Promise.all([window.plwl.gamesList(), window.plwl.gamesListRunning()])
const runningSet = new Set(running || [])
const users = (games || []).filter(g => (g.prefix || state.prefixes.default || 'Main') === name)
return { all: users, running: users.filter(g => runningSet.has(g.id)) }
}
function renderPrefixes() {
const list = qs('#prefix-list')
list.innerHTML = ''
@ -820,27 +968,70 @@ function renderPrefixes() {
</div>
${p.name === defs.default ? `<span class="badge primary">${t('prefixes.default')}</span>` : ''}
<div class="actions">
${locked ? '' : '<button class="button small" data-act="rename"></button>'}
<button class="button small" data-act="default" ${p.name === defs.default ? 'style="display:none"' : ''}></button>
<button class="button small danger" data-act="delete" ${locked ? 'style="display:none"' : ''}></button>
</div>
`
row.querySelector('.title').textContent = p.name
const renBtn = row.querySelector('[data-act=rename]')
if (renBtn) {
renBtn.textContent = t('prefixes.rename')
renBtn.onclick = async () => {
const users = await prefixUsers(p.name)
const name = await promptModal({
title: t('prefixes.renameTitle'),
label: t('prefixes.namePlaceholder'),
hint: users.running.length ? t('prefixes.inUseHint') : t('prefixes.renameHint'),
value: p.name,
confirm: t('prefixes.rename')
})
if (!name || name === p.name) return
const res = await window.plwl.prefixesRename(p.name, name)
if (!res || !res.ok) {
const err = (res && res.error) || ''
if (err === 'inUse') toast(t('prefixes.inUse', p.name, (res.apps || []).join(', ')), 'error')
else if (err === 'hasApps') toast(t('prefixes.hasApps', p.name, (res.apps || []).join(', ')), 'error')
else {
const known = { alreadyExists: 'prefixes.exists', invalidName: 'prefixes.invalidName', isMain: 'prefixes.mainLocked' }
toast(known[err] ? t(known[err]) : t('prefixes.renameFailed', err), 'error')
}
return
}
toast(t('prefixes.renamed', p.name, res.name), 'success')
state.prefixes = await window.plwl.prefixesList()
// the apps that run in this prefix point at the new name now
state.games = await window.plwl.gamesList()
renderPrefixes()
}
}
const defBtn = row.querySelector('[data-act=default]')
defBtn.textContent = t('prefixes.makeDefault')
defBtn.onclick = async () => {
const res = await window.plwl.prefixesSetDefault(p.name)
if (res && res.ok !== false) toast(t('toast.prefixDefault', p.name), 'success')
else if (res && res.error === 'inUse') toast(t('prefixes.inUse', p.name, (res.apps || []).join(', ')), 'error')
else if (res && res.error === 'notFound') toast(t('toast.prefixDeleteFailed', p.name), 'error')
state.prefixes = await window.plwl.prefixesList()
renderPrefixes()
}
const delBtn = row.querySelector('[data-act=delete]')
delBtn.innerHTML = `${ICONS.trash()}<span>${t('prefixes.delete')}</span>`
delBtn.onclick = async () => {
// refused before the confirm prompt, with the reason: the folder is
// either held by a running app or still referenced by registered ones
const users = await prefixUsers(p.name)
if (users.all.length) return toast(t('prefixes.hasApps', p.name, users.all.map(g => g.name).join(', ')), 'error')
const ok = await confirmModal(t('prefixes.deleteConfirm', p.name), t('prefixes.delete'))
if (!ok) return
const res = await window.plwl.prefixesDelete(p.name)
// removing a whole windows drive takes a while: measured progress modal
const res = await runWithProgress({
title: t('prefixes.deleting', p.name),
action: () => window.plwl.prefixesDelete(p.name)
})
if (res.ok) toast(t('toast.prefixDeleted', p.name), 'success')
else if (res.error === 'isMain') toast(t('prefixes.mainLocked'), 'error')
else if (res.error === 'inUse' || res.error === 'hasApps') toast(t('prefixes.hasApps', p.name, (res.apps || []).join(', ')), 'error')
else toast(t('toast.prefixDeleteFailed', p.name), 'error')
state.prefixes = await window.plwl.prefixesList()
renderPrefixes()
@ -874,12 +1065,36 @@ function renderWines() {
${w.version ? `<span class="badge">${w.version}</span>` : ''}
<div class="actions">
<button class="button small" data-act="default" ${isDefault ? 'style="display:none"' : ''}></button>
${w.id !== 'system' ? `<button class="button small" data-act="rename"></button>` : ''}
${w.id !== 'system' ? `<button class="button small danger" data-act="delete"></button>` : ''}
</div>
`
row.querySelector('.title').textContent = w.label
row.querySelector('.sub').textContent = displayPath(w.path) || t('wines.systemHint')
row.querySelector('.sub').title = row.querySelector('.sub').textContent
const renBtn = row.querySelector('[data-act=rename]')
if (renBtn) {
renBtn.textContent = t('wines.rename')
renBtn.onclick = async () => {
const name = await promptModal({
title: t('wines.renameTitle'),
label: t('common.name'),
hint: t('wines.renameHint'),
value: w.displayName || w.label,
confirm: t('wines.rename')
})
if (!name || name === (w.displayName || w.label)) return
const res = await window.plwl.winesSetDisplayName(w.id, name)
if (!res || !res.ok) {
const err = (res && res.error) || ''
toast(err === 'invalidName' ? t('wines.invalidName') : t('wines.renameFailed', err), 'error')
return
}
toast(t('wines.renamed', w.label, res.displayName), 'success')
state.wines = await window.plwl.winesList()
renderWines()
}
}
const defBtn = row.querySelector('[data-act=default]')
defBtn.textContent = t('wines.makeDefault')
defBtn.onclick = async () => {
@ -896,13 +1111,18 @@ function renderWines() {
delBtn.onclick = async () => {
const ok = await confirmModal(t('wines.deleteConfirm', w.label), t('wines.delete'))
if (!ok) return
const res = await window.plwl.winesDelete(w.id)
const res = await runWithProgress({
title: t('wines.deleting', w.label),
action: () => window.plwl.winesDelete(w.id)
})
if (res.ok) {
toast(t('wines.deleted', w.label), 'success')
state.wines = await window.plwl.winesList()
state.settings = await window.plwl.settingsGet()
} else if (res.error === 'isSystem') {
toast(t('wines.systemLocked'), 'error')
} else if (res.error === 'inUse') {
toast(t('wines.inUse', (res.apps || []).join(', ')), 'error')
}
renderWines()
}
@ -1201,6 +1421,37 @@ function barValues(info) {
return [bytes, total]
}
// One modal for operations with a measurable size (import, delete, long
// copies): the bar follows BYTES for the same reason the sync bar does, and a
// percent label sits next to it. Main-process work streams through the single
// import:progress slot (preload keeps only the newest listener), so the modal
// is the only thing showing progress at a time. `action` is the ipc call;
// the modal closes itself when it settles.
async function runWithProgress({ title, action }) {
const modal = showModal(`
<h2>${title}</h2>
<div class="progress-row"><progress></progress><span class="progress-pct"></span></div>
<p class="subtitle" id="op-status">${t('import.phase.count')}</p>
`)
const bar = modal.querySelector('progress')
const pct = modal.querySelector('.progress-pct')
const status = modal.querySelector('#op-status')
window.plwl.onImportProgress(data => {
if (!data || !data.phase) return
// the counting pass reports its own phase with the totals; the bar starts
// moving once the real units arrive
if (data.phase === 'count') return
renderProgressBar(bar, data.bytes, data.totalBytes)
pct.textContent = data.totalBytes ? Math.round(((data.bytes || 0) / data.totalBytes) * 100) + '%' : ''
if (data.totalFiles) status.textContent = t('import.phase.' + data.phase, data.files || 0, data.totalFiles)
})
try {
return await action()
} finally {
closeModal()
}
}
// "340 MB / 1.2 GB" once the total is known (the sending side learns it as it
// plans the units, the receiving side as the manifests arrive), otherwise
// just what has moved so far - so the line is never a lying zero.
@ -1616,6 +1867,19 @@ function wireEvents() {
})
qs('#btn-import-top').addEventListener('click', startImportFlow)
qs('#btn-import-empty').addEventListener('click', startImportFlow)
// a drop on the empty area of the grid moves the card to the end; drops on a
// card are handled by the card itself (and stop there)
const gameList = qs('#game-list')
gameList.addEventListener('dragover', e => {
if (dragId) e.preventDefault()
})
gameList.addEventListener('drop', e => {
if (!dragId || e.target !== gameList) return
e.preventDefault()
const id = dragId
dragId = null
commitOrder([...state.games.map(g => g.id).filter(x => x !== id), id])
})
qs('#btn-prefix-create').addEventListener('click', async () => {
const input = qs('#prefix-name-input')
const name = input.value.trim()
@ -1638,12 +1902,10 @@ function wireEvents() {
qs('#btn-wines-import').addEventListener('click', async () => {
const srcDir = await window.plwl.dialogPickFile({ properties: ['openDirectory'], title: t('wines.importPick') })
if (!srcDir) return
const modal = showModal(`
<h2>${t('wines.importing')}</h2>
<div class="progress-row"><progress></progress></div>
`)
const res = await window.plwl.winesImport(srcDir)
closeModal()
const res = await runWithProgress({
title: t('wines.importing'),
action: () => window.plwl.winesImport(srcDir)
})
if (res.ok) {
toast(t('wines.imported', res.path), 'success')
state.wines = await window.plwl.winesList()
@ -1678,7 +1940,10 @@ function wireEvents() {
if (res.ok) refreshConfigIcon()
}
qs('#gc-icon-auto').addEventListener('click', async () => {
applyIconResult(await window.plwl.gamesAutoIcon(state.currentGame.id))
// extract from the exe the field shows, not the saved one - the user may
// have just pointed the app at another exe and expects its icon
const exe = absGamePathInput(qs('#gc-exe').value.trim(), state.currentGame)
applyIconResult(await window.plwl.gamesAutoIcon(state.currentGame.id, exe))
})
qs('#gc-icon-browse').addEventListener('click', async () => {
// start in the app's own folder (prefix apps included) - that is where

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Before After
Before After

View file

@ -490,8 +490,10 @@ select {
margin: 1rem 0;
}
.game-card {
flex: 1 1 15rem;
min-width: 15rem;
position: relative;
cursor: grab;
flex: 1 1 17rem;
min-width: 17rem;
max-width: 24rem;
background: var(--panel-color);
border: var(--border-width) solid var(--light-border-color);
@ -526,6 +528,21 @@ select {
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); }
/* drag & drop reordering: the dragged card fades, the hovered one shows on
which side the dragged card will land */
.game-card.dragging { opacity: 0.4; }
.game-card.drop-before::after,
.game-card.drop-after::after {
content: '';
position: absolute;
top: 0.5rem;
bottom: 0.5rem;
width: 0.22rem;
border-radius: 0.11rem;
background: var(--text-color);
}
.game-card.drop-before::after { left: -0.5rem; }
.game-card.drop-after::after { right: -0.5rem; }
.game-card h3 {
font-size: 1.05rem;
font-family: 'Nunito Black', 'Nunito', sans-serif;
@ -542,7 +559,10 @@ select {
}
.badge {
font-size: 0.72rem;
padding: 0.1rem 0.5rem;
/* Nunito keeps a lot of air above the caps, so a line box centred by its
font metrics reads as "text sits high" (clearest on "Portable"); bias the
padding downwards, keeping the same overall height, to centre the label */
padding: 0.25em 0.7em 0.03em;
border-radius: 0.6rem;
border: var(--border-width) solid var(--light-border-color);
color: var(--darker-text-color);
@ -780,5 +800,10 @@ progress {
background: rgba(255, 255, 255, 0.08);
-webkit-appearance: none;
}
.progress-pct {
min-width: 2.6rem;
text-align: right;
font-variant-numeric: tabular-nums;
}
progress::-webkit-progress-bar { background: rgba(255, 255, 255, 0.08); border-radius: 0.4rem; }
progress::-webkit-progress-value { background: var(--text-color); border-radius: 0.4rem; }

View file

@ -1,6 +1,7 @@
'use strict'
const fs = require('fs')
const fsp = require('fs').promises
const path = require('path')
// Returns a non-existing path in `base` named `name` (or `name (2)`, `name (3)`...).
@ -12,4 +13,183 @@ function dedupeDir(base, name) {
return path.join(base, `${name} (${n})`)
}
module.exports = { dedupeDir }
// Progress is cheap to produce and expensive to render: hashing or copying a
// tree can emit tens of thousands of events, and forwarding every single one
// over IPC makes the window stutter instead of showing progress. Events are
// rate limited to ~10/s; `force` lets a caller push the value that matters
// (the end of a unit) through immediately.
const PROGRESS_MIN_MS = 100
function progressGate(emit) {
let last = 0
let pending = null
let timer = null
const flush = () => {
timer = null
if (!pending) return
last = Date.now()
const data = pending
pending = null
emit(data)
}
const push = (data, force) => {
pending = data
const wait = PROGRESS_MIN_MS - (Date.now() - last)
if (force || wait <= 0) {
if (timer) clearTimeout(timer)
flush()
} else if (!timer) {
timer = setTimeout(flush, wait)
if (timer.unref) timer.unref()
}
}
push.done = () => {
if (timer) clearTimeout(timer)
flush()
}
return push
}
// What a tree copy will have to move. Symlinks have no size of their own
// (they are recreated, not read), so they only count for the file total.
// `skipDir` is a single directory name to leave out (a game folder's
// redistributables) - walking the whole tree first is fast next to the copy.
async function treeSize(root) {
let files = 0
let bytes = 0
const walk = async dir => {
let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (entry.isSymbolicLink()) {
files++
} else if (entry.isDirectory()) {
await walk(full)
} else if (entry.isFile()) {
files++
try {
bytes += (await fsp.stat(full)).size
} catch {}
}
}
}
await walk(root)
return { files, bytes }
}
// Copy a directory tree the way an imported game/wine build has to arrive:
// symlinks recreated as they are (copyFile would silently drop them) and file
// permissions carried over (copyFile writes 0644, killing scripts and any
// binary the game executes itself). Reports cumulative { done, bytes } after
// every file through `onProgress`, so long copies can drive a progress bar.
async function copyTree(src, dst, onProgress) {
let done = 0
let bytes = 0
const gate = onProgress ? progressGate(onProgress) : null
const tick = () => {
if (gate) gate({ done, bytes }, false)
}
const walk = async (from, to) => {
let entries
try {
entries = await fsp.readdir(from, { withFileTypes: true })
} catch {
return
}
await fsp.mkdir(to, { recursive: true })
for (const entry of entries) {
const s = path.join(from, entry.name)
const d = path.join(to, entry.name)
if (entry.isSymbolicLink()) {
let target = null
try {
target = await fsp.readlink(s)
} catch {}
if (!target) continue
try {
await fsp.symlink(target, d)
} catch {}
done++
tick()
} else if (entry.isDirectory()) {
await walk(s, d)
} else if (entry.isFile()) {
let size = 0
try {
size = (await fsp.stat(s)).size
} catch {}
try {
await fsp.copyFile(s, d)
await fsp.chmod(d, (await fsp.stat(s)).mode & 0o777)
} catch {}
done++
bytes += size
tick()
}
}
}
await walk(src, dst)
if (gate) gate({ done, bytes }, true)
return { done, bytes }
}
// Delete a tree deepest-first so a long removal (a multi-GB prefix) can drive
// a progress bar instead of freezing the window on one rmSync. Symlinks are
// unlinked, never followed. Reports cumulative { files, bytes, dirs } through
// `onProgress`; a missing tree is simply done.
async function removeTree(root, onProgress) {
let files = 0
let dirs = 0
let bytes = 0
const gate = onProgress ? progressGate(onProgress) : null
const tick = () => {
if (gate) gate({ files, dirs, bytes }, false)
}
const walk = async dir => {
let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
return false
}
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
await walk(full)
try {
await fsp.rmdir(full)
} catch {}
dirs++
tick()
continue
}
let size = 0
try {
const st = await fsp.lstat(full)
size = st.isFile() ? st.size : 0
} catch {}
try {
await fsp.unlink(full)
} catch {}
files++
bytes += size
tick()
}
try {
await fsp.rmdir(dir)
dirs++
} catch {}
return true
}
await walk(root)
if (gate) gate({ files, dirs, bytes }, true)
return { files, dirs, bytes }
}
module.exports = { dedupeDir, copyTree, treeSize, removeTree, progressGate }

View file

@ -7,45 +7,16 @@ const { newGame, newGameId, addGame } = require('./library')
const { extractIconFromExe } = require('./icons')
const { listPrefixes, prefixDriveRoot } = require('./prefixes')
const { rewireSymlinks } = require('./symlinks')
const { copyTree, treeSize } = require('./fsutil')
// Directories that typically hold installers/redistributables, not the game.
const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc'])
// A game folder has to arrive as it was: symlinks are recreated (copyFile
// silently dropped them, so a linked asset or save folder vanished) and the
// permission bits are carried over (copyFile writes 0644, killing scripts and
// anything the game executes itself).
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.isSymbolicLink()) {
let target = null
try {
target = await fsp.readlink(s)
} catch {}
if (!target) continue
try {
await fsp.symlink(target, d)
} catch {}
if (onProgress) onProgress(s)
} else if (e.isDirectory()) {
await copyRecursive(s, d, onProgress)
} else if (e.isFile()) {
await fsp.copyFile(s, d)
try {
await fsp.chmod(d, (await fsp.stat(s)).mode & 0o777)
} catch {}
if (onProgress) onProgress(s)
}
}
})
}
// The copy itself lives in fsutil (copyTree): symlinks recreated, permissions
// carried over, byte-level progress. moveFolder only falls back to it across
// filesystems; a same-device rename reports nothing (nothing is copied).
function moveFolder(src, dst, onProgress) {
const fallback = () => copyRecursive(src, dst, onProgress).then(() => fsp.rm(src, { recursive: true, force: true }))
const fallback = () => copyTree(src, dst, onProgress).then(() => fsp.rm(src, { recursive: true, force: true }))
if (path.parse(src).root !== path.parse(dst).root) return fallback()
return fsp.rename(src, dst).catch(fallback)
}
@ -62,8 +33,15 @@ async function importGame(srcDir, mode, onProgress) {
let workdir = srcDir
if (mode === 'copy' || mode === 'move') {
const dst = path.join(DIRS.APPS, id)
if (mode === 'copy') await copyRecursive(srcDir, dst, onProgress)
else await moveFolder(srcDir, dst, onProgress)
if (mode === 'copy') {
// measured before it starts, so the bar can follow the bytes landing
const emit = (phase, detail) => {
if (onProgress) onProgress({ phase, label: name, ...detail })
}
emit('count', { files: 0, bytes: 0 })
const total = await treeSize(srcDir)
await copyTree(srcDir, dst, p => emit('copy', { files: p.done, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
} else await moveFolder(srcDir, dst, onProgress)
workdir = dst
// anything the game linked to INSIDE the launcher data (its own folder, an
// icon, a shared prefix file) becomes a relative link, so the imported app
@ -156,4 +134,4 @@ async function findDefaultExe(dir) {
return best
}
module.exports = { importGame, importGameFromPrefix, findDefaultExe, copyRecursive, moveFolder }
module.exports = { importGame, importGameFromPrefix, findDefaultExe, moveFolder }

View file

@ -17,7 +17,13 @@ function sendToWindow(win, channel, data) {
if (win && !win.isDestroyed()) win.webContents.send(channel, data)
}
// Game-process state: id -> pid (or null while the process is starting).
// Game-process state: id -> { pid (null while the process is starting), the
// prefix name and wine id the run was resolved to }. Both are frozen here:
// the default prefix/wine can be switched and the app's own config edited
// while it plays, and the in-use guards have to name what the process really
// locked - not what the registry says now. Editing an app's own prefix while
// it runs does not move the process, so the new folder is blocked next to the
// snapshot (a pre-edit prefix does not exist in it yet).
// onGamesChanged fires in the MAIN process whenever the running-games set
// changes (the tray menu rebuilds itself from it).
const STARTUP_GRACE_MS = 60000
@ -47,10 +53,10 @@ function createGamesService({ getWindow, onGamesChanged }) {
// restarted) would otherwise answer every later Play with "already
// running".
function prune() {
for (const [id, pid] of [...running]) {
if (alive(pid)) continue
for (const [id, entry] of [...running]) {
if (alive(entry.pid)) continue
const age = Date.now() - (startedAt.get(id) || 0)
if (!pid && age < STARTUP_GRACE_MS) continue // may still be starting
if (!entry.pid && age < STARTUP_GRACE_MS) continue // may still be starting
running.delete(id)
startedAt.delete(id)
}
@ -64,6 +70,22 @@ function createGamesService({ getWindow, onGamesChanged }) {
prune()
return [...running.keys()]
}
// What the still-running games actually use, for the "is this prefix/wine in
// use?" guards: [{ id, name, prefixes: [snapshot, ...], wine }]. The
// snapshot alone goes stale when a running app is pointed at another prefix
// (the process keeps its folder open); the live name alone misses the folder
// it currently holds. Together they name every folder the run touches.
const runningInfo = () => {
prune()
const out = []
for (const [id, entry] of running) {
const game = library.getGame(id)
const live = game && game.prefix ? library.prefixNameForGame(game) : ''
const prefixes = live && live !== entry.prefix ? [entry.prefix, live] : [entry.prefix]
out.push({ id, name: (game && game.name) || id, prefixes, wine: entry.wine })
}
return out
}
// "Open with PLWL" handoff: queue the file for the renderer (picker popup
// with wine version + prefix). Safe to call at any time - if the window is
@ -90,16 +112,14 @@ function createGamesService({ getWindow, onGamesChanged }) {
if (!game) return { ok: false, error: 'game-not-found' }
prune()
if (running.has(id)) return { ok: false, error: 'already-running' }
running.set(id, null)
startedAt.set(id, Date.now())
let wine
try {
wine = resolveWine(game)
} catch {
running.delete(id)
startedAt.delete(id)
return { ok: false, error: 'wine-not-found' }
}
running.set(id, { pid: null, prefix: library.prefixNameForGame(game), wine: wine.id })
startedAt.set(id, Date.now())
try {
const res = await runGame({ game, wine, systemWine: wine.type === 'system', onExit: () => handleExit(id) })
// stop was requested while the process was still starting: kill it and
@ -120,7 +140,8 @@ function createGamesService({ getWindow, onGamesChanged }) {
startedAt.delete(id)
return { ok: false, error: 'not-started' }
}
running.set(id, res.pid)
const entry = running.get(id)
if (entry) entry.pid = res.pid
sendToWindow(win, 'games:changed')
notifyChanged()
return { ok: true, pid: res.pid, logFile: res.logFile || null }
@ -134,8 +155,8 @@ function createGamesService({ getWindow, onGamesChanged }) {
// Stop always clears the state: whether the process was running, still
// starting (kill request for as soon as it has a pid) or already gone.
async function kill(id) {
const pid = running.get(id)
if (pid) killProcessGroup(pid)
const entry = running.get(id)
if (entry) killProcessGroup(entry.pid)
else if (running.has(id)) killRequests.add(id)
running.delete(id)
startedAt.delete(id)
@ -177,7 +198,7 @@ function createGamesService({ getWindow, onGamesChanged }) {
return importGameFromPrefix(prefix, srcDir)
}
return { isRunning, listRunning, start, kill, runToolFor, import: importTo, importFromPrefix, setOnGameExit, openExternally }
return { isRunning, listRunning, runningInfo, start, kill, runToolFor, import: importTo, importFromPrefix, setOnGameExit, openExternally }
}
module.exports = { createGamesService, killProcessGroup, sendToWindow }

View file

@ -25,10 +25,13 @@ function errorResult(err) {
const iconDir = id => path.join(DIRS.DATA, 'icons', id)
function clearIconDir(id) {
// `keep` spares one file (the icon just written): the icon protocol picks the
// LAST icon.* alphabetically, so a leftover icon.png from an earlier pick
// would otherwise keep showing the old icon.
function clearIconDir(id, keep) {
try {
for (const f of fs.readdirSync(iconDir(id))) {
if (f.startsWith('icon.')) fs.rmSync(path.join(iconDir(id), f), { force: true })
if (f.startsWith('icon.') && f !== keep) fs.rmSync(path.join(iconDir(id), f), { force: true })
}
} catch {}
}
@ -36,7 +39,11 @@ function clearIconDir(id) {
function extractGameIcon(id, exePath) {
const out = path.join(iconDir(id), 'icon.ico')
const res = extractIconFromExe(exePath, out)
if (res.ok) library.updateGame(id, { iconSource: out })
// on failure nothing is cleared, so the previous icon survives a wrong exe
if (res.ok) {
clearIconDir(id, 'icon.ico')
library.updateGame(id, { iconSource: out })
}
return res
}
@ -76,21 +83,57 @@ function registerIpc({ getWindow, onGamesChanged }) {
})
ipcMain.handle('wines:refresh', async () => (await wines.refreshWines()).filter(w => !w.disabled))
ipcMain.handle('wines:setDisabled', (e, id, disabled) => wines.setWineDisabled(id, disabled))
ipcMain.handle('wines:setDisplayName', (e, id, name) => wines.setWineDisplayName(id, name))
ipcMain.handle('wines:setDefault', (e, id) => wines.setDefaultWine(id))
ipcMain.handle('wines:delete', (e, id) => wines.deleteWine(id))
ipcMain.handle('wines:delete', async (e, id) => {
// the build's files back the running prefix (dlls, stub exes) - deleting
// it under a running game breaks that game
if (library.isWineInUse(id, games.runningInfo())) {
const apps = games.runningInfo().filter(r => r.wine === id).map(r => r.name)
return { ok: false, error: 'inUse', apps }
}
return wines.deleteWine(id, data => sendToWindow(getWindow(), 'import:progress', data))
})
ipcMain.handle('wines:import', async (e, srcDir) => {
try {
return await wines.importWineFolder(srcDir)
return await wines.importWineFolder(srcDir, data => sendToWindow(getWindow(), 'import:progress', data))
} catch (err) {
return errorResult(err)
}
})
// prefixes ---------------------------------------------------------------------
// A prefix folder must not move or disappear while a game runs in it: wine
// keeps the registry and user files open and would write into a folder that
// no longer exists (or, for a delete, lose them mid-run). The renderer shows
// these as a toast naming the blocking app.
const prefixInUse = name => {
const apps = library.appsUsingPrefix(name, games.runningInfo())
return apps.length ? { ok: false, error: 'inUse', apps: apps.map(r => r.name) } : null
}
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('prefixes:delete', async (e, name) => {
const busy = prefixInUse(name)
if (busy) return busy
// deleting a prefix that still has apps registered in it would orphan
// them (they fall back to the default prefix and lose their files)
const users = library.gamesUsingPrefix(name).map(g => g.name)
if (users.length) return { ok: false, error: 'hasApps', apps: users }
return prefixes.deletePrefix(name, data => sendToWindow(getWindow(), 'import:progress', data))
})
ipcMain.handle('prefixes:rename', (e, oldName, newName) => prefixInUse(oldName) || prefixes.renamePrefix(oldName, newName))
ipcMain.handle('prefixes:setDefault', (e, name) => {
// apps without a prefix field of their own resolve the default at launch;
// switching it now would silently move their next run (a prefix import is
// pinned to its own name)
const defs = prefixes.listPrefixes()
if (name !== defs.default) {
const affected = library.gamesFollowingDefault(games.runningInfo())
if (affected.length) return { ok: false, error: 'inUse', apps: affected.map(r => r.name) }
}
return prefixes.setDefaultPrefix(name)
})
// games: library -----------------------------------------------------------------
ipcMain.handle('games:list', () => library.loadGames())
@ -98,9 +141,10 @@ function registerIpc({ getWindow, onGamesChanged }) {
// 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:reorder', (e, ids) => library.reorderGames(ids))
ipcMain.handle('games:remove', (e, id) => {
shortcuts.removeShortcut(id)
return library.removeGame(id)
return library.removeGame(id, data => sendToWindow(getWindow(), 'import:progress', data))
})
ipcMain.handle('games:createShortcut', (e, id) => {
const game = library.getGame(id)
@ -152,11 +196,14 @@ function registerIpc({ getWindow, onGamesChanged }) {
if (!game || !game.exePath) return { ok: false, error: 'bad-request' }
return extractGameIcon(id, game.exePath)
})
ipcMain.handle('games:autoIcon', (e, id) => {
// exePath comes from the config field, which may hold a path the user just
// picked but has not saved yet - the icon has to come from THAT exe
ipcMain.handle('games:autoIcon', (e, id, exePath) => {
const game = library.getGame(id)
if (!game || !game.exePath) return { ok: false, error: 'bad-request' }
clearIconDir(id)
return extractGameIcon(id, game.exePath)
if (!game) return { ok: false, error: 'bad-request' }
const exe = exePath || game.exePath
if (!exe) return { ok: false, error: 'bad-request' }
return extractGameIcon(id, exe)
})
// "Open with PLWL" ------------------------------------------------------------------
@ -253,7 +300,7 @@ function registerIpc({ getWindow, onGamesChanged }) {
})
// game icons as a protocol so the renderer can display them safely ---------------------------
protocol.handle('plwl-icon', request => {
protocol.handle('plwl-icon', async request => {
const id = decodeURIComponent(new URL(request.url).hostname)
let file = null
try {
@ -265,7 +312,16 @@ function registerIpc({ getWindow, onGamesChanged }) {
if (pick) file = path.join(iconDir(id), pick)
} catch {}
if (!file) return new Response('', { status: 404 })
return net.fetch('file://' + file)
// a new icon lands at the SAME url, so the browser would keep its cached
// copy and the library card would only catch up after a restart - never
// let the icon request be cached
const res = await net.fetch('file://' + file)
return new Response(await res.arrayBuffer(), {
headers: {
'content-type': res.headers.get('content-type') || 'image/x-icon',
'cache-control': 'no-store'
}
})
})
return { games }

View file

@ -17,6 +17,7 @@ const readline = require('readline')
const { DIRS, readJson, writeJson, toPortablePath } = require('./paths')
const { rewireSymlinks, restoreExecutableBits } = require('./symlinks')
const { progressGate } = require('./fsutil')
const library = require('./library')
const wines = require('./wines')
const prefixes = require('./prefixes')
@ -375,45 +376,6 @@ function ownedRoot(rel) {
return SYNC_ROOTS.some(prefix => clean.startsWith(prefix)) ? clean : null
}
// Progress is cheap to produce and expensive to render: hashing or copying a
// prefix can emit tens of thousands of events, and forwarding every single one
// over IPC makes the window stutter instead of showing progress. Events are
// rate limited to ~10/s per phase; `force` lets a caller push the value that
// matters (the end of a unit) through immediately.
const PROGRESS_MIN_MS = 100
function progressGate(emit) {
let lastAt = 0
let pending = null
let timer = null
const flush = () => {
timer = null
if (!pending) return
const data = pending
pending = null
lastAt = Date.now()
emit(data)
}
return (data, force) => {
const now = Date.now()
if (force || now - lastAt >= PROGRESS_MIN_MS) {
if (timer) {
clearTimeout(timer)
timer = null
}
pending = null
lastAt = now
emit(data)
return
}
pending = data
if (!timer) {
timer = setTimeout(flush, PROGRESS_MIN_MS - (now - lastAt))
if (timer.unref) timer.unref()
}
}
}
async function mapLimit(items, limit, fn) {
const out = new Array(items.length)
let next = 0

View file

@ -82,6 +82,53 @@ function getGame(id) {
return loadGames().find(g => g.id === id) || null
}
// Which prefix a game's runs land in: an app without one follows the launcher
// default. The single place for that fallback (runner.buildEnv and the in-use
// guards both use it, so a rename/delete can never blame the wrong folder).
function prefixNameForGame(game) {
if (game && game.prefix) return game.prefix
const defs = readJson(path.join(DIRS.DATA, 'prefixes.json'), { default: 'Main', prefixes: [] })
return defs.default || 'Main'
}
// Apps whose runs land in this prefix - used to keep a prefix that any app
// still references from being deleted out from under it.
function gamesUsingPrefix(name) {
if (!name) return []
return loadGames().filter(g => prefixNameForGame(g) === name)
}
// In-use guards for registry edits: a prefix folder being renamed or deleted
// (or a wine build deleted) while a game runs in it would leave that process
// without its registry/files - the folder move/removal has to wait until the
// game stops. `running` is the games service's list of live runs
// ([{ name, prefixes, wine }]); prefixes lists every folder a run holds (its
// snapshot plus a new one it was pointed at mid-run).
function appsUsingPrefix(name, running) {
if (!name) return []
return (running || []).filter(r => (r.prefixes || []).includes(name))
}
function isPrefixInUse(name, running) {
return appsUsingPrefix(name, running).length > 0
}
// A run with no explicit wine falls back to System Wine, which cannot be
// deleted anyway - only an exact match on a registered build counts.
function isWineInUse(id, running) {
if (!id || id === 'system') return false
return (running || []).some(r => r.wine === id)
}
// Switching the default prefix would silently move the next launch of every
// app that has no prefix of its own - so it waits while any of them runs.
function gamesFollowingDefault(running) {
return (running || []).filter(r => {
const game = getGame(r.id)
return !game || !game.prefix
})
}
function addGame(game) {
const games = loadGames()
games.push(game)
@ -113,15 +160,22 @@ function clearPlaytime(id) {
return updateGame(id, { playSeconds: 0, lastPlayed: 0 })
}
function removeGame(id) {
// A portable app's folder is removed along with its registry entry - measured
// first (staging) so the renderer can show a bar while the tree is walked,
// instead of freezing on one rmSync of a multi-GB game.
async function removeGame(id, onProgress) {
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.APPS)) {
try {
require('fs').rmSync(g.path, { recursive: true, force: true })
} catch {}
const emit = (phase, detail) => {
if (onProgress) onProgress({ phase, label: g.name || id, ...detail })
}
const { treeSize, removeTree } = require('./fsutil')
emit('count', { files: 0, bytes: 0 })
const total = await treeSize(g.path)
await removeTree(g.path, p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
}
// prefix imports live inside the prefix: deleting launcher files would
// nuke the user's prefix (installed DLLs, saves of every other app), so
@ -171,10 +225,43 @@ function newGame({ id, name, exePath, workdir, mode }) {
}
}
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')
// The registry array IS the library order, so a drag & drop reorder rewrites
// it whole. Ids the caller did not send keep their relative order at the end:
// a renderer that raced an import must never drop an entry.
function reorderGames(ids) {
const games = loadGames()
const rest = new Map(games.map(g => [g.id, g]))
const out = []
for (const id of Array.isArray(ids) ? ids : []) {
const g = rest.get(String(id))
if (!g) continue
rest.delete(String(id))
out.push(g)
}
for (const g of games) if (rest.has(g.id)) out.push(g)
saveGames(out)
return out
}
module.exports = { loadGames, saveGames, portableGame, getGame, addGame, updateGame, removeGame, loadSettings, saveSettings, newGame, newGameId, resolvePrefixDir, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile }
// Follows a prefix rename through the app registry. Raw JSON on purpose:
// loadGames()/saveGames() would resolve and re-relativize every path against
// a prefix folder that was just renamed. The stored paths of prefix imports
// are relative to the prefix root, so only the name reference changes.
function renamePrefixRefs(oldName, newName) {
const games = readJson(REGISTRY, [])
let updated = 0
for (const g of games) {
if (g.prefix === oldName) {
g.prefix = newName
updated++
}
}
if (updated) writeJson(REGISTRY, games)
return updated
}
function resolvePrefixDir(game) {
return prefixPath(prefixNameForGame(game))
}
module.exports = { loadGames, saveGames, portableGame, getGame, addGame, updateGame, removeGame, reorderGames, renamePrefixRefs, prefixNameForGame, gamesUsingPrefix, appsUsingPrefix, isPrefixInUse, isWineInUse, gamesFollowingDefault, loadSettings, saveSettings, newGame, newGameId, resolvePrefixDir, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile }

27
src/names.js Normal file
View file

@ -0,0 +1,27 @@
'use strict'
// Names the user types: trim, collapse whitespace, drop control characters.
// An empty result means "invalid" - callers reject it instead of writing it.
function cleanName(name) {
return String(name || '')
.replace(/[\u0000-\u001f\u007f]/g, '')
.trim()
.replace(/\s+/g, ' ')
}
// A name that becomes a folder inside the launcher (a prefix): letters and
// digits of any language (Polish names keep their diacritics) plus the
// punctuation that is safe in a path segment. "." and ".." are path segments,
// not names.
function normalizePathName(name) {
const out = cleanName(name).replace(/[^\p{L}\p{N} ._-]/gu, '')
return /^\.+$/.test(out) ? '' : out
}
// A label only (a wine build's displayed name) - never used as a path, so it
// may keep whatever the user typed.
function normalizeLabel(name) {
return cleanName(name)
}
module.exports = { normalizePathName, normalizeLabel }

View file

@ -58,7 +58,9 @@ const DIRS = {
// the bundle does not ship (user-added languages) are left untouched.
function filesDiffer(a, b) {
try {
return !fs.readFileSync(a).equals(fs.readFileSync(b))
// compare as text on purpose: Buffer.equals() throws on a string, and that
// throw was read as "differs", so every boot recopied every translation
return fs.readFileSync(a, 'utf8') !== fs.readFileSync(b, 'utf8')
} catch {
return true // target missing/unreadable -> needs a (re)copy
}

View file

@ -4,6 +4,8 @@ const path = require('path')
const fs = require('fs')
const { DIRS, readJson, writeJson } = require('./paths')
const { rewireSymlinks } = require('./symlinks')
const { removeTree, treeSize } = require('./fsutil')
const { normalizePathName } = require('./names')
const REGISTRY = path.join(DIRS.DATA, 'prefixes.json')
const MAIN = 'Main'
@ -26,10 +28,6 @@ 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)
}
@ -62,7 +60,7 @@ function prefixDriveRoot(name) {
}
function createPrefix(name) {
const clean = normalizeName(name)
const clean = normalizePathName(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' }
@ -74,15 +72,21 @@ function createPrefix(name) {
return { ok: true, name: clean }
}
function deletePrefix(name) {
// A prefix is a whole windows drive - removal is measured before it starts
// (staging) so the UI can show a bar that actually moves while the tree is
// walked file by file (removeTree), instead of one blocking rmSync.
async function deletePrefix(name, onProgress) {
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 {}
const emit = (phase, detail) => {
if (onProgress) onProgress({ phase, label: name, ...detail })
}
emit('count', { files: 0, bytes: 0 })
const total = await treeSize(prefixPath(name))
await removeTree(prefixPath(name), p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
return { ok: true }
}
@ -95,6 +99,35 @@ async function healPrefix(name) {
return rewireSymlinks(prefixPath(name), [DIRS.FILES])
}
// Renaming a prefix moves its folder on disk and follows the name into the
// registry (default included) and every app that runs in it. Prefix imports
// keep their stored paths (they are relative to the prefix root), so only the
// reference changes - the files move with the folder.
function renamePrefix(oldName, newName) {
const clean = normalizePathName(newName)
if (!clean) return { ok: false, error: 'invalidName' }
const defs = listPrefixes()
const entry = defs.prefixes.find(p => p.name === oldName)
if (!entry) return { ok: false, error: 'notFound' }
if (entry.locked || String(oldName).toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'isMain' }
if (clean === oldName) return { ok: true, name: clean, games: 0 }
if (defs.prefixes.some(p => p.name === clean) || fs.existsSync(prefixPath(clean))) return { ok: false, error: 'alreadyExists' }
// a registered prefix whose folder wine has not created yet can still be
// renamed - only the registry entry exists
if (fs.existsSync(prefixPath(oldName))) {
try {
fs.renameSync(prefixPath(oldName), prefixPath(clean))
} catch (err) {
return { ok: false, error: String((err && err.message) || err) }
}
}
entry.name = clean
if (defs.default === oldName) defs.default = clean
savePrefixes(defs)
const games = require('./library').renamePrefixRefs(oldName, clean)
return { ok: true, name: clean, games }
}
function setDefaultPrefix(name) {
const defs = listPrefixes()
if (name && !defs.prefixes.some(p => p.name === name)) return { ok: false, error: 'notFound' }
@ -103,4 +136,4 @@ function setDefaultPrefix(name) {
return { ok: true }
}
module.exports = { listPrefixes, savePrefixes, createPrefix, deletePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoot, healPrefix, normalizeName, MAIN }
module.exports = { listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoot, healPrefix, MAIN }

View file

@ -42,12 +42,14 @@ contextBridge.exposeInMainWorld('plwl', {
winesList: () => ipcRenderer.invoke('wines:list'),
winesRefresh: () => ipcRenderer.invoke('wines:refresh'),
winesSetDisabled: (id, disabled) => ipcRenderer.invoke('wines:setDisabled', id, disabled),
winesSetDisplayName: (id, name) => ipcRenderer.invoke('wines:setDisplayName', id, name),
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),
prefixesRename: (oldName, newName) => ipcRenderer.invoke('prefixes:rename', oldName, newName),
prefixesSetDefault: name => ipcRenderer.invoke('prefixes:setDefault', name),
openwithTake: () => ipcRenderer.invoke('openwith:take'),
openwithRun: payload => ipcRenderer.invoke('openwith:run', payload),
@ -66,10 +68,11 @@ contextBridge.exposeInMainWorld('plwl', {
gamesList: () => ipcRenderer.invoke('games:list'),
gamesGet: id => ipcRenderer.invoke('games:get', id),
gamesUpdate: (id, patch) => ipcRenderer.invoke('games:update', id, patch),
gamesReorder: ids => ipcRenderer.invoke('games:reorder', ids),
gamesClearPlaytime: id => ipcRenderer.invoke('games:clearPlaytime', id),
gamesSetIconFile: (id, p) => ipcRenderer.invoke('games:setIconFile', id, p),
gamesExtractIcon: id => ipcRenderer.invoke('games:extractIcon', id),
gamesAutoIcon: id => ipcRenderer.invoke('games:autoIcon', id),
gamesAutoIcon: (id, exePath) => ipcRenderer.invoke('games:autoIcon', id, exePath),
gamesIconPath: id => ipcRenderer.invoke('games:iconPath', id),
gamesResetIcon: id => ipcRenderer.invoke('games:resetIcon', id),
gamesRemove: id => ipcRenderer.invoke('games:remove', id),

View file

@ -5,6 +5,7 @@ const fs = require('fs')
const { spawn } = require('child_process')
const { DIRS } = require('./paths')
const { prefixPath } = require('./prefixes')
const { prefixNameForGame } = require('./library')
// The fake Steam install Proton is pointed at through
// STEAM_COMPAT_CLIENT_INSTALL_PATH. Both folders Proton links into its prefix
@ -18,13 +19,6 @@ function ensureFakeSteam() {
return fake
}
// The prefix a run will use, by NAME (buildEnv resolves the same fallback).
function prefixNameFor(game) {
if (game && game.prefix) return game.prefix
const defs = require('./prefixes').listPrefixes()
return defs.default || 'default'
}
// Wine only ever repairs links that are BROKEN: a prefix that came from
// another machine (or from an older PLWL) can still hold absolute links into
// its build that happen to resolve here, and those would be synced out again.
@ -32,7 +26,7 @@ function prefixNameFor(game) {
// costs a single walk in the common (already relative) case.
const healedPrefixes = new Set()
async function healPrefixOnce(game) {
const name = prefixNameFor(game)
const name = prefixNameForGame(game)
const key = `${name}\u0000${DIRS.BUILD}`
if (!name || healedPrefixes.has(key)) return 0
healedPrefixes.add(key)
@ -78,11 +72,9 @@ function linkPrefix(compatData, prefixDir) {
function buildEnv({ game, wine }) {
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')
}
// same fallback the games service snapshots into its running entries
// (library.prefixNameForGame), so in-use checks always name this folder
const prefixDir = prefixPath(prefixNameForGame(game))
if (isProton) {
const fakeSteam = ensureFakeSteam()

View file

@ -4,7 +4,8 @@ const fs = require('fs')
const fsp = fs.promises
const path = require('path')
const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths')
const { dedupeDir } = require('./fsutil')
const { dedupeDir, copyTree, treeSize, removeTree } = require('./fsutil')
const { normalizeLabel } = require('./names')
const { rewireSymlinks, restoreExecutableBits, EXEC_NAMES } = require('./symlinks')
const WINES_DIR = DIRS.WINES
@ -125,7 +126,14 @@ async function refreshWines() {
}
}
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
// a display name the user gave the build has to survive every rescan, just
// like the disabled flag (both live in the registry, not in the folder)
const renamed = new Map(prev.filter(w => w.displayName).map(w => [w.id, w.displayName]))
for (const w of found) {
if (disabled.has(w.id)) w.disabled = true
if (renamed.has(w.id)) w.displayName = renamed.get(w.id)
applyDisplayName(w)
}
writeJson(REGISTRY, found.map(wineRel))
return found
}
@ -133,7 +141,7 @@ async function refreshWines() {
// Stored relative to FILES (portable), resolved to absolute for use - same
// scheme as the game registry. `id`, `name`, `label`, `version`, `type` pass through.
function wineAbs(w) {
return { ...w, path: resolvePortablePath(w.path), wineBin: resolvePortablePath(w.wineBin) }
return applyDisplayName({ ...w, path: resolvePortablePath(w.path), wineBin: resolvePortablePath(w.wineBin) })
}
function wineRel(w) {
@ -148,6 +156,29 @@ function getWine(id) {
return getWines().find(w => w.id === id) || null
}
// What the user typed wins over the folder-derived label everywhere the
// build is shown (library config, settings, Open with popup). Every registry
// read goes through here, so a stale stored label can never leak out.
function applyDisplayName(w) {
if (w && w.displayName) w.label = w.displayName
return w
}
// A build keeps its folder and id (apps point at the id, and prefix symlinks
// point inside the folder), so "renaming" only changes the shown label.
function setWineDisplayName(id, name) {
const wines = getWines()
const w = wines.find(x => x.id === id)
if (!w) return { ok: false, error: 'notFound' }
if (w.type === 'system') return { ok: false, error: 'isSystem' }
const clean = normalizeLabel(name)
if (!clean) return { ok: false, error: 'invalidName' }
w.displayName = clean
applyDisplayName(w)
writeJson(REGISTRY, wines.map(wineRel))
return { ok: true, displayName: clean }
}
function setWineDisabled(id, disabled) {
const wines = getWines()
const w = wines.find(x => x.id === id)
@ -179,13 +210,18 @@ function setDefaultWine(id) {
return { ok: true, defaultWine: id }
}
function deleteWine(id) {
// Same measured removal as a prefix (staging, then a bar driven by the files
// walking out): a build folder is multi-GB.
async function deleteWine(id, onProgress) {
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 {}
const emit = (phase, detail) => {
if (onProgress) onProgress({ phase, label: w.label || w.name || id, ...detail })
}
emit('count', { files: 0, bytes: 0 })
const total = await treeSize(w.path)
await removeTree(w.path, p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
writeJson(REGISTRY, wines.filter(x => x.id !== id).map(wineRel))
if (getDefaultWine() === id) require('./library').saveSettings({ defaultWine: 'system' })
return { ok: true }
@ -250,7 +286,7 @@ function buildAuditFailed(root, wineBin) {
return false
}
async function importWineFolder(srcDir) {
async function importWineFolder(srcDir, onProgress) {
const type = detectType(srcDir)
let copySrc = srcDir
if (!type) {
@ -263,7 +299,14 @@ async function importWineFolder(srcDir) {
if (build) copySrc = path.join(srcDir, build.name)
}
const dst = dedupeDir(WINES_DIR, path.basename(copySrc))
await fsp.cp(copySrc, dst, { recursive: true, verbatimSymlinks: true })
// a wine/proton build is big enough that the copy needs a real bar: count it
// first (staging), then report bytes as they land while it copies
const emit = (phase, detail) => {
if (onProgress) onProgress({ phase, label: path.basename(dst), ...detail })
}
emit('count', { files: 0, bytes: 0 })
const total = await treeSize(copySrc)
await copyTree(copySrc, dst, p => emit('copy', { files: p.done, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
let healed = { links: 0, executables: 0 }
try {
healed = await healBuild(dst)
@ -273,4 +316,4 @@ async function importWineFolder(srcDir) {
return { ok: true, path: toPortablePath(dst), symlinksFixed: healed.links, executablesFixed: healed.executables }
}
module.exports = { refreshWines, getWines, getWine, resolveWine, setWineDisabled, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }
module.exports = { refreshWines, getWines, getWine, resolveWine, setWineDisabled, setWineDisplayName, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }