package spm import ( "archive/tar" "bufio" "compress/gzip" "fmt" "io" "math/rand" "os" "os/exec" "path/filepath" "regexp" "runtime" "sync" "time" ) func DecompressToTemp(filePath string) (string, error) { UpdateProgress(0, "Decompressing package") // 1) Base temp dir baseTempDir := GetTempDir() // 2) Create a unique subfolder inside the base temp dir subfolderName := fmt.Sprintf("spm_decompress_%d", rand.Intn(1000000)) decompressDir := filepath.Join(baseTempDir, subfolderName) if err := os.MkdirAll(decompressDir, 0755); err != nil { return "", fmt.Errorf("failed to create decompress dir: %w", err) } // 3) Open the tar.gz file file, err := os.Open(filePath) if err != nil { return "", err } defer file.Close() gzr, err := gzip.NewReader(file) if err != nil { return "", err } defer gzr.Close() tarReader := tar.NewReader(gzr) // 4) Count total files var totalFiles, processedFiles int for { _, err := tarReader.Next() if err == io.EOF { break } if err != nil { return "", err } totalFiles++ } // 5) Reset file position and tar reader if _, err := file.Seek(0, io.SeekStart); err != nil { return "", err } if err := gzr.Reset(file); err != nil { return "", err } tarReader = tar.NewReader(gzr) // 6) Extract into `decompressDir` for { header, err := tarReader.Next() if err == io.EOF { break } if err != nil { return "", err } targetPath := filepath.Join(decompressDir, header.Name) switch header.Typeflag { case tar.TypeDir: if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil { return "", err } case tar.TypeReg: outFile, err := os.Create(targetPath) if err != nil { return "", err } if _, err := io.Copy(outFile, tarReader); err != nil { outFile.Close() return "", err } outFile.Close() } processedFiles++ UpdateProgress(int(float64(processedFiles)/float64(totalFiles)*100), "Decompressing package") } UpdateProgress(100, "Package decompressed") return decompressDir, nil } // tailLogFile continuously reads new lines from logFile until done is closed. // It prints each new line and searches for a percentage (e.g. " 47%") to call UpdateProgress. func tailLogFile(logFile string, done <-chan struct{}, wg *sync.WaitGroup) { defer wg.Done() var offset int64 re := regexp.MustCompile(`\s+(\d+)%`) for { select { case <-done: return default: f, err := os.Open(logFile) if err != nil { time.Sleep(200 * time.Millisecond) continue } // Seek to the last known offset. f.Seek(offset, io.SeekStart) scanner := bufio.NewScanner(f) for scanner.Scan() { line := scanner.Text() fmt.Printf("[ROBOPROGRESS-LOG] %s\n", line) // Look for a percentage. if matches := re.FindStringSubmatch(line); len(matches) == 2 { var percent int _, err := fmt.Sscanf(matches[1], "%d", &percent) if err == nil { fmt.Printf("[ROBOPROGRESS] Parsed progress: %d%%\n", percent) UpdateProgress(percent, "Copying files to install directory") } } } // Update the offset. newOffset, _ := f.Seek(0, io.SeekCurrent) offset = newOffset f.Close() time.Sleep(200 * time.Millisecond) } } } // MoveFilesToInstallDir copies files from tempDir to installDir. // On Windows it uses robocopy with logging so that as much output as possible is printed, // and a separate goroutine tails the log file to update progress. // The log file is saved (not automatically deleted) so you can inspect it. func MoveFilesToInstallDir(tempDir, installDir, pkgType string) error { // Ensure tempDir exists. if _, err := os.Stat(tempDir); os.IsNotExist(err) { return fmt.Errorf("tempDir does not exist: %s", tempDir) } // If package type is "browser", adjust installDir. if pkgType == "browser" { installDir = filepath.Join(installDir, "browser") } // Ensure destination exists. if err := os.MkdirAll(installDir, os.ModePerm); err != nil { return fmt.Errorf("failed to create installDir: %w", err) } if runtime.GOOS == "windows" { // Create a temporary log file. logFile := filepath.Join(os.TempDir(), fmt.Sprintf("robocopy_%d.log", time.Now().UnixNano())) // Print out the log file path so you can locate it manually. fmt.Printf("[INFO] Robocopy log file: %s\n", logFile) // Build robocopy command. // /E: copy subdirectories (including empty ones) // /TEE: output to console as well as to the log file. // /LOG:: write output to the log file. // We remove extra suppression flags so that robocopy prints as much as possible. cmd := exec.Command("robocopy", tempDir, installDir, "/E", "/TEE", fmt.Sprintf("/LOG:%s", logFile)) // Start robocopy. if err := cmd.Start(); err != nil { return fmt.Errorf("failed to start robocopy: %w", err) } // Set up a goroutine to tail the log file. doneTail := make(chan struct{}) var wg sync.WaitGroup wg.Add(1) go tailLogFile(logFile, doneTail, &wg) // Wait for robocopy to complete. err := cmd.Wait() // Signal the tail goroutine to stop. close(doneTail) wg.Wait() // Robocopy returns exit codes less than 8 as success. if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { if exitCode := exitErr.ExitCode(); exitCode >= 8 { return fmt.Errorf("robocopy failed: exit status %d", exitCode) } } else { return fmt.Errorf("robocopy failed: %w", err) } } // Mark progress as complete. UpdateProgress(100, "Copying files to install directory") // (Optional) If you want the log file to be removed automatically, uncomment the next line. // os.Remove(logFile) } else { // Non-Windows fallback: copy files one-by-one. var totalFiles, copiedFiles int err := filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if !info.IsDir() { totalFiles++ } return nil }) if err != nil { return err } err = filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } relPath, err := filepath.Rel(tempDir, path) if err != nil { return err } targetPath := filepath.Join(installDir, relPath) if info.IsDir() { if err := os.MkdirAll(targetPath, os.ModePerm); err != nil { return err } } else { if err := copyFile(path, targetPath); err != nil { return err } copiedFiles++ UpdateProgress(int(float64(copiedFiles)/float64(totalFiles)*100), "Copying files to install directory") } return nil }) if err != nil { return err } } // Clean up temporary directory. UpdateProgress(100, "Cleaning up temporary files") return os.RemoveAll(tempDir) } // copyFile copies the contents of the source file to the destination file. func copyFile(src, dst string) error { sourceFile, err := os.Open(src) if err != nil { return err } defer sourceFile.Close() // Create the destination file destinationFile, err := os.Create(dst) if err != nil { return err } defer destinationFile.Close() // Copy the file content if _, err := io.Copy(destinationFile, sourceFile); err != nil { return err } // Preserve file permissions info, err := sourceFile.Stat() if err != nil { return err } return os.Chmod(dst, info.Mode()) } // FinalizeInstall finalizes the installation by updating installed.ini. func FinalizeInstall(packageName, release, version, arch, osName string) error { installDir, err := GetInstallDir() if err != nil { return err } pkgInfo := AppIndexEntry{ Name: packageName, Version: version, Release: release, Arch: arch, OS: osName, } return UpdateInstalledPackage(installDir, pkgInfo) }