diff --git a/go.mod b/go.mod index dd3f66b..bf4d68b 100644 --- a/go.mod +++ b/go.mod @@ -2,16 +2,17 @@ module spitfire-luncher go 1.23.4 -require github.com/gen2brain/raylib-go/raylib v0.0.0-20250109172833-6dbba4f81a9b +require ( + github.com/gen2brain/raylib-go/raylib v0.0.0-20250109172833-6dbba4f81a9b + weforge.xyz/Spitfire/SPM v0.0.3 +) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/ebitengine/purego v0.7.1 // indirect - github.com/stretchr/testify v1.10.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // 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 + gopkg.in/yaml.v3 v3.0.1 // indirect ) - -replace spitfire-luncher/spm => ./spm - -require spitfire-luncher/spm v0.0.0 diff --git a/go.sum b/go.sum index c7bf812..02cf684 100644 --- a/go.sum +++ b/go.sum @@ -12,7 +12,10 @@ golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJ 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/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= +weforge.xyz/Spitfire/SPM v0.0.3 h1:+NXeNzfOjE935Aqf6bvnx+DmgE/D63orwNrx8aYwC/8= +weforge.xyz/Spitfire/SPM v0.0.3/go.mod h1:LkLCJPROt/UW0Ntlbm1Vk8CpqpBscY0IScfT3PprsC4= diff --git a/main.go b/main.go index 6cc64a7..e7d9324 100644 --- a/main.go +++ b/main.go @@ -11,8 +11,7 @@ import ( "sync" "time" - // Import your SPM-based installer code - "spitfire-luncher/spm" + spm "weforge.xyz/Spitfire/SPM" ) // ------------------------------------------------------------------- diff --git a/spm/appindex.go b/spm/appindex.go deleted file mode 100644 index 14a77be..0000000 --- a/spm/appindex.go +++ /dev/null @@ -1,123 +0,0 @@ -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 deleted file mode 100644 index 66b7366..0000000 --- a/spm/auto.go +++ /dev/null @@ -1,282 +0,0 @@ -package spm - -import ( - "fmt" - "os" - "path/filepath" - "strings" -) - -// 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 { - // If the package is already up-to-date, skip it instead of erroring out - if strings.Contains(err.Error(), "Already up-to-date") { - fmt.Printf("[INFO] Package '%s' is already up-to-date, skipping.\n", matchingEntry.Name) - continue - } - 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 { - // Again, if "Already up-to-date", skip - if strings.Contains(err.Error(), "Already up-to-date") { - fmt.Printf("[INFO] Package '%s' is already up-to-date, skipping.\n", matchingEntry.Name) - continue - } - 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 deleted file mode 100644 index dcfb9f8..0000000 --- a/spm/decompress.go +++ /dev/null @@ -1,90 +0,0 @@ -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 deleted file mode 100644 index ec62c24..0000000 --- a/spm/dirs.go +++ /dev/null @@ -1,144 +0,0 @@ -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 deleted file mode 100644 index e4ad88d..0000000 --- a/spm/download.go +++ /dev/null @@ -1,128 +0,0 @@ -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 fmt.Errorf("Already up-to-date") - } - - // 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 deleted file mode 100644 index a12e946..0000000 --- a/spm/go.mod +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index a8937af..0000000 --- a/spm/go.sum +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 9fbf274..0000000 --- a/spm/install.go +++ /dev/null @@ -1,304 +0,0 @@ -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 deleted file mode 100644 index 1f89eae..0000000 --- a/spm/installed_pacakges.go +++ /dev/null @@ -1,204 +0,0 @@ -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 deleted file mode 100644 index c72e84e..0000000 --- a/spm/progress.go +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 5c6ce34..0000000 --- a/spm/run.go +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index 607b6f6..0000000 --- a/spm/utils.go +++ /dev/null @@ -1,26 +0,0 @@ -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 -}