This commit is contained in:
partisan 2025-02-03 15:52:19 +01:00
commit d9dae02ffc
17 changed files with 1742 additions and 0 deletions

123
spm/appindex.go Normal file
View file

@ -0,0 +1,123 @@
package spm
import (
"bufio"
"fmt"
"io"
"net/http"
"os"
"strings"
)
const appIndexURL = "https://downloads.sourceforge.net/project/spitfire-browser/APPINDEX"
func DownloadAppIndex(dest string) error {
UpdateProgress(0, "Downloading APPINDEX")
resp, err := http.Get(appIndexURL)
if err != nil {
return err
}
defer resp.Body.Close()
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
totalSize := resp.ContentLength
var downloaded int64
// Track progress as bytes are downloaded
buf := make([]byte, 1024)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
downloaded += int64(n)
percentage := int(float64(downloaded) / float64(totalSize) * 100)
UpdateProgress(percentage, "Downloading APPINDEX")
if _, err := out.Write(buf[:n]); err != nil {
return err
}
}
if err == io.EOF {
break
}
if err != nil {
return err
}
}
UpdateProgress(100, "APPINDEX downloaded")
return nil
}
type AppIndexEntry struct {
Name string
Version string
Release string // "nightly" / "stable" / etc.
Arch string // e.g. "amd64", "386"
OS string // e.g. "windows", "linux"
Type string // "browser", "addon", "theme", etc.
DownloadURL string
}
func ParseAppIndex(filePath string) ([]AppIndexEntry, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer file.Close()
var entries []AppIndexEntry
scanner := bufio.NewScanner(file)
entry := AppIndexEntry{}
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "C:") {
// Start of a new entry
if entry.Name != "" {
entries = append(entries, entry)
entry = AppIndexEntry{}
}
}
parts := strings.SplitN(line, ":", 2)
if len(parts) < 2 {
continue
}
key, value := parts[0], parts[1]
switch key {
case "P":
entry.Name = value
case "R":
entry.Release = value
case "V":
entry.Version = value
case "A":
entry.Arch = value
case "p":
entry.OS = value
case "d":
entry.DownloadURL = value
case "o":
entry.Type = value
}
}
// Append the last entry if any
if entry.Name != "" {
entries = append(entries, entry)
}
// Log all parsed entries
fmt.Printf("[INFO] Total parsed entries: %d\n", len(entries))
for _, e := range entries {
fmt.Printf(" - Name: %s, Release: %s, Type: %s, OS: %s, Arch: %s, Version: %s, URL: %s\n",
e.Name, e.Release, e.Type, e.OS, e.Arch, e.Version, e.DownloadURL)
}
return entries, scanner.Err()
}

157
spm/auto.go Normal file
View file

