From aca9f839e30de21f505e5e30a7d907ce73d1daaa Mon Sep 17 00:00:00 2001 From: olcxja Date: Fri, 2 Oct 2026 14:05:49 +0200 Subject: [PATCH] added library reordering & prefix rename --- README.md | 4 +- development.md | 2 + files/translations/en-us.json | 16 ++- files/translations/pl.json | 16 ++- renderer/app.js | 194 +++++++++++++++++++++++++++++++++- renderer/plwl.png | Bin 9536 -> 9458 bytes renderer/style.css | 17 +++ src/ipc/index.js | 3 + src/library.js | 37 ++++++- src/names.js | 27 +++++ src/prefixes.js | 38 +++++-- src/preload.js | 3 + src/wines.js | 37 ++++++- 13 files changed, 374 insertions(+), 20 deletions(-) create mode 100644 src/names.js diff --git a/README.md b/README.md index ccdca10..fe8a008 100644 --- a/README.md +++ b/README.md @@ -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. 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 `/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 diff --git a/development.md b/development.md index 52cc7ac..a5677f0 100644 --- a/development.md +++ b/development.md @@ -20,6 +20,8 @@ src/ main process (Node/Electron backend) symlinks.js relative symlink rewiring + executable-bit restoration (shared by imports, prefixes and 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 diff --git a/files/translations/en-us.json b/files/translations/en-us.json index 4f17846..836c7b9 100644 --- a/files/translations/en-us.json +++ b/files/translations/en-us.json @@ -35,6 +35,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)", @@ -113,7 +114,12 @@ "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}", "wines.title": "Wine versions", "wines.subtitle": "You can import your wine/proton builds here. Proton runs fully without Steam.", "wines.system": "System Wine", @@ -132,7 +138,13 @@ "wines.delete": "Delete", "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.invalidName": "Invalid name", "wines.import": "Import", "wines.importPick": "Choose the Wine/Proton build folder", "wines.importing": "Importing Wine build…", diff --git a/files/translations/pl.json b/files/translations/pl.json index ba62d36..9ed8d54 100644 --- a/files/translations/pl.json +++ b/files/translations/pl.json @@ -35,6 +35,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)", @@ -113,7 +114,12 @@ "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}", "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", @@ -132,7 +138,13 @@ "wines.delete": "Usuń", "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.invalidName": "Nieprawidłowa nazwa", "wines.import": "Importuj", "wines.importPick": "Wybierz folder z buildem Wine/Proton", "wines.importing": "Importowanie builda Wine…", diff --git a/renderer/app.js b/renderer/app.js index 0fe34c4..7a1c30e 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -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) { `) - 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(` +

${title}

+ ${hint ? `

${hint}

` : ''} + + + + `) + 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,80 @@ 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 + 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 @@ -513,6 +634,7 @@ function renderLibrary() { renderLibrary() } list.appendChild(card) + wireCardDrag(card, game) attachCardIcon(card, game) }) } @@ -820,11 +942,38 @@ function renderPrefixes() { ${p.name === defs.default ? `${t('prefixes.default')}` : ''}
+ ${locked ? '' : ''}
` row.querySelector('.title').textContent = p.name + const renBtn = row.querySelector('[data-act=rename]') + if (renBtn) { + renBtn.textContent = t('prefixes.rename') + renBtn.onclick = async () => { + const name = await promptModal({ + title: t('prefixes.renameTitle'), + label: t('prefixes.namePlaceholder'), + hint: 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) || '' + 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 () => { @@ -874,12 +1023,36 @@ function renderWines() { ${w.version ? `${w.version}` : ''}
+ ${w.id !== 'system' ? `` : ''} ${w.id !== 'system' ? `` : ''}
` 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 () => { @@ -1616,6 +1789,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() diff --git a/renderer/plwl.png b/renderer/plwl.png index cfab4e6a14d524a387219b57ed55b480d3ffc7b1..ee0c558dbb65ef4e1acac8fbd50ec8f3a2b4171e 100644 GIT binary patch literal 9458 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 IF$etr1LCoDGynhq diff --git a/renderer/style.css b/renderer/style.css index 5da250a..23911a9 100644 --- a/renderer/style.css +++ b/renderer/style.css @@ -490,6 +490,8 @@ select { margin: 1rem 0; } .game-card { + position: relative; + cursor: grab; flex: 1 1 17rem; min-width: 17rem; max-width: 24rem; @@ -526,6 +528,21 @@ select { mask: url("data:image/svg+xml;utf8,") 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; diff --git a/src/ipc/index.js b/src/ipc/index.js index aa13757..d7cbb78 100644 --- a/src/ipc/index.js +++ b/src/ipc/index.js @@ -83,6 +83,7 @@ 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:import', async (e, srcDir) => { @@ -97,6 +98,7 @@ function registerIpc({ getWindow, onGamesChanged }) { 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:rename', (e, oldName, newName) => prefixes.renamePrefix(oldName, newName)) ipcMain.handle('prefixes:setDefault', (e, name) => prefixes.setDefaultPrefix(name)) // games: library ----------------------------------------------------------------- @@ -105,6 +107,7 @@ 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) diff --git a/src/library.js b/src/library.js index 422593b..0673e62 100644 --- a/src/library.js +++ b/src/library.js @@ -171,10 +171,45 @@ function newGame({ id, name, exePath, workdir, mode }) { } } +// 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 +} + +// 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) { if (game.prefix) return prefixPath(game.prefix) const defs = readJson(path.join(DIRS.DATA, 'prefixes.json'), { default: 'default', prefixes: [] }) return prefixPath(defs.default || 'default') } -module.exports = { loadGames, saveGames, portableGame, getGame, addGame, updateGame, removeGame, loadSettings, saveSettings, newGame, newGameId, resolvePrefixDir, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile } +module.exports = { loadGames, saveGames, portableGame, getGame, addGame, updateGame, removeGame, reorderGames, renamePrefixRefs, loadSettings, saveSettings, newGame, newGameId, resolvePrefixDir, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile } diff --git a/src/names.js b/src/names.js new file mode 100644 index 0000000..d4b2467 --- /dev/null +++ b/src/names.js @@ -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 } diff --git a/src/prefixes.js b/src/prefixes.js index ddbfdd9..84f51f5 100644 --- a/src/prefixes.js +++ b/src/prefixes.js @@ -4,6 +4,7 @@ const path = require('path') const fs = require('fs') const { DIRS, readJson, writeJson } = require('./paths') const { rewireSymlinks } = require('./symlinks') +const { normalizePathName } = require('./names') const REGISTRY = path.join(DIRS.DATA, 'prefixes.json') const MAIN = 'Main' @@ -26,10 +27,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 +59,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' } @@ -95,6 +92,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 +129,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 } diff --git a/src/preload.js b/src/preload.js index b10241c..ba8a43c 100644 --- a/src/preload.js +++ b/src/preload.js @@ -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,6 +68,7 @@ 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), diff --git a/src/wines.js b/src/wines.js index 2905856..683dd2c 100644 --- a/src/wines.js +++ b/src/wines.js @@ -5,6 +5,7 @@ const fsp = fs.promises const path = require('path') const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths') const { dedupeDir } = 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) @@ -273,4 +304,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 }