diff --git a/background.go b/background.go index f78221f..ab61980 100644 --- a/background.go +++ b/background.go @@ -25,7 +25,7 @@ func InitBackground(width, height int) { for i := range particles { particles[i].Pos = rl.Vector2{X: float32(rng.Intn(width)), Y: float32(rng.Intn(height))} particles[i].Vel = rl.Vector2{X: (rng.Float32() - 0.5) * 0.2, Y: (rng.Float32() - 0.5) * 0.2} - particles[i].Size = rng.Float32()*1.5 + 0.5 // Particles size ~0.5-2.0 + particles[i].Size = rng.Float32()*1.5 + 0.5 } } diff --git a/gui.go b/gui.go index c7a34bb..d7b1dd1 100644 --- a/gui.go +++ b/gui.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "log" "path/filepath" "time" @@ -10,110 +11,117 @@ import ( // ShowUpdateWindow displays the update GUI. func ShowUpdateWindow() { - screenW := 300 - screenH := 450 - rl.SetConfigFlags(rl.FlagWindowUndecorated) - rl.InitWindow(int32(screenW), int32(screenH), "Updating Spitfire") - - // 1) Use actual monitor refresh rate - monitor := rl.GetCurrentMonitor() - refreshRate := rl.GetMonitorRefreshRate(monitor) - rl.SetTargetFPS(int32(refreshRate)) - - InitBackground(screenW, screenH) - - logoPath := filepath.Join(getSpmDir(), "logo.png") - logo := rl.LoadTexture(logoPath) - defer rl.UnloadTexture(logo) - - var displayed float32 - scale := float32(0.5) var sf StateFile - sf = ReadState() - // 2) Update state asynchronously in a separate goroutine - go func() { + if sf.IsUpdating == true { + + log.Println("Stage=Installing => show update window.") + + screenW := 300 + screenH := 450 + rl.SetConfigFlags(rl.FlagWindowUndecorated) + rl.InitWindow(int32(screenW), int32(screenH), "Updating Spitfire") + + monitor := rl.GetCurrentMonitor() + refreshRate := rl.GetMonitorRefreshRate(monitor) + rl.SetTargetFPS(int32(refreshRate)) + + InitBackground(screenW, screenH) + + logoPath := filepath.Join(getSpmDir(), "logo.png") + logo := rl.LoadTexture(logoPath) + defer rl.UnloadTexture(logo) + + var displayed float32 + scale := float32(0.5) + + // Update state asynchronously in a separate goroutine + go func() { + for { + sf = ReadState() + time.Sleep(1 * time.Second) + } + }() + for { - sf = ReadState() - time.Sleep(1 * time.Second) - } - }() + tgt := float32(sf.Progress) - for { - tgt := float32(sf.Progress) + if rl.WindowShouldClose() { + break + } - if rl.WindowShouldClose() { - break + // Smooth interpolation for displayed progress + displayed += (tgt - displayed) * 0.15 + + rl.BeginDrawing() + rl.ClearBackground(rl.Black) + + // 2 px inside border for visibility + rl.DrawRectangleLinesEx( + rl.Rectangle{X: 1, Y: 1, Width: float32(screenW - 2), Height: float32(screenH - 2)}, + 2, + rl.White, + ) + + UpdateBackground(screenW, screenH) + DrawBackground(screenW, screenH) + + // Draw logo scaled + lw := float32(logo.Width) * scale + lx := float32(screenW)/2 - lw/2 + ly := float32(20) + rl.DrawTextureEx(logo, rl.Vector2{X: lx, Y: ly}, 0, scale, rl.White) + + // Progress bar + barW := float32(screenW - 60) + barH := float32(20) + barX := float32(30) + barY := float32(screenH/2 + 40) + frac := displayed / 100.0 + if frac < 0 { + frac = 0 + } else if frac > 1 { + frac = 1 + } + + fillRect := rl.Rectangle{X: barX, Y: barY, Width: barW * frac, Height: barH} + fullRect := rl.Rectangle{X: barX, Y: barY, Width: barW, Height: barH} + corner := float32(0.4) + + rl.DrawRectangleRounded(fillRect, corner, 6, rl.RayWhite) + rl.DrawRectangleRoundedLines(fullRect, corner, 6, rl.White) + + // Display status text + msg := updateStatusMsg + if !sf.IsUpdating { + msg = "Update complete!" + } + fontSize := int32(20) + txtW := rl.MeasureText(msg, fontSize) + txtX := (int32(screenW) - txtW) / 2 + txtY := int32(barY) - 30 + rl.DrawText(msg, txtX, txtY, fontSize, rl.White) + + // Display numeric progress + progStr := fmt.Sprintf("%.0f%%", displayed) + pw := rl.MeasureText(progStr, 20) + px := (int32(screenW) - pw) / 2 + py := int32(barY) + 30 + rl.DrawText(progStr, px, py, 20, rl.White) + + rl.EndDrawing() + + // If no longer updating => break window loop + if !sf.IsUpdating { + break + } } - // Smooth interpolation for displayed progress - displayed += (tgt - displayed) * 0.15 + rl.CloseWindow() - rl.BeginDrawing() - rl.ClearBackground(rl.Black) - - // 2 px inside border for visibility - rl.DrawRectangleLinesEx( - rl.Rectangle{X: 1, Y: 1, Width: float32(screenW - 2), Height: float32(screenH - 2)}, - 2, - rl.White, - ) - - UpdateBackground(screenW, screenH) - DrawBackground(screenW, screenH) - - // Draw logo scaled - lw := float32(logo.Width) * scale - lx := float32(screenW)/2 - lw/2 - ly := float32(20) - rl.DrawTextureEx(logo, rl.Vector2{X: lx, Y: ly}, 0, scale, rl.White) - - // Progress bar - barW := float32(screenW - 60) - barH := float32(20) - barX := float32(30) - barY := float32(screenH/2 + 40) - frac := displayed / 100.0 - if frac < 0 { - frac = 0 - } else if frac > 1 { - frac = 1 - } - - fillRect := rl.Rectangle{X: barX, Y: barY, Width: barW * frac, Height: barH} - fullRect := rl.Rectangle{X: barX, Y: barY, Width: barW, Height: barH} - corner := float32(0.4) - - rl.DrawRectangleRounded(fillRect, corner, 6, rl.RayWhite) - rl.DrawRectangleRoundedLines(fullRect, corner, 6, rl.White) - - // Display status text - msg := updateStatusMsg - if !sf.IsUpdating { - msg = "Update complete!" - } - fontSize := int32(20) - txtW := rl.MeasureText(msg, fontSize) - txtX := (int32(screenW) - txtW) / 2 - txtY := int32(barY) - 30 - rl.DrawText(msg, txtX, txtY, fontSize, rl.White) - - // Display numeric progress - progStr := fmt.Sprintf("%.0f%%", displayed) - pw := rl.MeasureText(progStr, 20) - px := (int32(screenW) - pw) / 2 - py := int32(barY) + 30 - rl.DrawText(progStr, px, py, 20, rl.White) - - rl.EndDrawing() - - // If no longer updating => break window loop - if !sf.IsUpdating { - break - } } - rl.CloseWindow() + LaunchBrowser() } diff --git a/main.go b/main.go index 86ce9f8..6cc64a7 100644 --- a/main.go +++ b/main.go @@ -26,7 +26,7 @@ var ( isBackgroundMode bool ) -// StateFile holds the update state from INI +// StateFile holds the update state from our INI type StateFile struct { IsUpdating bool Progress int32 @@ -199,8 +199,8 @@ func runBackgroundUpdater() { // Write the state to the update_state.ini file WriteState(isUpdating, int32(progress), task) - // Sleep for 1 second before updating the progress again - time.Sleep(1 * time.Second) + // Sleep for 1 hour before updating the progress again + time.Sleep(1 * time.Hour) } }() @@ -249,15 +249,9 @@ func main() { isBackgroundMode = true runBackgroundUpdater() return - } - - log.Println("Launcher started (foreground mode).") - sf := ReadState() - if sf.IsUpdating == true { - log.Println("Stage=Installing => show update window.") - ShowUpdateWindow() } else { - LaunchBrowser() + log.Println("Launcher started (foreground mode).") + ShowUpdateWindow() } fmt.Println("Exiting launcher.") } diff --git a/spm/auto.go b/spm/auto.go index 51de9f4..92e574a 100644 --- a/spm/auto.go +++ b/spm/auto.go @@ -7,12 +7,12 @@ import ( ) // pendingUpdates holds info about packages that have been downloaded/decompressed -// but not yet moved to the final install location. +// but not yet moved to the final install location, cuz Windows has this stupid file locking mechanism var pendingUpdates []AppIndexEntry // AutoDownloadUpdates downloads the APPINDEX file, parses it, compares against // currently installed packages, and if it finds a newer version, downloads -// and decompresses it into a temporary folder. The result is stored in pendingUpdates. +// and decompresses it into a temporary folder. The result is stored in pendingUpdates, so it can be used by AutoInstallUpdates(). func AutoDownloadUpdates() error { // 1) Download the APPINDEX file to a temporary location appIndexPath := filepath.Join(os.TempDir(), "APPINDEX") @@ -115,10 +115,10 @@ func AutoDownloadUpdates() error { return nil } -// AutoInstallUpdates installs any packages that were decompressed by AutoDownloadUpdates. +// AutoInstallUpdates installs any packages that were downloaded and decompressed by AutoDownloadUpdates. // It moves files from their temp directories to the final location and updates installed.ini. func AutoInstallUpdates() error { - installDir, err := GetDefaultInstallDir() + installDir, err := GetInstallDir() if err != nil { return err } @@ -155,3 +155,107 @@ func AutoInstallUpdates() error { pendingUpdates = nil return nil } + +func AutoDownloadSpecified(specs []AppIndexEntry) error { + // 1) Download the APPINDEX file to a temporary location + appIndexPath := filepath.Join(os.TempDir(), "APPINDEX") + fmt.Println("[INFO] Starting APPINDEX download to:", appIndexPath) + if err := DownloadAppIndex(appIndexPath); err != nil { + return fmt.Errorf("[ERROR] Failed to download APPINDEX: %w", err) + } + fmt.Println("[INFO] APPINDEX downloaded successfully") + + // 2) Parse the APPINDEX file + fmt.Println("[INFO] Parsing APPINDEX file:", appIndexPath) + entries, err := ParseAppIndex(appIndexPath) + if err != nil { + return fmt.Errorf("[ERROR] Failed to parse APPINDEX: %w", err) + } + fmt.Printf("[INFO] Parsed APPINDEX successfully, found %d entries\n", len(entries)) + + // 3) Get install directory to check for updates + installDir, err := GetInstallDir() + if err != nil { + return err + } + fmt.Println("[INFO] Install directory:", installDir) + + // 4) For each item in the passed specs, attempt to download if update is needed + for _, spec := range specs { + fmt.Printf("[INFO] Checking requested package: %+v\n", spec) + + // Find matching entry from the parsed APPINDEX + var matchingEntry *AppIndexEntry + for _, e := range entries { + if e.Name == spec.Name && + e.Release == spec.Release && + e.Type == spec.Type && + e.OS == spec.OS && + e.Arch == spec.Arch { + matchingEntry = &e + break + } + } + + if matchingEntry == nil { + fmt.Printf("[WARN] No matching APPINDEX entry found for package: %s (%s)\n", spec.Name, spec.Release) + continue + } + fmt.Printf("[INFO] Found matching APPINDEX entry: %+v\n", *matchingEntry) + + // // Check if an update is needed + // updateNeeded, err := IsUpdateNeeded( + // installDir, + // matchingEntry.Name, + // matchingEntry.Release, + // matchingEntry.Version, + // matchingEntry.Arch, + // matchingEntry.OS, + // ) + // if err != nil { + // return fmt.Errorf("[ERROR] Failed to check if update is needed for %s: %w", matchingEntry.Name, err) + // } + + // if !updateNeeded { + // fmt.Printf("[INFO] No update needed for package '%s'\n", matchingEntry.Name) + // continue + // } + + // 5) Download the package + downloadDir := GetTempDir() + fmt.Printf("[INFO] Downloading package '%s' to temporary folder: %s\n", matchingEntry.Name, downloadDir) + if err := DownloadPackageFromAppIndex( + appIndexPath, + matchingEntry.Name, + matchingEntry.Release, + matchingEntry.Type, + downloadDir, + ); err != nil { + return fmt.Errorf("[ERROR] Failed to download package '%s': %w", matchingEntry.Name, err) + } + fmt.Printf("[INFO] Package '%s' downloaded successfully to: %s\n", matchingEntry.Name, downloadDir) + + // 6) Decompress the package + fmt.Printf("[INFO] Decompressing package '%s'\n", matchingEntry.Name) + tempDir, err := DecompressPackage( + downloadDir, + matchingEntry.Name, + matchingEntry.Arch, + matchingEntry.OS, + matchingEntry.Type, + matchingEntry.Release, + matchingEntry.Version, + ) + if err != nil { + return fmt.Errorf("[ERROR] Failed to decompress package '%s': %w", matchingEntry.Name, err) + } + fmt.Printf("[INFO] Package '%s' decompressed successfully to: %s\n", matchingEntry.Name, tempDir) + + // 7) Store in pendingUpdates for AutoInstallUpdates + fmt.Printf("[INFO] Adding '%s' to pending updates\n", matchingEntry.Name) + pendingUpdates = append(pendingUpdates, *matchingEntry) + } + + fmt.Println("[INFO] AutoDownloadSpecifiedPackages completed successfully") + return nil +} diff --git a/spm/decompress.go b/spm/decompress.go new file mode 100644 index 0000000..dcfb9f8 --- /dev/null +++ b/spm/decompress.go @@ -0,0 +1,90 @@ +package spm + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// DecompressPackage determines the package format and decompresses it +func DecompressPackage(downloadDir, packageName, arch, osName, pkgType, release, version string) (string, error) { + // 1) Construct the .tar.gz name + expectedFileName := fmt.Sprintf( + "%s@%s@%s@%s@%s@%s.tar.gz", + packageName, arch, osName, pkgType, release, version, + ) + packagePath := filepath.Join(downloadDir, expectedFileName) + + // Check that file exists + if _, err := os.Stat(packagePath); os.IsNotExist(err) { + return "", fmt.Errorf("package file not found: %s", packagePath) + } + + // 2) Build the folder path (minus ".tar.gz") + folderName := strings.TrimSuffix(expectedFileName, ".tar.gz") + tempDir := GetTempDir() // e.g. C:\Users\\AppData\Local\Temp\spm_temp_164326 + decompressDir := filepath.Join(tempDir, folderName) + + // Ensure the folder + if err := os.MkdirAll(decompressDir, 0755); err != nil { + return "", fmt.Errorf("failed to create decompressDir: %w", err) + } + + // 3) Decompress everything into `decompressDir` + if err := decompressTarGz(packagePath, decompressDir); err != nil { + return "", fmt.Errorf("failed to decompress: %w", err) + } + + // Return the folder path we used + return decompressDir, nil +} + +func decompressTarGz(srcFile, destDir string) error { + f, err := os.Open(srcFile) + if err != nil { + return err + } + defer f.Close() + + gzr, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gzr.Close() + + tarReader := tar.NewReader(gzr) + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + + outPath := filepath.Join(destDir, header.Name) + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(outPath, os.FileMode(header.Mode)); err != nil { + return err + } + case tar.TypeReg: + outFile, err := os.Create(outPath) + if err != nil { + return err + } + _, err = io.Copy(outFile, tarReader) + outFile.Close() + if err != nil { + return err + } + default: + // huh + } + } + return nil +} diff --git a/spm/dirs.go b/spm/dirs.go new file mode 100644 index 0000000..ec62c24 --- /dev/null +++ b/spm/dirs.go @@ -0,0 +1,144 @@ +package spm + +import ( + "fmt" + "math/rand" + "os" + "path/filepath" + "runtime" + "sync" +) + +// global sync and variable for generated temp dir +var ( + tempDirOnce sync.Once + tempDirPath string +) + +// global variables for install dir +var ( + installMu sync.Mutex + installedDir string + installEnvVar = "SPITFIRE_INSTALL_DIR" +) + +// GetTempDir generates or retrieves a unique temp dir. +func GetTempDir() string { + tempDirOnce.Do(func() { + // Generate a unique temp dir name + tempDirPath = filepath.Join(os.TempDir(), fmt.Sprintf("spm_temp_%d", rand.Intn(1000000))) + + // Ensure the dir exists + if err := os.MkdirAll(tempDirPath, os.ModePerm); err != nil { + fmt.Printf("[ERROR] Failed to create temp directory: %v\n", err) + } else { + fmt.Printf("[INFO] Using temp directory: %s\n", tempDirPath) + } + }) + return tempDirPath +} + +// GetDefaultInstallDir generates the default installation dir +// based on the OS and environment, then also sets it via SetInstallDir. +// +// Please use GetInstallDir() instead of GetDefaultInstallDir() when interacting with spm. +func GetDefaultInstallDir() (string, error) { + var installDir string + + switch runtime.GOOS { + case "windows": + // Use C:\Program Files + programFiles := os.Getenv("ProgramFiles") + if programFiles == "" { + return "", fmt.Errorf("unable to determine default install directory on Windows") + } + installDir = filepath.Join(programFiles, "Spitfire") + + case "darwin": + // Use ~/Library/Application Support on macOS + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("unable to determine home directory on macOS: %w", err) + } + installDir = filepath.Join(homeDir, "Library", "Application Support", "Spitfire") + + case "linux": + // Use ~/.local/share/Spitfire on Linux + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("unable to determine home directory on Linux: %w", err) + } + installDir = filepath.Join(homeDir, ".local", "share", "Spitfire") + + default: + return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS) + } + + // Also store it globally so future calls to GetInstallDir() return the same + SetInstallDir(installDir) + return installDir, nil +} + +// SetDownloadFolder ensures customDir exists, returns it +func SetDownloadFolder(customDir string) (string, error) { + if err := os.MkdirAll(customDir, os.ModePerm); err != nil { + return "", err + } + return customDir, nil +} + +// SetInstallDir sets the global install dir variable and updates the persistent environment variable. +func SetInstallDir(path string) error { + installMu.Lock() + defer installMu.Unlock() + + installedDir = path + + // Persist the environment variable on Windows + if runtime.GOOS == "windows" { + err := persistSystemEnvVar(installEnvVar, path) + if err != nil { + return err + } + } else { + // For non-Windows platforms, just set it in the current process environment + err := os.Setenv(installEnvVar, path) + if err != nil { + return err + } + } + + return nil +} + +// GetInstallDir returns the currently set install dir if available. +// Otherwise, it calls GetDefaultInstallDir() and sets that. +func GetInstallDir() (string, error) { + + // If already set, return it + if installedDir != "" { + return installedDir, nil + } + + // Check if it's stored in the system environment variable + if envDir := os.Getenv(installEnvVar); envDir != "" { + installedDir = envDir + return installedDir, nil + } + + // Compute and store the default dir if not already set + defDir, err := GetDefaultInstallDir() + if err != nil { + return "", err + } + installedDir = defDir + + // Persist the default dir as an environment variable on Windows + if runtime.GOOS == "windows" { + _ = persistSystemEnvVar(installEnvVar, defDir) + } else { + _ = os.Setenv(installEnvVar, defDir) + } + + return defDir, nil +} diff --git a/spm/download.go b/spm/download.go index 8609823..30d239c 100644 --- a/spm/download.go +++ b/spm/download.go @@ -10,98 +10,6 @@ import ( "time" ) -// func DownloadPackage(pkg Package, destDir string, version, release, pkgType string) error { -// client := &http.Client{} - -// var resp *http.Response -// var err error -// for i := 0; i < 3; i++ { // Retry up to 3 times -// fmt.Printf("[INFO] Attempting to download package from URL: %s (Attempt %d)\n", pkg.DownloadURL, i+1) -// resp, err = client.Get(pkg.DownloadURL) -// if err == nil && resp.StatusCode == http.StatusOK { -// break -// } -// if err != nil { -// fmt.Printf("[ERROR] Attempt %d failed: %v\n", i+1, err) -// } -// if resp != nil && resp.StatusCode != http.StatusOK { -// fmt.Printf("[ERROR] Server returned status: %d\n", resp.StatusCode) -// } -// if i < 2 { -// time.Sleep(2 * time.Second) // Delay between retries -// } -// } -// if err != nil { -// return fmt.Errorf("[ERROR] Failed to download %s after 3 retries: %w", pkg.Name, err) -// } -// defer resp.Body.Close() - -// // Check content type -// contentType := resp.Header.Get("Content-Type") -// if contentType != "application/gzip" && contentType != "application/x-tar" { -// return fmt.Errorf("[ERROR] Invalid content type: %s. Expected a .tar.gz file.", contentType) -// } - -// // Generate the filename using the desired format -// filename := fmt.Sprintf("%s@%s@%s@%s@%s@%s.tar.gz", -// pkg.Name, // Name of the package -// pkg.Arch, // Architecture (e.g., amd64) -// pkg.OS, // Operating System (e.g., windows, linux) -// pkgType, // Type of the package (e.g., nightly, stable) -// release, // Release (e.g., nightly, stable) -// version, // Version of the package -// ) - -// // Construct the full file path -// filePath := filepath.Join(destDir, filename) -// fmt.Printf("[INFO] Saving package to: %s\n", filePath) - -// // Create the destination directory if it doesn't exist -// err = os.MkdirAll(destDir, 0755) -// if err != nil { -// return fmt.Errorf("[ERROR] Failed to create destination directory %s: %w", destDir, err) -// } - -// // Create the file to save the download -// out, err := os.Create(filePath) -// if err != nil { -// return fmt.Errorf("[ERROR] Failed to create file %s: %w", filePath, err) -// } -// defer out.Close() - -// // Track download progress -// totalSize := resp.ContentLength -// var downloaded int64 -// buf := make([]byte, 1024) -// for { -// n, err := resp.Body.Read(buf) -// if n > 0 { -// downloaded += int64(n) -// percentage := int(float64(downloaded) / float64(totalSize) * 100) -// UpdateProgress(percentage, fmt.Sprintf("Downloading %s", pkg.Name)) -// if _, err := out.Write(buf[:n]); err != nil { -// return fmt.Errorf("[ERROR] Failed to write to file %s: %w", filePath, err) -// } -// } -// if err == io.EOF { -// break -// } -// if err != nil { -// return fmt.Errorf("[ERROR] Error reading response body: %w", err) -// } -// } - -// UpdateProgress(100, fmt.Sprintf("%s downloaded", pkg.Name)) -// fmt.Printf("[INFO] Package %s downloaded successfully to: %s\n", pkg.Name, filePath) - -// // Validate that the file is a valid gzip or tar file -// if _, err := os.Stat(filePath); err != nil { -// return fmt.Errorf("[ERROR] Downloaded file does not exist: %w", err) -// } - -// return nil -// } - // DownloadPackageFromAppIndex selects and downloads the correct package from the APPINDEX. func DownloadPackageFromAppIndex(appIndexPath string, packageName string, release string, pkgType string, destDir string) error { // Parse the APPINDEX @@ -129,7 +37,7 @@ func DownloadPackageFromAppIndex(appIndexPath string, packageName string, releas } // Check if the package is already installed and up-to-date - installDir, err := GetDefaultInstallDir() + installDir, err := GetInstallDir() if err != nil { return fmt.Errorf("failed to get install directory: %w", err) } diff --git a/spm/install.go b/spm/install.go index 053be30..9fbf274 100644 --- a/spm/install.go +++ b/spm/install.go @@ -2,12 +2,18 @@ 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) { @@ -96,65 +102,159 @@ func DecompressToTemp(filePath string) (string, error) { 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 before processing + // Ensure tempDir exists. if _, err := os.Stat(tempDir); os.IsNotExist(err) { return fmt.Errorf("tempDir does not exist: %s", tempDir) } - // If the package type is "browser", set the subdirectory to "browser" + // If package type is "browser", adjust installDir. if pkgType == "browser" { installDir = filepath.Join(installDir, "browser") } - // Count total files to copy - 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 + // Ensure destination exists. + if err := os.MkdirAll(installDir, os.ModePerm); err != nil { + return fmt.Errorf("failed to create installDir: %w", err) } - // Copy files and track progress - err = filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error { + 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 } - relPath, err := filepath.Rel(tempDir, path) + 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 } - - targetPath := filepath.Join(installDir, relPath) - if info.IsDir() { - // Create directories in the install directory - if err := os.MkdirAll(targetPath, os.ModePerm); err != nil { - return err - } - } else { - // Copy files to the install directory - 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 + // Clean up temporary directory. UpdateProgress(100, "Cleaning up temporary files") return os.RemoveAll(tempDir) } @@ -189,7 +289,7 @@ func copyFile(src, dst string) error { // FinalizeInstall finalizes the installation by updating installed.ini. func FinalizeInstall(packageName, release, version, arch, osName string) error { - installDir, err := GetDefaultInstallDir() + installDir, err := GetInstallDir() if err != nil { return err } diff --git a/spm/run.go b/spm/run.go index d83619f..5c6ce34 100644 --- a/spm/run.go +++ b/spm/run.go @@ -21,6 +21,7 @@ func Run() error { } cmd := exec.Command(exePath) + cmd.Dir = filepath.Join(installDir, "browser") return cmd.Start() } diff --git a/spm/tempdir.go b/spm/tempdir.go deleted file mode 100644 index 7b7644c..0000000 --- a/spm/tempdir.go +++ /dev/null @@ -1,32 +0,0 @@ -package spm - -import ( - "fmt" - "math/rand" - "os" - "path/filepath" - "sync" - "time" -) - -var ( - tempDirOnce sync.Once - tempDirPath string -) - -// GetTempDir generates or retrieves a unique temp directory. -func GetTempDir() string { - tempDirOnce.Do(func() { - // Generate a unique temp directory name - rand.Seed(time.Now().UnixNano()) - tempDirPath = filepath.Join(os.TempDir(), fmt.Sprintf("spm_temp_%d", rand.Intn(1000000))) - - // Ensure the directory exists - if err := os.MkdirAll(tempDirPath, os.ModePerm); err != nil { - fmt.Printf("[ERROR] Failed to create temp directory: %v\n", err) - } else { - fmt.Printf("[INFO] Using temp directory: %s\n", tempDirPath) - } - }) - return tempDirPath -} diff --git a/spm/utils.go b/spm/utils.go index 4a03b31..607b6f6 100644 --- a/spm/utils.go +++ b/spm/utils.go @@ -1,84 +1,12 @@ package spm import ( - "archive/tar" - "compress/gzip" - "fmt" - "io" - "os" "os/exec" - "path/filepath" - "runtime" - "strings" - "sync" ) -// global mutable variable to store the chosen install directory -var ( - mu sync.Mutex - installedDir string - envVar = "SPITFIRE_INSTALL_DIR" // Environment variable name -) - -// SetInstallDir sets the global install directory variable and updates the persistent environment variable. -func SetInstallDir(path string) error { - mu.Lock() - defer mu.Unlock() - - installedDir = path - - // Persist the environment variable on Windows - if runtime.GOOS == "windows" { - err := persistSystemEnvVar(envVar, path) - if err != nil { - return err - } - } else { - // For non-Windows platforms, just set it in the current process environment - err := os.Setenv(envVar, path) - if err != nil { - return err - } - } - - return nil -} - -// GetInstallDir returns the currently set install directory if available; -// otherwise, it calls GetDefaultInstallDir() and sets that. -func GetInstallDir() (string, error) { - mu.Lock() - defer mu.Unlock() - - // If already set, return it - if installedDir != "" { - return installedDir, nil - } - - // Check if it's stored in the system environment variable - if envDir := os.Getenv(envVar); envDir != "" { - installedDir = envDir - return installedDir, nil - } - - // Compute and store the default directory if not already set - defDir, err := GetDefaultInstallDir() - if err != nil { - return "", err - } - installedDir = defDir - - // Persist the default directory as an environment variable on Windows - if runtime.GOOS == "windows" { - _ = persistSystemEnvVar(envVar, defDir) - } else { - _ = os.Setenv(envVar, defDir) - } - - return defDir, nil -} - // persistSystemEnvVar sets a persistent environment variable on Windows using the `setx` command. +// Perhaps support for other systems would be needed, but all of this "Launcher" thingy is probably going to end up +// being Windows-specific, as other superior systems have their own package managers. func persistSystemEnvVar(key, value string) error { cmd := exec.Command("cmd", "/C", "setx", key, value) err := cmd.Run() @@ -88,52 +16,6 @@ func persistSystemEnvVar(key, value string) error { return nil } -// GetDefaultInstallDir generates the default installation directory -// based on the OS and environment, then also sets it via SetInstallDir. -func GetDefaultInstallDir() (string, error) { - var installDir string - - switch runtime.GOOS { - case "windows": - programFiles := os.Getenv("ProgramFiles") - if programFiles == "" { - return "", fmt.Errorf("unable to determine default install directory on Windows") - } - installDir = filepath.Join(programFiles, "Spitfire") - - case "darwin": - // Use ~/Library/Application Support on macOS - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("unable to determine home directory on macOS: %w", err) - } - installDir = filepath.Join(homeDir, "Library", "Application Support", "Spitfire") - - case "linux": - // Use ~/.local/share/Spitfire on Linux - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("unable to determine home directory on Linux: %w", err) - } - installDir = filepath.Join(homeDir, ".local", "share", "Spitfire") - - default: - return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS) - } - - // Also store it globally so future calls to GetInstallDir() return the same - SetInstallDir(installDir) - return installDir, nil -} - -// SetDownloadFolder ensures customDir exists, returns it -func SetDownloadFolder(customDir string) (string, error) { - if err := os.MkdirAll(customDir, os.ModePerm); err != nil { - return "", err - } - return customDir, nil -} - // IsMatchingEntry checks if a package entry matches the requested specs func IsMatchingEntry(e AppIndexEntry, name, release, arch, osName, pkgType string) bool { return e.Name == name && @@ -142,83 +24,3 @@ func IsMatchingEntry(e AppIndexEntry, name, release, arch, osName, pkgType strin e.OS == osName && e.Type == pkgType } - -// DecompressPackage determines the package format and decompresses it -// DecompressPackage: uses a consistent folder name based on "expectedFileName". -func DecompressPackage(downloadDir, packageName, arch, osName, pkgType, release, version string) (string, error) { - // 1) Construct the .tar.gz name - expectedFileName := fmt.Sprintf( - "%s@%s@%s@%s@%s@%s.tar.gz", - packageName, arch, osName, pkgType, release, version, - ) - packagePath := filepath.Join(downloadDir, expectedFileName) - - // Check that file exists - if _, err := os.Stat(packagePath); os.IsNotExist(err) { - return "", fmt.Errorf("package file not found: %s", packagePath) - } - - // 2) Build the folder path (minus ".tar.gz") - folderName := strings.TrimSuffix(expectedFileName, ".tar.gz") - tempDir := GetTempDir() // e.g. C:\Users\\AppData\Local\Temp\spm_temp_164326 - decompressDir := filepath.Join(tempDir, folderName) - - // Ensure the folder - if err := os.MkdirAll(decompressDir, 0755); err != nil { - return "", fmt.Errorf("failed to create decompressDir: %w", err) - } - - // 3) Decompress everything into `decompressDir` - if err := decompressTarGz(packagePath, decompressDir); err != nil { - return "", fmt.Errorf("failed to decompress: %w", err) - } - - // Return the folder path we used - return decompressDir, nil -} - -func decompressTarGz(srcFile, destDir string) error { - f, err := os.Open(srcFile) - if err != nil { - return err - } - defer f.Close() - - gzr, err := gzip.NewReader(f) - if err != nil { - return err - } - defer gzr.Close() - - tarReader := tar.NewReader(gzr) - for { - header, err := tarReader.Next() - if err == io.EOF { - break - } - if err != nil { - return err - } - - outPath := filepath.Join(destDir, header.Name) - switch header.Typeflag { - case tar.TypeDir: - if err := os.MkdirAll(outPath, os.FileMode(header.Mode)); err != nil { - return err - } - case tar.TypeReg: - outFile, err := os.Create(outPath) - if err != nil { - return err - } - _, err = io.Copy(outFile, tarReader) - outFile.Close() - if err != nil { - return err - } - default: - // handle symlinks etc. if needed - } - } - return nil -}