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) prefixes.js prefix registry (files/wineprefixes)
symlinks.js relative symlink rewiring + executable-bit restoration symlinks.js relative symlink rewiring + executable-bit restoration
(shared by imports, prefixes and LAN sync) (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) importer.js app import (copy/move/leave/prefix)
names.js user-typed name sanitizing (prefix folder names, wine names.js user-typed name sanitizing (prefix folder names, wine
display names) 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 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 the helpers in library.js (prefixNameForGame/gamesUsingPrefix/is*InUse) and
answer { ok:false, error:'inUse'|'hasApps', apps:[names] } for the toast. 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, - CSS: reuse the existing variables/classes (.button, .field-label,
.custom-select, form-card); spacing rhythm comes from --button-margin and .custom-select, form-card); spacing rhythm comes from --button-margin and
the flex gap of the container, not per-element magic numbers. the flex gap of the container, not per-element magic numbers.

View file

@ -20,6 +20,7 @@
"library.running": "Running", "library.running": "Running",
"library.config": "Configure", "library.config": "Configure",
"library.remove": "Remove", "library.remove": "Remove",
"library.removing": "Removing {0}…",
"library.openFolder": "Open folder", "library.openFolder": "Open folder",
"library.lastPlayed": "Last played: {0}", "library.lastPlayed": "Last played: {0}",
"library.playtime": "Playtime: {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.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.pickFolder": "Choose the app folder",
"import.progress": "Importing {0}…", "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.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.done": "Import finished",
"import.failed": "Import failed: {0}", "import.failed": "Import failed: {0}",
"import.noExe": "no .exe file found in the selected folder", "import.noExe": "no .exe file found in the selected folder",
@ -107,6 +114,7 @@
"prefixes.default": "Default", "prefixes.default": "Default",
"prefixes.makeDefault": "Set as default", "prefixes.makeDefault": "Set as default",
"prefixes.delete": "Delete", "prefixes.delete": "Delete",
"prefixes.deleting": "Deleting prefix {0}…",
"prefixes.deleteConfirm": "Delete prefix {0} and all of its data?", "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.empty": "No prefixes yet. Create one, or let wine create the default on first run.",
"prefixes.created": "Prefix {0} created", "prefixes.created": "Prefix {0} created",
@ -139,6 +147,7 @@
"wines.makeDefault": "Set as default", "wines.makeDefault": "Set as default",
"wines.defaultSet": "{0} is now the default Wine version", "wines.defaultSet": "{0} is now the default Wine version",
"wines.delete": "Delete", "wines.delete": "Delete",
"wines.deleting": "Deleting {0}…",
"wines.deleteConfirm": "Delete {0} from files/wines?\nThe build folder will be permanently removed.", "wines.deleteConfirm": "Delete {0} from files/wines?\nThe build folder will be permanently removed.",
"wines.deleted": "{0} deleted", "wines.deleted": "{0} deleted",
"wines.systemLocked": "System Wine cannot be renamed or deleted", "wines.systemLocked": "System Wine cannot be renamed or deleted",

View file

