added library reordering & prefix rename
This commit is contained in:
parent
f8ce9a9945
commit
aca9f839e3
13 changed files with 374 additions and 20 deletions
|
|
@ -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