@ -0,0 +1,157 @@
package spm
import (
"fmt"
"os"
"path/filepath"
)
// pendingUpdates holds info about packages that have been downloaded/decompressed
// but not yet moved to the final install location.
var pendingUpdates []AppIndexEntry
// AutoDownloadUpdates downloads the APPINDEX file, parses it, compares against
// currently installed packages, and if it finds a newer version, downloads
// and decompresses it into a temporary folder. The result is stored in pendingUpdates.
func AutoDownloadUpdates() error {
// 1) Download the APPINDEX file to a temporary location
appIndexPath := filepath.Join(os.TempDir(), "APPINDEX")
fmt.Println("[INFO] Starting APPINDEX download to:", appIndexPath)
err := DownloadAppIndex(appIndexPath)
if err != nil {
return fmt.Errorf("[ERROR] Failed to download APPINDEX: %w", err)
}
fmt.Println("[INFO] APPINDEX downloaded successfully")
// 2) Parse the APPINDEX file
fmt.Println("[INFO] Parsing APPINDEX file:", appIndexPath)
entries, err := ParseAppIndex(appIndexPath)
if err != nil {
return fmt.Errorf("[ERROR] Failed to parse APPINDEX: %w", err)
}
fmt.Printf("[INFO] Parsed APPINDEX successfully, found %d entries\n", len(entries))
// 3) Load installed packages
fmt.Println("[INFO] Loading installed packages")
installDir, err := GetInstallDir()
if err != nil {
return err
}
fmt.Println("[INFO] Install directory:", installDir)
installedPkgs, err := loadInstalledPackages(installDir)
if err != nil {
return fmt.Errorf("[ERROR] Failed to load installed packages: %w", err)
}
fmt.Printf("[INFO] Loaded %d installed packages\n", len(installedPkgs))
// 4) Process entries for installed packages only
for _, installed := range installedPkgs {
fmt.Printf("[INFO] Checking updates for installed package: %+v\n", installed)
// Filter APPINDEX entries that match the installed package's attributes
var matchingEntry *AppIndexEntry
for _, entry := range entries {
if entry.Name == installed.Name &&
entry.Release == installed.Release &&
entry.Type == installed.Type &&
entry.OS == installed.OS &&
entry.Arch == installed.Arch {
matchingEntry = &entry
break
}
}
if matchingEntry == nil {
fmt.Printf("[WARN] No matching APPINDEX entry found for installed package: %s (%s)\n", installed.Name, installed.Release)
continue
}
fmt.Printf("[INFO] Found matching APPINDEX entry: %+v\n", *matchingEntry)
// Determine if an update is needed
updateNeeded, err := IsUpdateNeeded(installDir, matchingEntry.Name, matchingEntry.Release, matchingEntry.Version, matchingEntry.Arch, matchingEntry.OS)
if err != nil {
return fmt.Errorf("[ERROR] Failed to check if update is needed for %s: %w", matchingEntry.Name, err)
}
if !updateNeeded {
fmt.Printf("[INFO] No update needed for package '%s'\n", matchingEntry.Name)
continue
}
// 5) Download the package into a temporary download folder
downloadDir := GetTempDir()
fmt.Printf("[INFO] Downloading package '%s' to temporary folder: %s\n", matchingEntry.Name, downloadDir)
err = DownloadPackageFromAppIndex(appIndexPath, matchingEntry.Name, matchingEntry.Release, matchingEntry.Type, downloadDir)
if err != nil {
return fmt.Errorf("[ERROR] Failed to download package '%s': %w", matchingEntry.Name, err)
}
fmt.Printf("[INFO] Package '%s' downloaded successfully to: %s\n", matchingEntry.Name, downloadDir)
// 6) Decompress the package into another temp folder
fmt.Printf("[INFO] Decompressing package '%s'\n", matchingEntry.Name)
tempDir, err := DecompressPackage(downloadDir, matchingEntry.Name, matchingEntry.Arch, matchingEntry.OS, matchingEntry.Type, matchingEntry.Release, matchingEntry.Version)
if err != nil {
return fmt.Errorf("[ERROR] Failed to decompress package '%s': %w", matchingEntry.Name, err)
}
fmt.Printf("[INFO] Package '%s' decompressed successfully to: %s\n", matchingEntry.Name, tempDir)
// 7) Store in pendingUpdates so that AutoInstallUpdates can finish the job
fmt.Printf("[INFO] Adding '%s' to pending updates\n", matchingEntry.Name)
pendingUpdates = append(pendingUpdates, AppIndexEntry{
Name: matchingEntry.Name,
Version: matchingEntry.Version,
Release: matchingEntry.Release,
Arch: matchingEntry.Arch,
OS: matchingEntry.OS,
Type: matchingEntry.Type,
})
}
fmt.Println("[INFO] AutoDownloadUpdates completed successfully")
return nil
}
// AutoInstallUpdates installs any packages that were decompressed by AutoDownloadUpdates.
// It moves files from their temp directories to the final location and updates installed.ini.
func AutoInstallUpdates() error {
installDir, err := GetDefaultInstallDir()
if err != nil {
return err
}
for _, entry := range pendingUpdates {
// 1) Construct the same .tar.gz name we used when decompressing
fileName := fmt.Sprintf("%s@%s@%s@%s@%s@%s",
entry.Name, // no 'packageName'
entry.Arch, // matches 'arch'
entry.OS, // matches 'os'
entry.Type, // matches 'type'
entry.Release, // matches 'release'
entry.Version, // matches 'version'
)
// 3) Combine with your global temp dir
tempBase := GetTempDir() // e.g. C:\Users\YourUser\AppData\Local\Temp\spm_temp_164326
decompressedDir := filepath.Join(tempBase, fileName)
// 4) Move files from that decompressedDir
fmt.Printf("[INFO] Installing %s from %s\n", entry.Name, decompressedDir)
err := MoveFilesToInstallDir(decompressedDir, installDir, entry.Type)
if err != nil {
return fmt.Errorf("failed to move files for %s: %w", entry.Name, err)
}
// 5) Finalize
err = FinalizeInstall(entry.Name, entry.Release, entry.Version, entry.Arch, entry.OS)
if err != nil {
return fmt.Errorf("failed to finalize install for %s: %w", entry.Name, err)
}
}
pendingUpdates = nil
return nil
}

