From d9dae02ffc0b5a71222b42ae93efdbe58be18aab Mon Sep 17 00:00:00 2001 From: partisan Date: Mon, 3 Feb 2025 15:52:19 +0100 Subject: [PATCH] Init --- .gitignore | 3 + background.go | 59 +++++++++ go.mod | 16 +++ go.sum | 10 ++ gui.go | 119 +++++++++++++++++ main.go | 263 ++++++++++++++++++++++++++++++++++++++ spm/appindex.go | 123 ++++++++++++++++++ spm/auto.go | 157 +++++++++++++++++++++++ spm/download.go | 220 +++++++++++++++++++++++++++++++ spm/go.mod | 5 + spm/go.sum | 2 + spm/install.go | 204 +++++++++++++++++++++++++++++ spm/installed_pacakges.go | 204 +++++++++++++++++++++++++++++ spm/progress.go | 31 +++++ spm/run.go | 70 ++++++++++ spm/tempdir.go | 32 +++++ spm/utils.go | 224 ++++++++++++++++++++++++++++++++ 17 files changed, 1742 insertions(+) create mode 100644 .gitignore create mode 100644 background.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 gui.go create mode 100644 main.go create mode 100644 spm/appindex.go create mode 100644 spm/auto.go create mode 100644 spm/download.go create mode 100644 spm/go.mod create mode 100644 spm/go.sum create mode 100644 spm/install.go create mode 100644 spm/installed_pacakges.go create mode 100644 spm/progress.go create mode 100644 spm/run.go create mode 100644 spm/tempdir.go create mode 100644 spm/utils.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7787b5e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +downloads/ +spitfire-luncher.exe +spitfire-luncher \ No newline at end of file diff --git a/background.go b/background.go new file mode 100644 index 0000000..f78221f --- /dev/null +++ b/background.go @@ -0,0 +1,59 @@ +package main + +import ( + "math/rand" + "time" + + rl "github.com/gen2brain/raylib-go/raylib" +) + +type Particle struct { + Pos rl.Vector2 + Vel rl.Vector2 + Size float32 +} + +var particles []Particle +var topColor = rl.Color{R: 0x20, G: 0x0F, B: 0x3C, A: 0xFF} // #200F3C top +var bottomColor = rl.Color{R: 0x3B, G: 0x0B, B: 0x42, A: 0xFF} // #3B0B42 bottom +var particleColor = rl.Color{R: 0xD4, G: 0xB0, B: 0xB5, A: 0x80} // D4B0B5 with some transparency + +var rng = rand.New(rand.NewSource(time.Now().UnixNano())) // Local RNG + +func InitBackground(width, height int) { + particles = make([]Particle, 20) + 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 + } +} + +func UpdateBackground(screenWidth, screenHeight int) { + deltaTime := rl.GetFrameTime() // Time in seconds since the last frame + for i := range particles { + particles[i].Pos.X += particles[i].Vel.X * deltaTime * 60 // Adjust for frame rate + particles[i].Pos.Y += particles[i].Vel.Y * deltaTime * 60 + + // Wrap around screen + if particles[i].Pos.X < 0 { + particles[i].Pos.X += float32(screenWidth) + } else if particles[i].Pos.X > float32(screenWidth) { + particles[i].Pos.X -= float32(screenWidth) + } + if particles[i].Pos.Y < 0 { + particles[i].Pos.Y += float32(screenHeight) + } else if particles[i].Pos.Y > float32(screenHeight) { + particles[i].Pos.Y -= float32(screenHeight) + } + } +} + +func DrawBackground(screenWidth, screenHeight int) { + // Draw vertical gradient + rl.DrawRectangleGradientV(0, 0, int32(screenWidth), int32(screenHeight), topColor, bottomColor) + // Draw particles + for _, p := range particles { + rl.DrawCircleV(p.Pos, p.Size, particleColor) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9020865 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module spitfire-luncher + +go 1.23.4 + +require github.com/gen2brain/raylib-go/raylib v0.0.0-20250109172833-6dbba4f81a9b + +require ( + github.com/ebitengine/purego v0.7.1 // indirect + golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect + golang.org/x/sys v0.20.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect +) + +replace spitfire-luncher/spm => ./spm + +require spitfire-luncher/spm v0.0.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fcea25d --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/ebitengine/purego v0.7.1 h1:6/55d26lG3o9VCZX8lping+bZcmShseiqlh2bnUDiPA= +github.com/ebitengine/purego v0.7.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ= +github.com/gen2brain/raylib-go/raylib v0.0.0-20250109172833-6dbba4f81a9b h1:JJfspevP3YOXcSKVABizYOv++yMpTJIdPUtoDzF/RWw= +github.com/gen2brain/raylib-go/raylib v0.0.0-20250109172833-6dbba4f81a9b/go.mod h1:BaY76bZk7nw1/kVOSQObPY1v1iwVE1KHAGMfvI6oK1Q= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= diff --git a/gui.go b/gui.go new file mode 100644 index 0000000..c7a34bb --- /dev/null +++ b/gui.go @@ -0,0 +1,119 @@ +package main + +import ( + "fmt" + "path/filepath" + "time" + + rl "github.com/gen2brain/raylib-go/raylib" +) + +// 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() { + for { + sf = ReadState() + time.Sleep(1 * time.Second) + } + }() + + for { + tgt := float32(sf.Progress) + + 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 + } + } + + rl.CloseWindow() +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..86ce9f8 --- /dev/null +++ b/main.go @@ -0,0 +1,263 @@ +package main + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + // Import your SPM-based installer code + "spitfire-luncher/spm" +) + +// ------------------------------------------------------------------- +// GLOBALS +// ------------------------------------------------------------------- +var ( + copyingInProgress int32 + updateStatusMsg string + browserCmd *exec.Cmd + browserMutex sync.Mutex // Protects the lock file map + isBackgroundMode bool +) + +// StateFile holds the update state from INI +type StateFile struct { + IsUpdating bool + Progress int32 + Stage string // "Idle","Downloading","Decompressing","ReadyToInstall","Installing","Complete","Failed" +} + +// ------------------------------------------------------------------- +// LOG INIT +// ------------------------------------------------------------------- +func init() { + log.SetFlags(0) + log.SetPrefix("[UPDATER] ") +} + +// ------------------------------------------------------------------- +// INI Helpers +// ------------------------------------------------------------------- +func getSpmDir() string { + installDir, err := spm.GetDefaultInstallDir() + if err != nil { + log.Println("Warning: Using '.' for spmDir =>", err) + return "." + } + spmDir := filepath.Join(installDir, "spm") + _ = os.MkdirAll(spmDir, 0755) + return spmDir +} + +func getStateFilePath() string { + return filepath.Join(getSpmDir(), "update_state.ini") +} + +func WriteState(isUpdating bool, progress int32, stage string) { + content := "[UpdateState]\n" + + fmt.Sprintf("IsUpdating=%v\n", isUpdating) + + fmt.Sprintf("Progress=%d\n", progress) + + fmt.Sprintf("Stage=%s\n", stage) + _ = os.WriteFile(getStateFilePath(), []byte(content), 0644) +} + +func ReadState() StateFile { + sf := StateFile{} + data, err := os.ReadFile(getStateFilePath()) + if err != nil { + return sf + } + + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "[") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + switch key { + case "IsUpdating": + sf.IsUpdating = (val == "true") + case "Progress": + if i, errAtoi := strconv.Atoi(val); errAtoi == nil { + sf.Progress = int32(i) + } + case "Stage": + sf.Stage = val + } + } + return sf +} + +// ------------------------------------------------------------------- +// BROWSER +// ------------------------------------------------------------------- +// LaunchBrowser starts the browser and creates a unique lock file in the spmdir. +func LaunchBrowser() { + spmDir := getSpmDir() + + // Generate a unique lock file name (based on timestamp) + lockFileName := fmt.Sprintf("browser_lock_%d.lock", time.Now().UnixNano()) + lockFilePath := filepath.Join(spmDir, lockFileName) + + // Create the lock file + file, err := os.Create(lockFilePath) + if err != nil { + fmt.Println("Failed to create lock file:", err) + return + } + file.Close() + + // Run the browser process and wait for it to finish + fmt.Println("Starting browser...") + err = spm.RunAndWait() + if err != nil { + fmt.Println("Browser process encountered an error:", err) + } + + // Remove the lock file after the browser finishes + fmt.Printf("Removing lock file: %s\n", lockFilePath) + err = os.Remove(lockFilePath) + if err != nil { + fmt.Println("Failed to remove lock file:", err) + } else { + fmt.Println("Lock file removed successfully.") + } +} + +// IsBrowserRunning checks if there are any browser lock files in the spmdir. +func IsBrowserRunning() bool { + spmDir := getSpmDir() + + // Look for lock files in the spmdir + lockFiles, err := filepath.Glob(filepath.Join(spmDir, "browser_lock_*.lock")) + if err != nil { + fmt.Println("Error checking for lock files:", err) + return false + } + + // Return true if at least one lock file is found + return len(lockFiles) > 0 +} + +// ------------------------------------------------------------------- +// BACKGROUND SERVICE +// ------------------------------------------------------------------- +func runBackgroundUpdater() { + // Delete the update_state.ini file on startup + stateFilePath := getStateFilePath() + if _, err := os.Stat(stateFilePath); err == nil { + err = os.Remove(stateFilePath) + if err != nil { + log.Printf("Failed to delete state file %s: %v\n", stateFilePath, err) + } else { + log.Printf("Deleted state file: %s\n", stateFilePath) + } + } else { + log.Printf("State file does not exist, no need to delete: %s\n", stateFilePath) + } + + // Remove all lock files in the spmdir + spmDir := getSpmDir() + lockFilePattern := filepath.Join(spmDir, "browser_lock_*.lock") + lockFiles, err := filepath.Glob(lockFilePattern) + if err != nil { + log.Printf("Failed to scan for lock files in %s: %v\n", spmDir, err) + } else { + for _, lockFile := range lockFiles { + err := os.Remove(lockFile) + if err != nil { + log.Printf("Failed to delete lock file %s: %v\n", lockFile, err) + } else { + log.Printf("Deleted lock file: %s\n", lockFile) + } + } + } + + log.Println("Background updater started (no GUI).") + + // Start a separate goroutine to periodically update the progress in update_state.ini + go func() { + for { + // Get current progress and task from spm + progress, task := spm.GetProgress() + + // Determine if the updater is in "Copying files to install directory" stage + isUpdating := task == "Copying files to install directory" + + // 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) + } + }() + + // Main loop for periodic AutoDownloadUpdates and AutoInstallUpdates + for { + // Run AutoDownloadUpdates every 10 seconds + err := spm.AutoDownloadUpdates() + if err != nil { + log.Printf("AutoDownloadUpdates failed: %v\n", err) + WriteState(false, 0, fmt.Sprintf("Update failed: %v", err)) + time.Sleep(10 * time.Second) + continue + } + + // Check if the browser is running + if IsBrowserRunning() { + log.Println("Browser is running. Waiting before installing updates.") + time.Sleep(10 * time.Second) + continue + } + + // If the browser is not running, run AutoInstallUpdates + log.Println("\nBrowser is not running. Starting installation of updates.") + err = spm.AutoInstallUpdates() + if err != nil { + log.Printf("AutoInstallUpdates failed: %v\n", err) + WriteState(false, 0, fmt.Sprintf("Installation failed: %v", err)) + time.Sleep(10 * time.Second) + continue + } + + // Write to update_state.ini indicating updates are complete + WriteState(false, 100, "Complete") + log.Println("Updates installed successfully. Waiting before next check.") + + // Wait 10 seconds before checking again + time.Sleep(10 * time.Second) + } +} + +// ------------------------------------------------------------------- +// MAIN +// ------------------------------------------------------------------- +func main() { + if len(os.Args) > 1 && os.Args[1] == "--update-service" { + 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() + } + fmt.Println("Exiting launcher.") +} diff --git a/spm/appindex.go b/spm/appindex.go new file mode 100644 index 0000000..14a77be --- /dev/null +++ b/spm/appindex.go @@ -0,0 +1,123 @@ +package spm + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "strings" +) + +const appIndexURL = "https://downloads.sourceforge.net/project/spitfire-browser/APPINDEX" + +func DownloadAppIndex(dest string) error { + UpdateProgress(0, "Downloading APPINDEX") + resp, err := http.Get(appIndexURL) + if err != nil { + return err + } + defer resp.Body.Close() + + out, err := os.Create(dest) + if err != nil { + return err + } + defer out.Close() + + totalSize := resp.ContentLength + var downloaded int64 + + // Track progress as bytes are downloaded + 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, "Downloading APPINDEX") + if _, err := out.Write(buf[:n]); err != nil { + return err + } + } + if err == io.EOF { + break + } + if err != nil { + return err + } + } + + UpdateProgress(100, "APPINDEX downloaded") + return nil +} + +type AppIndexEntry struct { + Name string + Version string + Release string // "nightly" / "stable" / etc. + Arch string // e.g. "amd64", "386" + OS string // e.g. "windows", "linux" + Type string // "browser", "addon", "theme", etc. + DownloadURL string +} + +func ParseAppIndex(filePath string) ([]AppIndexEntry, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + + var entries []AppIndexEntry + scanner := bufio.NewScanner(file) + entry := AppIndexEntry{} + + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "C:") { + // Start of a new entry + if entry.Name != "" { + entries = append(entries, entry) + entry = AppIndexEntry{} + } + } + + parts := strings.SplitN(line, ":", 2) + if len(parts) < 2 { + continue + } + + key, value := parts[0], parts[1] + switch key { + case "P": + entry.Name = value + case "R": + entry.Release = value + case "V": + entry.Version = value + case "A": + entry.Arch = value + case "p": + entry.OS = value + case "d": + entry.DownloadURL = value + case "o": + entry.Type = value + } + } + + // Append the last entry if any + if entry.Name != "" { + entries = append(entries, entry) + } + + // Log all parsed entries + fmt.Printf("[INFO] Total parsed entries: %d\n", len(entries)) + for _, e := range entries { + fmt.Printf(" - Name: %s, Release: %s, Type: %s, OS: %s, Arch: %s, Version: %s, URL: %s\n", + e.Name, e.Release, e.Type, e.OS, e.Arch, e.Version, e.DownloadURL) + } + + return entries, scanner.Err() +} diff --git a/spm/auto.go b/spm/auto.go new file mode 100644 index 0000000..51de9f4 --- /dev/null +++ b/spm/auto.go @@ -0,0 +1,157 @@ +package spm + +import ( + "fmt" + "os" + "path/filepath" +) + +// pendingUpdates holds info about packages that have been downloaded/decompressed +// but not yet moved to the final install location. +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. +func AutoDownloadUpdates() error { + // 1) Download the APPINDEX file to a temporary location + appIndexPath := filepath.Join(os.TempDir(), "APPINDEX") + fmt.Println("[INFO] Starting APPINDEX download to:", appIndexPath) + err := DownloadAppIndex(appIndexPath) + if 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) Load installed packages + fmt.Println("[INFO] Loading installed packages") + installDir, err := GetInstallDir() + if err != nil { + return err + } + fmt.Println("[INFO] Install directory:", installDir) + + installedPkgs, err := loadInstalledPackages(installDir) + if err != nil { + return fmt.Errorf("[ERROR] Failed to load installed packages: %w", err) + } + fmt.Printf("[INFO] Loaded %d installed packages\n", len(installedPkgs)) + + // 4) Process entries for installed packages only + for _, installed := range installedPkgs { + fmt.Printf("[INFO] Checking updates for installed package: %+v\n", installed) + + // Filter APPINDEX entries that match the installed package's attributes + var matchingEntry *AppIndexEntry + for _, entry := range entries { + if entry.Name == installed.Name && + entry.Release == installed.Release && + entry.Type == installed.Type && + entry.OS == installed.OS && + entry.Arch == installed.Arch { + matchingEntry = &entry + break + } + } + + if matchingEntry == nil { + fmt.Printf("[WARN] No matching APPINDEX entry found for installed package: %s (%s)\n", installed.Name, installed.Release) + continue + } + + fmt.Printf("[INFO] Found matching APPINDEX entry: %+v\n", *matchingEntry) + + // Determine 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 into a temporary download folder + downloadDir := GetTempDir() + fmt.Printf("[INFO] Downloading package '%s' to temporary folder: %s\n", matchingEntry.Name, downloadDir) + + err = DownloadPackageFromAppIndex(appIndexPath, matchingEntry.Name, matchingEntry.Release, matchingEntry.Type, downloadDir) + if 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 into another temp folder + 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 so that AutoInstallUpdates can finish the job + fmt.Printf("[INFO] Adding '%s' to pending updates\n", matchingEntry.Name) + pendingUpdates = append(pendingUpdates, AppIndexEntry{ + Name: matchingEntry.Name, + Version: matchingEntry.Version, + Release: matchingEntry.Release, + Arch: matchingEntry.Arch, + OS: matchingEntry.OS, + Type: matchingEntry.Type, + }) + } + + fmt.Println("[INFO] AutoDownloadUpdates completed successfully") + return nil +} + +// AutoInstallUpdates installs any packages that were decompressed by AutoDownloadUpdates. +// It moves files from their temp directories to the final location and updates installed.ini. +func AutoInstallUpdates() error { + installDir, err := GetDefaultInstallDir() + if err != nil { + return err + } + + for _, entry := range pendingUpdates { + // 1) Construct the same .tar.gz name we used when decompressing + fileName := fmt.Sprintf("%s@%s@%s@%s@%s@%s", + entry.Name, // no 'packageName' + entry.Arch, // matches 'arch' + entry.OS, // matches 'os' + entry.Type, // matches 'type' + entry.Release, // matches 'release' + entry.Version, // matches 'version' + ) + + // 3) Combine with your global temp dir + tempBase := GetTempDir() // e.g. C:\Users\YourUser\AppData\Local\Temp\spm_temp_164326 + decompressedDir := filepath.Join(tempBase, fileName) + + // 4) Move files from that decompressedDir + fmt.Printf("[INFO] Installing %s from %s\n", entry.Name, decompressedDir) + err := MoveFilesToInstallDir(decompressedDir, installDir, entry.Type) + if err != nil { + return fmt.Errorf("failed to move files for %s: %w", entry.Name, err) + } + + // 5) Finalize + err = FinalizeInstall(entry.Name, entry.Release, entry.Version, entry.Arch, entry.OS) + if err != nil { + return fmt.Errorf("failed to finalize install for %s: %w", entry.Name, err) + } + } + + pendingUpdates = nil + return nil +} diff --git a/spm/download.go b/spm/download.go new file mode 100644 index 0000000..8609823 --- /dev/null +++ b/spm/download.go @@ -0,0 +1,220 @@ +package spm + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "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 + entries, err := ParseAppIndex(appIndexPath) + if err != nil { + return fmt.Errorf("failed to parse APPINDEX: %w", err) + } + + // Find the right entry + var selected *AppIndexEntry + for _, e := range entries { + if e.Name == packageName && + e.Release == release && + e.Type == pkgType && + e.OS == runtime.GOOS && + e.Arch == runtime.GOARCH { + selected = &e + break + } + } + + // Handle no matching entry + if selected == nil { + return fmt.Errorf("package not found in APPINDEX: %s (release: %s, type: %s, os: %s, arch: %s)", packageName, release, pkgType, runtime.GOOS, runtime.GOARCH) + } + + // Check if the package is already installed and up-to-date + installDir, err := GetDefaultInstallDir() + if err != nil { + return fmt.Errorf("failed to get install directory: %w", err) + } + needsUpdate, err := IsUpdateNeeded(installDir, packageName, release, selected.Version, selected.Arch, selected.OS) + if err != nil { + return fmt.Errorf("failed to check update status: %w", err) + } + if !needsUpdate { + UpdateProgress(0, "Already up-to-date, skipping download.") + return nil // Skip download + } + + // Download the package + UpdateProgress(0, fmt.Sprintf("Downloading %s %s (%s)...", packageName, selected.Version, selected.Type)) + resp, err := http.Get(selected.DownloadURL) + if err != nil { + return fmt.Errorf("failed to download package: %w", err) + } + defer resp.Body.Close() + + // Save the downloaded file + downloadedFileName := filepath.Base(selected.DownloadURL) + downloadedFilePath := filepath.Join(destDir, downloadedFileName) + + out, err := os.OpenFile(downloadedFilePath, os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer out.Close() + + totalSize := resp.ContentLength + var downloaded int64 + buf := make([]byte, 32*1024) // Use a larger buffer for efficiency + + for { + n, errRead := resp.Body.Read(buf) + if n > 0 { + downloaded += int64(n) + percentage := int(float64(downloaded) / float64(totalSize) * 100) + UpdateProgress(percentage, fmt.Sprintf("Downloading %s %s (%s)...", packageName, selected.Version, selected.Type)) + if _, errWrite := out.Write(buf[:n]); errWrite != nil { + return fmt.Errorf("failed to write to output file: %w", errWrite) + } + } + if errRead == io.EOF { + break + } + if errRead != nil { + return fmt.Errorf("error while reading response: %w", errRead) + } + } + + // Ensure the file handle is closed before renaming + out.Close() + + // Construct the expected filename + expectedFileName := fmt.Sprintf("%s@%s@%s@%s@%s@%s.tar.gz", + packageName, selected.Arch, selected.OS, selected.Type, selected.Release, selected.Version) + + expectedFilePath := filepath.Join(destDir, expectedFileName) + + // I dont know why is this happening, I dont want to know but sometimes some process is helding up the donwloaded files so thats why it retries here + maxRetries := 5 + for i := 0; i < maxRetries; i++ { + err = os.Rename(downloadedFilePath, expectedFilePath) + if err == nil { + break + } + + // Check if file is in use + f, checkErr := os.Open(downloadedFilePath) + if checkErr != nil { + return fmt.Errorf("file is locked by another process: %w", checkErr) + } + f.Close() + + if i < maxRetries-1 { + time.Sleep(500 * time.Millisecond) // Wait before retrying + } + } + + if err != nil { + return fmt.Errorf("failed to rename downloaded file after retries: %w", err) + } + + UpdateProgress(100, fmt.Sprintf("Downloaded %s %s (%s).", packageName, selected.Version, selected.Type)) + return nil +} diff --git a/spm/go.mod b/spm/go.mod new file mode 100644 index 0000000..a12e946 --- /dev/null +++ b/spm/go.mod @@ -0,0 +1,5 @@ +module spm + +go 1.23.4 + +require gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/spm/go.sum b/spm/go.sum new file mode 100644 index 0000000..a8937af --- /dev/null +++ b/spm/go.sum @@ -0,0 +1,2 @@ +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= diff --git a/spm/install.go b/spm/install.go new file mode 100644 index 0000000..053be30 --- /dev/null +++ b/spm/install.go @@ -0,0 +1,204 @@ +package spm + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "math/rand" + "os" + "path/filepath" +) + +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 +} + +func MoveFilesToInstallDir(tempDir, installDir, pkgType string) error { + // Ensure tempDir exists before processing + 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 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 + } + + // Copy files and track progress + 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() { + // 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 + 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 := GetDefaultInstallDir() + if err != nil { + return err + } + pkgInfo := AppIndexEntry{ + Name: packageName, + Version: version, + Release: release, + Arch: arch, + OS: osName, + } + return UpdateInstalledPackage(installDir, pkgInfo) +} diff --git a/spm/installed_pacakges.go b/spm/installed_pacakges.go new file mode 100644 index 0000000..1f89eae --- /dev/null +++ b/spm/installed_pacakges.go @@ -0,0 +1,204 @@ +package spm + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + + "gopkg.in/ini.v1" +) + +// getInstalledPackagesPath determines the path for the installed.ini file. +func getInstalledPackagesPath(installDir string) string { + spmDir := filepath.Join(installDir, "spm") + _ = os.MkdirAll(spmDir, 0755) + return filepath.Join(spmDir, "installed.ini") +} + +// loadInstalledPackages reads the installed.ini file and parses it. +func loadInstalledPackages(installDir string) ([]AppIndexEntry, error) { + installedFile := getInstalledPackagesPath(installDir) + + cfg, err := ini.LoadSources(ini.LoadOptions{AllowNonUniqueSections: true}, installedFile) + if err != nil { + if os.IsNotExist(err) { + return []AppIndexEntry{}, nil // Return empty slice if file doesn't exist + } + return nil, err + } + + var installed []AppIndexEntry + for _, section := range cfg.Sections() { + if section.Name() == "DEFAULT" { + continue + } + + // Read fields + name := section.Key("P").String() + version := section.Key("V").String() + release := section.Key("R").String() + typeVal := section.Key("o").String() + arch := section.Key("A").MustString(runtime.GOARCH) // Default to system arch + osName := section.Key("p").MustString(runtime.GOOS) // Default to system OS + + // Append to slice + installed = append(installed, AppIndexEntry{ + Name: name, + Version: version, + Release: release, + Type: typeVal, + Arch: arch, + OS: osName, + }) + } + + return installed, nil +} + +// saveInstalledPackages writes the installed packages into installed.ini. +func saveInstalledPackages(installDir string, pkgs []AppIndexEntry) error { + installedFile := getInstalledPackagesPath(installDir) + + cfg := ini.Empty() + for _, pkg := range pkgs { + section, err := cfg.NewSection(pkg.Name) + if err != nil { + return err + } + section.Key("P").SetValue(pkg.Name) + section.Key("V").SetValue(pkg.Version) + section.Key("R").SetValue(pkg.Release) + section.Key("o").SetValue(pkg.Type) + } + + return cfg.SaveTo(installedFile) +} + +// isNewerVersion compares two version strings and determines if `newVer` is newer than `oldVer`. +func isNewerVersion(oldVer, newVer string) bool { + // Handle date-based versions (e.g., nightly: YYYY.MM.DD) + if isDateVersion(oldVer) && isDateVersion(newVer) { + return strings.Compare(newVer, oldVer) > 0 + } + + // Handle semantic versions (e.g., stable: v1.0.1) + if isSemVer(oldVer) && isSemVer(newVer) { + return compareSemVer(oldVer, newVer) > 0 + } + + // Fallback to lexicographical comparison for unknown formats + return strings.Compare(newVer, oldVer) > 0 +} + +// isDateVersion checks if a version string is in the format YYYY.MM.DD. +func isDateVersion(version string) bool { + parts := strings.Split(version, ".") + if len(parts) != 3 { + return false + } + for _, part := range parts { + if _, err := strconv.Atoi(part); err != nil { + return false + } + } + return true +} + +// isSemVer checks if a version string is in the format vMAJOR.MINOR.PATCH. +func isSemVer(version string) bool { + if !strings.HasPrefix(version, "v") { + return false + } + parts := strings.Split(strings.TrimPrefix(version, "v"), ".") + if len(parts) != 3 { + return false + } + for _, part := range parts { + if _, err := strconv.Atoi(part); err != nil { + return false + } + } + return true +} + +// compareSemVer compares two semantic version strings (vMAJOR.MINOR.PATCH). +// Returns: +// - 1 if `v2` is newer than `v1` +// - 0 if `v1` and `v2` are equal +// - -1 if `v1` is newer than `v2` +func compareSemVer(v1, v2 string) int { + parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".") + parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".") + + for i := 0; i < len(parts1); i++ { + num1, _ := strconv.Atoi(parts1[i]) + num2, _ := strconv.Atoi(parts2[i]) + if num1 > num2 { + return 1 + } + if num1 < num2 { + return -1 + } + } + return 0 +} + +// IsUpdateNeeded checks if the given package version is newer than what's installed. +func IsUpdateNeeded(installDir, name, release, newVersion, arch, osName string) (bool, error) { + installed, err := loadInstalledPackages(installDir) + if err != nil { + return false, err + } + + for _, pkg := range installed { + if pkg.Name == name && pkg.Release == release && pkg.Arch == arch && pkg.OS == osName { + fmt.Printf("Found installed package: %v\n", pkg) + if isNewerVersion(pkg.Version, newVersion) { + fmt.Println("Update is needed") + return true, nil + } + fmt.Println("No update needed") + return false, nil + } + } + + fmt.Println("Package not installed, update needed") + return true, nil +} + +// UpdateInstalledPackage writes/updates the new package version in installed.ini. +func UpdateInstalledPackage(installDir string, pkg AppIndexEntry) error { + installed, err := loadInstalledPackages(installDir) + if err != nil { + return err + } + + updated := false + for i, p := range installed { + if p.Name == pkg.Name && p.Release == pkg.Release && p.Arch == pkg.Arch && p.OS == pkg.OS { + installed[i].Version = pkg.Version + updated = true + break + } + } + if !updated { + installed = append(installed, pkg) + } + + return saveInstalledPackages(installDir, installed) +} + +// // DebugInstalled prints installed packages for debugging. +// func DebugInstalled(installDir string) { +// pkgs, err := loadInstalledPackages(installDir) +// if err != nil { +// fmt.Println("DebugInstalled error:", err) +// return +// } +// for _, p := range pkgs { +// fmt.Printf("Installed: %s v%s [%s] (arch=%s, os=%s)\n", p.Name, p.Version, p.Release, p.Arch, p.OS) +// } +// } diff --git a/spm/progress.go b/spm/progress.go new file mode 100644 index 0000000..c72e84e --- /dev/null +++ b/spm/progress.go @@ -0,0 +1,31 @@ +package spm + +import ( + "fmt" + "sync" +) + +type Progress struct { + mu sync.Mutex + percentage int + task string +} + +var progress = Progress{} + +// UpdateProgress sets the current percentage and task. +func UpdateProgress(percentage int, task string) { + progress.mu.Lock() + defer progress.mu.Unlock() + progress.percentage = percentage + progress.task = task + fmt.Printf("\r[%3d%%] %s", percentage, task) // Print progress to the terminal + // Next line on 100% ? +} + +// GetProgress returns the current progress state. +func GetProgress() (int, string) { + progress.mu.Lock() + defer progress.mu.Unlock() + return progress.percentage, progress.task +} diff --git a/spm/run.go b/spm/run.go new file mode 100644 index 0000000..d83619f --- /dev/null +++ b/spm/run.go @@ -0,0 +1,70 @@ +package spm + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" +) + +// Run locates and starts the installed Spitfire browser without waiting for it to exit. +func Run() error { + installDir, err := GetInstallDir() + if err != nil { + return err + } + + exePath := filepath.Join(installDir, "browser", "spitfire.exe") + if runtime.GOOS != "windows" { + exePath = filepath.Join(installDir, "browser", "spitfire") + } + + cmd := exec.Command(exePath) + return cmd.Start() +} + +// RunAndWait locates and starts the installed Spitfire browser and waits for it to exit. +func RunAndWait() error { + installDir, err := GetInstallDir() + if err != nil { + return fmt.Errorf("failed to get install directory: %w", err) + } + + // Construct the browser executable path + exePath := filepath.Join(installDir, "browser", "spitfire.exe") + if runtime.GOOS != "windows" { + exePath = filepath.Join(installDir, "browser", "spitfire") + } + + // Check if the browser executable exists + if _, err := os.Stat(exePath); err != nil { + return fmt.Errorf("browser executable not found at %s: %w", exePath, err) + } + + // Create the command + cmd := exec.Command(exePath) + + // Attach standard output and error for debugging + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Start the browser process + fmt.Printf("Starting browser: %s\n", exePath) + err = cmd.Start() + if err != nil { + return fmt.Errorf("failed to start browser: %w", err) + } + + // Print the PID for debugging + fmt.Printf("Browser process started with PID %d\n", cmd.Process.Pid) + + // Wait for the process to exit + err = cmd.Wait() + if err != nil { + return fmt.Errorf("browser exited with error: %w", err) + } + + fmt.Println("Browser exited successfully.") + return nil +} diff --git a/spm/tempdir.go b/spm/tempdir.go new file mode 100644 index 0000000..7b7644c --- /dev/null +++ b/spm/tempdir.go @@ -0,0 +1,32 @@ +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 new file mode 100644 index 0000000..4a03b31 --- /dev/null +++ b/spm/utils.go @@ -0,0 +1,224 @@ +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. +func persistSystemEnvVar(key, value string) error { + cmd := exec.Command("cmd", "/C", "setx", key, value) + err := cmd.Run() + if err != nil { + return err + } + 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 && + e.Release == release && + e.Arch == arch && + 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 +}