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 cfab4e6..ee0c558 100644 Binary files a/renderer/plwl.png and b/renderer/plwl.png differ 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 }