220
spm/download.go Normal file
View file

@ -0,0 +1,220 @@
package spm
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"time"
)
// func DownloadPackage(pkg Package, destDir string, version, release, pkgType string) error {
// client := &http.Client{}
// var resp *http.Response
// var err error
// for i := 0; i < 3; i++ { // Retry up to 3 times
// fmt.Printf("[INFO] Attempting to download package from URL: %s (Attempt %d)\n", pkg.DownloadURL, i+1)
// resp, err = client.Get(pkg.DownloadURL)
// if err == nil && resp.StatusCode == http.StatusOK {
// break
// }
// if err != nil {
// fmt.Printf("[ERROR] Attempt %d failed: %v\n", i+1, err)
// }
// if resp != nil && resp.StatusCode != http.StatusOK {
// fmt.Printf("[ERROR] Server returned status: %d\n", resp.StatusCode)
// }
// if i < 2 {
// time.Sleep(2 * time.Second) // Delay between retries
// }
// }
// if err != nil {
// return fmt.Errorf("[ERROR] Failed to download %s after 3 retries: %w", pkg.Name, err)
// }
// defer resp.Body.Close()
// // Check content type
// contentType := resp.Header.Get("Content-Type")
// if contentType != "application/gzip" && contentType != "application/x-tar" {
// return fmt.Errorf("[ERROR] Invalid content type: %s. Expected a .tar.gz file.", contentType)
// }
// // Generate the filename using the desired format
// filename := fmt.Sprintf("%s@%s@%s@%s@%s@%s.tar.gz",
// pkg.Name, // Name of the package
// pkg.Arch, // Architecture (e.g., amd64)
// pkg.OS, // Operating System (e.g., windows, linux)
// pkgType, // Type of the package (e.g., nightly, stable)
// release, // Release (e.g., nightly, stable)
// version, // Version of the package
// )
// // Construct the full file path
// filePath := filepath.Join(destDir, filename)
// fmt.Printf("[INFO] Saving package to: %s\n", filePath)
// // Create the destination directory if it doesn't exist
// err = os.MkdirAll(destDir, 0755)
// if err != nil {
// return fmt.Errorf("[ERROR] Failed to create destination directory %s: %w", destDir, err)
// }
// // Create the file to save the download
// out, err := os.Create(filePath)
// if err != nil {
// return fmt.Errorf("[ERROR] Failed to create file %s: %w", filePath, err)
// }
// defer out.Close()
// // Track download progress
// totalSize := resp.ContentLength
// var downloaded int64
// buf := make([]byte, 1024)
// for {
// n, err := resp.Body.Read(buf)
// if n > 0 {
// downloaded += int64(n)
// percentage := int(float64(downloaded) / float64(totalSize) * 100)
// UpdateProgress(percentage, fmt.Sprintf("Downloading %s", pkg.Name))
// if _, err := out.Write(buf[:n]); err != nil {
// return fmt.Errorf("[ERROR] Failed to write to file %s: %w", filePath, err)
// }
// }
// if err == io.EOF {
// break
// }
// if err != nil {
// return fmt.Errorf("[ERROR] Error reading response body: %w", err)
// }
// }
// UpdateProgress(100, fmt.Sprintf("%s downloaded", pkg.Name))
// fmt.Printf("[INFO] Package %s downloaded successfully to: %s\n", pkg.Name, filePath)
// // Validate that the file is a valid gzip or tar file
// if _, err := os.Stat(filePath); err != nil {
// return fmt.Errorf("[ERROR] Downloaded file does not exist: %w", err)
// }
// return nil
// }
// DownloadPackageFromAppIndex selects and downloads the correct package from the APPINDEX.
func DownloadPackageFromAppIndex(appIndexPath string, packageName string, release string, pkgType string, destDir string) error {
// Parse the APPINDEX
entries, err := ParseAppIndex(appIndexPath)
if err != nil {
return fmt.Errorf("failed to parse APPINDEX: %w", err)
}
// Find the right entry
var selected *AppIndexEntry
for _, e := range entries {
if e.Name == packageName &&
e.Release == release &&
e.Type == pkgType &&
e.OS == runtime.GOOS &&
e.Arch == runtime.GOARCH {
selected = &e
break
}
}
// Handle no matching entry
if selected == nil {
return fmt.Errorf("package not found in APPINDEX: %s (release: %s, type: %s, os: %s, arch: %s)", packageName, release, pkgType, runtime.GOOS, runtime.GOARCH)
}
// Check if the package is already installed and up-to-date
installDir, err := GetDefaultInstallDir()
if err != nil {
return fmt.Errorf("failed to get install directory: %w", err)
}
needsUpdate, err := IsUpdateNeeded(installDir, packageName, release, selected.Version, selected.Arch, selected.OS)
if err != nil {
return fmt.Errorf("failed to check update status: %w", err)
}
if !needsUpdate {
UpdateProgress(0, "Already up-to-date, skipping download.")
return nil // Skip download
}
// Download the package
UpdateProgress(0, fmt.Sprintf("Downloading %s %s (%s)...", packageName, selected.Version, selected.Type))
resp, err := http.Get(selected.DownloadURL)
if err != nil {
return fmt.Errorf("failed to download package: %w", err)
}
defer resp.Body.Close()
// Save the downloaded file
downloadedFileName := filepath.Base(selected.DownloadURL)
downloadedFilePath := filepath.Join(destDir, downloadedFileName)
out, err := os.OpenFile(downloadedFilePath, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer out.Close()
totalSize := resp.ContentLength
var downloaded int64
buf := make([]byte, 32*1024) // Use a larger buffer for efficiency
for {
n, errRead := resp.Body.Read(buf)
if n > 0 {
downloaded += int64(n)
percentage := int(float64(downloaded) / float64(totalSize) * 100)
UpdateProgress(percentage, fmt.Sprintf("Downloading %s %s (%s)...", packageName, selected.Version, selected.Type))
if _, errWrite := out.Write(buf[:n]); errWrite != nil {
return fmt.Errorf("failed to write to output file: %w", errWrite)
}
}
if errRead == io.EOF {
break
}
if errRead != nil {
return fmt.Errorf("error while reading response: %w", errRead)
}
}
// Ensure the file handle is closed before renaming
out.Close()
// Construct the expected filename
expectedFileName := fmt.Sprintf("%s@%s@%s@%s@%s@%s.tar.gz",
packageName, selected.Arch, selected.OS, selected.Type, selected.Release, selected.Version)
expectedFilePath := filepath.Join(destDir, expectedFileName)
// I dont know why is this happening, I dont want to know but sometimes some process is helding up the donwloaded files so thats why it retries here
maxRetries := 5
for i := 0; i < maxRetries; i++ {
err = os.Rename(downloadedFilePath, expectedFilePath)
if err == nil {
break
}
// Check if file is in use
f, checkErr := os.Open(downloadedFilePath)
if checkErr != nil {
return fmt.Errorf("file is locked by another process: %w", checkErr)
}
f.Close()
if i < maxRetries-1 {
time.Sleep(500 * time.Millisecond) // Wait before retrying
}
}
if err != nil {
return fmt.Errorf("failed to rename downloaded file after retries: %w", err)
}
UpdateProgress(100, fmt.Sprintf("Downloaded %s %s (%s).", packageName, selected.Version, selected.Type))
return nil
}

5
spm/go.mod Normal file
View file

@ -0,0 +1,5 @@
module spm
go 1.23.4
require gopkg.in/ini.v1 v1.67.0 // indirect

2
spm/go.sum Normal file
View file

@ -0,0 +1,2 @@
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=

204
spm/install.go Normal file
View file

@ -0,0 +1,204 @@
package spm
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"math/rand"
"os"
"path/filepath"
)
func DecompressToTemp(filePath string) (string, error) {
UpdateProgress(0, "Decompressing package")
// 1) Base temp dir
baseTempDir := GetTempDir()
// 2) Create a unique subfolder inside the base temp dir
subfolderName := fmt.Sprintf("spm_decompress_%d", rand.Intn(1000000))
decompressDir := filepath.Join(baseTempDir, subfolderName)
if err := os.MkdirAll(decompressDir, 0755); err != nil {
return "", fmt.Errorf("failed to create decompress dir: %w", err)
}
// 3) Open the tar.gz file
file, err := os.Open(filePath)
if err != nil {
return "", err
}
defer file.Close()
gzr, err := gzip.NewReader(file)
if err != nil {
return "", err
}
defer gzr.Close()
tarReader := tar.NewReader(gzr)
// 4) Count total files
var totalFiles, processedFiles int
for {
_, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return "", err
}
totalFiles++
}
// 5) Reset file position and tar reader
if _, err := file.Seek(0, io.SeekStart); err != nil {
return "", err
}
if err := gzr.Reset(file); err != nil {
return "", err
}
tarReader = tar.NewReader(gzr)
// 6) Extract into `decompressDir`
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return "", err
}
targetPath := filepath.Join(decompressDir, header.Name)
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil {
return "", err
}
case tar.TypeReg:
outFile, err := os.Create(targetPath)
if err != nil {
return "", err
}
if _, err := io.Copy(outFile, tarReader); err != nil {
outFile.Close()
return "", err
}
outFile.Close()
}
processedFiles++
UpdateProgress(int(float64(processedFiles)/float64(totalFiles)*100), "Decompressing package")
}
UpdateProgress(100, "Package decompressed")
return decompressDir, nil
}
func MoveFilesToInstallDir(tempDir, installDir, pkgType string) error {
// Ensure tempDir exists before processing
if _, err := os.Stat(tempDir); os.IsNotExist(err) {
return fmt.Errorf("tempDir does not exist: %s", tempDir)
}
// If the package type is "browser", set the subdirectory to "browser"
if pkgType == "browser" {
installDir = filepath.Join(installDir, "browser")
}
// Count total files to copy
var totalFiles, copiedFiles int
err := filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
totalFiles++
}
return nil
})
if err != nil {
return err
}
// Copy files and track progress
err = filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(tempDir, path)
if err != nil {
return err
}
targetPath := filepath.Join(installDir, relPath)
if info.IsDir() {
// Create directories in the install directory
if err := os.MkdirAll(targetPath, os.ModePerm); err != nil {
return err
}
} else {
// Copy files to the install directory
if err := copyFile(path, targetPath); err != nil {
return err
}
copiedFiles++
UpdateProgress(int(float64(copiedFiles)/float64(totalFiles)*100), "Copying files to install directory")
}
return nil
})
if err != nil {
return err
}
// Clean up temporary directory
UpdateProgress(100, "Cleaning up temporary files")
return os.RemoveAll(tempDir)
}
// copyFile copies the contents of the source file to the destination file.
func copyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
// Create the destination file
destinationFile, err := os.Create(dst)
if err != nil {
return err
}
defer destinationFile.Close()
// Copy the file content
if _, err := io.Copy(destinationFile, sourceFile); err != nil {
return err
}
// Preserve file permissions
info, err := sourceFile.Stat()
if err != nil {
return err
}
return os.Chmod(dst, info.Mode())
}
// FinalizeInstall finalizes the installation by updating installed.ini.
func FinalizeInstall(packageName, release, version, arch, osName string) error {
installDir, err := GetDefaultInstallDir()
if err != nil {
return err
}
pkgInfo := AppIndexEntry{
Name: packageName,
Version: version,
Release: release,
Arch: arch,
OS: osName,
}
return UpdateInstalledPackage(installDir, pkgInfo)
}

