fix cloning

This commit is contained in:
KingKDot
2026-07-20 15:44:04 -05:00
parent 8dbb8c7c29
commit ed177760b3
6 changed files with 438 additions and 63 deletions
@@ -637,20 +637,52 @@ func cloneBrowserProfile(browserName string, srcUserData string, lite bool, onPr
// Calculate total size for progress reporting
var totalBytes int64
var progressMu sync.Mutex
progress := func(percent int, copiedBytes, totalBytes int64, status string) {
if onProgress == nil {
return
}
progressMu.Lock()
defer progressMu.Unlock()
defer func() {
if r := recover(); r != nil {
log.Printf("[panic] backstage %s clone progress callback: %v", browserName, r)
}
}()
onProgress(percent, copiedBytes, totalBytes, status)
}
if onProgress != nil {
onProgress(0, 0, 0, "scanning")
progress(0, 0, 0, "scanning")
totalBytes = calcCloneSize(srcUserData, skipDirs)
onProgress(0, 0, totalBytes, "cloning")
progress(0, 0, totalBytes, "cloning")
}
type copyJob struct {
src string
dst string
src string
dst string
rel string
size int64
}
var jobs []copyJob
collectFile := func(src, dst string) {
jobs = append(jobs, copyJob{src: src, dst: dst})
if isCloneLockFileName(filepath.Base(src)) {
return
}
info, err := os.Lstat(src)
if err != nil {
log.Printf("backstage %s: warning: could not stat %s: %v", browserName, src, err)
return
}
if !isCloneableFile(info) {
log.Printf("backstage %s: skipping non-regular file %s", browserName, src)
return
}
rel, err := filepath.Rel(srcUserData, src)
if err != nil {
rel = src
}
jobs = append(jobs, copyJob{src: src, dst: dst, rel: rel, size: info.Size()})
}
// Get browser info to check profile structure
@@ -708,37 +740,95 @@ func cloneBrowserProfile(browserName string, srcUserData string, lite bool, onPr
jobCh := make(chan copyJob, 256)
var wg sync.WaitGroup
var copiedBytes atomic.Int64
var failedCopies atomic.Int64
var lastPercent atomic.Int32
lastPercent.Store(-1)
copyProgress := func(status string) {
if onProgress == nil {
return
}
cur := copiedBytes.Load()
if totalBytes > 0 && cur > totalBytes {
cur = totalBytes
}
pct := 0
if totalBytes > 0 {
pct = int(cur * 100 / totalBytes)
if pct > 100 {
pct = 100
}
}
progress(pct, cur, totalBytes, status)
}
reportProgress := func(n int64) {
if onProgress == nil || totalBytes <= 0 || n <= 0 {
return
}
cur := copiedBytes.Add(n)
if cur > totalBytes {
cur = totalBytes
}
pct := int32(cur * 100 / totalBytes)
if pct > 100 {
pct = 100
}
prev := lastPercent.Load()
if pct > prev && lastPercent.CompareAndSwap(prev, pct) {
onProgress(int(pct), cur, totalBytes, "cloning")
progress(int(pct), cur, totalBytes, "cloning")
}
}
for i := 0; i < numWorkers; i++ {
copyAttempt := func(job copyJob) (n int64, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic while copying %s: %v", job.src, r)
}
}()
return forceCopyFile(job.src, job.dst)
}
const (
cloneCopyMaxAttempts = 3
cloneCopyRetryDelay = 750 * time.Millisecond
)
copyOne := func(job copyJob) (int64, error) {
var lastErr error
for attempt := range cloneCopyMaxAttempts {
if attempt == 0 {
copyProgress("copying|" + job.rel)
} else {
copyProgress(fmt.Sprintf("retrying|%d|%d|%s|%v", attempt+1, cloneCopyMaxAttempts, job.rel, lastErr))
}
n, err := copyAttempt(job)
if err == nil {
return n, nil
}
lastErr = err
log.Printf("backstage %s: copy attempt %d/%d failed for %s: %v", browserName, attempt+1, cloneCopyMaxAttempts, job.src, err)
if attempt+1 < cloneCopyMaxAttempts {
time.Sleep(cloneCopyRetryDelay)
}
}
return 0, lastErr
}
for range numWorkers {
wg.Add(1)
go func() {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("[panic] backstage file copy worker: %v", r)
}
}()
for job := range jobCh {
n, err := forceCopyFile(job.src, job.dst)
n, err := copyOne(job)
if err != nil {
log.Printf("backstage %s: warning: could not copy %s: %v", browserName, job.src, err)
failedCopies.Add(1)
copyProgress(fmt.Sprintf("skipped|%s|%v", job.rel, err))
log.Printf("backstage %s: error: could not copy %s after retries; continuing: %v", browserName, job.src, err)
if job.size > 0 {
reportProgress(job.size)
}
} else {
reportProgress(n)
}
@@ -753,22 +843,45 @@ func cloneBrowserProfile(browserName string, srcUserData string, lite bool, onPr
wg.Wait()
if onProgress != nil {
onProgress(100, totalBytes, totalBytes, "done")
if failed := failedCopies.Load(); failed > 0 {
progress(100, totalBytes, totalBytes, fmt.Sprintf("done_with_errors|%d", failed))
} else {
progress(100, totalBytes, totalBytes, "done")
}
}
return cloneBase, nil
}
func isCloneableFile(info os.FileInfo) bool {
if info == nil {
return false
}
mode := info.Mode()
return mode.IsRegular() && mode&os.ModeSymlink == 0
}
func isCloneLockFileName(name string) bool {
return strings.EqualFold(name, "LOCK") || strings.EqualFold(name, "lockfile")
}
func isCloneableDirEntry(entry os.DirEntry) bool {
return entry != nil && entry.IsDir() && entry.Type()&os.ModeSymlink == 0
}
func collectProfileDir(src, dst string, skipDirs map[string]bool, collect func(string, string)) {
entries, err := os.ReadDir(src)
if err != nil {
return
}
for _, entry := range entries {
if entry.Type()&os.ModeSymlink != 0 {
continue
}
name := entry.Name()
s := filepath.Join(src, name)
d := filepath.Join(dst, name)
if entry.IsDir() {
if isCloneableDirEntry(entry) {
if skipDirs[strings.ToLower(name)] {
continue
}
@@ -781,7 +894,19 @@ func collectProfileDir(src, dst string, skipDirs map[string]bool, collect func(s
func collectDirFiles(src, dst string, collect func(string, string)) {
filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
if err != nil {
return nil
}
if info.Mode()&os.ModeSymlink != 0 {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
if info.IsDir() {
return nil
}
if isCloneLockFileName(filepath.Base(path)) || !isCloneableFile(info) {
return nil
}
rel, _ := filepath.Rel(src, path)
@@ -808,7 +933,7 @@ func calcCloneSize(srcUserData string, skipDirs map[string]bool) int64 {
for _, entry := range entries {
name := entry.Name()
p := filepath.Join(srcUserData, name)
if entry.IsDir() {
if isCloneableDirEntry(entry) {
isProfile := false
if isFirefox {
isProfile = isFirefoxProfile(name)
@@ -821,8 +946,8 @@ func calcCloneSize(srcUserData string, skipDirs map[string]bool) int64 {
} else if !skipDirs[strings.ToLower(name)] {
total += calcDirSize(p)
}
} else {
if info, err := entry.Info(); err == nil {
} else if !isCloneLockFileName(name) {
if info, err := entry.Info(); err == nil && isCloneableFile(info) {
total += info.Size()
}
}
@@ -839,12 +964,12 @@ func calcProfileSize(dir string, skipDirs map[string]bool) int64 {
for _, entry := range entries {
name := entry.Name()
p := filepath.Join(dir, name)
if entry.IsDir() {
if isCloneableDirEntry(entry) {
if !skipDirs[strings.ToLower(name)] {
total += calcDirSize(p)
}
} else {
if info, err := entry.Info(); err == nil {
} else if !isCloneLockFileName(name) {
if info, err := entry.Info(); err == nil && isCloneableFile(info) {
total += info.Size()
}
}
@@ -854,11 +979,17 @@ func calcProfileSize(dir string, skipDirs map[string]bool) int64 {
func calcDirSize(dir string) int64 {
var total int64
filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() {
if info.Mode()&os.ModeSymlink != 0 {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
if !info.IsDir() && !isCloneLockFileName(filepath.Base(path)) && isCloneableFile(info) {
total += info.Size()
}
return nil
@@ -3,7 +3,10 @@
package capture
import (
"os"
"path/filepath"
"slices"
"strings"
"syscall"
"testing"
)
@@ -54,6 +57,64 @@ func TestAppendEnvironmentOverridesPreservesUnicodeAndReplacesRDI(t *testing.T)
}
}
func TestCloneBrowserProfileProgressPanicDoesNotAbortCopy(t *testing.T) {
src := t.TempDir()
if err := os.WriteFile(filepath.Join(src, "Local State"), []byte("state"), 0600); err != nil {
t.Fatalf("write Local State: %v", err)
}
profileDir := filepath.Join(src, "Default")
if err := os.MkdirAll(profileDir, 0700); err != nil {
t.Fatalf("mkdir profile: %v", err)
}
if err := os.WriteFile(filepath.Join(profileDir, "Cookies"), []byte("cookies"), 0600); err != nil {
t.Fatalf("write Cookies: %v", err)
}
if err := os.WriteFile(filepath.Join(profileDir, "LOCK"), []byte("lock"), 0600); err != nil {
t.Fatalf("write LOCK: %v", err)
}
if err := os.WriteFile(filepath.Join(profileDir, "lockfile"), []byte("lockfile"), 0600); err != nil {
t.Fatalf("write lockfile: %v", err)
}
var statuses []string
cloneDir, err := cloneBrowserProfile("Chrome", src, false, func(percent int, copiedBytes, totalBytes int64, status string) {
statuses = append(statuses, status)
if status == "cloning" || percent == 100 {
panic("progress callback failed")
}
})
if err != nil {
t.Fatalf("cloneBrowserProfile: %v", err)
}
if !slices.ContainsFunc(statuses, func(status string) bool {
return strings.HasPrefix(status, "copying|")
}) {
t.Fatalf("clone progress did not report current copy file: %#v", statuses)
}
if got, err := os.ReadFile(filepath.Join(cloneDir, "Local State")); err != nil || string(got) != "state" {
t.Fatalf("cloned Local State = %q, %v", got, err)
}
if got, err := os.ReadFile(filepath.Join(cloneDir, "Default", "Cookies")); err != nil || string(got) != "cookies" {
t.Fatalf("cloned Cookies = %q, %v", got, err)
}
for _, skipped := range []string{"LOCK", "lockfile"} {
if _, err := os.Stat(filepath.Join(cloneDir, "Default", skipped)); !os.IsNotExist(err) {
t.Fatalf("lock file %s should not be cloned; stat err=%v", skipped, err)
}
}
}
func TestGetProcessesLockingFileShortPathDoesNotPanic(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("getProcessesLockingFile panicked for short path: %v", r)
}
}()
_ = getProcessesLockingFile("a")
}
func testEnvironmentBlock(t *testing.T, entries ...string) []uint16 {
t.Helper()
@@ -3,6 +3,7 @@
package capture
import (
"errors"
"io"
"log"
"os"
@@ -59,6 +60,8 @@ const (
fileTypeDisk = 0x0001
)
var errHijackHandleNotFound = errors.New("matching locked file handle not found")
type systemHandleTableEntryInfoEx struct {
Object uintptr
UniqueProcessId uintptr
@@ -80,7 +83,24 @@ func forceReadFile(filePath string) ([]byte, error) {
}
log.Printf("[handle-hijack] file locked, attempting hijack: %s", filePath)
dupHandle, hijackErr := duplicateLockedFileHandle(filePath, err)
if hijackErr != nil {
return nil, hijackErr
}
defer procCloseHandle.Call(dupHandle)
data = readFileFromHandle(dupHandle)
if data == nil {
return nil, err
}
log.Printf("[handle-hijack] read %d bytes via hijacking", len(data))
return data, nil
}
func duplicateLockedFileHandle(filePath string, fallbackErr error) (uintptr, error) {
if fallbackErr == nil {
fallbackErr = errHijackHandleNotFound
}
lockingPids := getProcessesLockingFile(filePath)
currentProcess, _, _ := procGetCurrentProcess.Call()
@@ -101,7 +121,7 @@ func forceReadFile(filePath string) ([]byte, error) {
continue
}
if status != 0 {
return nil, err
return 0, fallbackErr
}
break
}
@@ -171,18 +191,21 @@ func forceReadFile(filePath string) ([]byte, error) {
}
log.Printf("[handle-hijack] found matching handle from PID %d", pid)
data := readFileFromHandle(dupHandle)
procCloseHandle.Call(dupHandle)
if data != nil {
log.Printf("[handle-hijack] read %d bytes via hijacking", len(data))
return data, nil
}
return dupHandle, nil
}
return nil, err
return 0, fallbackErr
}
func forceCopyFile(src, dst string) (int64, error) {
if shouldTryHijackBeforeCopy(src) {
n, err := copyFileViaHijack(src, dst, nil)
if err == nil {
return n, nil
}
log.Printf("[handle-hijack] pre-copy hijack failed for %s: %v; falling back to normal copy", src, err)
}
n, err := copyFileCount(src, dst)
if err == nil {
return n, nil
@@ -191,18 +214,50 @@ func forceCopyFile(src, dst string) (int64, error) {
return 0, err
}
data, hijackErr := forceReadFile(src)
if hijackErr != nil || data == nil {
n, hijackErr := copyFileViaHijack(src, dst, err)
if hijackErr != nil {
log.Printf("[handle-hijack] locked-file hijack failed for %s: %v", src, hijackErr)
return 0, hijackErr
}
return n, nil
}
func shouldTryHijackBeforeCopy(src string) bool {
lower := strings.ToLower(filepath.ToSlash(src))
return strings.Contains(lower, "/gpupersistentcache/") || strings.HasSuffix(lower, "/cache.db")
}
func copyFileViaHijack(src, dst string, fallbackErr error) (int64, error) {
log.Printf("[handle-hijack] attempting streaming hijack copy: %s", src)
dupHandle, hijackErr := duplicateLockedFileHandle(src, fallbackErr)
if hijackErr != nil {
return 0, hijackErr
}
return copyDuplicatedHandleToFile(src, dst, dupHandle)
}
func copyDuplicatedHandleToFile(src, dst string, dupHandle uintptr) (int64, error) {
in := os.NewFile(dupHandle, src)
if in == nil {
procCloseHandle.Call(dupHandle)
return 0, os.ErrInvalid
}
defer in.Close()
if err := os.MkdirAll(getDir(dst), 0700); err != nil {
return 0, err
}
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return 0, err
}
defer out.Close()
if mkErr := os.MkdirAll(getDir(dst), 0700); mkErr != nil {
return 0, mkErr
n, err := io.Copy(out, in)
if err == nil {
log.Printf("[handle-hijack] copied %d bytes via hijacked handle", n)
}
if wErr := os.WriteFile(dst, data, 0600); wErr != nil {
return 0, wErr
}
return int64(len(data)), nil
return n, err
}
func getDir(path string) string {
@@ -242,7 +297,6 @@ func readFileFromHandle(handle uintptr) []byte {
if ok == 0 || fileSize <= 0 {
return nil
}
baseAddr, _, _ := procMapViewOfFile.Call(
hMapping, fileMapRead, 0, 0, uintptr(fileSize),
)
@@ -283,7 +337,11 @@ type rmProcessInfo struct {
}
func getProcessesLockingFile(filePath string) []uint32 {
sessionKey, _ := syscall.UTF16PtrFromString("ovd_" + filePath[len(filePath)-8:])
suffixStart := len(filePath) - 8
if suffixStart < 0 {
suffixStart = 0
}
sessionKey, _ := syscall.UTF16PtrFromString("ovd_" + filePath[suffixStart:])
var sessionHandle uint32
ret, _, _ := procRmStartSession.Call(
+128 -12
View File
@@ -422,7 +422,109 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
const cloneProgressBar = document.getElementById("cloneProgressBar");
const cloneProgressPct = document.getElementById("cloneProgressPct");
const cloneProgressLabel = document.getElementById("cloneProgressLabel");
const cloneSkippedList = document.getElementById("cloneSkippedList");
let cloneHideTimer = null;
let cloneSkippedExpanded = false;
let cloneSkippedFiles = [];
function renderCloneSkippedList() {
if (!cloneSkippedList || !cloneProgressLabel) return;
cloneProgressLabel.setAttribute("aria-expanded", cloneSkippedExpanded ? "true" : "false");
if (!cloneSkippedExpanded || cloneSkippedFiles.length === 0) {
cloneSkippedList.classList.add("hidden");
cloneSkippedList.replaceChildren();
return;
}
cloneSkippedList.replaceChildren();
for (const item of cloneSkippedFiles) {
const row = document.createElement("div");
row.className = "border-b border-slate-800 py-1 last:border-b-0";
const file = document.createElement("div");
file.className = "font-mono text-violet-200 break-all";
file.textContent = item.file || "unknown file";
row.appendChild(file);
if (item.error) {
const err = document.createElement("div");
err.className = "mt-1 text-rose-300 break-all";
err.textContent = item.error;
row.appendChild(err);
}
cloneSkippedList.appendChild(row);
}
cloneSkippedList.classList.remove("hidden");
}
function setCloneSkippedExpanded(expanded) {
cloneSkippedExpanded = Boolean(expanded && cloneSkippedFiles.length > 0);
renderCloneSkippedList();
}
function addCloneSkipped(status) {
const parts = String(status || "").split("|");
if (parts[0] !== "skipped") return;
const file = parts[1] || "";
const error = parts.slice(2).join("|");
const key = `${file}\n${error}`;
if (!cloneSkippedFiles.some((item) => `${item.file}\n${item.error}` === key)) {
cloneSkippedFiles.push({ file, error });
}
renderCloneSkippedList();
}
cloneProgressLabel?.addEventListener("click", () => {
setCloneSkippedExpanded(!cloneSkippedExpanded);
});
cloneProgressLabel?.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setCloneSkippedExpanded(!cloneSkippedExpanded);
}
});
function cloneProgressText(status, browser, copiedMB, totalMB) {
const parts = String(status || "").split("|");
const kind = parts[0] || "";
if (kind === "copying") {
const file = parts.slice(1).join("|");
return {
text: `Copying ${browser} — ${file || "current file"}`,
title: file,
};
}
if (kind === "retrying") {
const attempt = parts[1] || "?";
const max = parts[2] || "?";
const file = parts[3] || "";
const err = parts.slice(4).join("|");
return {
text: `Retrying ${browser} copy (${attempt}/${max}) — ${file || "current file"}`,
title: err ? `${file}\n${err}` : file,
};
}
if (kind === "skipped") {
const file = parts[1] || "";
const err = parts.slice(2).join("|");
return {
text: `Skipped ${browser} copy — ${file || "file"}`,
title: err ? `${file}\n${err}` : file,
};
}
if (kind === "failed") {
const detail = parts.slice(1).join("|");
return {
text: `${browser} clone failed — ${detail || "copy error"}`,
title: detail,
};
}
return {
text: `Cloning ${browser} — ${copiedMB} / ${totalMB} MB`,
title: "",
};
}
function handleCloneProgress(msg) {
if (!cloneProgressEl) return;
@@ -437,30 +539,44 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
cloneHideTimer = null;
}
if (status === "done") {
cloneProgressBar.style.width = "100%";
cloneProgressPct.textContent = "100%";
cloneProgressLabel.textContent = `${browser} clone complete`;
cloneHideTimer = setTimeout(() => {
cloneProgressEl.classList.add("hidden");
cloneProgressEl.classList.remove("flex");
}, 3000);
return;
}
cloneProgressEl.classList.remove("hidden");
cloneProgressEl.classList.add("flex");
if (status === "scanning") {
cloneSkippedFiles = [];
setCloneSkippedExpanded(false);
cloneProgressBar.style.width = "0%";
cloneProgressPct.textContent = "…";
cloneProgressLabel.textContent = `Scanning ${browser} profile`;
cloneProgressLabel.title = "";
return;
}
if (String(status).startsWith("skipped|")) {
addCloneSkipped(status);
}
if (status === "done" || String(status).startsWith("done_with_errors|")) {
cloneProgressBar.style.width = "100%";
cloneProgressPct.textContent = "100%";
const failedCount = String(status).startsWith("done_with_errors|") ? String(status).split("|")[1] || "0" : "";
cloneProgressLabel.textContent = failedCount ? `${browser} clone complete — ${failedCount} skipped (click to expand)` : `${browser} clone complete`;
cloneProgressLabel.title = failedCount ? `${failedCount} file(s) failed to copy. Click to expand.` : "";
if (!failedCount) {
cloneHideTimer = setTimeout(() => {
cloneProgressEl.classList.add("hidden");
cloneProgressEl.classList.remove("flex");
setCloneSkippedExpanded(false);
}, 3000);
}
return;
}
const detail = cloneProgressText(status, browser, copiedMB, totalMB);
cloneProgressBar.style.width = `${pct}%`;
cloneProgressPct.textContent = `${pct}%`;
cloneProgressLabel.textContent = `Cloning ${browser} — ${copiedMB} / ${totalMB} MB`;
cloneProgressLabel.textContent = detail.text;
cloneProgressLabel.title = detail.title;
}
const dxgiStatusEl = document.getElementById("dxgiStatus");
+3 -2
View File
@@ -48,14 +48,15 @@
<div
id="cloneProgress"
class="hidden items-center gap-2 bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2 text-sm text-slate-300"
class="hidden relative items-center gap-2 bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2 text-sm text-slate-300"
>
<i class="fa-solid fa-clone text-violet-400"></i>
<span id="cloneProgressLabel">Cloning profile…</span>
<span id="cloneProgressLabel" class="inline-block max-w-[28rem] truncate align-middle cursor-pointer select-none" role="button" tabindex="0" aria-expanded="false" title="">Cloning profile…</span>
<div class="w-32 h-2 bg-slate-700 rounded-full overflow-hidden">
<div id="cloneProgressBar" class="h-full bg-violet-500 rounded-full transition-all duration-200" style="width: 0%"></div>
</div>
<span id="cloneProgressPct" class="pill pill-ghost font-mono text-violet-200 min-w-[3ch] text-right">0%</span>
<div id="cloneSkippedList" class="hidden absolute left-0 top-full z-50 mt-2 w-[36rem] max-w-[calc(100vw-2rem)] max-h-48 overflow-auto rounded-lg border border-slate-700 bg-slate-950/95 p-2 text-xs text-slate-300 shadow-xl"></div>
</div>
<div
id="dxgiStatus"
@@ -944,15 +944,23 @@ export function handleWebcamDevices(clientId: string, payload: any) {
}
}
export function handlebackstageCloneProgress(clientId: string, payload: any) {
export function handlebackstageCloneProgress(clientId: string, payload: unknown) {
const data = payload && typeof payload === "object" ? payload as Record<string, unknown> : {};
const browser = String(data.browser || "");
const status = String(data.status || "");
if (status.startsWith("copying|") || status.startsWith("retrying|")) {
logger.debug(`[backstage] clone client=${clientId} browser=${browser} status=${status}`);
} else if (status.startsWith("failed|") || status.startsWith("skipped|") || status.startsWith("done_with_errors|")) {
logger.warn(`[backstage] clone client=${clientId} browser=${browser} status=${status}`);
}
for (const session of sessionManager.getbackstageSessionsForClient(clientId)) {
safeSendViewer(session.viewer, {
type: "backstage_clone_progress",
browser: String(payload.browser || ""),
percent: Number(payload.percent) || 0,
copiedBytes: Number(payload.copiedBytes) || 0,
totalBytes: Number(payload.totalBytes) || 0,
status: String(payload.status || ""),
browser,
percent: Number(data.percent) || 0,
copiedBytes: Number(data.copiedBytes) || 0,
totalBytes: Number(data.totalBytes) || 0,
status,
});
}
}