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
|
||||
|
||||
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 `<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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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…",
|
||||
|
|
|
|||
|
|
@ -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…",
|
||||
|
|
|
|||
194
renderer/app.js
194
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) {
|
|||
<button class="button primary" id="modal-ok">${okLabel || t('dialog.ok')}</button>
|
||||
</div>
|
||||
`)
|
||||
modal.querySelector('#modal-cancel').onclick = () => {
|
||||
const finish = ok => {
|
||||
settleModal(ok)
|
||||
closeModal()
|
||||
resolve(false)
|
||||
}
|
||||
modal.querySelector('#modal-ok').onclick = () => {
|
||||
modal.querySelector('#modal-cancel').onclick = () => finish(false)
|
||||
modal.querySelector('#modal-ok').onclick = () => finish(true)
|
||||
modalSettle = v => resolve(!!v)
|
||||
})
|
||||
}
|
||||
|
||||
// Shared "type a name" modal (prefix rename, wine display name): Enter
|
||||
// submits, Cancel/Escape/backdrop resolve null.
|
||||
function promptModal({ title, label, value, confirm, hint }) {
|
||||
return new Promise(resolve => {
|
||||
const modal = showModal(`
|
||||
<h2>${title}</h2>
|
||||
${hint ? `<p class="subtitle">${hint}</p>` : ''}
|
||||
<label class="field-label">${label}</label>
|
||||
<input type="text" id="prompt-input">
|
||||
<div class="modal-actions">
|
||||
<button class="button" id="modal-cancel">${t('dialog.cancel')}</button>
|
||||
<button class="button primary" id="modal-ok">${confirm || t('dialog.ok')}</button>
|
||||
</div>
|
||||
`)
|
||||
const input = modal.querySelector('#prompt-input')
|
||||
input.value = value || ''
|
||||
input.focus()
|
||||
input.select()
|
||||
const finish = v => {
|
||||
settleModal(v)
|
||||
closeModal()
|
||||
resolve(true)
|
||||
}
|
||||
const submit = () => finish(input.value.trim() || null)
|
||||
input.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
})
|
||||
modal.querySelector('#modal-cancel').onclick = () => finish(null)
|
||||
modal.querySelector('#modal-ok').onclick = submit
|
||||
modalSettle = v => resolve(v)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -427,6 +474,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() {
|
|||
</div>
|
||||
${p.name === defs.default ? `<span class="badge primary">${t('prefixes.default')}</span>` : ''}
|
||||
<div class="actions">
|
||||
${locked ? '' : '<button class="button small" data-act="rename"></button>'}
|
||||
<button class="button small" data-act="default" ${p.name === defs.default ? 'style="display:none"' : ''}></button>
|
||||
<button class="button small danger" data-act="delete" ${locked ? 'style="display:none"' : ''}></button>
|
||||
</div>
|
||||
`
|
||||
row.querySelector('.title').textContent = p.name
|
||||
const renBtn = row.querySelector('[data-act=rename]')
|
||||
if (renBtn) {
|
||||
renBtn.textContent = t('prefixes.rename')
|
||||
renBtn.onclick = async () => {
|
||||
const 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 ? `<span class="badge">${w.version}</span>` : ''}
|
||||
<div class="actions">
|
||||
<button class="button small" data-act="default" ${isDefault ? 'style="display:none"' : ''}></button>
|
||||
${w.id !== 'system' ? `<button class="button small" data-act="rename"></button>` : ''}
|
||||
${w.id !== 'system' ? `<button class="button small danger" data-act="delete"></button>` : ''}
|
||||
</div>
|
||||
`
|
||||
row.querySelector('.title').textContent = w.label
|
||||
row.querySelector('.sub').textContent = displayPath(w.path) || t('wines.systemHint')
|
||||
row.querySelector('.sub').title = row.querySelector('.sub').textContent
|
||||
const renBtn = row.querySelector('[data-act=rename]')
|
||||
if (renBtn) {
|
||||
renBtn.textContent = t('wines.rename')
|
||||
renBtn.onclick = async () => {
|
||||
const name = await promptModal({
|
||||
title: t('wines.renameTitle'),
|
||||
label: t('common.name'),
|
||||
hint: t('wines.renameHint'),
|
||||
value: w.displayName || w.label,
|
||||
confirm: t('wines.rename')
|
||||
})
|
||||
if (!name || name === (w.displayName || w.label)) return
|
||||
const res = await window.plwl.winesSetDisplayName(w.id, name)
|
||||
if (!res || !res.ok) {
|
||||
const err = (res && res.error) || ''
|
||||
toast(err === 'invalidName' ? t('wines.invalidName') : t('wines.renameFailed', err), 'error')
|
||||
return
|
||||
}
|
||||
toast(t('wines.renamed', w.label, res.displayName), 'success')
|
||||
state.wines = await window.plwl.winesList()
|
||||
renderWines()
|
||||
}
|
||||
}
|
||||
const defBtn = row.querySelector('[data-act=default]')
|
||||
defBtn.textContent = t('wines.makeDefault')
|
||||
defBtn.onclick = async () => {
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
.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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'><rect x='3' y='3' width='18' height='18' rx='2'/><circle cx='8.5' cy='8.5' r='1.5'/><path d='M21 15l-5-5L5 21'/></svg>") center / contain no-repeat;
|
||||
}
|
||||
.game-card:hover { border-color: rgba(255, 255, 255, 0.2); }
|
||||
/* drag & drop reordering: the dragged card fades, the hovered one shows on
|
||||
which side the dragged card will land */
|
||||
.game-card.dragging { opacity: 0.4; }
|
||||
.game-card.drop-before::after,
|
||||
.game-card.drop-after::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
width: 0.22rem;
|
||||
border-radius: 0.11rem;
|
||||
background: var(--text-color);
|
||||
}
|
||||
.game-card.drop-before::after { left: -0.5rem; }
|
||||
.game-card.drop-after::after { right: -0.5rem; }
|
||||
.game-card h3 {
|
||||
font-size: 1.05rem;
|
||||
font-family: 'Nunito Black', 'Nunito', sans-serif;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
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 { 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 }
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
37
src/wines.js
37
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 }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue