From 48473f98c5e50e65192b433339da58d9f14c6d58 Mon Sep 17 00:00:00 2001 From: partisan Date: Tue, 4 Feb 2025 17:14:00 +0100 Subject: [PATCH] updated spm package --- go.mod | 8 + go.sum | 11 + installer.go | 77 +++---- main.go | 3 + spm/appindex.go | 231 ++++++++++--------- spm/auto.go | 261 +++++++++++++++++++++ spm/decompress.go | 90 ++++++++ spm/dirs.go | 144 ++++++++++++ spm/download.go | 219 ++++++++++-------- spm/go.mod | 7 + spm/go.sum | 10 + spm/install.go | 465 +++++++++++++++++++++++++------------- spm/installed_pacakges.go | 204 +++++++++++++++++ spm/progress.go | 61 ++--- spm/run.go | 71 ++++++ spm/utils.go | 97 +++----- 16 files changed, 1454 insertions(+), 505 deletions(-) create mode 100644 spm/auto.go create mode 100644 spm/decompress.go create mode 100644 spm/dirs.go create mode 100644 spm/go.mod create mode 100644 spm/go.sum create mode 100644 spm/installed_pacakges.go create mode 100644 spm/run.go diff --git a/go.mod b/go.mod index cf10541..3f6fe5f 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,15 @@ go 1.21.1 require github.com/gen2brain/raylib-go/raylib v0.0.0-20241215043839-7a899c5e3aae require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/ebitengine/purego v0.7.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect golang.org/x/sys v0.22.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace spitfire-installer/spm => ./spm + +require spitfire-installer/spm v0.0.1 diff --git a/go.sum b/go.sum index 981536e..3c6c256 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,19 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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-20241215043839-7a899c5e3aae h1:zuCOZlow6XuDW6rPRRrbBx2PaS58igRW5zLaBpo5R70= github.com/gen2brain/raylib-go/raylib v0.0.0-20241215043839-7a899c5e3aae/go.mod h1:BaY76bZk7nw1/kVOSQObPY1v1iwVE1KHAGMfvI6oK1Q= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w= golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/installer.go b/installer.go index 33d4787..57c4f2f 100644 --- a/installer.go +++ b/installer.go @@ -2,7 +2,7 @@ package main import ( "fmt" - "path/filepath" + "runtime" "spitfire-installer/spm" ) @@ -19,10 +19,6 @@ type Installer struct { DoneInstall bool LastError error PendingInstall bool - - // Paths - DownloadDir string - TempDir string } // NewInstaller creates a new Installer with initial state. @@ -36,66 +32,67 @@ func (inst *Installer) StartDownloadDecompress() { go func() { defer func() { inst.IsDownloading = false + // Signal that download phase is complete. inst.DoneDownload = (inst.LastError == nil) - - // If user requested install while we were downloading (PendingInstall), - // automatically do the install now that we're done decompressing. + // If a final install was requested, go ahead. if inst.PendingInstall && inst.DoneDownload && !inst.IsInstalling && !inst.DoneInstall { inst.doFinalInstall() } }() spm.UpdateProgress(0, "Preparing to download...") - inst.DownloadDir = spm.GetTempDownloadDir() - // 1) Download APPINDEX - appIndexPath := filepath.Join(inst.DownloadDir, "APPINDEX") - spm.UpdateProgress(0, "Downloading APPINDEX") - if err := spm.DownloadAppIndex(appIndexPath); err != nil { + // Define the package specifications. + specs := []spm.AppIndexEntry{ + { + Name: "spitfire-luncher", + Release: "nightly", + OS: runtime.GOOS, + Arch: runtime.GOARCH, + Type: "luncher", + }, + { + Name: "spitfire-browser", + Release: "nightly", + OS: runtime.GOOS, + Arch: runtime.GOARCH, + Type: "browser", + }, + } + + spm.UpdateProgress(0, "Downloading specified packages...") + if err := spm.AutoDownloadSpecified(specs); err != nil { + fmt.Println("AutoDownloadSpecifiedPackages failed:", err) inst.LastError = err return } - // 2) Download package - packageName := "spitfire-browser" - release := "nightly" - spm.UpdateProgress(0, "Downloading package...") - if err := spm.DownloadPackageFromAppIndex(appIndexPath, packageName, release, inst.DownloadDir); err != nil { - inst.LastError = err - return - } - - // 3) Decompress - spm.UpdateProgress(0, "Decompressing...") - tempDir, err := spm.DecompressPackage(inst.DownloadDir) - if err != nil { - inst.LastError = err - return - } - inst.TempDir = tempDir + spm.UpdateProgress(0, "Download and decompression complete!") + // Here, update your installer state so FinalInstall() can proceed. + inst.DoneDownload = true }() } // FinalInstall is called by the UI to request installation. // If download is done, it runs immediately, otherwise sets PendingInstall=true. func (inst *Installer) FinalInstall() { - // Already installed or installing => ignore repeated calls + // Already installed or installing => ignore repeated calls. if inst.IsInstalling || inst.DoneInstall { return } - // If not done downloading, just mark that we want to install once finished + // If not done downloading, mark that we want to install once finished. if !inst.DoneDownload { fmt.Println("Cannot install now: download and decompression not complete -> pending install.") inst.PendingInstall = true return } - // Otherwise, go ahead and install now + // Otherwise, go ahead and install now. inst.doFinalInstall() } -// doFinalInstall does the actual file move and sets states +// doFinalInstall does the actual installation by invoking AutoInstallUpdates. func (inst *Installer) doFinalInstall() { inst.IsInstalling = true inst.PendingInstall = false // we are fulfilling the install now @@ -106,16 +103,8 @@ func (inst *Installer) doFinalInstall() { inst.DoneInstall = (inst.LastError == nil) }() - // Generate default install directory - installDir, err := spm.GetDefaultInstallDir() - if err != nil { - inst.LastError = fmt.Errorf("failed to determine default install directory: %w", err) - return - } - - // Move files - spm.UpdateProgress(0, "Installing...") - if err := spm.MoveFilesToInstallDir(inst.TempDir, installDir); err != nil { + spm.UpdateProgress(0, "Installing updates...") + if err := spm.AutoInstallUpdates(); err != nil { inst.LastError = err return } diff --git a/main.go b/main.go index f79b3d0..a267f20 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "fmt" "math" "os" + "spitfire-installer/spm" rl "github.com/gen2brain/raylib-go/raylib" ) @@ -29,6 +30,8 @@ var ( const finalStep = 3 func main() { + spm.Run() + monitor := rl.GetCurrentMonitor() if monitor < 0 { monitor = 0 // Fallback to the primary monitor diff --git a/spm/appindex.go b/spm/appindex.go index debdf28..14a77be 100644 --- a/spm/appindex.go +++ b/spm/appindex.go @@ -1,108 +1,123 @@ -package spm - -import ( - "bufio" - "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 - Arch string - OS string - 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:") { - if entry.Name != "" { - entries = append(entries, entry) - entry = AppIndexEntry{} - } - } - parts := strings.SplitN(line, ":", 2) - if len(parts) < 2 { - continue - } - - switch parts[0] { - case "P": - entry.Name = parts[1] - case "R": - entry.Release = parts[1] - case "V": - entry.Version = parts[1] - case "A": - entry.Arch = parts[1] - case "p": - entry.OS = parts[1] - case "d": - entry.DownloadURL = parts[1] - } - } - - if entry.Name != "" { - entries = append(entries, entry) - } - - return entries, scanner.Err() -} +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..92e574a --- /dev/null +++ b/spm/auto.go @@ -0,0 +1,261 @@ +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, 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, 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") + 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 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 := GetInstallDir() + 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 +} + +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 fe6dd32..30d239c 100644 --- a/spm/download.go +++ b/spm/download.go @@ -1,91 +1,128 @@ -package spm - -import ( - "errors" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "runtime" -) - -type Package struct { - Name string - Arch string - OS string - DownloadURL string -} - -func DownloadPackage(pkg Package, destDir string) error { - UpdateProgress(0, fmt.Sprintf("Downloading %s", pkg.Name)) - resp, err := http.Get(pkg.DownloadURL) - if err != nil { - return err - } - defer resp.Body.Close() - - filePath := filepath.Join(destDir, filepath.Base(pkg.DownloadURL)) - out, err := os.Create(filePath) - 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, fmt.Sprintf("Downloading %s", pkg.Name)) - if _, err := out.Write(buf[:n]); err != nil { - return err - } - } - if err == io.EOF { - break - } - if err != nil { - return err - } - } - - UpdateProgress(100, fmt.Sprintf("%s downloaded", pkg.Name)) - return nil -} - -func FindPackage(entries []AppIndexEntry, name, release, arch, os string) (*AppIndexEntry, error) { - for _, entry := range entries { - if entry.Name == name && entry.Release == release && entry.Arch == arch && entry.OS == os { - return &entry, nil - } - } - return nil, errors.New("package not found") -} - -func DownloadPackageFromAppIndex(appIndexPath, packageName, release, destDir string) error { - arch := runtime.GOARCH - osName := runtime.GOOS - - entries, err := ParseAppIndex(appIndexPath) - if err != nil { - return err - } - - entry, err := FindPackage(entries, packageName, release, arch, osName) - if err != nil { - return err - } - - return DownloadPackage(Package{ - Name: entry.Name, - Arch: entry.Arch, - OS: entry.OS, - DownloadURL: entry.DownloadURL, - }, destDir) -} +package spm + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "time" +) + +// 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 := GetInstallDir() + 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..0980669 --- /dev/null +++ b/spm/go.mod @@ -0,0 +1,7 @@ +module spm + +go 1.21 + +require gopkg.in/ini.v1 v1.67.0 + +require github.com/stretchr/testify v1.10.0 // indirect diff --git a/spm/go.sum b/spm/go.sum new file mode 100644 index 0000000..be248fb --- /dev/null +++ b/spm/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/spm/install.go b/spm/install.go index 5879747..9fbf274 100644 --- a/spm/install.go +++ b/spm/install.go @@ -1,161 +1,304 @@ -package spm - -import ( - "archive/tar" - "compress/gzip" - "io" - "os" - "path/filepath" -) - -func DecompressToTemp(filePath string) (string, error) { - UpdateProgress(0, "Decompressing package") - tempDir, err := os.MkdirTemp("", "spm_decompress") - if err != nil { - return "", err - } - - 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) - var totalFiles, processedFiles int - // Count total files - for { - _, err := tarReader.Next() - if err == io.EOF { - break - } - if err != nil { - return "", err - } - totalFiles++ - } - - file.Seek(0, io.SeekStart) - gzr.Reset(file) - tarReader = tar.NewReader(gzr) - - for { - header, err := tarReader.Next() - if err == io.EOF { - break - } - if err != nil { - return "", err - } - - targetPath := filepath.Join(tempDir, 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 { - return "", err - } - outFile.Close() - } - - processedFiles++ - UpdateProgress(int(float64(processedFiles)/float64(totalFiles)*100), "Decompressing package") - } - - UpdateProgress(100, "Package decompressed") - return tempDir, nil -} - -func MoveFilesToInstallDir(tempDir, installDir string) error { - // 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()) -} +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) +} 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 index 8c87f2c..c72e84e 100644 --- a/spm/progress.go +++ b/spm/progress.go @@ -1,30 +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 -} - -// GetProgress returns the current progress state. -func GetProgress() (int, string) { - progress.mu.Lock() - defer progress.mu.Unlock() - return progress.percentage, progress.task -} +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..5c6ce34 --- /dev/null +++ b/spm/run.go @@ -0,0 +1,71 @@ +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) + cmd.Dir = filepath.Join(installDir, "browser") + 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/utils.go b/spm/utils.go index 666cabe..607b6f6 100644 --- a/spm/utils.go +++ b/spm/utils.go @@ -1,71 +1,26 @@ -package spm - -import ( - "fmt" - "os" - "path/filepath" - "runtime" -) - -func GetTempDownloadDir() string { - dir, err := os.MkdirTemp("", "spm_downloads") - if err != nil { - panic(err) - } - return dir -} - -func SetDownloadFolder(customDir string) (string, error) { - if err := os.MkdirAll(customDir, os.ModePerm); err != nil { - return "", err - } - return customDir, nil -} - -// GetDefaultInstallDir generates the default installation directory based on the OS and environment. -func GetDefaultInstallDir() (string, error) { - var installDir string - - switch runtime.GOOS { - case "windows": - // Use %APPDATA% or Program Files on Windows - appData := os.Getenv("APPDATA") - if appData != "" { - installDir = filepath.Join(appData, "Spitfire") - } else { - 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 or /opt 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) - } - - return installDir, nil -} - -// DecompressPackage determines the appropriate package format and decompresses it. -func DecompressPackage(downloadDir string) (string, error) { - osName := runtime.GOOS - packagePath := filepath.Join(downloadDir, fmt.Sprintf("browser-amd64-nightly-%s.tar.gz", osName)) // If file naming changes this will break!! - return DecompressToTemp(packagePath) -} +package spm + +import ( + "os/exec" +) + +// 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() + if err != nil { + return err + } + return 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 +}