@ -20,6 +20,7 @@
"library.running": "Uruchomiona", "library.running": "Uruchomiona",
"library.config": "Konfiguracja", "library.config": "Konfiguracja",
"library.remove": "Usuń", "library.remove": "Usuń",
"library.removing": "Usuwanie {0}…",
"library.openFolder": "Otwórz folder", "library.openFolder": "Otwórz folder",
"library.lastPlayed": "Ostatnio grane: {0}", "library.lastPlayed": "Ostatnio grane: {0}",
"library.playtime": "Czas gry: {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.prefix.noDriveC": "ten prefiks nie ma jeszcze drive_c, uruchom w nim dowolną apkę, żeby wine go utworzył",
"import.pickFolder": "Wybierz folder aplikacji", "import.pickFolder": "Wybierz folder aplikacji",
"import.progress": "Importowanie {0}…", "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.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.done": "Import zakończony",
"import.failed": "Import nie powiódł się: {0}", "import.failed": "Import nie powiódł się: {0}",
"import.noExe": "nie znaleziono pliku .exe w wybranym folderze", "import.noExe": "nie znaleziono pliku .exe w wybranym folderze",
@ -107,6 +114,7 @@
"prefixes.default": "Domyślny", "prefixes.default": "Domyślny",
"prefixes.makeDefault": "Ustaw jako domyślny", "prefixes.makeDefault": "Ustaw jako domyślny",
"prefixes.delete": "Usuń", "prefixes.delete": "Usuń",
"prefixes.deleting": "Usuwanie prefiksu {0}…",
"prefixes.deleteConfirm": "Usunąć prefiks {0} wraz z całą jego zawartością?", "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.empty": "Brak prefiksów. Utwórz jeden, albo pozwól wine utworzyć domyślny przy pierwszym uruchomieniu.",
"prefixes.created": "Utworzono prefiks {0}", "prefixes.created": "Utworzono prefiks {0}",
@ -139,6 +147,7 @@
"wines.makeDefault": "Ustaw jako domyślną", "wines.makeDefault": "Ustaw jako domyślną",
"wines.defaultSet": "{0} jest teraz domyślną wersją Wine", "wines.defaultSet": "{0} jest teraz domyślną wersją Wine",
"wines.delete": "Usuń", "wines.delete": "Usuń",
"wines.deleting": "Usuwanie {0}…",
"wines.deleteConfirm": "Usunąć {0} z files/wines?\nFolder z buildem zostanie trwale usunięty.", "wines.deleteConfirm": "Usunąć {0} z files/wines?\nFolder z buildem zostanie trwale usunięty.",
"wines.deleted": "Usunięto {0}", "wines.deleted": "Usunięto {0}",
"wines.systemLocked": "Wine systemowego nie można zmienić ani usunąć", "wines.systemLocked": "Wine systemowego nie można zmienić ani usunąć",

View file

