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

@ -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)

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) {
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
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 { 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 }

View file

@ -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),

View file

@ -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 }