fix cloning
This commit is contained in:
@@ -637,20 +637,52 @@ func cloneBrowserProfile(browserName string, srcUserData string, lite bool, onPr
|
|||||||
|
|
||||||
// Calculate total size for progress reporting
|
// Calculate total size for progress reporting
|
||||||
var totalBytes int64
|
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 {
|
if onProgress != nil {
|
||||||
onProgress(0, 0, 0, "scanning")
|
progress(0, 0, 0, "scanning")
|
||||||
totalBytes = calcCloneSize(srcUserData, skipDirs)
|
totalBytes = calcCloneSize(srcUserData, skipDirs)
|
||||||
onProgress(0, 0, totalBytes, "cloning")
|
progress(0, 0, totalBytes, "cloning")
|
||||||
}
|
}
|
||||||
|
|
||||||
type copyJob struct {
|
type copyJob struct {
|
||||||
src string
|
src string
|
||||||
dst string
|
dst string
|
||||||
|
rel string
|
||||||
|
size int64
|
||||||
}
|
}
|
||||||
var jobs []copyJob
|
var jobs []copyJob
|
||||||
|
|
||||||
collectFile := func(src, dst string) {
|
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
|
// 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)
|
jobCh := make(chan copyJob, 256)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
var copiedBytes atomic.Int64
|
var copiedBytes atomic.Int64
|
||||||
|
var failedCopies atomic.Int64
|
||||||
var lastPercent atomic.Int32
|
var lastPercent atomic.Int32
|
||||||
lastPercent.Store(-1)
|
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) {
|
reportProgress := func(n int64) {
|
||||||
if onProgress == nil || totalBytes <= 0 || n <= 0 {
|
if onProgress == nil || totalBytes <= 0 || n <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cur := copiedBytes.Add(n)
|
cur := copiedBytes.Add(n)
|
||||||
|
if cur > totalBytes {
|
||||||
|
cur = totalBytes
|
||||||
|
}
|
||||||
pct := int32(cur * 100 / totalBytes)
|
pct := int32(cur * 100 / totalBytes)
|
||||||
if pct > 100 {
|
if pct > 100 {
|
||||||
pct = 100
|
pct = 100
|
||||||
}
|
}
|
||||||
prev := lastPercent.Load()
|
prev := lastPercent.Load()
|
||||||
if pct > prev && lastPercent.CompareAndSwap(prev, pct) {
|
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)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
log.Printf("[panic] backstage file copy worker: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
for job := range jobCh {
|
for job := range jobCh {
|
||||||
n, err := forceCopyFile(job.src, job.dst)
|
n, err := copyOne(job)
|
||||||
if err != nil {
|
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 {
|
} else {
|
||||||
reportProgress(n)
|
reportProgress(n)
|
||||||
}
|
}
|
||||||
@@ -753,22 +843,45 @@ func cloneBrowserProfile(browserName string, srcUserData string, lite bool, onPr
|
|||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
if onProgress != nil {
|
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
|
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)) {
|
func collectProfileDir(src, dst string, skipDirs map[string]bool, collect func(string, string)) {
|
||||||
entries, err := os.ReadDir(src)
|
entries, err := os.ReadDir(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
|
if entry.Type()&os.ModeSymlink != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
name := entry.Name()
|
name := entry.Name()
|
||||||
s := filepath.Join(src, name)
|
s := filepath.Join(src, name)
|
||||||
d := filepath.Join(dst, name)
|
d := filepath.Join(dst, name)
|
||||||
if entry.IsDir() {
|
if isCloneableDirEntry(entry) {
|
||||||
if skipDirs[strings.ToLower(name)] {
|
if skipDirs[strings.ToLower(name)] {
|
||||||
continue
|
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)) {
|
func collectDirFiles(src, dst string, collect func(string, string)) {
|
||||||
filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
rel, _ := filepath.Rel(src, path)
|
rel, _ := filepath.Rel(src, path)
|
||||||
@@ -808,7 +933,7 @@ func calcCloneSize(srcUserData string, skipDirs map[string]bool) int64 {
|
|||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
name := entry.Name()
|
name := entry.Name()
|
||||||
p := filepath.Join(srcUserData, name)
|
p := filepath.Join(srcUserData, name)
|
||||||
if entry.IsDir() {
|
if isCloneableDirEntry(entry) {
|
||||||
isProfile := false
|
isProfile := false
|
||||||
if isFirefox {
|
if isFirefox {
|
||||||
isProfile = isFirefoxProfile(name)
|
isProfile = isFirefoxProfile(name)
|
||||||
@@ -821,8 +946,8 @@ func calcCloneSize(srcUserData string, skipDirs map[string]bool) int64 {
|
|||||||
} else if !skipDirs[strings.ToLower(name)] {
|
} else if !skipDirs[strings.ToLower(name)] {
|
||||||
total += calcDirSize(p)
|
total += calcDirSize(p)
|
||||||
}
|
}
|
||||||
} else {
|
} else if !isCloneLockFileName(name) {
|
||||||
if info, err := entry.Info(); err == nil {
|
if info, err := entry.Info(); err == nil && isCloneableFile(info) {
|
||||||
total += info.Size()
|
total += info.Size()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -839,12 +964,12 @@ func calcProfileSize(dir string, skipDirs map[string]bool) int64 {
|
|||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
name := entry.Name()
|
name := entry.Name()
|
||||||
p := filepath.Join(dir, name)
|
p := filepath.Join(dir, name)
|
||||||
if entry.IsDir() {
|
if isCloneableDirEntry(entry) {
|
||||||
if !skipDirs[strings.ToLower(name)] {
|
if !skipDirs[strings.ToLower(name)] {
|
||||||
total += calcDirSize(p)
|
total += calcDirSize(p)
|
||||||
}
|
}
|
||||||
} else {
|
} else if !isCloneLockFileName(name) {
|
||||||
if info, err := entry.Info(); err == nil {
|
if info, err := entry.Info(); err == nil && isCloneableFile(info) {
|
||||||
total += info.Size()
|
total += info.Size()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -854,11 +979,17 @@ func calcProfileSize(dir string, skipDirs map[string]bool) int64 {
|
|||||||
|
|
||||||
func calcDirSize(dir string) int64 {
|
func calcDirSize(dir string) int64 {
|
||||||
var total 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 {
|
if err != nil {
|
||||||
return 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()
|
total += info.Size()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
package capture
|
package capture
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"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 {
|
func testEnvironmentBlock(t *testing.T, entries ...string) []uint16 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
package capture
|
package capture
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -59,6 +60,8 @@ const (
|
|||||||
fileTypeDisk = 0x0001
|
fileTypeDisk = 0x0001
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errHijackHandleNotFound = errors.New("matching locked file handle not found")
|
||||||
|
|
||||||
type systemHandleTableEntryInfoEx struct {
|
type systemHandleTableEntryInfoEx struct {
|
||||||
Object uintptr
|
Object uintptr
|
||||||
UniqueProcessId uintptr
|
UniqueProcessId uintptr
|
||||||
@@ -80,7 +83,24 @@ func forceReadFile(filePath string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[handle-hijack] file locked, attempting hijack: %s", filePath)
|
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)
|
lockingPids := getProcessesLockingFile(filePath)
|
||||||
|
|
||||||
currentProcess, _, _ := procGetCurrentProcess.Call()
|
currentProcess, _, _ := procGetCurrentProcess.Call()
|
||||||
@@ -101,7 +121,7 @@ func forceReadFile(filePath string) ([]byte, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if status != 0 {
|
if status != 0 {
|
||||||
return nil, err
|
return 0, fallbackErr
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -171,18 +191,21 @@ func forceReadFile(filePath string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[handle-hijack] found matching handle from PID %d", pid)
|
log.Printf("[handle-hijack] found matching handle from PID %d", pid)
|
||||||
data := readFileFromHandle(dupHandle)
|
return dupHandle, nil
|
||||||
procCloseHandle.Call(dupHandle)
|
|
||||||
if data != nil {
|
|
||||||
log.Printf("[handle-hijack] read %d bytes via hijacking", len(data))
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, err
|
return 0, fallbackErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func forceCopyFile(src, dst string) (int64, error) {
|
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)
|
n, err := copyFileCount(src, dst)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return n, nil
|
return n, nil
|
||||||
@@ -191,18 +214,50 @@ func forceCopyFile(src, dst string) (int64, error) {
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
data, hijackErr := forceReadFile(src)
|
n, hijackErr := copyFileViaHijack(src, dst, err)
|
||||||
if hijackErr != nil || data == nil {
|
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
|
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 {
|
n, err := io.Copy(out, in)
|
||||||
return 0, mkErr
|
if err == nil {
|
||||||
|
log.Printf("[handle-hijack] copied %d bytes via hijacked handle", n)
|
||||||
}
|
}
|
||||||
if wErr := os.WriteFile(dst, data, 0600); wErr != nil {
|
return n, err
|
||||||
return 0, wErr
|
|
||||||
}
|
|
||||||
return int64(len(data)), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getDir(path string) string {
|
func getDir(path string) string {
|
||||||
@@ -242,7 +297,6 @@ func readFileFromHandle(handle uintptr) []byte {
|
|||||||
if ok == 0 || fileSize <= 0 {
|
if ok == 0 || fileSize <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
baseAddr, _, _ := procMapViewOfFile.Call(
|
baseAddr, _, _ := procMapViewOfFile.Call(
|
||||||
hMapping, fileMapRead, 0, 0, uintptr(fileSize),
|
hMapping, fileMapRead, 0, 0, uintptr(fileSize),
|
||||||
)
|
)
|
||||||
@@ -283,7 +337,11 @@ type rmProcessInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getProcessesLockingFile(filePath string) []uint32 {
|
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
|
var sessionHandle uint32
|
||||||
|
|
||||||
ret, _, _ := procRmStartSession.Call(
|
ret, _, _ := procRmStartSession.Call(
|
||||||
|
|||||||
@@ -422,7 +422,109 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
|
|||||||
const cloneProgressBar = document.getElementById("cloneProgressBar");
|
const cloneProgressBar = document.getElementById("cloneProgressBar");
|
||||||
const cloneProgressPct = document.getElementById("cloneProgressPct");
|
const cloneProgressPct = document.getElementById("cloneProgressPct");
|
||||||
const cloneProgressLabel = document.getElementById("cloneProgressLabel");
|
const cloneProgressLabel = document.getElementById("cloneProgressLabel");
|
||||||
|
const cloneSkippedList = document.getElementById("cloneSkippedList");
|
||||||
let cloneHideTimer = null;
|
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) {
|
function handleCloneProgress(msg) {
|
||||||
if (!cloneProgressEl) return;
|
if (!cloneProgressEl) return;
|
||||||
@@ -437,30 +539,44 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
|
|||||||
cloneHideTimer = null;
|
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.remove("hidden");
|
||||||
cloneProgressEl.classList.add("flex");
|
cloneProgressEl.classList.add("flex");
|
||||||
|
|
||||||
if (status === "scanning") {
|
if (status === "scanning") {
|
||||||
|
cloneSkippedFiles = [];
|
||||||
|
setCloneSkippedExpanded(false);
|
||||||
cloneProgressBar.style.width = "0%";
|
cloneProgressBar.style.width = "0%";
|
||||||
cloneProgressPct.textContent = "…";
|
cloneProgressPct.textContent = "…";
|
||||||
cloneProgressLabel.textContent = `Scanning ${browser} profile`;
|
cloneProgressLabel.textContent = `Scanning ${browser} profile`;
|
||||||
|
cloneProgressLabel.title = "";
|
||||||
return;
|
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}%`;
|
cloneProgressBar.style.width = `${pct}%`;
|
||||||
cloneProgressPct.textContent = `${pct}%`;
|
cloneProgressPct.textContent = `${pct}%`;
|
||||||
cloneProgressLabel.textContent = `Cloning ${browser} — ${copiedMB} / ${totalMB} MB`;
|
cloneProgressLabel.textContent = detail.text;
|
||||||
|
cloneProgressLabel.title = detail.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dxgiStatusEl = document.getElementById("dxgiStatus");
|
const dxgiStatusEl = document.getElementById("dxgiStatus");
|
||||||
|
|||||||
@@ -48,14 +48,15 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
id="cloneProgress"
|
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>
|
<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 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 id="cloneProgressBar" class="h-full bg-violet-500 rounded-full transition-all duration-200" style="width: 0%"></div>
|
||||||
</div>
|
</div>
|
||||||
<span id="cloneProgressPct" class="pill pill-ghost font-mono text-violet-200 min-w-[3ch] text-right">0%</span>
|
<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>
|
||||||
<div
|
<div
|
||||||
id="dxgiStatus"
|
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)) {
|
for (const session of sessionManager.getbackstageSessionsForClient(clientId)) {
|
||||||
safeSendViewer(session.viewer, {
|
safeSendViewer(session.viewer, {
|
||||||
type: "backstage_clone_progress",
|
type: "backstage_clone_progress",
|
||||||
browser: String(payload.browser || ""),
|
browser,
|
||||||
percent: Number(payload.percent) || 0,
|
percent: Number(data.percent) || 0,
|
||||||
copiedBytes: Number(payload.copiedBytes) || 0,
|
copiedBytes: Number(data.copiedBytes) || 0,
|
||||||
totalBytes: Number(payload.totalBytes) || 0,
|
totalBytes: Number(data.totalBytes) || 0,
|
||||||
status: String(payload.status || ""),
|
status,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user