@ -628,7 +628,11 @@ function renderLibrary() {
: (inPrefix ? t('library.removePrefixNote') : '') : (inPrefix ? t('library.removePrefixNote') : '')
const ok = await confirmModal(t('library.removeConfirm', game.name, note), t('library.remove')) const ok = await confirmModal(t('library.removeConfirm', game.name, note), t('library.remove'))
if (!ok) return 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') toast(t('toast.gameRemoved', game.name), 'success')
state.games = await window.plwl.gamesList() state.games = await window.plwl.gamesList()
renderLibrary() renderLibrary()
@ -755,14 +759,22 @@ async function doImport(srcDir, mode) {
const name = srcDir.split('/').filter(Boolean).pop() const name = srcDir.split('/').filter(Boolean).pop()
const modal = showModal(` const modal = showModal(`
<h2>${t('import.progress', name)}</h2> <h2>${t('import.progress', name)}</h2>
<div class="progress-row"><progress id="import-progress"></progress></div> <div class="progress-row"><progress id="import-progress"></progress><span class="progress-pct"></span></div>
<p id="import-status"></p> <p class="subtitle" id="import-status"></p>
`) `)
let count = 0 let count = 0
window.plwl.onImportProgress(() => { window.plwl.onImportProgress(data => {
count++ count++
const bar = modal.querySelector('#import-progress')
const pct = modal.querySelector('.progress-pct')
const status = modal.querySelector('#import-status') 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 { try {
const game = await window.plwl.gamesImport(srcDir, mode) const game = await window.plwl.gamesImport(srcDir, mode)
@ -895,9 +907,11 @@ function wireOpenWith() {
// copied, paths stay prefix-relative. // copied, paths stay prefix-relative.
async function doPrefixImport(prefix, srcDir) { async function doPrefixImport(prefix, srcDir) {
const name = srcDir.split('/').filter(Boolean).pop() const name = srcDir.split('/').filter(Boolean).pop()
const modal = showModal(` // nothing is copied here - the app is just registered in place, so there is
<h2>${t('import.progress', name)}</h2> // no progress to show (no bar that would sit at zero)
<div class="progress-row"><progress></progress></div> showModal(`
<h2>${t('import.adding', name)}</h2>
<p class="subtitle">${t('import.prefix.registering')}</p>
`) `)
try { try {
const game = await window.plwl.gamesImportFromPrefix(prefix, srcDir) 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') 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')) const ok = await confirmModal(t('prefixes.deleteConfirm', p.name), t('prefixes.delete'))
if (!ok) return 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') if (res.ok) toast(t('toast.prefixDeleted', p.name), 'success')
else if (res.error === 'isMain') toast(t('prefixes.mainLocked'), 'error') 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 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 () => { delBtn.onclick = async () => {
const ok = await confirmModal(t('wines.deleteConfirm', w.label), t('wines.delete')) const ok = await confirmModal(t('wines.deleteConfirm', w.label), t('wines.delete'))
if (!ok) return 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) { if (res.ok) {
toast(t('wines.deleted', w.label), 'success') toast(t('wines.deleted', w.label), 'success')
state.wines = await window.plwl.winesList() state.wines = await window.plwl.winesList()
@ -1398,6 +1419,37 @@ function barValues(info) {
return [bytes, total] 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 // "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 // 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. // 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 () => { qs('#btn-wines-import').addEventListener('click', async () => {
const srcDir = await window.plwl.dialogPickFile({ properties: ['openDirectory'], title: t('wines.importPick') }) const srcDir = await window.plwl.dialogPickFile({ properties: ['openDirectory'], title: t('wines.importPick') })
if (!srcDir) return if (!srcDir) return
const modal = showModal(` const res = await runWithProgress({
<h2>${t('wines.importing')}</h2> title: t('wines.importing'),
<div class="progress-row"><progress></progress></div> action: () => window.plwl.winesImport(srcDir)
`) })
const res = await window.plwl.winesImport(srcDir)
closeModal()
if (res.ok) { if (res.ok) {
toast(t('wines.imported', res.path), 'success') toast(t('wines.imported', res.path), 'success')
state.wines = await window.plwl.winesList() state.wines = await window.plwl.winesList()

View file

@ -797,5 +797,10 @@ progress {
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
-webkit-appearance: none; -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-bar { background: rgba(255, 255, 255, 0.08); border-radius: 0.4rem; }
progress::-webkit-progress-value { background: var(--text-color); border-radius: 0.4rem; } progress::-webkit-progress-value { background: var(--text-color); border-radius: 0.4rem; }

View file

@ -1,6 +1,7 @@
'use strict' 'use strict'
const fs = require('fs') const fs = require('fs')
const fsp = require('fs').promises
const path = require('path') const path = require('path')
// Returns a non-existing path in `base` named `name` (or `name (2)`, `name (3)`...). // 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})`) 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 { extractIconFromExe } = require('./icons')
const { listPrefixes, prefixDriveRoot } = require('./prefixes') const { listPrefixes, prefixDriveRoot } = require('./prefixes')
const { rewireSymlinks } = require('./symlinks') const { rewireSymlinks } = require('./symlinks')
const { copyTree, treeSize } = require('./fsutil')
// Directories that typically hold installers/redistributables, not the game. // Directories that typically hold installers/redistributables, not the game.
const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc']) const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc'])
// A game folder has to arrive as it was: symlinks are recreated (copyFile // The copy itself lives in fsutil (copyTree): symlinks recreated, permissions
// silently dropped them, so a linked asset or save folder vanished) and the // carried over, byte-level progress. moveFolder only falls back to it across
// permission bits are carried over (copyFile writes 0644, killing scripts and // filesystems; a same-device rename reports nothing (nothing is copied).
// 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)
}
}
})
}
function moveFolder(src, dst, onProgress) { 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() if (path.parse(src).root !== path.parse(dst).root) return fallback()
return fsp.rename(src, dst).catch(fallback) return fsp.rename(src, dst).catch(fallback)
} }
@ -62,8 +33,15 @@ async function importGame(srcDir, mode, onProgress) {
let workdir = srcDir let workdir = srcDir
if (mode === 'copy' || mode === 'move') { if (mode === 'copy' || mode === 'move') {
const dst = path.join(DIRS.APPS, id) const dst = path.join(DIRS.APPS, id)
if (mode === 'copy') await copyRecursive(srcDir, dst, onProgress) if (mode === 'copy') {
else await moveFolder(srcDir, dst, onProgress) // 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 workdir = dst
// anything the game linked to INSIDE the launcher data (its own folder, an // 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 // icon, a shared prefix file) becomes a relative link, so the imported app
@ -156,4 +134,4 @@ async function findDefaultExe(dir) {
return best 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:setDisabled', (e, id, disabled) => wines.setWineDisabled(id, disabled))
ipcMain.handle('wines:setDisplayName', (e, id, name) => wines.setWineDisplayName(id, name)) ipcMain.handle('wines:setDisplayName', (e, id, name) => wines.setWineDisplayName(id, name))
ipcMain.handle('wines:setDefault', (e, id) => wines.setDefaultWine(id)) 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 // the build's files back the running prefix (dlls, stub exes) - deleting
// it under a running game breaks that game // it under a running game breaks that game
if (library.isWineInUse(id, games.runningInfo())) { if (library.isWineInUse(id, games.runningInfo())) {
const apps = games.runningInfo().filter(r => r.wine === id).map(r => r.name) const apps = games.runningInfo().filter(r => r.wine === id).map(r => r.name)
return { ok: false, error: 'inUse', apps } 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) => { ipcMain.handle('wines:import', async (e, srcDir) => {
try { try {
return await wines.importWineFolder(srcDir) return await wines.importWineFolder(srcDir, data => sendToWindow(getWindow(), 'import:progress', data))
} catch (err) { } catch (err) {
return errorResult(err) return errorResult(err)
} }
@ -113,14 +113,14 @@ function registerIpc({ getWindow, onGamesChanged }) {
} }
ipcMain.handle('prefixes:list', () => prefixes.listPrefixes()) ipcMain.handle('prefixes:list', () => prefixes.listPrefixes())
ipcMain.handle('prefixes:create', (e, name) => prefixes.createPrefix(name)) 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) const busy = prefixInUse(name)
if (busy) return busy if (busy) return busy
// deleting a prefix that still has apps registered in it would orphan // deleting a prefix that still has apps registered in it would orphan
// them (they fall back to the default prefix and lose their files) // them (they fall back to the default prefix and lose their files)
const users = library.gamesUsingPrefix(name).map(g => g.name) const users = library.gamesUsingPrefix(name).map(g => g.name)
if (users.length) return { ok: false, error: 'hasApps', apps: users } 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:rename', (e, oldName, newName) => prefixInUse(oldName) || prefixes.renamePrefix(oldName, newName))
ipcMain.handle('prefixes:setDefault', (e, name) => { 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:reorder', (e, ids) => library.reorderGames(ids))
ipcMain.handle('games:remove', (e, id) => { ipcMain.handle('games:remove', (e, id) => {
shortcuts.removeShortcut(id) shortcuts.removeShortcut(id)
return library.removeGame(id) return library.removeGame(id, data => sendToWindow(getWindow(), 'import:progress', data))
}) })
ipcMain.handle('games:createShortcut', (e, id) => { ipcMain.handle('games:createShortcut', (e, id) => {
const game = library.getGame(id) const game = library.getGame(id)

View file

@ -17,6 +17,7 @@ const readline = require('readline')
const { DIRS, readJson, writeJson, toPortablePath } = require('./paths') const { DIRS, readJson, writeJson, toPortablePath } = require('./paths')
const { rewireSymlinks, restoreExecutableBits } = require('./symlinks') const { rewireSymlinks, restoreExecutableBits } = require('./symlinks')
const { progressGate } = require('./fsutil')
const library = require('./library') const library = require('./library')
const wines = require('./wines') const wines = require('./wines')
const prefixes = require('./prefixes') const prefixes = require('./prefixes')
@ -375,45 +376,6 @@ function ownedRoot(rel) {
return SYNC_ROOTS.some(prefix => clean.startsWith(prefix)) ? clean : null 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) { async function mapLimit(items, limit, fn) {
const out = new Array(items.length) const out = new Array(items.length)
let next = 0 let next = 0

View file

@ -160,15 +160,22 @@ function clearPlaytime(id) {
return updateGame(id, { playSeconds: 0, lastPlayed: 0 }) 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 games = loadGames()
const idx = games.findIndex(g => g.id === id) const idx = games.findIndex(g => g.id === id)
if (idx === -1) return false if (idx === -1) return false
const g = games[idx] const g = games[idx]
if (g.imported && g.path && g.path.startsWith(DIRS.APPS)) { if (g.imported && g.path && g.path.startsWith(DIRS.APPS)) {
try { const emit = (phase, detail) => {
require('fs').rmSync(g.path, { recursive: true, force: true }) if (onProgress) onProgress({ phase, label: g.name || id, ...detail })
} catch {} }
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 // prefix imports live inside the prefix: deleting launcher files would
// nuke the user's prefix (installed DLLs, saves of every other app), so // 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. // the bundle does not ship (user-added languages) are left untouched.
function filesDiffer(a, b) { function filesDiffer(a, b) {
try { 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 { } catch {
return true // target missing/unreadable -> needs a (re)copy return true // target missing/unreadable -> needs a (re)copy
} }

View file

@ -4,6 +4,7 @@ const path = require('path')
const fs = require('fs') const fs = require('fs')
const { DIRS, readJson, writeJson } = require('./paths') const { DIRS, readJson, writeJson } = require('./paths')
const { rewireSymlinks } = require('./symlinks') const { rewireSymlinks } = require('./symlinks')
const { removeTree, treeSize } = require('./fsutil')
const { normalizePathName } = require('./names') const { normalizePathName } = require('./names')
const REGISTRY = path.join(DIRS.DATA, 'prefixes.json') const REGISTRY = path.join(DIRS.DATA, 'prefixes.json')
@ -71,15 +72,21 @@ function createPrefix(name) {
return { ok: true, name: clean } 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' } if (String(name).toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'isMain' }
const defs = listPrefixes() const defs = listPrefixes()
defs.prefixes = defs.prefixes.filter(p => p.name !== name) defs.prefixes = defs.prefixes.filter(p => p.name !== name)
if (defs.default === name) defs.default = MAIN if (defs.default === name) defs.default = MAIN
savePrefixes(defs) savePrefixes(defs)
try { const emit = (phase, detail) => {
fs.rmSync(prefixPath(name), { recursive: true, force: true }) if (onProgress) onProgress({ phase, label: name, ...detail })
} catch {} }
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 } return { ok: true }
} }

View file

@ -4,7 +4,7 @@ const fs = require('fs')
const fsp = fs.promises const fsp = fs.promises
const path = require('path') const path = require('path')
const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths') const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths')
const { dedupeDir } = require('./fsutil') const { dedupeDir, copyTree, treeSize, removeTree } = require('./fsutil')
const { normalizeLabel } = require('./names') const { normalizeLabel } = require('./names')
const { rewireSymlinks, restoreExecutableBits, EXEC_NAMES } = require('./symlinks') const { rewireSymlinks, restoreExecutableBits, EXEC_NAMES } = require('./symlinks')
@ -210,13 +210,18 @@ function setDefaultWine(id) {
return { ok: true, defaultWine: 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 wines = getWines()
const w = wines.find(x => x.id === id) const w = wines.find(x => x.id === id)
if (!w || w.system) return { ok: false, error: w ? 'isSystem' : 'notFound' } if (!w || w.system) return { ok: false, error: w ? 'isSystem' : 'notFound' }
try { const emit = (phase, detail) => {
fs.rmSync(w.path, { recursive: true, force: true }) if (onProgress) onProgress({ phase, label: w.label || w.name || id, ...detail })
} catch {} }
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)) writeJson(REGISTRY, wines.filter(x => x.id !== id).map(wineRel))
if (getDefaultWine() === id) require('./library').saveSettings({ defaultWine: 'system' }) if (getDefaultWine() === id) require('./library').saveSettings({ defaultWine: 'system' })
return { ok: true } return { ok: true }
@ -281,7 +286,7 @@ function buildAuditFailed(root, wineBin) {
return false return false
} }
async function importWineFolder(srcDir) { async function importWineFolder(srcDir, onProgress) {
const type = detectType(srcDir) const type = detectType(srcDir)
let copySrc = srcDir let copySrc = srcDir
if (!type) { if (!type) {
@ -294,7 +299,14 @@ async function importWineFolder(srcDir) {
if (build) copySrc = path.join(srcDir, build.name) if (build) copySrc = path.join(srcDir, build.name)
} }
const dst = dedupeDir(WINES_DIR, path.basename(copySrc)) 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 } let healed = { links: 0, executables: 0 }
try { try {
healed = await healBuild(dst) healed = await healBuild(dst)