added library reordering & prefix rename
This commit is contained in:
parent
f8ce9a9945
commit
aca9f839e3
13 changed files with 374 additions and 20 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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…",
|
||||||
|
|
|
||||||
|
|
@ -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…",
|
||||||
|
|
|
||||||
194
renderer/app.js
194
renderer/app.js
|
|
@ -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 |
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
27
src/names.js
Normal 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 }
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
37
src/wines.js
37
src/wines.js
|
|
@ -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 }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue