added library reordering & prefix rename

This commit is contained in:
olcxja 2026-10-02 14:05:49 +02:00
commit aca9f839e3
13 changed files with 374 additions and 20 deletions

View file

@ -41,7 +41,7 @@ cd PLWL
## Importing from a wine prefix ## 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 ## "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 ## Wine & Proton support
- **System Wine** — uses the `wine` binary from PATH. - **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`). - **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 ## Translations

View file

@ -20,6 +20,8 @@ src/ main process (Node/Electron backend)
symlinks.js relative symlink rewiring + executable-bit restoration symlinks.js relative symlink rewiring + executable-bit restoration
(shared by imports, prefixes and LAN sync) (shared by imports, prefixes and LAN sync)
importer.js app import (copy/move/leave/prefix) 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 openwith.js "Open with PLWL": ad-hoc runs, no library entry
shortcuts.js .desktop shortcut generation shortcuts.js .desktop shortcut generation
paths.js portable layout, FILES-relative path helpers, translation sync paths.js portable layout, FILES-relative path helpers, translation sync

View file

@ -35,6 +35,7 @@
"library.removePrefixNote": "\nFiles stay in the prefix, only the launcher entry is removed.", "library.removePrefixNote": "\nFiles stay in the prefix, only the launcher entry is removed.",
"library.tools": "Wine tools", "library.tools": "Wine tools",
"library.viewLogs": "View logs", "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.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.copy": "Copy into launcher (portable)",
"import.mode.move": "Move into launcher (portable)", "import.mode.move": "Move into launcher (portable)",
@ -113,7 +114,12 @@
"prefixes.defaultSet": "{0} is now the default prefix", "prefixes.defaultSet": "{0} is now the default prefix",
"prefixes.exists": "A prefix with this name already exists", "prefixes.exists": "A prefix with this name already exists",
"prefixes.invalidName": "Invalid prefix name", "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.title": "Wine versions",
"wines.subtitle": "You can import your wine/proton builds here. Proton runs fully without Steam.", "wines.subtitle": "You can import your wine/proton builds here. Proton runs fully without Steam.",
"wines.system": "System Wine", "wines.system": "System Wine",
@ -132,7 +138,13 @@
"wines.delete": "Delete", "wines.delete": "Delete",
"wines.deleteConfirm": "Delete {0} from files/wines?\nThe build folder will be permanently removed.", "wines.deleteConfirm": "Delete {0} from files/wines?\nThe build folder will be permanently removed.",
"wines.deleted": "{0} deleted", "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.import": "Import",
"wines.importPick": "Choose the Wine/Proton build folder", "wines.importPick": "Choose the Wine/Proton build folder",
"wines.importing": "Importing Wine build…", "wines.importing": "Importing Wine build…",

View file

@ -35,6 +35,7 @@
"library.removePrefixNote": "\nPliki zostają w prefiksie, usuwany jest tylko wpis w launcherze.", "library.removePrefixNote": "\nPliki zostają w prefiksie, usuwany jest tylko wpis w launcherze.",
"library.tools": "Narzędzia Wine", "library.tools": "Narzędzia Wine",
"library.viewLogs": "Podejrzyj logi", "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.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.copy": "Skopiuj do launchera (przenośne)",
"import.mode.move": "Przenieś 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.defaultSet": "{0} jest teraz prefiksem domyślnym",
"prefixes.exists": "Prefiks o tej nazwie już istnieje", "prefixes.exists": "Prefiks o tej nazwie już istnieje",
"prefixes.invalidName": "Nieprawidłowa nazwa prefiksu", "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.title": "Wersje Wine",
"wines.subtitle": "Tutaj możesz zaimportować swoje buildy wine/proton. Proton działa w pełni bez Steama.", "wines.subtitle": "Tutaj możesz zaimportować swoje buildy wine/proton. Proton działa w pełni bez Steama.",
"wines.system": "Wine systemowe", "wines.system": "Wine systemowe",
@ -132,7 +138,13 @@
"wines.delete": "Usuń", "wines.delete": "Usuń",
"wines.deleteConfirm": "Usunąć {0} z files/wines?\nFolder z buildem zostanie trwale usunięty.", "wines.deleteConfirm": "Usunąć {0} z files/wines?\nFolder z buildem zostanie trwale usunięty.",
"wines.deleted": "Usunięto {0}", "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.import": "Importuj",
"wines.importPick": "Wybierz folder z buildem Wine/Proton", "wines.importPick": "Wybierz folder z buildem Wine/Proton",
"wines.importing": "Importowanie builda Wine…", "wines.importing": "Importowanie builda Wine…",

View file

@ -123,8 +123,20 @@ let modalSwapTimer = null
// operation) close it. // operation) close it.
let modalLocked = false 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) { function showModal(html, opts) {
modalLocked = !!(opts && opts.locked) modalLocked = !!(opts && opts.locked)
settleModal(null)
if (modalTeardownTimer) { if (modalTeardownTimer) {
clearTimeout(modalTeardownTimer) clearTimeout(modalTeardownTimer)
modalTeardownTimer = null modalTeardownTimer = null
@ -185,6 +197,7 @@ function closeModal() {
const overlay = qs('#modal-overlay') const overlay = qs('#modal-overlay')
const modal = qs('#modal') const modal = qs('#modal')
if (overlay.style.display === 'none') return if (overlay.style.display === 'none') return
settleModal(null)
modal.classList.add('closing') modal.classList.add('closing')
overlay.classList.remove('show') overlay.classList.remove('show')
if (modalTeardownTimer) clearTimeout(modalTeardownTimer) if (modalTeardownTimer) clearTimeout(modalTeardownTimer)
@ -212,14 +225,48 @@ function confirmModal(message, okLabel) {
<button class="button primary" id="modal-ok">${okLabel || t('dialog.ok')}</button> <button class="button primary" id="modal-ok">${okLabel || t('dialog.ok')}</button>
</div> </div>
`) `)
modal.querySelector('#modal-cancel').onclick = () => { const finish = ok => {
settleModal(ok)
closeModal() 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() 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) 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) { async function attachCardIcon(card, game) {
// placeholder first so every card has a stable icon slot; swapped for the // 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 // real icon once it loads - games without one just keep the placeholder
@ -513,6 +634,7 @@ function renderLibrary() {
renderLibrary() renderLibrary()
} }
list.appendChild(card) list.appendChild(card)
wireCardDrag(card, game)
attachCardIcon(card, game) attachCardIcon(card, game)
}) })
} }
@ -820,11 +942,38 @@ function renderPrefixes() {
</div> </div>
${p.name === defs.default ? `<span class="badge primary">${t('prefixes.default')}</span>` : ''} ${p.name === defs.default ? `<span class="badge primary">${t('prefixes.default')}</span>` : ''}
<div class="actions"> <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" data-act="default" ${p.name === defs.default ? 'style="display:none"' : ''}></button>
<button class="button small danger" data-act="delete" ${locked ? 'style="display:none"' : ''}></button> <button class="button small danger" data-act="delete" ${locked ? 'style="display:none"' : ''}></button>
</div> </div>
` `
row.querySelector('.title').textContent = p.name 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]') const defBtn = row.querySelector('[data-act=default]')
defBtn.textContent = t('prefixes.makeDefault') defBtn.textContent = t('prefixes.makeDefault')
defBtn.onclick = async () => { defBtn.onclick = async () => {
@ -874,12 +1023,36 @@ function renderWines() {
${w.version ? `<span class="badge">${w.version}</span>` : ''} ${w.version ? `<span class="badge">${w.version}</span>` : ''}
<div class="actions"> <div class="actions">
<button class="button small" data-act="default" ${isDefault ? 'style="display:none"' : ''}></button> <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>` : ''} ${w.id !== 'system' ? `<button class="button small danger" data-act="delete"></button>` : ''}
</div> </div>
` `
row.querySelector('.title').textContent = w.label row.querySelector('.title').textContent = w.label
row.querySelector('.sub').textContent = displayPath(w.path) || t('wines.systemHint') row.querySelector('.sub').textContent = displayPath(w.path) || t('wines.systemHint')
row.querySelector('.sub').title = row.querySelector('.sub').textContent 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]') const defBtn = row.querySelector('[data-act=default]')
defBtn.textContent = t('wines.makeDefault') defBtn.textContent = t('wines.makeDefault')
defBtn.onclick = async () => { defBtn.onclick = async () => {
@ -1616,6 +1789,19 @@ function wireEvents() {
}) })
qs('#btn-import-top').addEventListener('click', startImportFlow) qs('#btn-import-top').addEventListener('click', startImportFlow)
qs('#btn-import-empty').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 () => { qs('#btn-prefix-create').addEventListener('click', async () => {
const input = qs('#prefix-name-input') const input = qs('#prefix-name-input')
const name = input.value.trim() const name = input.value.trim()

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,6 +490,8 @@ select {
margin: 1rem 0; margin: 1rem 0;
} }
.game-card { .game-card {
position: relative;
cursor: grab;
flex: 1 1 17rem; flex: 1 1 17rem;
min-width: 17rem; min-width: 17rem;
max-width: 24rem; max-width: 24rem;
@ -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; mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'><rect x='3' y='3' width='18' height='18' rx='2'/><circle cx='8.5' cy='8.5' r='1.5'/><path d='M21 15l-5-5L5 21'/></svg>") center / contain no-repeat;
} }
.game-card:hover { border-color: rgba(255, 255, 255, 0.2); } .game-card: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 { .game-card h3 {
font-size: 1.05rem; font-size: 1.05rem;
font-family: 'Nunito Black', 'Nunito', sans-serif; font-family: 'Nunito Black', 'Nunito', sans-serif;

View file

@ -83,6 +83,7 @@ function registerIpc({ getWindow, onGamesChanged }) {
}) })
ipcMain.handle('wines:refresh', async () => (await wines.refreshWines()).filter(w => !w.disabled)) 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: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:setDefault', (e, id) => wines.setDefaultWine(id))
ipcMain.handle('wines:delete', (e, id) => wines.deleteWine(id)) ipcMain.handle('wines:delete', (e, id) => wines.deleteWine(id))
ipcMain.handle('wines:import', async (e, srcDir) => { ipcMain.handle('wines:import', async (e, srcDir) => {
@ -97,6 +98,7 @@ function registerIpc({ getWindow, onGamesChanged }) {
ipcMain.handle('prefixes:list', () => prefixes.listPrefixes()) ipcMain.handle('prefixes:list', () => prefixes.listPrefixes())
ipcMain.handle('prefixes:create', (e, name) => prefixes.createPrefix(name)) ipcMain.handle('prefixes:create', (e, name) => prefixes.createPrefix(name))
ipcMain.handle('prefixes:delete', (e, name) => prefixes.deletePrefix(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)) ipcMain.handle('prefixes:setDefault', (e, name) => prefixes.setDefaultPrefix(name))
// games: library ----------------------------------------------------------------- // games: library -----------------------------------------------------------------
@ -105,6 +107,7 @@ function registerIpc({ getWindow, onGamesChanged }) {
// prefix pin is enforced inside library.updateGame (prefix/importSource // prefix pin is enforced inside library.updateGame (prefix/importSource
// fields are ignored for prefix imports), so nothing extra needed here // 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:update', (e, id, patch) => library.updateGame(id, patch))
ipcMain.handle('games:reorder', (e, ids) => library.reorderGames(ids))
ipcMain.handle('games:remove', (e, id) => { ipcMain.handle('games:remove', (e, id) => {
shortcuts.removeShortcut(id) shortcuts.removeShortcut(id)
return library.removeGame(id) return library.removeGame(id)

View file

@ -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) { function resolvePrefixDir(game) {
if (game.prefix) return prefixPath(game.prefix) if (game.prefix) return prefixPath(game.prefix)
const defs = readJson(path.join(DIRS.DATA, 'prefixes.json'), { default: 'default', prefixes: [] }) const defs = readJson(path.join(DIRS.DATA, 'prefixes.json'), { default: 'default', prefixes: [] })
return prefixPath(defs.default || 'default') 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 }

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

@ -4,6 +4,7 @@ const path = require('path')
const fs = require('fs') const fs = require('fs')
const { DIRS, readJson, writeJson } = require('./paths') const { DIRS, readJson, writeJson } = require('./paths')
const { rewireSymlinks } = require('./symlinks') const { rewireSymlinks } = require('./symlinks')
const { normalizePathName } = require('./names')
const REGISTRY = path.join(DIRS.DATA, 'prefixes.json') const REGISTRY = path.join(DIRS.DATA, 'prefixes.json')
const MAIN = 'Main' const MAIN = 'Main'
@ -26,10 +27,6 @@ function savePrefixes(defs) {
writeJson(REGISTRY, defs) writeJson(REGISTRY, defs)
} }
function normalizeName(name) {
return String(name || '').trim().replace(/[^\w .-]/g, '').replace(/\s+/g, ' ')
}
function prefixPath(name) { function prefixPath(name) {
return path.join(DIRS.PREFIXES, name) return path.join(DIRS.PREFIXES, name)
} }
@ -62,7 +59,7 @@ function prefixDriveRoot(name) {
} }
function createPrefix(name) { function createPrefix(name) {
const clean = normalizeName(name) const clean = normalizePathName(name)
if (!clean) return { ok: false, error: 'invalidName' } if (!clean) return { ok: false, error: 'invalidName' }
if (clean.toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'alreadyExists' } if (clean.toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'alreadyExists' }
if (fs.existsSync(prefixPath(clean))) 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]) 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) { function setDefaultPrefix(name) {
const defs = listPrefixes() const defs = listPrefixes()
if (name && !defs.prefixes.some(p => p.name === name)) return { ok: false, error: 'notFound' } if (name && !defs.prefixes.some(p => p.name === name)) return { ok: false, error: 'notFound' }
@ -103,4 +129,4 @@ function setDefaultPrefix(name) {
return { ok: true } 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'), winesList: () => ipcRenderer.invoke('wines:list'),
winesRefresh: () => ipcRenderer.invoke('wines:refresh'), winesRefresh: () => ipcRenderer.invoke('wines:refresh'),
winesSetDisabled: (id, disabled) => ipcRenderer.invoke('wines:setDisabled', id, disabled), 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), winesSetDefault: id => ipcRenderer.invoke('wines:setDefault', id),
winesDelete: id => ipcRenderer.invoke('wines:delete', id), winesDelete: id => ipcRenderer.invoke('wines:delete', id),
winesImport: srcDir => ipcRenderer.invoke('wines:import', srcDir), winesImport: srcDir => ipcRenderer.invoke('wines:import', srcDir),
prefixesList: () => ipcRenderer.invoke('prefixes:list'), prefixesList: () => ipcRenderer.invoke('prefixes:list'),
prefixesCreate: name => ipcRenderer.invoke('prefixes:create', name), prefixesCreate: name => ipcRenderer.invoke('prefixes:create', name),
prefixesDelete: name => ipcRenderer.invoke('prefixes:delete', name), prefixesDelete: name => ipcRenderer.invoke('prefixes:delete', name),
prefixesRename: (oldName, newName) => ipcRenderer.invoke('prefixes:rename', oldName, newName),
prefixesSetDefault: name => ipcRenderer.invoke('prefixes:setDefault', name), prefixesSetDefault: name => ipcRenderer.invoke('prefixes:setDefault', name),
openwithTake: () => ipcRenderer.invoke('openwith:take'), openwithTake: () => ipcRenderer.invoke('openwith:take'),
openwithRun: payload => ipcRenderer.invoke('openwith:run', payload), openwithRun: payload => ipcRenderer.invoke('openwith:run', payload),
@ -66,6 +68,7 @@ contextBridge.exposeInMainWorld('plwl', {
gamesList: () => ipcRenderer.invoke('games:list'), gamesList: () => ipcRenderer.invoke('games:list'),
gamesGet: id => ipcRenderer.invoke('games:get', id), gamesGet: id => ipcRenderer.invoke('games:get', id),
gamesUpdate: (id, patch) => ipcRenderer.invoke('games:update', id, patch), gamesUpdate: (id, patch) => ipcRenderer.invoke('games:update', id, patch),
gamesReorder: ids => ipcRenderer.invoke('games:reorder', ids),
gamesClearPlaytime: id => ipcRenderer.invoke('games:clearPlaytime', id), gamesClearPlaytime: id => ipcRenderer.invoke('games:clearPlaytime', id),
gamesSetIconFile: (id, p) => ipcRenderer.invoke('games:setIconFile', id, p), gamesSetIconFile: (id, p) => ipcRenderer.invoke('games:setIconFile', id, p),
gamesExtractIcon: id => ipcRenderer.invoke('games:extractIcon', id), gamesExtractIcon: id => ipcRenderer.invoke('games:extractIcon', id),

View file

@ -5,6 +5,7 @@ const fsp = fs.promises
const path = require('path') const path = require('path')
const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths') const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths')
const { dedupeDir } = require('./fsutil') const { dedupeDir } = require('./fsutil')
const { normalizeLabel } = require('./names')
const { rewireSymlinks, restoreExecutableBits, EXEC_NAMES } = require('./symlinks') const { rewireSymlinks, restoreExecutableBits, EXEC_NAMES } = require('./symlinks')
const WINES_DIR = DIRS.WINES 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)) 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)) writeJson(REGISTRY, found.map(wineRel))
return found return found
} }
@ -133,7 +141,7 @@ async function refreshWines() {
// Stored relative to FILES (portable), resolved to absolute for use - same // Stored relative to FILES (portable), resolved to absolute for use - same
// scheme as the game registry. `id`, `name`, `label`, `version`, `type` pass through. // scheme as the game registry. `id`, `name`, `label`, `version`, `type` pass through.
function wineAbs(w) { 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) { function wineRel(w) {
@ -148,6 +156,29 @@ function getWine(id) {
return getWines().find(w => w.id === id) || null 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) { function setWineDisabled(id, disabled) {
const wines = getWines() const wines = getWines()
const w = wines.find(x => x.id === id) 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 } 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 }