From f8ce9a994520cc86165e21b4c2c47c8f42dfbfbe Mon Sep 17 00:00:00 2001 From: olcxja Date: Fri, 2 Oct 2026 08:59:27 +0200 Subject: [PATCH 1/7] fixes: - fixed icon saving - slightly improved library layout --- renderer/app.js | 5 ++++- renderer/style.css | 4 ++-- src/ipc/index.js | 37 ++++++++++++++++++++++++++++--------- src/preload.js | 2 +- 4 files changed, 35 insertions(+), 13 deletions(-) diff --git a/renderer/app.js b/renderer/app.js index 8250fd0..0fe34c4 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1678,7 +1678,10 @@ function wireEvents() { if (res.ok) refreshConfigIcon() } qs('#gc-icon-auto').addEventListener('click', async () => { - applyIconResult(await window.plwl.gamesAutoIcon(state.currentGame.id)) + // extract from the exe the field shows, not the saved one - the user may + // have just pointed the app at another exe and expects its icon + const exe = absGamePathInput(qs('#gc-exe').value.trim(), state.currentGame) + applyIconResult(await window.plwl.gamesAutoIcon(state.currentGame.id, exe)) }) qs('#gc-icon-browse').addEventListener('click', async () => { // start in the app's own folder (prefix apps included) - that is where diff --git a/renderer/style.css b/renderer/style.css index 54f3d52..5da250a 100644 --- a/renderer/style.css +++ b/renderer/style.css @@ -490,8 +490,8 @@ select { margin: 1rem 0; } .game-card { - flex: 1 1 15rem; - min-width: 15rem; + flex: 1 1 17rem; + min-width: 17rem; max-width: 24rem; background: var(--panel-color); border: var(--border-width) solid var(--light-border-color); diff --git a/src/ipc/index.js b/src/ipc/index.js index cedccea..aa13757 100644 --- a/src/ipc/index.js +++ b/src/ipc/index.js @@ -25,10 +25,13 @@ function errorResult(err) { const iconDir = id => path.join(DIRS.DATA, 'icons', id) -function clearIconDir(id) { +// `keep` spares one file (the icon just written): the icon protocol picks the +// LAST icon.* alphabetically, so a leftover icon.png from an earlier pick +// would otherwise keep showing the old icon. +function clearIconDir(id, keep) { try { for (const f of fs.readdirSync(iconDir(id))) { - if (f.startsWith('icon.')) fs.rmSync(path.join(iconDir(id), f), { force: true }) + if (f.startsWith('icon.') && f !== keep) fs.rmSync(path.join(iconDir(id), f), { force: true }) } } catch {} } @@ -36,7 +39,11 @@ function clearIconDir(id) { function extractGameIcon(id, exePath) { const out = path.join(iconDir(id), 'icon.ico') const res = extractIconFromExe(exePath, out) - if (res.ok) library.updateGame(id, { iconSource: out }) + // on failure nothing is cleared, so the previous icon survives a wrong exe + if (res.ok) { + clearIconDir(id, 'icon.ico') + library.updateGame(id, { iconSource: out }) + } return res } @@ -152,11 +159,14 @@ function registerIpc({ getWindow, onGamesChanged }) { if (!game || !game.exePath) return { ok: false, error: 'bad-request' } return extractGameIcon(id, game.exePath) }) - ipcMain.handle('games:autoIcon', (e, id) => { + // exePath comes from the config field, which may hold a path the user just + // picked but has not saved yet - the icon has to come from THAT exe + ipcMain.handle('games:autoIcon', (e, id, exePath) => { const game = library.getGame(id) - if (!game || !game.exePath) return { ok: false, error: 'bad-request' } - clearIconDir(id) - return extractGameIcon(id, game.exePath) + if (!game) return { ok: false, error: 'bad-request' } + const exe = exePath || game.exePath + if (!exe) return { ok: false, error: 'bad-request' } + return extractGameIcon(id, exe) }) // "Open with PLWL" ------------------------------------------------------------------ @@ -253,7 +263,7 @@ function registerIpc({ getWindow, onGamesChanged }) { }) // game icons as a protocol so the renderer can display them safely --------------------------- - protocol.handle('plwl-icon', request => { + protocol.handle('plwl-icon', async request => { const id = decodeURIComponent(new URL(request.url).hostname) let file = null try { @@ -265,7 +275,16 @@ function registerIpc({ getWindow, onGamesChanged }) { if (pick) file = path.join(iconDir(id), pick) } catch {} if (!file) return new Response('', { status: 404 }) - return net.fetch('file://' + file) + // a new icon lands at the SAME url, so the browser would keep its cached + // copy and the library card would only catch up after a restart - never + // let the icon request be cached + const res = await net.fetch('file://' + file) + return new Response(await res.arrayBuffer(), { + headers: { + 'content-type': res.headers.get('content-type') || 'image/x-icon', + 'cache-control': 'no-store' + } + }) }) return { games } diff --git a/src/preload.js b/src/preload.js index f8cc490..b10241c 100644 --- a/src/preload.js +++ b/src/preload.js @@ -69,7 +69,7 @@ contextBridge.exposeInMainWorld('plwl', { gamesClearPlaytime: id => ipcRenderer.invoke('games:clearPlaytime', id), gamesSetIconFile: (id, p) => ipcRenderer.invoke('games:setIconFile', id, p), gamesExtractIcon: id => ipcRenderer.invoke('games:extractIcon', id), - gamesAutoIcon: id => ipcRenderer.invoke('games:autoIcon', id), + gamesAutoIcon: (id, exePath) => ipcRenderer.invoke('games:autoIcon', id, exePath), gamesIconPath: id => ipcRenderer.invoke('games:iconPath', id), gamesResetIcon: id => ipcRenderer.invoke('games:resetIcon', id), gamesRemove: id => ipcRenderer.invoke('games:remove', id), From aca9f839e30de21f505e5e30a7d907ce73d1daaa Mon Sep 17 00:00:00 2001 From: olcxja Date: Fri, 2 Oct 2026 14:05:49 +0200 Subject: [PATCH 2/7] added library reordering & prefix rename --- README.md | 4 +- development.md | 2 + files/translations/en-us.json | 16 ++- files/translations/pl.json | 16 ++- renderer/app.js | 194 +++++++++++++++++++++++++++++++++- renderer/plwl.png | Bin 9536 -> 9458 bytes renderer/style.css | 17 +++ src/ipc/index.js | 3 + src/library.js | 37 ++++++- src/names.js | 27 +++++ src/prefixes.js | 38 +++++-- src/preload.js | 3 + src/wines.js | 37 ++++++- 13 files changed, 374 insertions(+), 20 deletions(-) create mode 100644 src/names.js diff --git a/README.md b/README.md index ccdca10..fe8a008 100644 --- a/README.md +++ b/README.md @@ -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 `/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 diff --git a/development.md b/development.md index 52cc7ac..a5677f0 100644 --- a/development.md +++ b/development.md @@ -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 diff --git a/files/translations/en-us.json b/files/translations/en-us.json index 4f17846..836c7b9 100644 --- a/files/translations/en-us.json +++ b/files/translations/en-us.json @@ -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…", diff --git a/files/translations/pl.json b/files/translations/pl.json index ba62d36..9ed8d54 100644 --- a/files/translations/pl.json +++ b/files/translations/pl.json @@ -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…", diff --git a/renderer/app.js b/renderer/app.js index 0fe34c4..7a1c30e 100644 --- a/renderer/app.js +++ b/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) { `) - 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(` +

${title}

+ ${hint ? `

${hint}

` : ''} + + + + `) + 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() { ${p.name === defs.default ? `${t('prefixes.default')}` : ''}
+ ${locked ? '' : ''}
` 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 ? `${w.version}` : ''}
+ ${w.id !== 'system' ? `` : ''} ${w.id !== 'system' ? `` : ''}
` 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() diff --git a/renderer/plwl.png b/renderer/plwl.png index cfab4e6a14d524a387219b57ed55b480d3ffc7b1..ee0c558dbb65ef4e1acac8fbd50ec8f3a2b4171e 100644 GIT binary patch literal 9458 zcmb7Kc{r47*nfv%ETQZ>Wi4b+BBMo=5M{|WgGwbkk!1`e$6m>jJ+fzOvBu2gWT{Zc zzRoz7DeE8%2H!Kz>3siu|9sbl!TUbX{oLE{-k#_yriL7B!fX%(aa=SyZvjCt@GA^r zVFn-eKE=1d2dkISbzcbLY@vU{+B}PIfPV`48QAz;^}OX5;N)`?3J3@|e#gVj*Tu=} z=5bFS*NjD7VF;3hE}lPgEiiK-E7(i4FKY=`1(@`l4QuM9=EBRlRQL%&!g6_+Oi*0o^Z9>RsAK z(iZ-gmV+6jxjT^N&=Vs|MK*}LB*p-yf5fs{9x{4$jGqaLzV;~(l6+cf!wX4U@WnD< z&zCy?-!EJBF0s}XR8&+v$Q*EN*Tx;hM?!k-f=C{HM!X2re*n#iO{?UQ*(~S9N@HKn z?(<3AP_PA{(NIvisEG7#R_(9rd_Za=I_0orAS$Xlo7EQ&Q) zBw>`S819kpJ*%U37&D{C$4)^F4HZ=5uFW;50`3(~Ts>@=yo(pCQ7_aT?s+?@iIbbW z#wP#`1#G1!LpV-+)J`^6G@QhTJuMtxnh~%xe)`A8jrn3{26V@>MWi+FbXnX)5TrT1 z<;z)oS2(@!NRs)>(qq>&)ptar3h%V<{s`GZP3(4A2D!R6PY1kNatlScjjxAQ4TWay zFh8jryLRWK%S zG{y&k<&*dQu9nmxnPqx7`O@K}i!H<57bvm8*2ig9e6fAGH8q^?-@h*|DNzz&5yrX- zwrB+h2kcngMyK)(LO&K4J-TlUIzV=exl?6XmkLz0wWIxlEZA7!IEN)li|6{S1Any5 znWIpN!^6~jjdvjNO9g`i8;fHPECRWY%X`7_gISkcU6paybYlmV(UYp*z2-bI ztFhjOGtZ{nAVr4;n!N#`2Rg2X)Y8<9TwXRWF75FRdCP+F_KYA_0Nu@WxnIzAM9u%S z=9Aef$<8KovU41fBr@cd&Ba7!x^*+m-*eF+1Bw?fnd z>-l1w^@P!eLM?JYTg}JZRYX=~0Hew{UbGqGZLONJ(wORMryFWQ5vcZ5lE6kvTVyjy zkw5ljxx0AY_Xsz}bJ&%i=ba66-knkUybAsJVY3?$H-UsriSZOzBngU$Fp)D!0xL4q zPfgP;mB!|Rwzk8uS=@2H3EdlO3tI*62ukfeP#T6zWXdHB>S=3--|#sA5tL_)fmj<6 zICEceO>T8{$EYqrd;2_!*!Ae$#ph35Vc))cZyss2QN~)iY*Pcdd3Y{Dd01hCeO>(B z{B~ROMAxU$`p%gdC#byV0! zM97~g6e)}wEAgt(V_>9Z`o<-2^3Xogj}$;5As~~S<5?-IcdrSm?cdDN-F~}c&pyqK-cA7=M$GHrRMOj)#5TEW# z`8YLv-dz7w)`=5+rN`_JyU1EFU{&qiMzhn>4*E$0Cjw_g?M-xdMM}N+y7%TuVfEaN zuNO`NZWfwZ3LAeFS{zy01?trTbVUwO{biCmK7x!zo|%t0mHK2iFZ~`w})a^E|Gpvz`WLVNW{b$66QsPAM_L0c7oy zy9c{l!O{VMhYLH&ha2}b50H2u4o5QWN_oVu~=4|QGlV!;2pepBcl zkgoHb#&hMOLpfaP?Ef6{hWz~fI~l9cg^?pH(7&cpu1MAH9@iM~6C-6BD>vnpqqSmv zL?ICFZ;y7}xMmZ4hVE$K<=Pce_b!ThK_vd~ho*)HS)gnIs5HI$jrAdkB6ayiIRHXm zmH|vtr^R1{bPfz3c^bmMU^FF+Jqq=zjs4!_%>;st5$j9kh8{$Ty+r`<+E=-i9u0bO ziO=3`RS)8gBwHVq7y3_7oIu`|>mwJh4g-bE zn=2cZ^pKP~Atp*4`z1}yB6yAm*uFP)J-pmZKTbduiGJKKlrN`!TpCVHeeOI1iy$`B z)nrT=NmhXAQ%2Tgsh@yz$UPGWD;(&8OC<#yEBWtH*&)n>q$K|TeJGIZCIBms1zsvc zmC?55qhXi8%dy+sv}c)RoEW^97HSDV^>z?rn4zJZ)oYvRNOl1j#3?$MsAP8{zf#$W zphcww`1v_ce|B5NGm&y8n$Sk*Pvj68B#9Wgk9ixsS#DXc6asq@|V-4 zl2lkX!~Q0_cf(9fI$w7)>g$RdN^@6Fxf*)3NJO8%f|5`kX{@Okb-PkeVa$fp?P=~b zu%!;0qidY!^UaMhR@0vhL#u7R{w4mFI9~|t&z_5JA_MfGlzi#>to2nF4q1hjSznBT zp%(sLZxuAfk6)Zb1VT&?(Ln;yl38U z(i$6*Tm(&oOL)o9?G4}#;sdZLmj{(^uVTFjTt@p;wFD`Q{hk}yO)NhyW6Bq5gL>6` zd3p7yuv9mOne4+1HXhz zK&Cl*?j<z|OB&)w|KOk)R{^5*T_2JJ4xD zZ7_uqU+vPSSkC;qtgPk3g9U>Sx*WddAi4bg{cmoApe(9$;&xAy4_H#dCMrrzJ-&d~ z9C(fY7|9NU5S*g;{&Kr-MAV1ja!b_H&FdTY1u)PROFqK{Q9?aA+pgb@5b@S`Ddo&# z38+hNThw@GJaj{>xb$vI{TmX3;Tn(1K#LUBvwqrYG+Y+_{{Wu&4eHEl&G1oeL!J^m9VDGO-$(Q z3E%?Hbu=qjSA0b7PFgEQ>Zbu$1M8}%>d}NDJ{S_c!$Ml9Esx9@pnM5Y3aQ**r@aUG zl5gy`v<`iFkL8gVFsnoOO)?7I(cL^ntR{b0?W*nV8SZujj-xjK|GGw@M&w|+P^Q)n zYfQ9NWSD=7!l;bl`xEsR!3a389!SxidU%`Xm_mTZz|FOdz4ei>H&dwt7H7cve){@} zySS{z!=W?k*8Gq3-{ZNUC{D3Ay_MzVq5W&n`ZOoaHTwa-t=)*?!tyq8ulY5~(KwR8 zMco%#QQ{V-xbAJY-tLf@&!a!}HGz@KnPmfQ_IqsKDu3L}S09jXTIMS%s(b2-#Sbq$ z7xfBV*m6`DdahsTKB5iq3EQJ^y>;MWk)Snz&Cn$|edp$erWYN81eD(JL)#lm%5$#2 z_>zzmM)7@y4`HAmOJ?ZR8I${R;boP_{hd@kP#E75o>WG%EBokPuv#SMZJ(0>YZ08h z>`lOn18wL;U8(-K?WMg#;$a~fV;!W~vQ7c!77pZ-CWjh6CC3kfnwsx94ZS6H)6;Dg z9X|~IHNL&ESMe%mXrP`JDM2ZgMOPT=le*W*R07wcFE4|&V+l&|U6b4Hy<-pt3fx(H z_)Vky9tY#I&U0Qyc0nUYFLfqc7`TSY1b))Z#uF7LK*1=byuH==aU%SpjA z3-k-{TsPbpo7eg!6xSTyOsQoojsq>EI@J96tmWmoD3w#EY)RIi2bv>bNRl}OKeYQ| z$KWjKZNk~yXQY8pRG?F3L9@h8aTY}3#6UK0Zj{KR3OEYNQ!+XSvLhG)0HuaU9N3F9 zI|0YeYd4EIJeYQcK^x=2wFPTT$t+0x@<(2TCq4M^ zl@0(b$222T9k2s)bi+#6=8?39N)bbbWKLX#fa9Q)dtC0_ z?rd6)>Q@S5cro3_U2CQz^=(K9Y<4zkXiMCnAlf$pwR5-D$!^d)rFv>hJGt93{2^lR zvQ8TphI0DlK`17R^BPEmy{6g^eb|~+_^XvY-SNRR-vLf>rYm9qz^s@!kJB1*ZnKPf zM{WA$`b-S0jrw+95>dasHLbiSqQ8dcg4fC4t|&62bpJ}?V@T#gLi2;m$P`Zz$BUnD zs13e%GqePz&kZFr?p+QWGGtiig9A$r3=|CBXqI%R<=*bjh8^Iz-pFTr$C96>55-2z z1Tn*b!p@%~xjjqa*hb(gigGZQhD9Utin6yNbI(l^xZtk=O;c(Vek4@{OT)X*^3tmirQGX(5dHFazf(2ME3eRJvd)!?Q{FdEj+9>b#j{+AP0}&L_*R@7j;*^_E>zN^>m*k=pMn)XHM*T0a540T)iBYjs{xyDn27$u zuXA&v1%&Mu3sTibWY55LmiGFR$9f370?@RxGXrs}KZ|eGaw2TIcJE7Hy=~vn>(x>)gopPd08^Lw{u$OZ555(Vr~x zPk(&cTI0I@V3|>QX&i&Y#N$uO)M+iKkHsy)V10ovYukPxYta6y?Ug$3dKHEY_)LsHI* z>ya&$3P~NL?BK=LPOVA;;H4lw=+}=!uN{IF7VF8!K?CWFuVP@~XF$s8H>oFh&)zS} zxH!wQ3W8LkZrt*+cbk-K(&S*3(`lf8-@#hX%5rUf-K&ulM#y(7YG^<2ClH(k>{&1o zVfQ<;M&f{}l6-Tvb!}~Jl@@$grdVk-GP13|JVRAi_g-)UQ};DV8aZb#{KLX|=hXZR z(>g&2jwE=AKZ&I}%=z4@CB^$1iR3r1n3Ko;%66%lsp794l}5Y1}3Wr6Va2$e%!iYgOBs+B^4 z^jhm{94S>ERKW~Zd}*ZA{Lc^fOQ}3)KQa9Slol17zz_Bl&HcaN)ajjP`F~~JGXDm! z-ha*`0KNxw;Y|!B%21hTQ=oU1w^Dzs3b|h_^_9B0CmMpfs$&%tt?g~4_+32LkBy19 za-cXc%LWYwV?9fzbuJJH>%5+pFV}byO)2bK9cxOe!URt{y=zSC0=uuH)^_~}W}{{m zp3&b~For92$Ex-~4oY~4P+S|SaaSNH|BxG~{qr^)=kmaY?y*a9(HLFNY4L(oN`Bi=olqQUlX_UEUu-mV-j>c#ig6HkSRI$Kd-~+R# zu;>=$FUrJJQYu;DDm?5cQ=u$+d>oEjyi8$_CFPie7qh0-Y7r`RKVaNGghs8mE)A%cLq20I3Pn_f#Fp}Gb=;>0|tbke6gvKq}N@z_C( z?YLW?HAukZ49@r;qG^+_;E6*+k-+?0@(cuCHtQXCK^t!^c%lxi3ra||?e1(Z54t4g z)z%{VSdb(qPy^AI>=h`Nx1?)obqIbF=o=WAd}e%{Ad_kz5hcDQgrQAJ*p6!{f^0C0 z;h)z51j(6Zy1;XS3Pb%q zaY!Ah_V#xJHD-?IpLZ@hJ(8e&_`R;EY{%5Nfa!X=>=vs52-AVK{F$K1kQzKSX&8#& zoKIB^%=M$7mvfGSlqEYEz!1HOvI)YnQo8Vo)9ZuzOzuvgB6{_ELsw9`?nCO{VV5J} z-_CLE+zV2|$Cbh``hTB-B9LU!yHx+Nu)W0P*|JJgWw`C5t*szd-&ND0NPV2TB0Sw3 zWao7a2mjbV1+OG%eQ`G#Ne5lhGy4);zO@x#uKC0Ov^J1GD5)#FtFqK4D^g4Z1Hc01 zmQ+%0o<1KF695&|W6!#Y)Xfe4w7gu1UOG&4-6 zf`LvzRjo`|x&|v{yG;4=D~SwRj@zFfqsAp(8bPmCv|f~Fv0^qR0szX^&dxE@m|mM` zi=C5(T36zD=YTB*Gvd4{ErxA@ns7YL|G|?sPHx-vc6InumfUpv=iMZyoOFul)mc(|+yT zpkXOWZ`=%Ilg9O_gQDmr>}+(tus$dsofh&yV@24|L278Fx3}~`j(+58PNAi+Yjll=j;EwBsN<0 zae6upYQI6+Ii&CDx#SHkrO92`=L@)MU*D%rH;?i0#sKogBHu$h$MGJM7fejbonlF; zKT`)Tfk6c)f(9K-6yQaTO$Vwy5ry8r4+1h*+gY(?#bk_w-h_Vj?3c#awYSG!Lf-HVhAyM2-mB_LrDa(YIM zj+&a$Nk}8!Q^jMTudlN~u`csB(M*crk?#@g*z`hopBuNHctO9`PU9rd$|55%_9gG6 zBZ$5Gyvfm^Z-ax+1E}5FjdV4bm3gzuT zE2S5}^)*U8B3>$*SGOb&ou_2{Ba29IFyZvCWqXB34mBIJ``KDBpuqs`A++LAmED9k z-<3M^o3IujJUo1NeN?40v+9*4Q@_S2hh)BIn==@I0ZzCGo_A%a?mIfdofI^n_wYEfVdO#AAa#pd{;cWLThBkw1c z-JM6hI)Hxi3lbCzT<0avyS3H?eh^<<^VO$_?8~Z;aSsfKZr@Hz8t4*Sq`&q2`I)$8 z`{SptUv0H%@T8wYT)X^9yVwBS6zmsY{z|@B4FU7t98A>e;Ir ze0;BTX?NdvHwTOR;U1?7y8KC0f2aSJ;PT{8&b>yew;&5khfO>HP6Y;#!o$aKK z_hK)&04AfxM56g)fM0`2SttNhl}zu}>oaA$=(sUmm8xz3ql|n{%W4rzb0hVvBrc(m zzn6nFv}#%E!0?JbjHtX!4S%pY!?(%F5mr?jK(;!{nEa#+TnQ*WwRsb&6dIZNdguJr ztAv0IC3<49Pvj$Uc=++mtyLe?hrH4x)Kj%@iaw_yW@cttX!@$kwS)I<(gGm}?n}Q5 z!2G5RE+RZ1;`Zc!SC5Xy)zmzgapf}1A1-<`U#DQL$0T1^QK7)^)5Qa=xTa7&TU$2HZ8>OqGWyR1y`vAWKPdv-iHUvLOHifBVLwE`cz7lSg-^ju z)Vrd7pApj=09tW;3hCe$fgW+7b}rR|`K^36Bh)U&s>>m{RA&0(vM^}LfPo9cejNBu z!KdcB+4N=^Rp9bD>nmBPRAm_SV*vin6?VU-I?Fd>4-iLK{w)Ue*}h${^C5?E_U{o8nNzsZ$j;p9J@gVk3$#7hM$;7ytO@ zqpU;&`uGU6_XUN8XL6fD#ht6uoy9J|MxPnYKeOn)dM%t40wdfy+F1i9F5i|~miS9W zJ%U2o@zK%CYKHLrNatx7r>dc$@$&uq+?Yi39mp=pXg;K8S^pVdF$BFq$v8InZ3?$i z9aKUu<${s`eeWI|44*+eF(_qUE#=3AIAT z6ShLeSZ_!;`bIMcCF6>7hZeVD8*)|nbRalp0i?7CA?}vd{}0a$!{9|L48iuIXR@%Ne5Pnx;)!CY3|58 z75SJ5qgTxD-)sKy>CN8VyLVsnzj?txWtQ|w{QB3JN2xP|zI8yr#*5O=R$dkMTol|& zu%0wGIq;%6wbkg6=TrYS@ycu7Fm&(!g$%YvPP8=x6}x|xWE@_V<-2QBBZ|3Mn#YJQ z1{V(0)rAUp(AHjE$S%RI)nL!>_*-hG%D~ zk?D`wVIf{y#24+yHPRV#A8hUAm1!DT=JR4zp`xb_uae*@f6VL9GSXWeyKhit#+!7^ zwoV!j@D77(H%8ObK^IlHqj~NB?>jgDuM0Z;dx(bAy`MF&xe4ID5_HkP^nArxbj1Gv Dt@3+$zccFj{QmyFUc82T&N6SQ|v6lED!{-pEf#i0fJ!QPZ-3s z4}94AzH|j2%sxg|eh|dbM*oGq_kQUN9t!yzUiQD}?dl)o=<5On1qB_vcEi)p$=i=3^v48s^v49(c1_@uP>y|2j{c5k zwPV}9yGGBp9(>(^jHK>`J%tP;2Fv82yG8*@ERfU%(Rc>v&ZYPNx5umXdQiQ(j?U=E zkeP2;Yh5r~4&w2IU(!$pY?AG@3XUIwSt5;nZB9eX&`kQS6$3E}!o08bO;X*^bn^2K z3J6GibHvo<(jl0GzrVly{H*|JTM-w@!t{#al8QxPVCS(55rvM^_NQF$qxT2u?zdDV(hcXYlj_^qzoB3xjmDViCap=X7 z(UFnW7h_Rng@vq8x@1I~D{hK`n0F&N_5=e-r&(-$`_p#YH0jA$lxkifGj#lT8K+#_ zszy60#MyL&pKsnkM~~C|tYoUOWVR{zQT{PwX3m!dPrs;trMxj9)%*I#8ehq^rgjm* zHO<{9$u0kg@P-yDYLpUAO5`wSgP6e?+ZhqMi^-L+cqJl%K?+IXq3{Bz-&uJ<(( zN7ow6A^33M^QUr~(3jgRe*6WOSk~4~OWi;H?7|u`hUUwRy}7a&J@t~|);@Jj&D^pw zZg%#>R0cc?QK+3%TvAd!#S(D6;)n=zwd}G~>eVU6FPchfo@r-fRCRUZDHJPtaW1NB zqbCa6sO4m!B6v)0|1l>gpE6ekTz03_c6+%?ab9cG>!VGIC2v}Ps_y^Zky4bpyzE!w z*OLVO(0PEvWScsMh6?{8OU=!>`sQ!Uogl@B4Wp=51PUeO%(-)tDUa}lFSwZy{0Ajj z-a@uBYmPP&DF?!qYwernG$xM(>{C(SU*9^vrpG`!_h2-~__@lVLy67kK$7r>9pS*bSCL6om z+wN}vf{7YL)SVe8r%#GXLE=YC7;*jmWDmpX2&m-qL%*IBqce^k9t;bW3I%^?*F5#t zi7qRXYi~c9TWE+yoge(&m6!J`nJLKmdQ?*DZrYfS*M;My5jP?wQ?4-Bi!^wI)B zc%#*VUewjoi%v<2H+nv|9+u|Y6Um!sT6}*fmswj3I(brNSF!mqlB@rr?EQ*Ij|7h^ zq)L#Z!+}eA*OENFML9Ur8l=8MpWO~!Kdg8P4r1%q&2|z3hwn@t84-FrbJM4X8T*et z(9oC|x#EI(Qhz`9+nM`Yi~qEfW;=|+8!KUgcb#Ak&dyian;*maUMc51HWg3#7cNeI zw?`#xdf4OY)dRe|su4Hus?sj0>r8(+ej^Eio1X48PbhPP`FnKbzxetn{WQeIc!};0 znM8rj;vWLZ=N!Vr&)AQ1yw1<-FGwBOSNZRCPKa)fr^IVentNA%KJTNahnO}qA~Bl<^cwr)qz4N=;XlTzrLkeG;D!}GmX*3T)Q2O;R?IQTFhCYe&R z0(}YevI3$}!|xFVR_W|FGDlhz79KMqT}>7^miyx31>GoiH>m8gQmf#$ zte2IVc5JP7zZ}yHr!{~9b;ZlC*O%C>t{tvH9h%dmzLC*Mgz>adSyG;*4 z_!kXg%1)Hz_Wp>z{)@<9xvy)|y9JjNxlgl0PEHpsVXqv9Feygj*`_9ow}5hF$nPS4 zMx#287z!pY4I^Lco;)>E=ayz z8wj@ApzEPXl$M}&+K07=A-u4VcFs!jE)R6_)Ty+C4P~;j+WE9U0kXWrLA81}!@E^o z(|)x~TJ7XN($Gr}2UhjcC1YT@%uGIi`eAeh1x+K>kQXOdTH5_HyzZ&;$OEF|uO=R+ zr*r#D{av#;-J1}Np6%{1F&|M@J1KbpiivfyJhu68()q&d_j4dO@>0JxP2w;`CnV1I zJyZ=>@*A@|(+V><|6r_gtkapN&_g4WuH>_%`0Y?uEMzw5e=zc7)0qj0!*cRzkVutC zMTJH3|CrY!SA5VvS=#S*LNA)ZK#4yLrCz%FHOmP*X$*lV+v9tsmD8DL;rpce@1y+! z@l1@kOU4w|pBq$a!+tI%Y{j1DYk+KNu&pYMLY-X!mqL+y-=z(X&c8$Jy_EVJb?f@n z5!Id^{vNnbPXjL$SCOtH^It%UkzxCbOC8xF<-cCnh7yvJ0^ye*qo-vUpudt>2vD=! zu4EKtA}?#JaIGkKxSBnLL-UDgo;4Q>)Dt3d{~E(4g5*X+>-TN#knawPAs8Su6SUy} z!KjEw-{O?n=s6H^X)`9IE>c&-0A+E0G`#5;|3@Qd4gL3-z}z*CDcC^-%&t1T5W z^BFA*{!bB8i?Jx^Ugy(V&3QQPDM{>SAO!*mV$UK~FbwqUlOgO${v|z0o=5PGd1~+V zG-1`h{10(Z({~K{tW5?FNu-$ISYWun&g}_IBrR4wbA=W9PlPZMyf2>>*=)-UDUT+G z|Bw3UZIoQVK4Oow!SQJsWDB}&OqVpaaGGQjWj_^oG|}SJKffC3ht$*%(Lc}b$yo06 zM#gz;sfGEEmC!BT7S#v{KlKV^_GJ^2P0DW|K;}9<%9yc5_k^Lp=JeKUMSJ@U0QGwh z(KDtr+0bQ7>d8$IXH5u#)O;Nt<)a62{r6uKs;}YWa0va|o@T<0iO}n#l|n_9r7$Q< z+fNwD@#B4eUc|oeM$X;3fTgmKwU3OLDcVkQZt03)e9|D$+-r6b&C4EwiKhGs9?s4k zCp>{;3=5H0>XvSd2aXNh6RQux;B$7V`SIjX6z%8OO8Zc;y0od|`kZmPYiEE!OatvzSXfFZimO%aiuP3pSB zi+(X!G<->#nkBMJNg#jIH|M6}oc635fUPFv<~sK8fU9BO^csbYlAba_+78#heXJ#} zN<>640y)TvAsYQT40zN9dC7uF7-7o0qxWUjn?V641Y6Z*s*BbjJH{bwZ`H&ty5Bv^ z4P|F~Lbq_Lj}$sP4fRjHx82fx$xeoi z?M2bjL8?I~bxm4&hOK#l4?Bd0c_6XP5C3fD#1O}Ya3Wn{#dZvz`KdU%TY&Td$2OCf zCJ)`m4b2XXV)NXTDHTsfqfoE4WgTvCws`jq>yqH@varN)Q$=Yl)Q@W(EJD5?!jV+E zFRz@6Ree)eer+wubmd~VNH6{rcSO7>0T zW)suX+^l++kW`!}aFMhbFBA}1UESGT!{OpZ-nx28>F`Eizu&+8^9l+It~P?omyMl0 z!@m^dMay!AFeGiM#3nZL#^~+AB1<^blcgOMh2$hYPcwF_*H~0k)1R5#XypWnP8be{ zU#{{nH!S_uWjUMl@Jb8{vUqj*+RMqXmBx5~9k15fIwJDO16~!&wtvVg4QmI-BX>7_ zK57h$3$jTFvVG0)ENR)OYo zgb7=3<$RMx=X;u&@Ixa~dcvn|o>UXLU1jL{rfb9Fy3aT3Fa&TM=yKy$+Z^)J`HhB> zuJ0?mtS^ql6w)UsuW@unD%Gx~p;L>88Qk__H!;f2$ zo%f!9|M5fZ^VdznZiFELe@`I_Bq*AA)xG13 z=TySCJ}rsuE1$3VCqzXF2w1V$iICt2XX!G6Wru@FxRIWlK*%=Brt&XY20vSJ21hE~ z*ai=UdNjWI5n-~Dq0!FJyznVsaqC*dyIBq@u1y=}r(t^=1zia(;N0F^Ii$-p9_&M_w&RC` z&rISL*cpTBXgE0bZ%N&vg1Wl8^^bR0vWT$o%|UZZA&-cy-zy)s79xtf?8vZKU!baB z1no>j5)u{Oe1YrB8&zFZ_quD-?u8H*3SKI%Ff*^Z+W6vyO!)6SOPlX4STc<0gxY?T zE8U#s*RZo;BfVoxvA(|z_&JDJ<)Kmn)$q6~IWuJvJbfGoTn~9XptN!0zRW~6$Zqui%D`(yY>u5RiOwA)+C9?WZBW*-eS@mIofQ6!!IPSIi>zE3Yd9&JP%` zYWRv<$G5k4oD8w?l#Kg9MC5^B!HzD{e1zIjsKa(9v%ftt2L=HzZ;TldxFku1-rwPU z|JtSNVch!EE{&6zYI!Afh#;a9_(O0JhHdtx$j*M}S(>e&P-;kqopWIfL)Te#4-TZ+Vv2mV6<==>d(-@mkcphP@sQb~?-L!625;ShHT-H{ z8rWLPxDvS&z{YU}VTK~XOI#T@4pOZ!{o+2odRtw$7NapmZaF)Ifvw*eSV~tU(hNg_ zi-LM20#0|u&^w|09IWxEV8he= z$6H!QV;7@Pq{|ojY|kdQo11Z!Wp9dGTH=GJqwOtAE?W$OOCT$;2=SMOuVyE-PDW*`GC*nSUL$BF3uh34HV& zokw$)NSdD}(*gCLd7KP`2n@u=!9lzu`}pqi(gO}ro!c!(orIFt3Ps7Vw0l5B7TQ#b z8Xtx_%}FB94B?xTMD0s-$YpoCoi>;4_|N4|41WH+Uo6Bqt<$245kou%^z~8r7G|ka z`#qfVGAn~k^>{+S(vSeT^DdAHRPAk(jme{Zv5lh@Zdx)j-AdE~JrKimYYYieDLO+@ z%`eVo##2wsBB)lPhhK9Fpe=gqaPb02A0jTjl0?6-TQ3l;qcQfw)?9bjE+qaCgz|wi z0?;CSdu-QCBbjA)21E1i?(Pt!cM}=_30b)SF-JnP=8rHX@>*&+=2PB6hboot&ND#?ZdG z`>V&HsKl^+p2PZ7T>b!l7)^!FT)EHR4 zD^nztez+ip_AB{JP1gq#l3SJMu<7shM!BHF42w%)-1UJ!)y*oJh)COWtfr)?ps66G zb5JNZR@|>{r`UT(uO|nC_BP@Vpix7@*D@pBh~>{{VDVY7cvyno3EsRug_Wg|-v_<@ zs8mQGpn|3%(rzu$wr-<*l$8&+*mr>*RbM|&RJXdm+{oR>hiR;4>W+KmfZd&~Xq1mM zeF-P%j}NP>->zTw-8e(c4x-_7m%q4*lRj%vaWmIim}#MSR7V1(adYxrwO7sbZ0CJ7 zUHO9hvi6`XuDR+!Y-G`3#y01H3q+ZTM(rrv4-f`f*AIE&mnO zZTdI($c^BHclr-QOKsbtK!9{JKk__Enm=XnjpUYU{M=_MV)JW~Zgnn!U~gS2faY-6 z-lzu;rwkQR)sD13)eG2%A(nv>fFBWwb=F`YSb(H1Tz$QkIc(zyeqrHSm!1sIr!S3R z0yi3dRU8gkb-C!IGHqHB;*La`oPEPZMeKg{@aA;-^x`b}huf~M zj*bp{m5^xS*G}1-w=GfIs;a7vp$Udzg@vSFbYxRM~U%ZI`Lcn)A9OrCqpL^JkP2A4cz zOAbn?1$`bt;I6&Lt1^ry_9iC~)8s%gYa6T=d=f!$bw&_Qa8q4p=?$`mq$F87j;N)W z>_V?HUvBL+OT`_>t!^=6wdu&n637vU{`X%I3>!jlCaC8d&T4>4>U|R02EW^v8x>5v zwZcnfz^(#V>wIk%-xpExaIsCYvds<62^8qnWP3s5`j0>bLp>aGH-a-q8}wRxi>-X+ z6<>#eTWWF8^UQh>ndFGK?>krGoj%Eg{p9)}EoQQ#hss1c5V z4(N@>PSm61>m#R{8vp;sR$m8ETnz|3H_W#DWcb=ydI#t^{A9?{hNR4tB~Xa>YV%TCCCDE?41zXfK5Or6i`lYDB7(q1 z7?;Ik?4yWidCupIvSb&~;&D?c!W!U4U+I$sF^CnScZEzCT-Rca^NzR5xaUHC3M3EbTk}x3|#-CP$Z0PY$8SL`s}Pn zy1u-j+`{?hA%7MzZ8~jxmZY)!P`Re#d*8)6%3QDItBaoC7Jv3vj9QiH8rP4hYC7u= z6n4&3F=2?o);<7awqL$)Qn6!k@p*_jHi7rOO5pwHs6Zrfn{Se=a)LJ zfB(q!54nP#q9Y>E4<^oag(Ys)X%#GXK6?VStOSs|LgGSrsis|ir;oxwK1iE3A&I2q z-@`K?FaRs5sh&=wY|f?A7oP(%Ns~Wz0G|OS1BeM77kSJC(HHKaB6Q~fKp`CySv8}# zhc9xD(9u^*P3kI*$&kUI+~xx`G{KE1 zu(pR@YOg99b64Li+W8j%-=;!`_`#RptQzbz63RK3tQ;f_?azX#Sd7H&(OeS2KKU#J z`AI_i1FgSSJ~Q~s6o&bpX=j+^5wH+2?X6*e*I$nl| zGVHXBbO}+pdjJv^si3#U3zlZaq|DzXpi+A9p^3V5VLicE4tlBZc=(2vy1MWcp6e$N zgpvnDtpz(9l42abz1`tx`c!G102CTJN<$FC12a`)98}&IvCc@41D+!Yr&mTnyPuJ1 z^BeiaZ`y}E;k%w_bQlFWku84)s(exMI89nI*<%Pa;FZH|TAIya}evq;d+Nk(z0+1-5radE^VVfz~#^bR?m zr#uw@kFF-5J2FzRIo{Zu_4=6$qlNi6t9a@J2ZF&J2vW)Z-#UHj$B)HzeAPyL`3gO%D^4d>uoXYf#j?1avQLm zb0K^NH~3XjGv4?08T!ck{{cYXlpZ1T8Megn75(Ooytq(VS6!n;efS{Bzx?OV=Elb9 zDZifOp@)62uzn>jO~DK^|TB z7B0XZ%yKVa1vXQeF~GU(z6uG`d@L=Oj>?OsrWAuW2nH5L(6Hc(HbVmgahnvyZU{oO z(BB29u72v4$^P$N{qZj1TkF)wNVLyo!nihiF1x9_z$}$?tku!HfFJ8H-&n4=_Ve-{4^AK4K)hc&@UymzVwF9eFKPqrV#W zolZUx%5~>^GW8Mc!MUJ-s-}OQAA=+mQzF<`tvI2@Y zkjnv&Q2>qQRLv}J`1=9Y0&r8GC(ju8PZ$mX~(;NtGd%G%)d$6m$% zsy=CK_!x*-2$xTWpQ=rJb-w@V?vjFeyEJ`7nnu);Y!|e)9xZe3yl}fs^xKuB zHJ)agjnu#f{#%k%*Psd1SXVzbwY(HY)9D%c**75#smZt3)zyIw(qPQ4xF$^>Qh|$+ zvV#Q&T&`VJQ?j~1Rq4f_gs$s`{-&&_7%gd=h>l2&i6Qw1`})r0_oi)eG<%;)H+ZN0 zetcYs=Z-w+HF0Z|t~m+kxd8kN<|Kyaxmc>678moiIILauyQJVmcI&yntH^UFA@e|? z(!y`C&btkTM8Ab;0D7dv~t6*~8{`jHp>gJ}ZrNvs-00&s^%3^GeYD`Sb$=k<1 zGVMDKeo|##m_z3j;#QSMw5iVEg$eN@cmG8-TqHZOhCwdm@pHcU0|lRt%+1fI7=M&Z zKO>Q93^&UyJW!YsQ|SAE28Pk!f-T`xwzyW{4!Lq{aZj7)Jq?_k;0(9A2{AGI@`9eu zK}{m03E4vdHQ9w3&|PySew%xy6=$;)bCBdg`l}*e59IHUURkG*VOHKhZW@~lK$XH` zNp=NtKlpK82-4A-_s0trv*A9E{AibDM6m&dg>alj+2!k}6`sFBezCvVv&-rfoHOPm zF?jEbAr8ezj(n*QR-K>!^W2SOqZkhv=(`3On|XMAO)FA3h$Q7kN1EKVa{tUA-av}% z929<5cb=ztKTc3B-k%5Bm!jR8bJ2XmLLu6(>tR(=-QkmCvs=an^_3)6-D*c+(DX#f IF$etr1LCoDGynhq diff --git a/renderer/style.css b/renderer/style.css index 5da250a..23911a9 100644 --- a/renderer/style.css +++ b/renderer/style.css @@ -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,") 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; diff --git a/src/ipc/index.js b/src/ipc/index.js index aa13757..d7cbb78 100644 --- a/src/ipc/index.js +++ b/src/ipc/index.js @@ -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) diff --git a/src/library.js b/src/library.js index 422593b..0673e62 100644 --- a/src/library.js +++ b/src/library.js @@ -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 } diff --git a/src/names.js b/src/names.js new file mode 100644 index 0000000..d4b2467 --- /dev/null +++ b/src/names.js @@ -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 } diff --git a/src/prefixes.js b/src/prefixes.js index ddbfdd9..84f51f5 100644 --- a/src/prefixes.js +++ b/src/prefixes.js @@ -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 } diff --git a/src/preload.js b/src/preload.js index b10241c..ba8a43c 100644 --- a/src/preload.js +++ b/src/preload.js @@ -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), diff --git a/src/wines.js b/src/wines.js index 2905856..683dd2c 100644 --- a/src/wines.js +++ b/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 } From 38eea05789db431d2d224f65bbaee54e1ae5489c Mon Sep 17 00:00:00 2001 From: olcxja Date: Fri, 2 Oct 2026 14:46:21 +0200 Subject: [PATCH 3/7] guard prefix and wine edits while their apps run --- README.md | 2 +- development.md | 11 ++++++-- files/translations/en-us.json | 4 +++ files/translations/pl.json | 4 +++ renderer/app.js | 30 ++++++++++++++++++-- src/ipc/games.js | 45 +++++++++++++++++++++-------- src/ipc/index.js | 42 ++++++++++++++++++++++++--- src/library.js | 53 ++++++++++++++++++++++++++++++++--- src/runner.js | 18 ++++-------- 9 files changed, 170 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index fe8a008..9d42d9b 100644 --- a/README.md +++ b/README.md @@ -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. 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. +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. Edits wait for running apps: while anything from a prefix is playing, renaming or deleting it (and switching the default prefix away from an app that has none of its own) is refused with the name of the app to close first — wine keeps its registry and user files open in that folder. Deleting is also refused while a prefix still has apps registered in it, so an app is never silently orphaned into the default 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 diff --git a/development.md b/development.md index a5677f0..8d6c3a6 100644 --- a/development.md +++ b/development.md @@ -14,7 +14,8 @@ src/ main process (Node/Electron backend) games.js games service: run/stop state, wine resolution, open-with handoff lansync.js LAN sync handlers (identity, devices, re-roll, sync + progress) runner.js spawns wine/proton (env, prefix, playtime, logs) - library.js apps registry (apps.json) + settings + playtime files + library.js apps registry (apps.json) + settings + playtime files, plus + the "which prefix does this app use" / in-use guards wines.js wine/proton build scan, registry, portable symlink healing prefixes.js prefix registry (files/wineprefixes) symlinks.js relative symlink rewiring + executable-bit restoration @@ -37,7 +38,8 @@ renderer/ UI (plain HTML/CSS/JS, no framework) style.css the whole look; CSS variables at the top files/ portable data the launcher ships/creates at runtime translations/ en-us.json, pl.json (copied to the data dir on start) -test/ plain node scripts, run them with `node test/.test.js` +test/ plain node scripts, run them with `node test/.test.js`; + electron test/.test.js for ones that need the real IPC/Electron tools/ install.sh / createshortcut.sh / build helpers ``` @@ -66,6 +68,11 @@ handlers in src/ipc/. Keep new APIs in that pattern. try/catch-and-ignore with a comment saying why it is safe to ignore. - Windows apps run through runGame()/buildEnv() - never spawn wine directly somewhere else (prefix handling, Proton env and playtime live there). +- A prefix or wine build that a running app uses cannot be renamed/deleted + (wine holds its registry and user files open in that folder): the guards sit + next to the handlers in src/ipc/, use the games service's running list plus + the helpers in library.js (prefixNameForGame/gamesUsingPrefix/is*InUse) and + answer { ok:false, error:'inUse'|'hasApps', apps:[names] } for the toast. - CSS: reuse the existing variables/classes (.button, .field-label, .custom-select, form-card); spacing rhythm comes from --button-margin and the flex gap of the container, not per-element magic numbers. diff --git a/files/translations/en-us.json b/files/translations/en-us.json index 836c7b9..4f193d3 100644 --- a/files/translations/en-us.json +++ b/files/translations/en-us.json @@ -120,6 +120,9 @@ "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}", + "prefixes.inUse": "Cannot change prefix {0} while an app from it is running: {1}. Close it first.", + "prefixes.inUseHint": "An app is running from this prefix right now - close it before renaming.", + "prefixes.hasApps": "Cannot delete prefix {0} - these apps still use it: {1}. Point them at another prefix or remove them first.", "wines.title": "Wine versions", "wines.subtitle": "You can import your wine/proton builds here. Proton runs fully without Steam.", "wines.system": "System Wine", @@ -144,6 +147,7 @@ "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.inUse": "Cannot delete this Wine version while an app using it is running: {0}. Close it first.", "wines.invalidName": "Invalid name", "wines.import": "Import", "wines.importPick": "Choose the Wine/Proton build folder", diff --git a/files/translations/pl.json b/files/translations/pl.json index 9ed8d54..85dfe12 100644 --- a/files/translations/pl.json +++ b/files/translations/pl.json @@ -120,6 +120,9 @@ "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}", + "prefixes.inUse": "Nie można zmienić prefiksu {0}, gdy działa z niego aplikacja: {1}. Najpierw ją zamknij.", + "prefixes.inUseHint": "Właśnie działa aplikacja z tego prefiksu - zamknij ją przed zmianą nazwy.", + "prefixes.hasApps": "Nie można usunąć prefiksu {0} - używają go jeszcze aplikacje: {1}. Wskaż im inny prefiks albo usuń je najpierw.", "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", @@ -144,6 +147,7 @@ "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.inUse": "Nie można usunąć tej wersji Wine, gdy działa aplikacja, która jej używa: {0}. Najpierw ją zamknij.", "wines.invalidName": "Nieprawidłowa nazwa", "wines.import": "Importuj", "wines.importPick": "Wybierz folder z buildem Wine/Proton", diff --git a/renderer/app.js b/renderer/app.js index 7a1c30e..8875e49 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -921,6 +921,16 @@ async function doPrefixImport(prefix, srcDir) { } } +// Apps the launcher would name when it refuses a prefix rename/delete: a +// running app (blocking immediately) or a registered one (orphaned by a +// delete). Fetched fresh - the Prefixes view does not keep the library hot. +async function prefixUsers(name) { + const [games, running] = await Promise.all([window.plwl.gamesList(), window.plwl.gamesListRunning()]) + const runningSet = new Set(running || []) + const users = (games || []).filter(g => (g.prefix || state.prefixes.default || 'Main') === name) + return { all: users, running: users.filter(g => runningSet.has(g.id)) } +} + function renderPrefixes() { const list = qs('#prefix-list') list.innerHTML = '' @@ -952,10 +962,11 @@ function renderPrefixes() { if (renBtn) { renBtn.textContent = t('prefixes.rename') renBtn.onclick = async () => { + const users = await prefixUsers(p.name) const name = await promptModal({ title: t('prefixes.renameTitle'), label: t('prefixes.namePlaceholder'), - hint: t('prefixes.renameHint'), + hint: users.running.length ? t('prefixes.inUseHint') : t('prefixes.renameHint'), value: p.name, confirm: t('prefixes.rename') }) @@ -963,8 +974,12 @@ function renderPrefixes() { 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') + if (err === 'inUse') toast(t('prefixes.inUse', p.name, (res.apps || []).join(', ')), 'error') + else if (err === 'hasApps') toast(t('prefixes.hasApps', p.name, (res.apps || []).join(', ')), 'error') + else { + 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') @@ -979,17 +994,24 @@ function renderPrefixes() { defBtn.onclick = async () => { const res = await window.plwl.prefixesSetDefault(p.name) if (res && res.ok !== false) toast(t('toast.prefixDefault', p.name), 'success') + else if (res && res.error === 'inUse') toast(t('prefixes.inUse', p.name, (res.apps || []).join(', ')), 'error') + else if (res && res.error === 'notFound') toast(t('toast.prefixDeleteFailed', p.name), 'error') state.prefixes = await window.plwl.prefixesList() renderPrefixes() } const delBtn = row.querySelector('[data-act=delete]') delBtn.innerHTML = `${ICONS.trash()}${t('prefixes.delete')}` delBtn.onclick = async () => { + // refused before the confirm prompt, with the reason: the folder is + // either held by a running app or still referenced by registered ones + const users = await prefixUsers(p.name) + if (users.all.length) return toast(t('prefixes.hasApps', p.name, users.all.map(g => g.name).join(', ')), 'error') const ok = await confirmModal(t('prefixes.deleteConfirm', p.name), t('prefixes.delete')) if (!ok) return const res = await window.plwl.prefixesDelete(p.name) if (res.ok) toast(t('toast.prefixDeleted', p.name), 'success') else if (res.error === 'isMain') toast(t('prefixes.mainLocked'), 'error') + else if (res.error === 'inUse' || res.error === 'hasApps') toast(t('prefixes.hasApps', p.name, (res.apps || []).join(', ')), 'error') else toast(t('toast.prefixDeleteFailed', p.name), 'error') state.prefixes = await window.plwl.prefixesList() renderPrefixes() @@ -1076,6 +1098,8 @@ function renderWines() { state.settings = await window.plwl.settingsGet() } else if (res.error === 'isSystem') { toast(t('wines.systemLocked'), 'error') + } else if (res.error === 'inUse') { + toast(t('wines.inUse', (res.apps || []).join(', ')), 'error') } renderWines() } diff --git a/src/ipc/games.js b/src/ipc/games.js index b72c8bb..e9d620d 100644 --- a/src/ipc/games.js +++ b/src/ipc/games.js @@ -17,7 +17,13 @@ function sendToWindow(win, channel, data) { if (win && !win.isDestroyed()) win.webContents.send(channel, data) } -// Game-process state: id -> pid (or null while the process is starting). +// Game-process state: id -> { pid (null while the process is starting), the +// prefix name and wine id the run was resolved to }. Both are frozen here: +// the default prefix/wine can be switched and the app's own config edited +// while it plays, and the in-use guards have to name what the process really +// locked - not what the registry says now. Editing an app's own prefix while +// it runs does not move the process, so the new folder is blocked next to the +// snapshot (a pre-edit prefix does not exist in it yet). // onGamesChanged fires in the MAIN process whenever the running-games set // changes (the tray menu rebuilds itself from it). const STARTUP_GRACE_MS = 60000 @@ -47,10 +53,10 @@ function createGamesService({ getWindow, onGamesChanged }) { // restarted) would otherwise answer every later Play with "already // running". function prune() { - for (const [id, pid] of [...running]) { - if (alive(pid)) continue + for (const [id, entry] of [...running]) { + if (alive(entry.pid)) continue const age = Date.now() - (startedAt.get(id) || 0) - if (!pid && age < STARTUP_GRACE_MS) continue // may still be starting + if (!entry.pid && age < STARTUP_GRACE_MS) continue // may still be starting running.delete(id) startedAt.delete(id) } @@ -64,6 +70,22 @@ function createGamesService({ getWindow, onGamesChanged }) { prune() return [...running.keys()] } + // What the still-running games actually use, for the "is this prefix/wine in + // use?" guards: [{ id, name, prefixes: [snapshot, ...], wine }]. The + // snapshot alone goes stale when a running app is pointed at another prefix + // (the process keeps its folder open); the live name alone misses the folder + // it currently holds. Together they name every folder the run touches. + const runningInfo = () => { + prune() + const out = [] + for (const [id, entry] of running) { + const game = library.getGame(id) + const live = game && game.prefix ? library.prefixNameForGame(game) : '' + const prefixes = live && live !== entry.prefix ? [entry.prefix, live] : [entry.prefix] + out.push({ id, name: (game && game.name) || id, prefixes, wine: entry.wine }) + } + return out + } // "Open with PLWL" handoff: queue the file for the renderer (picker popup // with wine version + prefix). Safe to call at any time - if the window is @@ -90,16 +112,14 @@ function createGamesService({ getWindow, onGamesChanged }) { if (!game) return { ok: false, error: 'game-not-found' } prune() if (running.has(id)) return { ok: false, error: 'already-running' } - running.set(id, null) - startedAt.set(id, Date.now()) let wine try { wine = resolveWine(game) } catch { - running.delete(id) - startedAt.delete(id) return { ok: false, error: 'wine-not-found' } } + running.set(id, { pid: null, prefix: library.prefixNameForGame(game), wine: wine.id }) + startedAt.set(id, Date.now()) try { const res = await runGame({ game, wine, systemWine: wine.type === 'system', onExit: () => handleExit(id) }) // stop was requested while the process was still starting: kill it and @@ -120,7 +140,8 @@ function createGamesService({ getWindow, onGamesChanged }) { startedAt.delete(id) return { ok: false, error: 'not-started' } } - running.set(id, res.pid) + const entry = running.get(id) + if (entry) entry.pid = res.pid sendToWindow(win, 'games:changed') notifyChanged() return { ok: true, pid: res.pid, logFile: res.logFile || null } @@ -134,8 +155,8 @@ function createGamesService({ getWindow, onGamesChanged }) { // Stop always clears the state: whether the process was running, still // starting (kill request for as soon as it has a pid) or already gone. async function kill(id) { - const pid = running.get(id) - if (pid) killProcessGroup(pid) + const entry = running.get(id) + if (entry) killProcessGroup(entry.pid) else if (running.has(id)) killRequests.add(id) running.delete(id) startedAt.delete(id) @@ -177,7 +198,7 @@ function createGamesService({ getWindow, onGamesChanged }) { return importGameFromPrefix(prefix, srcDir) } - return { isRunning, listRunning, start, kill, runToolFor, import: importTo, importFromPrefix, setOnGameExit, openExternally } + return { isRunning, listRunning, runningInfo, start, kill, runToolFor, import: importTo, importFromPrefix, setOnGameExit, openExternally } } module.exports = { createGamesService, killProcessGroup, sendToWindow } diff --git a/src/ipc/index.js b/src/ipc/index.js index d7cbb78..701d50a 100644 --- a/src/ipc/index.js +++ b/src/ipc/index.js @@ -85,7 +85,15 @@ function registerIpc({ getWindow, onGamesChanged }) { 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:delete', (e, id) => { + // the build's files back the running prefix (dlls, stub exes) - deleting + // it under a running game breaks that game + if (library.isWineInUse(id, games.runningInfo())) { + const apps = games.runningInfo().filter(r => r.wine === id).map(r => r.name) + return { ok: false, error: 'inUse', apps } + } + return wines.deleteWine(id) + }) ipcMain.handle('wines:import', async (e, srcDir) => { try { return await wines.importWineFolder(srcDir) @@ -95,11 +103,37 @@ function registerIpc({ getWindow, onGamesChanged }) { }) // prefixes --------------------------------------------------------------------- + // A prefix folder must not move or disappear while a game runs in it: wine + // keeps the registry and user files open and would write into a folder that + // no longer exists (or, for a delete, lose them mid-run). The renderer shows + // these as a toast naming the blocking app. + const prefixInUse = name => { + const apps = library.appsUsingPrefix(name, games.runningInfo()) + return apps.length ? { ok: false, error: 'inUse', apps: apps.map(r => r.name) } : null + } 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)) + ipcMain.handle('prefixes:delete', (e, name) => { + const busy = prefixInUse(name) + if (busy) return busy + // deleting a prefix that still has apps registered in it would orphan + // them (they fall back to the default prefix and lose their files) + const users = library.gamesUsingPrefix(name).map(g => g.name) + if (users.length) return { ok: false, error: 'hasApps', apps: users } + return prefixes.deletePrefix(name) + }) + ipcMain.handle('prefixes:rename', (e, oldName, newName) => prefixInUse(oldName) || prefixes.renamePrefix(oldName, newName)) + ipcMain.handle('prefixes:setDefault', (e, name) => { + // apps without a prefix field of their own resolve the default at launch; + // switching it now would silently move their next run (a prefix import is + // pinned to its own name) + const defs = prefixes.listPrefixes() + if (name !== defs.default) { + const affected = library.gamesFollowingDefault(games.runningInfo()) + if (affected.length) return { ok: false, error: 'inUse', apps: affected.map(r => r.name) } + } + return prefixes.setDefaultPrefix(name) + }) // games: library ----------------------------------------------------------------- ipcMain.handle('games:list', () => library.loadGames()) diff --git a/src/library.js b/src/library.js index 0673e62..1b1afe7 100644 --- a/src/library.js +++ b/src/library.js @@ -82,6 +82,53 @@ function getGame(id) { return loadGames().find(g => g.id === id) || null } +// Which prefix a game's runs land in: an app without one follows the launcher +// default. The single place for that fallback (runner.buildEnv and the in-use +// guards both use it, so a rename/delete can never blame the wrong folder). +function prefixNameForGame(game) { + if (game && game.prefix) return game.prefix + const defs = readJson(path.join(DIRS.DATA, 'prefixes.json'), { default: 'Main', prefixes: [] }) + return defs.default || 'Main' +} + +// Apps whose runs land in this prefix - used to keep a prefix that any app +// still references from being deleted out from under it. +function gamesUsingPrefix(name) { + if (!name) return [] + return loadGames().filter(g => prefixNameForGame(g) === name) +} + +// In-use guards for registry edits: a prefix folder being renamed or deleted +// (or a wine build deleted) while a game runs in it would leave that process +// without its registry/files - the folder move/removal has to wait until the +// game stops. `running` is the games service's list of live runs +// ([{ name, prefixes, wine }]); prefixes lists every folder a run holds (its +// snapshot plus a new one it was pointed at mid-run). +function appsUsingPrefix(name, running) { + if (!name) return [] + return (running || []).filter(r => (r.prefixes || []).includes(name)) +} + +function isPrefixInUse(name, running) { + return appsUsingPrefix(name, running).length > 0 +} + +// A run with no explicit wine falls back to System Wine, which cannot be +// deleted anyway - only an exact match on a registered build counts. +function isWineInUse(id, running) { + if (!id || id === 'system') return false + return (running || []).some(r => r.wine === id) +} + +// Switching the default prefix would silently move the next launch of every +// app that has no prefix of its own - so it waits while any of them runs. +function gamesFollowingDefault(running) { + return (running || []).filter(r => { + const game = getGame(r.id) + return !game || !game.prefix + }) +} + function addGame(game) { const games = loadGames() games.push(game) @@ -207,9 +254,7 @@ function renamePrefixRefs(oldName, newName) { } 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') + return prefixPath(prefixNameForGame(game)) } -module.exports = { loadGames, saveGames, portableGame, getGame, addGame, updateGame, removeGame, reorderGames, renamePrefixRefs, loadSettings, saveSettings, newGame, newGameId, resolvePrefixDir, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile } +module.exports = { loadGames, saveGames, portableGame, getGame, addGame, updateGame, removeGame, reorderGames, renamePrefixRefs, prefixNameForGame, gamesUsingPrefix, appsUsingPrefix, isPrefixInUse, isWineInUse, gamesFollowingDefault, loadSettings, saveSettings, newGame, newGameId, resolvePrefixDir, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile } diff --git a/src/runner.js b/src/runner.js index 897249d..c27a827 100644 --- a/src/runner.js +++ b/src/runner.js @@ -5,6 +5,7 @@ const fs = require('fs') const { spawn } = require('child_process') const { DIRS } = require('./paths') const { prefixPath } = require('./prefixes') +const { prefixNameForGame } = require('./library') // The fake Steam install Proton is pointed at through // STEAM_COMPAT_CLIENT_INSTALL_PATH. Both folders Proton links into its prefix @@ -18,13 +19,6 @@ function ensureFakeSteam() { return fake } -// The prefix a run will use, by NAME (buildEnv resolves the same fallback). -function prefixNameFor(game) { - if (game && game.prefix) return game.prefix - const defs = require('./prefixes').listPrefixes() - return defs.default || 'default' -} - // Wine only ever repairs links that are BROKEN: a prefix that came from // another machine (or from an older PLWL) can still hold absolute links into // its build that happen to resolve here, and those would be synced out again. @@ -32,7 +26,7 @@ function prefixNameFor(game) { // costs a single walk in the common (already relative) case. const healedPrefixes = new Set() async function healPrefixOnce(game) { - const name = prefixNameFor(game) + const name = prefixNameForGame(game) const key = `${name}\u0000${DIRS.BUILD}` if (!name || healedPrefixes.has(key)) return 0 healedPrefixes.add(key) @@ -78,11 +72,9 @@ function linkPrefix(compatData, prefixDir) { function buildEnv({ game, wine }) { const env = { ...process.env } const isProton = wine && wine.type === 'proton' - let prefixDir = game.prefix ? prefixPath(game.prefix) : null - if (!prefixDir) { - const defs = require('./prefixes').listPrefixes() - prefixDir = prefixPath(defs.default || 'default') - } + // same fallback the games service snapshots into its running entries + // (library.prefixNameForGame), so in-use checks always name this folder + const prefixDir = prefixPath(prefixNameForGame(game)) if (isProton) { const fakeSteam = ensureFakeSteam() From 5e342b6fe5b4497a1cd2d8900fcf983195cb764b Mon Sep 17 00:00:00 2001 From: olcxja Date: Fri, 2 Oct 2026 15:19:00 +0200 Subject: [PATCH 4/7] show progress for imports and deletions --- development.md | 7 ++ files/translations/en-us.json | 9 ++ files/translations/pl.json | 9 ++ renderer/app.js | 82 ++++++++++++--- renderer/style.css | 5 + src/fsutil.js | 182 +++++++++++++++++++++++++++++++++- src/importer.js | 52 +++------- src/ipc/index.js | 12 +-- src/lansync.js | 40 +------- src/library.js | 15 ++- src/paths.js | 4 +- src/prefixes.js | 15 ++- src/wines.js | 26 +++-- 13 files changed, 343 insertions(+), 115 deletions(-) diff --git a/development.md b/development.md index 8d6c3a6..ebafbfd 100644 --- a/development.md +++ b/development.md @@ -20,6 +20,8 @@ src/ main process (Node/Electron backend) prefixes.js prefix registry (files/wineprefixes) symlinks.js relative symlink rewiring + executable-bit restoration (shared by imports, prefixes and LAN sync) + fsutil.js tree copy/remove with byte progress + rate-gated progress + events (imports, prefix/wine/app deletion, LAN sync) importer.js app import (copy/move/leave/prefix) names.js user-typed name sanitizing (prefix folder names, wine display names) @@ -73,6 +75,11 @@ handlers in src/ipc/. Keep new APIs in that pattern. next to the handlers in src/ipc/, use the games service's running list plus the helpers in library.js (prefixNameForGame/gamesUsingPrefix/is*InUse) and answer { ok:false, error:'inUse'|'hasApps', apps:[names] } for the toast. +- Long operations stream progress through the single import:progress slot as + { phase: 'count'|'copy'|'remove', files, bytes, totalFiles, totalBytes } and + the renderer drives a bar from it (runWithProgress for deletes/imports). + Emit the 'count' phase first (a treeSize walk) and copy/remove through the + fsutil helpers; never ship a progress bar no data can move. - CSS: reuse the existing variables/classes (.button, .field-label, .custom-select, form-card); spacing rhythm comes from --button-margin and the flex gap of the container, not per-element magic numbers. diff --git a/files/translations/en-us.json b/files/translations/en-us.json index 4f193d3..fae2169 100644 --- a/files/translations/en-us.json +++ b/files/translations/en-us.json @@ -20,6 +20,7 @@ "library.running": "Running", "library.config": "Configure", "library.remove": "Remove", + "library.removing": "Removing {0}…", "library.openFolder": "Open folder", "library.lastPlayed": "Last played: {0}", "library.playtime": "Playtime: {0}", @@ -48,7 +49,13 @@ "import.prefix.noDriveC": "this prefix has no drive_c yet, run any app in it so wine creates one", "import.pickFolder": "Choose the app folder", "import.progress": "Importing {0}…", + "import.adding": "Adding {0}…", + "import.prefix.registering": "Registering the app from the prefix - nothing is copied, it keeps running where it is.", "import.fileDone": "{0} files copied", + "import.phase.count": "Counting files…", + "import.phase.copy": "Copying files: {0} / {1}", + "import.phase.remove": "Deleting: {0} / {1}", + "import.bytes": "{0} / {1}", "import.done": "Import finished", "import.failed": "Import failed: {0}", "import.noExe": "no .exe file found in the selected folder", @@ -107,6 +114,7 @@ "prefixes.default": "Default", "prefixes.makeDefault": "Set as default", "prefixes.delete": "Delete", + "prefixes.deleting": "Deleting prefix {0}…", "prefixes.deleteConfirm": "Delete prefix {0} and all of its data?", "prefixes.empty": "No prefixes yet. Create one, or let wine create the default on first run.", "prefixes.created": "Prefix {0} created", @@ -139,6 +147,7 @@ "wines.makeDefault": "Set as default", "wines.defaultSet": "{0} is now the default Wine version", "wines.delete": "Delete", + "wines.deleting": "Deleting {0}…", "wines.deleteConfirm": "Delete {0} from files/wines?\nThe build folder will be permanently removed.", "wines.deleted": "{0} deleted", "wines.systemLocked": "System Wine cannot be renamed or deleted", diff --git a/files/translations/pl.json b/files/translations/pl.json index 85dfe12..0decbf4 100644 --- a/files/translations/pl.json +++ b/files/translations/pl.json @@ -20,6 +20,7 @@ "library.running": "Uruchomiona", "library.config": "Konfiguracja", "library.remove": "Usuń", + "library.removing": "Usuwanie {0}…", "library.openFolder": "Otwórz folder", "library.lastPlayed": "Ostatnio grane: {0}", "library.playtime": "Czas gry: {0}", @@ -48,7 +49,13 @@ "import.prefix.noDriveC": "ten prefiks nie ma jeszcze drive_c, uruchom w nim dowolną apkę, żeby wine go utworzył", "import.pickFolder": "Wybierz folder aplikacji", "import.progress": "Importowanie {0}…", + "import.adding": "Dodawanie {0}…", + "import.prefix.registering": "Aplikacja jest rejestrowana z prefiksu - nic nie jest kopiowane, działa dalej tam, gdzie jest.", "import.fileDone": "Skopiowane pliki: {0}", + "import.phase.count": "Liczenie plików…", + "import.phase.copy": "Kopiowanie plików: {0} / {1}", + "import.phase.remove": "Usuwanie: {0} / {1}", + "import.bytes": "{0} / {1}", "import.done": "Import zakończony", "import.failed": "Import nie powiódł się: {0}", "import.noExe": "nie znaleziono pliku .exe w wybranym folderze", @@ -107,6 +114,7 @@ "prefixes.default": "Domyślny", "prefixes.makeDefault": "Ustaw jako domyślny", "prefixes.delete": "Usuń", + "prefixes.deleting": "Usuwanie prefiksu {0}…", "prefixes.deleteConfirm": "Usunąć prefiks {0} wraz z całą jego zawartością?", "prefixes.empty": "Brak prefiksów. Utwórz jeden, albo pozwól wine utworzyć domyślny przy pierwszym uruchomieniu.", "prefixes.created": "Utworzono prefiks {0}", @@ -139,6 +147,7 @@ "wines.makeDefault": "Ustaw jako domyślną", "wines.defaultSet": "{0} jest teraz domyślną wersją Wine", "wines.delete": "Usuń", + "wines.deleting": "Usuwanie {0}…", "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 zmienić ani usunąć", diff --git a/renderer/app.js b/renderer/app.js index 8875e49..8667258 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -628,7 +628,11 @@ function renderLibrary() { : (inPrefix ? t('library.removePrefixNote') : '') const ok = await confirmModal(t('library.removeConfirm', game.name, note), t('library.remove')) if (!ok) return - await window.plwl.gamesRemove(game.id) + // portable apps delete their folder too - show how far that got + await runWithProgress({ + title: t('library.removing', game.name), + action: () => window.plwl.gamesRemove(game.id) + }) toast(t('toast.gameRemoved', game.name), 'success') state.games = await window.plwl.gamesList() renderLibrary() @@ -755,14 +759,22 @@ async function doImport(srcDir, mode) { const name = srcDir.split('/').filter(Boolean).pop() const modal = showModal(`

${t('import.progress', name)}

-
-

+
+

`) let count = 0 - window.plwl.onImportProgress(() => { + window.plwl.onImportProgress(data => { count++ + const bar = modal.querySelector('#import-progress') + const pct = modal.querySelector('.progress-pct') const status = modal.querySelector('#import-status') - if (status) status.textContent = t('import.fileDone', count) + // byte totals drive the bar when the copy knows them; a rename or a + // symlink-only run sends plain path events and only bumps the count + if (bar && data && data.totalBytes) { + renderProgressBar(bar, data.bytes, data.totalBytes) + if (pct) pct.textContent = Math.round(((data.bytes || 0) / data.totalBytes) * 100) + '%' + } + if (status) status.textContent = t('import.fileDone', (data && data.files) || count) }) try { const game = await window.plwl.gamesImport(srcDir, mode) @@ -895,9 +907,11 @@ function wireOpenWith() { // copied, paths stay prefix-relative. async function doPrefixImport(prefix, srcDir) { const name = srcDir.split('/').filter(Boolean).pop() - const modal = showModal(` -

${t('import.progress', name)}

-
+ // nothing is copied here - the app is just registered in place, so there is + // no progress to show (no bar that would sit at zero) + showModal(` +

${t('import.adding', name)}

+

${t('import.prefix.registering')}

`) try { const game = await window.plwl.gamesImportFromPrefix(prefix, srcDir) @@ -1008,7 +1022,11 @@ function renderPrefixes() { if (users.all.length) return toast(t('prefixes.hasApps', p.name, users.all.map(g => g.name).join(', ')), 'error') const ok = await confirmModal(t('prefixes.deleteConfirm', p.name), t('prefixes.delete')) if (!ok) return - const res = await window.plwl.prefixesDelete(p.name) + // removing a whole windows drive takes a while: measured progress modal + const res = await runWithProgress({ + title: t('prefixes.deleting', p.name), + action: () => window.plwl.prefixesDelete(p.name) + }) if (res.ok) toast(t('toast.prefixDeleted', p.name), 'success') else if (res.error === 'isMain') toast(t('prefixes.mainLocked'), 'error') else if (res.error === 'inUse' || res.error === 'hasApps') toast(t('prefixes.hasApps', p.name, (res.apps || []).join(', ')), 'error') @@ -1091,7 +1109,10 @@ function renderWines() { delBtn.onclick = async () => { const ok = await confirmModal(t('wines.deleteConfirm', w.label), t('wines.delete')) if (!ok) return - const res = await window.plwl.winesDelete(w.id) + const res = await runWithProgress({ + title: t('wines.deleting', w.label), + action: () => window.plwl.winesDelete(w.id) + }) if (res.ok) { toast(t('wines.deleted', w.label), 'success') state.wines = await window.plwl.winesList() @@ -1398,6 +1419,37 @@ function barValues(info) { return [bytes, total] } +// One modal for operations with a measurable size (import, delete, long +// copies): the bar follows BYTES for the same reason the sync bar does, and a +// percent label sits next to it. Main-process work streams through the single +// import:progress slot (preload keeps only the newest listener), so the modal +// is the only thing showing progress at a time. `action` is the ipc call; +// the modal closes itself when it settles. +async function runWithProgress({ title, action }) { + const modal = showModal(` +

${title}

+
+

${t('import.phase.count')}

+ `) + const bar = modal.querySelector('progress') + const pct = modal.querySelector('.progress-pct') + const status = modal.querySelector('#op-status') + window.plwl.onImportProgress(data => { + if (!data || !data.phase) return + // the counting pass reports its own phase with the totals; the bar starts + // moving once the real units arrive + if (data.phase === 'count') return + renderProgressBar(bar, data.bytes, data.totalBytes) + pct.textContent = data.totalBytes ? Math.round(((data.bytes || 0) / data.totalBytes) * 100) + '%' : '' + if (data.totalFiles) status.textContent = t('import.phase.' + data.phase, data.files || 0, data.totalFiles) + }) + try { + return await action() + } finally { + closeModal() + } +} + // "340 MB / 1.2 GB" once the total is known (the sending side learns it as it // plans the units, the receiving side as the manifests arrive), otherwise // just what has moved so far - so the line is never a lying zero. @@ -1848,12 +1900,10 @@ function wireEvents() { qs('#btn-wines-import').addEventListener('click', async () => { const srcDir = await window.plwl.dialogPickFile({ properties: ['openDirectory'], title: t('wines.importPick') }) if (!srcDir) return - const modal = showModal(` -

${t('wines.importing')}

-
- `) - const res = await window.plwl.winesImport(srcDir) - closeModal() + const res = await runWithProgress({ + title: t('wines.importing'), + action: () => window.plwl.winesImport(srcDir) + }) if (res.ok) { toast(t('wines.imported', res.path), 'success') state.wines = await window.plwl.winesList() diff --git a/renderer/style.css b/renderer/style.css index 23911a9..b05d345 100644 --- a/renderer/style.css +++ b/renderer/style.css @@ -797,5 +797,10 @@ progress { background: rgba(255, 255, 255, 0.08); -webkit-appearance: none; } +.progress-pct { + min-width: 2.6rem; + text-align: right; + font-variant-numeric: tabular-nums; +} progress::-webkit-progress-bar { background: rgba(255, 255, 255, 0.08); border-radius: 0.4rem; } progress::-webkit-progress-value { background: var(--text-color); border-radius: 0.4rem; } diff --git a/src/fsutil.js b/src/fsutil.js index 1d57bc1..119dce1 100644 --- a/src/fsutil.js +++ b/src/fsutil.js @@ -1,6 +1,7 @@ 'use strict' const fs = require('fs') +const fsp = require('fs').promises const path = require('path') // Returns a non-existing path in `base` named `name` (or `name (2)`, `name (3)`...). @@ -12,4 +13,183 @@ function dedupeDir(base, name) { return path.join(base, `${name} (${n})`) } -module.exports = { dedupeDir } +// Progress is cheap to produce and expensive to render: hashing or copying a +// tree can emit tens of thousands of events, and forwarding every single one +// over IPC makes the window stutter instead of showing progress. Events are +// rate limited to ~10/s; `force` lets a caller push the value that matters +// (the end of a unit) through immediately. +const PROGRESS_MIN_MS = 100 + +function progressGate(emit) { + let last = 0 + let pending = null + let timer = null + const flush = () => { + timer = null + if (!pending) return + last = Date.now() + const data = pending + pending = null + emit(data) + } + const push = (data, force) => { + pending = data + const wait = PROGRESS_MIN_MS - (Date.now() - last) + if (force || wait <= 0) { + if (timer) clearTimeout(timer) + flush() + } else if (!timer) { + timer = setTimeout(flush, wait) + if (timer.unref) timer.unref() + } + } + push.done = () => { + if (timer) clearTimeout(timer) + flush() + } + return push +} + +// What a tree copy will have to move. Symlinks have no size of their own +// (they are recreated, not read), so they only count for the file total. +// `skipDir` is a single directory name to leave out (a game folder's +// redistributables) - walking the whole tree first is fast next to the copy. +async function treeSize(root) { + let files = 0 + let bytes = 0 + const walk = async dir => { + let entries + try { + entries = await fsp.readdir(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const full = path.join(dir, entry.name) + if (entry.isSymbolicLink()) { + files++ + } else if (entry.isDirectory()) { + await walk(full) + } else if (entry.isFile()) { + files++ + try { + bytes += (await fsp.stat(full)).size + } catch {} + } + } + } + await walk(root) + return { files, bytes } +} + +// Copy a directory tree the way an imported game/wine build has to arrive: +// symlinks recreated as they are (copyFile would silently drop them) and file +// permissions carried over (copyFile writes 0644, killing scripts and any +// binary the game executes itself). Reports cumulative { done, bytes } after +// every file through `onProgress`, so long copies can drive a progress bar. +async function copyTree(src, dst, onProgress) { + let done = 0 + let bytes = 0 + const gate = onProgress ? progressGate(onProgress) : null + const tick = () => { + if (gate) gate({ done, bytes }, false) + } + const walk = async (from, to) => { + let entries + try { + entries = await fsp.readdir(from, { withFileTypes: true }) + } catch { + return + } + await fsp.mkdir(to, { recursive: true }) + for (const entry of entries) { + const s = path.join(from, entry.name) + const d = path.join(to, entry.name) + if (entry.isSymbolicLink()) { + let target = null + try { + target = await fsp.readlink(s) + } catch {} + if (!target) continue + try { + await fsp.symlink(target, d) + } catch {} + done++ + tick() + } else if (entry.isDirectory()) { + await walk(s, d) + } else if (entry.isFile()) { + let size = 0 + try { + size = (await fsp.stat(s)).size + } catch {} + try { + await fsp.copyFile(s, d) + await fsp.chmod(d, (await fsp.stat(s)).mode & 0o777) + } catch {} + done++ + bytes += size + tick() + } + } + } + await walk(src, dst) + if (gate) gate({ done, bytes }, true) + return { done, bytes } +} + +// Delete a tree deepest-first so a long removal (a multi-GB prefix) can drive +// a progress bar instead of freezing the window on one rmSync. Symlinks are +// unlinked, never followed. Reports cumulative { files, bytes, dirs } through +// `onProgress`; a missing tree is simply done. +async function removeTree(root, onProgress) { + let files = 0 + let dirs = 0 + let bytes = 0 + const gate = onProgress ? progressGate(onProgress) : null + const tick = () => { + if (gate) gate({ files, dirs, bytes }, false) + } + const walk = async dir => { + let entries + try { + entries = await fsp.readdir(dir, { withFileTypes: true }) + } catch { + return false + } + for (const entry of entries) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + await walk(full) + try { + await fsp.rmdir(full) + } catch {} + dirs++ + tick() + continue + } + let size = 0 + try { + const st = await fsp.lstat(full) + size = st.isFile() ? st.size : 0 + } catch {} + try { + await fsp.unlink(full) + } catch {} + files++ + bytes += size + tick() + } + try { + await fsp.rmdir(dir) + dirs++ + } catch {} + return true + } + await walk(root) + if (gate) gate({ files, dirs, bytes }, true) + return { files, dirs, bytes } +} + +module.exports = { dedupeDir, copyTree, treeSize, removeTree, progressGate } + diff --git a/src/importer.js b/src/importer.js index ffd10e4..1fb015b 100644 --- a/src/importer.js +++ b/src/importer.js @@ -7,45 +7,16 @@ const { newGame, newGameId, addGame } = require('./library') const { extractIconFromExe } = require('./icons') const { listPrefixes, prefixDriveRoot } = require('./prefixes') const { rewireSymlinks } = require('./symlinks') +const { copyTree, treeSize } = require('./fsutil') // Directories that typically hold installers/redistributables, not the game. const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc']) -// A game folder has to arrive as it was: symlinks are recreated (copyFile -// silently dropped them, so a linked asset or save folder vanished) and the -// permission bits are carried over (copyFile writes 0644, killing scripts and -// anything the game executes itself). -function copyRecursive(src, dst, onProgress) { - return fsp.readdir(src, { withFileTypes: true }).then(async entries => { - await fsp.mkdir(dst, { recursive: true }) - for (const e of entries) { - const s = path.join(src, e.name) - const d = path.join(dst, e.name) - if (e.isSymbolicLink()) { - let target = null - try { - target = await fsp.readlink(s) - } catch {} - if (!target) continue - try { - await fsp.symlink(target, d) - } catch {} - if (onProgress) onProgress(s) - } else if (e.isDirectory()) { - await copyRecursive(s, d, onProgress) - } else if (e.isFile()) { - await fsp.copyFile(s, d) - try { - await fsp.chmod(d, (await fsp.stat(s)).mode & 0o777) - } catch {} - if (onProgress) onProgress(s) - } - } - }) -} - +// The copy itself lives in fsutil (copyTree): symlinks recreated, permissions +// carried over, byte-level progress. moveFolder only falls back to it across +// filesystems; a same-device rename reports nothing (nothing is copied). function moveFolder(src, dst, onProgress) { - const fallback = () => copyRecursive(src, dst, onProgress).then(() => fsp.rm(src, { recursive: true, force: true })) + const fallback = () => copyTree(src, dst, onProgress).then(() => fsp.rm(src, { recursive: true, force: true })) if (path.parse(src).root !== path.parse(dst).root) return fallback() return fsp.rename(src, dst).catch(fallback) } @@ -62,8 +33,15 @@ async function importGame(srcDir, mode, onProgress) { let workdir = srcDir if (mode === 'copy' || mode === 'move') { const dst = path.join(DIRS.APPS, id) - if (mode === 'copy') await copyRecursive(srcDir, dst, onProgress) - else await moveFolder(srcDir, dst, onProgress) + if (mode === 'copy') { + // measured before it starts, so the bar can follow the bytes landing + const emit = (phase, detail) => { + if (onProgress) onProgress({ phase, label: name, ...detail }) + } + emit('count', { files: 0, bytes: 0 }) + const total = await treeSize(srcDir) + await copyTree(srcDir, dst, p => emit('copy', { files: p.done, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes })) + } else await moveFolder(srcDir, dst, onProgress) workdir = dst // anything the game linked to INSIDE the launcher data (its own folder, an // icon, a shared prefix file) becomes a relative link, so the imported app @@ -156,4 +134,4 @@ async function findDefaultExe(dir) { return best } -module.exports = { importGame, importGameFromPrefix, findDefaultExe, copyRecursive, moveFolder } +module.exports = { importGame, importGameFromPrefix, findDefaultExe, moveFolder } diff --git a/src/ipc/index.js b/src/ipc/index.js index 701d50a..03b23a4 100644 --- a/src/ipc/index.js +++ b/src/ipc/index.js @@ -85,18 +85,18 @@ function registerIpc({ getWindow, onGamesChanged }) { 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) => { + ipcMain.handle('wines:delete', async (e, id) => { // the build's files back the running prefix (dlls, stub exes) - deleting // it under a running game breaks that game if (library.isWineInUse(id, games.runningInfo())) { const apps = games.runningInfo().filter(r => r.wine === id).map(r => r.name) return { ok: false, error: 'inUse', apps } } - return wines.deleteWine(id) + return wines.deleteWine(id, data => sendToWindow(getWindow(), 'import:progress', data)) }) ipcMain.handle('wines:import', async (e, srcDir) => { try { - return await wines.importWineFolder(srcDir) + return await wines.importWineFolder(srcDir, data => sendToWindow(getWindow(), 'import:progress', data)) } catch (err) { return errorResult(err) } @@ -113,14 +113,14 @@ 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) => { + ipcMain.handle('prefixes:delete', async (e, name) => { const busy = prefixInUse(name) if (busy) return busy // deleting a prefix that still has apps registered in it would orphan // them (they fall back to the default prefix and lose their files) const users = library.gamesUsingPrefix(name).map(g => g.name) if (users.length) return { ok: false, error: 'hasApps', apps: users } - return prefixes.deletePrefix(name) + return prefixes.deletePrefix(name, data => sendToWindow(getWindow(), 'import:progress', data)) }) ipcMain.handle('prefixes:rename', (e, oldName, newName) => prefixInUse(oldName) || prefixes.renamePrefix(oldName, newName)) ipcMain.handle('prefixes:setDefault', (e, name) => { @@ -144,7 +144,7 @@ function registerIpc({ getWindow, onGamesChanged }) { ipcMain.handle('games:reorder', (e, ids) => library.reorderGames(ids)) ipcMain.handle('games:remove', (e, id) => { shortcuts.removeShortcut(id) - return library.removeGame(id) + return library.removeGame(id, data => sendToWindow(getWindow(), 'import:progress', data)) }) ipcMain.handle('games:createShortcut', (e, id) => { const game = library.getGame(id) diff --git a/src/lansync.js b/src/lansync.js index e13fdc8..0d39e71 100644 --- a/src/lansync.js +++ b/src/lansync.js @@ -17,6 +17,7 @@ const readline = require('readline') const { DIRS, readJson, writeJson, toPortablePath } = require('./paths') const { rewireSymlinks, restoreExecutableBits } = require('./symlinks') +const { progressGate } = require('./fsutil') const library = require('./library') const wines = require('./wines') const prefixes = require('./prefixes') @@ -375,45 +376,6 @@ function ownedRoot(rel) { return SYNC_ROOTS.some(prefix => clean.startsWith(prefix)) ? clean : null } -// Progress is cheap to produce and expensive to render: hashing or copying a -// prefix can emit tens of thousands of events, and forwarding every single one -// over IPC makes the window stutter instead of showing progress. Events are -// rate limited to ~10/s per phase; `force` lets a caller push the value that -// matters (the end of a unit) through immediately. -const PROGRESS_MIN_MS = 100 - -function progressGate(emit) { - let lastAt = 0 - let pending = null - let timer = null - const flush = () => { - timer = null - if (!pending) return - const data = pending - pending = null - lastAt = Date.now() - emit(data) - } - return (data, force) => { - const now = Date.now() - if (force || now - lastAt >= PROGRESS_MIN_MS) { - if (timer) { - clearTimeout(timer) - timer = null - } - pending = null - lastAt = now - emit(data) - return - } - pending = data - if (!timer) { - timer = setTimeout(flush, PROGRESS_MIN_MS - (now - lastAt)) - if (timer.unref) timer.unref() - } - } -} - async function mapLimit(items, limit, fn) { const out = new Array(items.length) let next = 0 diff --git a/src/library.js b/src/library.js index 1b1afe7..309d3c9 100644 --- a/src/library.js +++ b/src/library.js @@ -160,15 +160,22 @@ function clearPlaytime(id) { return updateGame(id, { playSeconds: 0, lastPlayed: 0 }) } -function removeGame(id) { +// A portable app's folder is removed along with its registry entry - measured +// first (staging) so the renderer can show a bar while the tree is walked, +// instead of freezing on one rmSync of a multi-GB game. +async function removeGame(id, onProgress) { const games = loadGames() const idx = games.findIndex(g => g.id === id) if (idx === -1) return false const g = games[idx] if (g.imported && g.path && g.path.startsWith(DIRS.APPS)) { - try { - require('fs').rmSync(g.path, { recursive: true, force: true }) - } catch {} + const emit = (phase, detail) => { + if (onProgress) onProgress({ phase, label: g.name || id, ...detail }) + } + const { treeSize, removeTree } = require('./fsutil') + emit('count', { files: 0, bytes: 0 }) + const total = await treeSize(g.path) + await removeTree(g.path, p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes })) } // prefix imports live inside the prefix: deleting launcher files would // nuke the user's prefix (installed DLLs, saves of every other app), so diff --git a/src/paths.js b/src/paths.js index 598cf6e..f99e020 100644 --- a/src/paths.js +++ b/src/paths.js @@ -58,7 +58,9 @@ const DIRS = { // the bundle does not ship (user-added languages) are left untouched. function filesDiffer(a, b) { try { - return !fs.readFileSync(a).equals(fs.readFileSync(b)) + // compare as text on purpose: Buffer.equals() throws on a string, and that + // throw was read as "differs", so every boot recopied every translation + return fs.readFileSync(a, 'utf8') !== fs.readFileSync(b, 'utf8') } catch { return true // target missing/unreadable -> needs a (re)copy } diff --git a/src/prefixes.js b/src/prefixes.js index 84f51f5..81befde 100644 --- a/src/prefixes.js +++ b/src/prefixes.js @@ -4,6 +4,7 @@ const path = require('path') const fs = require('fs') const { DIRS, readJson, writeJson } = require('./paths') const { rewireSymlinks } = require('./symlinks') +const { removeTree, treeSize } = require('./fsutil') const { normalizePathName } = require('./names') const REGISTRY = path.join(DIRS.DATA, 'prefixes.json') @@ -71,15 +72,21 @@ function createPrefix(name) { return { ok: true, name: clean } } -function deletePrefix(name) { +// A prefix is a whole windows drive - removal is measured before it starts +// (staging) so the UI can show a bar that actually moves while the tree is +// walked file by file (removeTree), instead of one blocking rmSync. +async function deletePrefix(name, onProgress) { if (String(name).toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'isMain' } const defs = listPrefixes() defs.prefixes = defs.prefixes.filter(p => p.name !== name) if (defs.default === name) defs.default = MAIN savePrefixes(defs) - try { - fs.rmSync(prefixPath(name), { recursive: true, force: true }) - } catch {} + const emit = (phase, detail) => { + if (onProgress) onProgress({ phase, label: name, ...detail }) + } + emit('count', { files: 0, bytes: 0 }) + const total = await treeSize(prefixPath(name)) + await removeTree(prefixPath(name), p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes })) return { ok: true } } diff --git a/src/wines.js b/src/wines.js index 683dd2c..5f7bf72 100644 --- a/src/wines.js +++ b/src/wines.js @@ -4,7 +4,7 @@ const fs = require('fs') const fsp = fs.promises const path = require('path') const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths') -const { dedupeDir } = require('./fsutil') +const { dedupeDir, copyTree, treeSize, removeTree } = require('./fsutil') const { normalizeLabel } = require('./names') const { rewireSymlinks, restoreExecutableBits, EXEC_NAMES } = require('./symlinks') @@ -210,13 +210,18 @@ function setDefaultWine(id) { return { ok: true, defaultWine: id } } -function deleteWine(id) { +// Same measured removal as a prefix (staging, then a bar driven by the files +// walking out): a build folder is multi-GB. +async function deleteWine(id, onProgress) { const wines = getWines() const w = wines.find(x => x.id === id) if (!w || w.system) return { ok: false, error: w ? 'isSystem' : 'notFound' } - try { - fs.rmSync(w.path, { recursive: true, force: true }) - } catch {} + const emit = (phase, detail) => { + if (onProgress) onProgress({ phase, label: w.label || w.name || id, ...detail }) + } + emit('count', { files: 0, bytes: 0 }) + const total = await treeSize(w.path) + await removeTree(w.path, p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes })) writeJson(REGISTRY, wines.filter(x => x.id !== id).map(wineRel)) if (getDefaultWine() === id) require('./library').saveSettings({ defaultWine: 'system' }) return { ok: true } @@ -281,7 +286,7 @@ function buildAuditFailed(root, wineBin) { return false } -async function importWineFolder(srcDir) { +async function importWineFolder(srcDir, onProgress) { const type = detectType(srcDir) let copySrc = srcDir if (!type) { @@ -294,7 +299,14 @@ async function importWineFolder(srcDir) { if (build) copySrc = path.join(srcDir, build.name) } const dst = dedupeDir(WINES_DIR, path.basename(copySrc)) - await fsp.cp(copySrc, dst, { recursive: true, verbatimSymlinks: true }) + // a wine/proton build is big enough that the copy needs a real bar: count it + // first (staging), then report bytes as they land while it copies + const emit = (phase, detail) => { + if (onProgress) onProgress({ phase, label: path.basename(dst), ...detail }) + } + emit('count', { files: 0, bytes: 0 }) + const total = await treeSize(copySrc) + await copyTree(copySrc, dst, p => emit('copy', { files: p.done, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes })) let healed = { links: 0, executables: 0 } try { healed = await healBuild(dst) From c8dd0919bee9ae6c0a3e412fc6fbd4300f6777f1 Mon Sep 17 00:00:00 2001 From: olcxja Date: Fri, 2 Oct 2026 16:15:58 +0200 Subject: [PATCH 5/7] clear the drag fade when a card is dropped in place --- renderer/app.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/renderer/app.js b/renderer/app.js index 8667258..fc18481 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -518,6 +518,8 @@ function wireCardDrag(card, game) { }) card.addEventListener('dragend', () => { dragId = null + // a drop in place never re-renders the list, so drop the fade here too + card.classList.remove('dragging') clearDropMarks() }) card.addEventListener('dragover', e => { From 02b56b65342a6ddcd2e91c69f37a4bf41684094b Mon Sep 17 00:00:00 2001 From: olcxja Date: Fri, 2 Oct 2026 16:30:46 +0200 Subject: [PATCH 6/7] center badge labels --- renderer/style.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/renderer/style.css b/renderer/style.css index b05d345..e2b89a6 100644 --- a/renderer/style.css +++ b/renderer/style.css @@ -559,7 +559,10 @@ select { } .badge { font-size: 0.72rem; - padding: 0.1rem 0.5rem; + /* Nunito keeps a lot of air above the caps, so a line box centred by its + font metrics reads as "text sits high" (clearest on "Portable"); bias the + padding downwards, keeping the same overall height, to centre the label */ + padding: 0.25em 0.7em 0.03em; border-radius: 0.6rem; border: var(--border-width) solid var(--light-border-color); color: var(--darker-text-color); From 3e054dd9de5d241b2628de4dc28d5da5ead2acfe Mon Sep 17 00:00:00 2001 From: olcxja Date: Fri, 2 Oct 2026 16:39:37 +0200 Subject: [PATCH 7/7] bump to 0.3.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4f7fb7f..67a274d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "PLWL", "productName": "PLWL", - "version": "0.1.3", + "version": "0.3.0", "description": "Portable Linux Wine Launcher", "main": "src/main.js", "scripts": {