204
spm/installed_pacakges.go Normal file
View file

@ -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)
// }
// }

31
spm/progress.go Normal file
View file

@ -0,0 +1,31 @@
package spm
import (
"fmt"
"sync"
)
type Progress struct {
mu sync.Mutex
percentage int
task string
}
var progress = Progress{}
// UpdateProgress sets the current percentage and task.
func UpdateProgress(percentage int, task string) {
progress.mu.Lock()
defer progress.mu.Unlock()
progress.percentage = percentage
progress.task = task
fmt.Printf("\r[%3d%%] %s", percentage, task) // Print progress to the terminal
// Next line on 100% ?
}
// GetProgress returns the current progress state.
func GetProgress() (int, string) {
progress.mu.Lock()
defer progress.mu.Unlock()
return progress.percentage, progress.task
}

70
spm/run.go Normal file
View file

@ -0,0 +1,70 @@
package spm
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
)
// Run locates and starts the installed Spitfire browser without waiting for it to exit.
func Run() error {
installDir, err := GetInstallDir()
if err != nil {
return err
}
exePath := filepath.Join(installDir, "browser", "spitfire.exe")
if runtime.GOOS != "windows" {
exePath = filepath.Join(installDir, "browser", "spitfire")
}
cmd := exec.Command(exePath)
return cmd.Start()
}
// RunAndWait locates and starts the installed Spitfire browser and waits for it to exit.
func RunAndWait() error {
installDir, err := GetInstallDir()
if err != nil {
return fmt.Errorf("failed to get install directory: %w", err)
}
// Construct the browser executable path
exePath := filepath.Join(installDir, "browser", "spitfire.exe")
if runtime.GOOS != "windows" {
exePath = filepath.Join(installDir, "browser", "spitfire")
}
// Check if the browser executable exists
if _, err := os.Stat(exePath); err != nil {
return fmt.Errorf("browser executable not found at %s: %w", exePath, err)
}
// Create the command
cmd := exec.Command(exePath)
// Attach standard output and error for debugging
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// Start the browser process
fmt.Printf("Starting browser: %s\n", exePath)
err = cmd.Start()
if err != nil {
return fmt.Errorf("failed to start browser: %w", err)
}
// Print the PID for debugging
fmt.Printf("Browser process started with PID %d\n", cmd.Process.Pid)
// Wait for the process to exit
err = cmd.Wait()
if err != nil {
return fmt.Errorf("browser exited with error: %w", err)
}
fmt.Println("Browser exited successfully.")
return nil
}

