From 5e342b6fe5b4497a1cd2d8900fcf983195cb764b Mon Sep 17 00:00:00 2001 From: olcxja Date: Fri, 2 Oct 2026 15:19:00 +0200 Subject: [PATCH] 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)