show progress for imports and deletions

This commit is contained in:
olcxja 2026-10-02 15:19:00 +02:00
commit 5e342b6fe5
13 changed files with 343 additions and 115 deletions

View file

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

View file

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

View file

@ -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ąć",

View file

@ -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(`
<h2>${t('import.progress', name)}</h2>
<div class="progress-row"><progress id="import-progress"></progress></div>
<p id="import-status"></p>
<div class="progress-row"><progress id="import-progress"></progress><span class="progress-pct"></span></div>
<p class="subtitle" id="import-status"></p>
`)
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(`
<h2>${t('import.progress', name)}</h2>
<div class="progress-row"><progress></progress></div>
// 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(`
<h2>${t('import.adding', name)}</h2>
<p class="subtitle">${t('import.prefix.registering')}</p>
`)
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(`
<h2>${title}</h2>
<div class="progress-row"><progress></progress><span class="progress-pct"></span></div>
<p class="subtitle" id="op-status">${t('import.phase.count')}</p>
`)
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(`
<h2>${t('wines.importing')}</h2>
<div class="progress-row"><progress></progress></div>
`)
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()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,6 +4,7 @@ const path = require('path')
const fs = require('fs')
const { DIRS, readJson, writeJson } = require('./paths')
const { rewireSymlinks } = require('./symlinks')
const { 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 }
}

View file

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