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)