32
spm/tempdir.go Normal file
View file

@ -0,0 +1,32 @@
package spm
import (
"fmt"
"math/rand"
"os"
"path/filepath"
"sync"
"time"
)
var (
tempDirOnce sync.Once
tempDirPath string
)
// GetTempDir generates or retrieves a unique temp directory.
func GetTempDir() string {
tempDirOnce.Do(func() {
// Generate a unique temp directory name
rand.Seed(time.Now().UnixNano())
tempDirPath = filepath.Join(os.TempDir(), fmt.Sprintf("spm_temp_%d", rand.Intn(1000000)))
// Ensure the directory exists
if err := os.MkdirAll(tempDirPath, os.ModePerm); err != nil {
fmt.Printf("[ERROR] Failed to create temp directory: %v\n", err)
} else {
fmt.Printf("[INFO] Using temp directory: %s\n", tempDirPath)
}
})
return tempDirPath
}

224
spm/utils.go Normal file
View file

@ -0,0 +1,224 @@
package spm
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
)
// global mutable variable to store the chosen install directory
var (
mu sync.Mutex
installedDir string
envVar = "SPITFIRE_INSTALL_DIR" // Environment variable name
)
// SetInstallDir sets the global install directory variable and updates the persistent environment variable.
func SetInstallDir(path string) error {
mu.Lock()
defer mu.Unlock()
installedDir = path
// Persist the environment variable on Windows
if runtime.GOOS == "windows" {
err := persistSystemEnvVar(envVar, path)
if err != nil {
return err
}
} else {
// For non-Windows platforms, just set it in the current process environment
err := os.Setenv(envVar, path)
if err != nil {
return err
}
}
return nil
}
// GetInstallDir returns the currently set install directory if available;
// otherwise, it calls GetDefaultInstallDir() and sets that.
func GetInstallDir() (string, error) {
mu.Lock()
defer mu.Unlock()
// If already set, return it
if installedDir != "" {
return installedDir, nil
}
// Check if it's stored in the system environment variable
if envDir := os.Getenv(envVar); envDir != "" {
installedDir = envDir
return installedDir, nil
}
// Compute and store the default directory if not already set
defDir, err := GetDefaultInstallDir()
if err != nil {
return "", err
}
installedDir = defDir
// Persist the default directory as an environment variable on Windows
if runtime.GOOS == "windows" {
_ = persistSystemEnvVar(envVar, defDir)
} else {
_ = os.Setenv(envVar, defDir)
}
return defDir, nil
}
// persistSystemEnvVar sets a persistent environment variable on Windows using the `setx` command.
func persistSystemEnvVar(key, value string) error {
cmd := exec.Command("cmd", "/C", "setx", key, value)
err := cmd.Run()
if err != nil {
return err
}
return nil
}
// GetDefaultInstallDir generates the default installation directory
// based on the OS and environment, then also sets it via SetInstallDir.
func GetDefaultInstallDir() (string, error) {
var installDir string
switch runtime.GOOS {
case "windows":
programFiles := os.Getenv("ProgramFiles")
if programFiles == "" {
return "", fmt.Errorf("unable to determine default install directory on Windows")
}
installDir = filepath.Join(programFiles, "Spitfire")
case "darwin":
// Use ~/Library/Application Support on macOS
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("unable to determine home directory on macOS: %w", err)
}
installDir = filepath.Join(homeDir, "Library", "Application Support", "Spitfire")
case "linux":
// Use ~/.local/share/Spitfire on Linux
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("unable to determine home directory on Linux: %w", err)
}
installDir = filepath.Join(homeDir, ".local", "share", "Spitfire")
default:
return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}
// Also store it globally so future calls to GetInstallDir() return the same
SetInstallDir(installDir)
return installDir, nil
}
// SetDownloadFolder ensures customDir exists, returns it
func SetDownloadFolder(customDir string) (string, error) {
if err := os.MkdirAll(customDir, os.ModePerm); err != nil {
return "", err
}
return customDir, nil
}
// IsMatchingEntry checks if a package entry matches the requested specs
func IsMatchingEntry(e AppIndexEntry, name, release, arch, osName, pkgType string) bool {
return e.Name == name &&
e.Release == release &&
e.Arch == arch &&
e.OS == osName &&
e.Type == pkgType
}
// DecompressPackage determines the package format and decompresses it
// DecompressPackage: uses a consistent folder name based on "expectedFileName".
func DecompressPackage(downloadDir, packageName, arch, osName, pkgType, release, version string) (string, error) {
// 1) Construct the .tar.gz name
expectedFileName := fmt.Sprintf(
"%s@%s@%s@%s@%s@%s.tar.gz",
packageName, arch, osName, pkgType, release, version,
)
packagePath := filepath.Join(downloadDir, expectedFileName)
// Check that file exists
if _, err := os.Stat(packagePath); os.IsNotExist(err) {
return "", fmt.Errorf("package file not found: %s", packagePath)
}
// 2) Build the folder path (minus ".tar.gz")
folderName := strings.TrimSuffix(expectedFileName, ".tar.gz")
tempDir := GetTempDir() // e.g. C:\Users\<User>\AppData\Local\Temp\spm_temp_164326
decompressDir := filepath.Join(tempDir, folderName)
// Ensure the folder
if err := os.MkdirAll(decompressDir, 0755); err != nil {
return "", fmt.Errorf("failed to create decompressDir: %w", err)
}
// 3) Decompress everything into `decompressDir`
if err := decompressTarGz(packagePath, decompressDir); err != nil {
return "", fmt.Errorf("failed to decompress: %w", err)
}
// Return the folder path we used
return decompressDir, nil
}
func decompressTarGz(srcFile, destDir string) error {
f, err := os.Open(srcFile)
if err != nil {
return err
}
defer f.Close()
gzr, err := gzip.NewReader(f)
if err != nil {
return err
}
defer gzr.Close()
tarReader := tar.NewReader(gzr)
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
outPath := filepath.Join(destDir, header.Name)
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(outPath, os.FileMode(header.Mode)); err != nil {
return err
}
case tar.TypeReg:
outFile, err := os.Create(outPath)
if err != nil {
return err
}
_, err = io.Copy(outFile, tarReader)
outFile.Close()
if err != nil {
return err
}
default:
// handle symlinks etc. if needed
}
}
return nil
}