From d0187f94d7e056c4aaed9fbc5b66bfd5f542dd64 Mon Sep 17 00:00:00 2001 From: partisan Date: Sun, 9 Mar 2025 11:42:53 +0100 Subject: [PATCH] Init --- go.mod | 15 ++ go.sum | 13 ++ headers.go | 107 +++++++++++ main.go | 15 ++ spm/appindex.go | 382 ++++++++++++++++++++++++++++++++++++++ spm/auto.go | 252 +++++++++++++++++++++++++ spm/decompress.go | 143 ++++++++++++++ spm/dirs.go | 144 ++++++++++++++ spm/download.go | 128 +++++++++++++ spm/go.mod | 7 + spm/go.sum | 10 + spm/install.go | 304 ++++++++++++++++++++++++++++++ spm/installed_pacakges.go | 214 +++++++++++++++++++++ spm/progress.go | 31 ++++ spm/register_unix.go | 37 ++++ spm/register_win.go | 147 +++++++++++++++ spm/run_unix.go | 70 +++++++ spm/run_win.go | 64 +++++++ spm/search.go | 242 ++++++++++++++++++++++++ spm/utils.go | 26 +++ store.go | 59 ++++++ templates/index.html | 25 +++ templates/store.html | 54 ++++++ 23 files changed, 2489 insertions(+) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 headers.go create mode 100644 main.go create mode 100644 spm/appindex.go create mode 100644 spm/auto.go create mode 100644 spm/decompress.go create mode 100644 spm/dirs.go create mode 100644 spm/download.go create mode 100644 spm/go.mod create mode 100644 spm/go.sum create mode 100644 spm/install.go create mode 100644 spm/installed_pacakges.go create mode 100644 spm/progress.go create mode 100644 spm/register_unix.go create mode 100644 spm/register_win.go create mode 100644 spm/run_unix.go create mode 100644 spm/run_win.go create mode 100644 spm/search.go create mode 100644 spm/utils.go create mode 100644 store.go create mode 100644 templates/index.html create mode 100644 templates/store.html diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..71c7853 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module spitfire-store + +go 1.21.1 + +replace spitfire-store/spm => ./spm + +require spitfire-store/spm v0.0.1 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.30.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8847bcd --- /dev/null +++ b/go.sum @@ -0,0 +1,13 @@ +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= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.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/headers.go b/headers.go new file mode 100644 index 0000000..6aa2004 --- /dev/null +++ b/headers.go @@ -0,0 +1,107 @@ +package main + +import ( + "html/template" + "log" + "net/http" + + "spitfire-store/spm" +) + +func last(s []string) string { + if len(s) == 0 { + return "" + } + return s[len(s)-1] +} + +var storeTemplate = template.Must(template.New("store.html").Funcs(template.FuncMap{ + "last": last, +}).ParseFiles("templates/store.html")) +var indexTemplate = template.Must(template.ParseFiles("templates/index.html")) + +// indexHandler renders the main search page. +func indexHandler(w http.ResponseWriter, r *http.Request) { + indexTemplate.Execute(w, nil) +} + +// searchHandler uses spm.SearchPackages with the given query & filter, +// converts results into a slice of maps for the template, and renders them. +func searchHandler(w http.ResponseWriter, r *http.Request) { + query := r.FormValue("q") + filter := r.FormValue("filter") + + if query == "" { + http.Redirect(w, r, "/", http.StatusFound) + return + } + + log.Printf("Search query=%s, filter=%s", query, filter) + + // Update local SPM index first + if err := spm.UpdateIndex(); err != nil { + http.Error(w, "Failed to update local index", http.StatusInternalServerError) + log.Printf("spm.UpdateIndex error: %v", err) + return + } + + // Call our unified search. Ensure spm.SearchPackages handles + // "addon", "theme", "layout", "bundle", "config", "all", etc. + results, err := spm.SearchPackages(query, filter) + if err != nil { + http.Error(w, "Search error", http.StatusInternalServerError) + log.Printf("spm.SearchPackages error: %v", err) + return + } + + log.Printf("Total results for query=%q, filter=%q: %d", query, filter, len(results)) + + // Convert each result into a map for the template. + // For Mozilla addons, e.Type might be "addon" or "theme". + // For SPM, e.Type might be "layout", "bundle", "config", etc. + var items []map[string]interface{} + for _, e := range results { + item := map[string]interface{}{ + "type": e.Type, + "name": e.Name, + "release": e.Release, + "os": e.OS, + "arch": e.Arch, + "downloadURL": e.DownloadURL, + "icon": e.Icon, + "url": e.URL, + "screenshots": e.Screenshots, + } + items = append(items, item) + } + + // Render the store template with items + if err := storeTemplate.Execute(w, items); err != nil { + log.Printf("Error executing template: %v", err) + } +} + +// installHandler for AMO add-ons (unchanged) +func installHandler(w http.ResponseWriter, r *http.Request) { + slug := r.URL.Query().Get("slug") + if slug == "" { + http.Error(w, "Missing add-on slug", http.StatusBadRequest) + return + } + addon, err := fetchAddonDetails(slug) + if err != nil { + http.Error(w, "Failed to fetch add-on details", http.StatusInternalServerError) + log.Printf("Error fetching details for slug '%s': %v", slug, err) + return + } + var xpiURL string + if len(addon.CurrentVersion.Files) > 0 { + xpiURL = addon.CurrentVersion.Files[0].URL + } else if addon.CurrentVersion.File != nil { + xpiURL = addon.CurrentVersion.File.URL + } else { + http.Redirect(w, r, addon.URL, http.StatusFound) + return + } + http.Redirect(w, r, xpiURL, http.StatusFound) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..222c38f --- /dev/null +++ b/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "log" + "net/http" +) + +func main() { + http.HandleFunc("/", indexHandler) + http.HandleFunc("/search", searchHandler) + http.HandleFunc("/install", installHandler) // for AMO add-on installs + + log.Println("Starting server on http://localhost:8080") + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/spm/appindex.go b/spm/appindex.go new file mode 100644 index 0000000..9129ac9 --- /dev/null +++ b/spm/appindex.go @@ -0,0 +1,382 @@ +package spm + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/ini.v1" +) + +// AppIndexEntry represents a single entry in an app index. +type AppIndexEntry struct { + Name string // from "P:" + Version string // from "V:" + Release string // from "R:" + Arch string // from "A:" + OS string // from "p:" + Type string // from "o:" + DownloadURL string // from "d:" + Maintainer string // from "m:" + Icon string // from "I:" + Screenshots []string // from "S:" + Tags []string // from "T:" + Description string // from "X:" + URL string // from "U:" + License string // from "L:" + Dependencies []string // from "D:" (split comma-delimited) + Notes string // from "r:" + CompressedFile string // from "C:" + UncompressedFile string // from "c:" +} + +// RemoteIndex represents a remote APPINDEX repository. +type RemoteIndex struct { + Name string `json:"name"` + Link string `json:"link"` +} + +var ( + // defaultRemoteIndexes holds the default remote index. + defaultRemoteIndexes = []RemoteIndex{ + { + Name: "default", + Link: "https://downloads.sourceforge.net/project/spitfire-browser/APPINDEX", + }, + } + // remoteIndexes holds the current remote indexes in use. + remoteIndexes = defaultRemoteIndexes +) + +// downloadAppIndex downloads an APPINDEX from the given URL and writes it to dest. +func downloadAppIndex(url, dest string) error { + UpdateProgress(0, "Downloading APPINDEX") + resp, err := http.Get(url) + 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 +} + +// parseAppIndexFromReader parses an APPINDEX from any io.Reader. +func parseAppIndexFromReader(r io.Reader) ([]AppIndexEntry, error) { + var entries []AppIndexEntry + scanner := bufio.NewScanner(r) + entry := AppIndexEntry{} + + for scanner.Scan() { + line := scanner.Text() + + // "C:" signals the start of a new entry. Append the previous one. + if strings.HasPrefix(line, "C:") { + // Start of a new entry + if entry.Name != "" { + entries = append(entries, entry) + } + entry = AppIndexEntry{} + continue + } + + 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 "o": + entry.Type = value + case "d": + entry.DownloadURL = value + case "X": + entry.Description = value + case "U": + entry.URL = value + case "L": + entry.License = value + case "m": + entry.Maintainer = value + case "D": + entry.Dependencies = strings.Split(value, ",") + case "I": + entry.Icon = value + case "S": + entry.Screenshots = strings.Split(value, ",") + case "T": + entry.Tags = strings.Split(value, ",") + case "r": + entry.Notes = value + } + } + + // Append the last entry if we didn't encounter another "C:" + if entry.Name != "" { + entries = append(entries, entry) + } + + return entries, scanner.Err() +} + +// parseAppIndex reads the APPINDEX file at filePath and parses its contents. +func parseAppIndex(filePath string) ([]AppIndexEntry, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + + entries, err := parseAppIndexFromReader(file) + if err != nil { + return nil, err + } + + fmt.Printf("[INFO] Total parsed entries from %s: %d\n", filePath, 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, nil +} + +// saveIndex saves the current list of remote indexes to an INI file located in +// the spm directory under installDir, but only if it's different from the default. +func saveIndex() error { + // Only save if remoteIndexes differs from defaultRemoteIndexes. + if len(remoteIndexes) == len(defaultRemoteIndexes) { + same := true + for i, ri := range remoteIndexes { + if ri != defaultRemoteIndexes[i] { + same = false + break + } + } + if same { + return nil + } + } + + installDir, err := GetInstallDir() + if err != nil { + return err + } + spmDir := filepath.Join(installDir, "spm") + if err := os.MkdirAll(spmDir, 0755); err != nil { + return err + } + filePath := filepath.Join(spmDir, "sources.ini") + + cfg := ini.Empty() + sec, err := cfg.NewSection("RemoteIndexes") + if err != nil { + return err + } + // Save each remote index as a key/value pair. + for _, ri := range remoteIndexes { + if _, err := sec.NewKey(ri.Name, ri.Link); err != nil { + return err + } + } + + return cfg.SaveTo(filePath) +} + +// loadIndex loads the list of remote indexes from an INI file located in +// the spm directory under installDir. If the file is missing, it sets remoteIndexes to the default. +func loadIndex() error { + installDir, err := GetInstallDir() + if err != nil { + return err + } + spmDir := filepath.Join(installDir, "spm") + filePath := filepath.Join(spmDir, "sources.ini") + cfg, err := ini.Load(filePath) + if err != nil { + // If file is missing or can't be loaded, use the default. + remoteIndexes = defaultRemoteIndexes + return nil + } + sec := cfg.Section("RemoteIndexes") + var loaded []RemoteIndex + for _, key := range sec.Keys() { + loaded = append(loaded, RemoteIndex{ + Name: key.Name(), + Link: key.Value(), + }) + } + remoteIndexes = loaded + return nil +} + +// UpdateIndex downloads fresh APPINDEX files from all remote sources and saves them +// into the temp directory. If the app is registered, it loads the remote indexes +// from the INI file (or uses the default if not available) and saves them after updating. +func UpdateIndex() error { + tempDir := GetTempDir() + var sources []RemoteIndex + if IsRegistered() { + // Try to load persisted remote indexes. + if err := loadIndex(); err != nil { + // If loading fails, fall back to the default remote indexes. + sources = defaultRemoteIndexes + } else { + sources = remoteIndexes + } + } else { + // Not registered: use default remote indexes. + sources = defaultRemoteIndexes + } + + // Download each APPINDEX file. + for _, ri := range sources { + localPath := filepath.Join(tempDir, fmt.Sprintf("appindex_%s.txt", ri.Name)) + if err := downloadAppIndex(ri.Link, localPath); err != nil { + return fmt.Errorf("[WARN] AppIndex: failed downloading %s: %v", ri.Link, err) + } + } + + // If registered, save the current remote indexes. + if IsRegistered() { + if err := saveIndex(); err != nil { + return fmt.Errorf("[WARN] AppIndex: failed saving indexes: %v", err) + } + } + return nil +} + +// GetIndex parses APPINDEX data from local files in the temp directory. +// If a file is missing, it downloads the corresponding APPINDEX first. +// If the app is registered, it loads remote indexes from the INI file. +// Otherwise, it uses the default remote index. +func GetIndex() ([]AppIndexEntry, error) { + var allEntries []AppIndexEntry + tempDir := GetTempDir() + var sources []RemoteIndex + if IsRegistered() { + if err := loadIndex(); err != nil { + sources = defaultRemoteIndexes + } else { + sources = remoteIndexes + } + } else { + sources = defaultRemoteIndexes + } + + // For each remote source, ensure the APPINDEX file exists (downloading if needed), + // then parse its contents. + for _, ri := range sources { + localPath := filepath.Join(tempDir, fmt.Sprintf("appindex_%s.txt", ri.Name)) + if _, err := os.Stat(localPath); os.IsNotExist(err) { + if err := downloadAppIndex(ri.Link, localPath); err != nil { + return nil, fmt.Errorf("[WARN] AppIndex: failed downloading %s: %v", ri.Link, err) + } + } + entries, err := parseAppIndex(localPath) + if err != nil { + return nil, fmt.Errorf("[WARN] AppIndex: failed parsing %s: %v", localPath, err) + } + allEntries = append(allEntries, entries...) + } + return allEntries, nil +} + +// AddIndex adds a new remote index (name and link) into the list, +// sorts the list by name, and if the app is registered, saves the updated list. +func AddIndex(name, link string) error { + // If registered, load current indexes first. + if IsRegistered() { + if err := loadIndex(); err != nil { + return fmt.Errorf("[WARN] AppIndex: failed loading indexes: %w", err) + } + } + + ri := RemoteIndex{ + Name: name, + Link: link, + } + remoteIndexes = append(remoteIndexes, ri) + sort.Slice(remoteIndexes, func(i, j int) bool { + return remoteIndexes[i].Name < remoteIndexes[j].Name + }) + + // If registered, persist the changes. + if IsRegistered() { + if err := saveIndex(); err != nil { + return fmt.Errorf("[WARN] AppIndex: failed saving indexes: %w", err) + } + } + return nil +} + +// RemoveIndex removes any remote index with the given name from the list, +// and if the app is registered, saves the updated list. +func RemoveIndex(name string) error { + // If registered, load current indexes first. + if IsRegistered() { + if err := loadIndex(); err != nil { + return fmt.Errorf("[WARN] AppIndex: failed loading indexes: %w", err) + } + } + + var updated []RemoteIndex + for _, ri := range remoteIndexes { + if ri.Name != name { + updated = append(updated, ri) + } + } + remoteIndexes = updated + + // If registered, persist the changes. + if IsRegistered() { + if err := saveIndex(); err != nil { + return fmt.Errorf("[WARN] AppIndex: failed saving indexes: %w", err) + } + } + return nil +} diff --git a/spm/auto.go b/spm/auto.go new file mode 100644 index 0000000..d544a44 --- /dev/null +++ b/spm/auto.go @@ -0,0 +1,252 @@ +package spm + +import ( + "fmt" + "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 + +// DownloadUpdates 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 InstallUpdates(). +func DownloadUpdates() error { + // 1) Download the APPINDEX file to a temporary location + err := UpdateIndex() + if err != nil { + return fmt.Errorf("[ERROR] Failed to download APPINDEX: %w", err) + } + fmt.Println("[INFO] APPINDEX downloaded successfully") + + // 2) Parse the APPINDEX file + entries, err := GetIndex() + 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(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 InstallUpdates 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] DownloadUpdates completed successfully") + return nil +} + +// InstallUpdates installs any packages that were downloaded and decompressed by DownloadUpdates. +// It moves files from their temp directories to the final location and updates installed.ini. +func InstallUpdates() 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 DownloadSpecified(specs []AppIndexEntry) error { + // 1) Download the APPINDEX file to a temporary location + if err := UpdateIndex(); err != nil { + return fmt.Errorf("[ERROR] Failed to download APPINDEX: %w", err) + } + fmt.Println("[INFO] APPINDEX downloaded successfully") + + // 2) Parse the APPINDEX file + entries, err := GetIndex() + 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) + + 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( + 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) + + // Add to pendingUpdates for InstallUpdates + 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..00003e2 --- /dev/null +++ b/spm/decompress.go @@ -0,0 +1,143 @@ +package spm + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// DecompressPackage now passes UpdateProgress to decompressTarGz. +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, UpdateProgress); err != nil { + return "", fmt.Errorf("failed to decompress: %w", err) + } + + // Return the folder path we used + return decompressDir, nil +} + +// decompressTarGz takes an additional updateProgress callback to report progress. +func decompressTarGz(srcFile, destDir string, updateProgress func(int, string)) error { + f, err := os.Open(srcFile) + if err != nil { + return err + } + defer f.Close() + + fileInfo, err := f.Stat() + if err != nil { + return err + } + totalSize := fileInfo.Size() + + // Wrap the file reader so we can track progress. + progressReader := &ProgressReader{ + Reader: f, + Total: totalSize, + Callback: updateProgress, + } + + gzr, err := gzip.NewReader(progressReader) + 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: + outPath := filepath.Join(destDir, header.Name) + // Ensure the parent directory exists + if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { + return err + } + outFile, err := os.Create(outPath) + if err != nil { + return err + } + _, err = io.Copy(outFile, tarReader) + outFile.Close() + if err != nil { + return err + } + default: + // ignoring other types for now + } + + // Update progress after extracting each file. + if updateProgress != nil { + percent := int((progressReader.BytesRead * 100) / totalSize) + name := header.Name + if len(name) > 50 { + name = name[len(name)-50:] + } + updateProgress(percent, fmt.Sprintf("Extracted: %s", name)) + } + } + + // Final update: extraction complete. + if updateProgress != nil { + updateProgress(100, "Extraction complete") + } + + return nil +} + +// ProgressReader wraps an io.Reader to count bytes and update progress. +type ProgressReader struct { + io.Reader + Total int64 + BytesRead int64 + Callback func(int, string) +} + +func (pr *ProgressReader) Read(p []byte) (int, error) { + n, err := pr.Reader.Read(p) + pr.BytesRead += int64(n) + if pr.Callback != nil && pr.Total > 0 { + percent := int((pr.BytesRead * 100) / pr.Total) + pr.Callback(percent, "Decompressing...") + } + return n, err +} 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 new file mode 100644 index 0000000..c6464f2 --- /dev/null +++ b/spm/download.go @@ -0,0 +1,128 @@ +package spm + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "time" +) + +// DownloadPackageFromAppIndex selects and downloads the correct package from the APPINDEX. +func DownloadPackageFromAppIndex(packageName string, release string, pkgType string, destDir string) error { + // Parse the APPINDEX + entries, err := GetIndex() + 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 downloaded files so thats why it retries here + maxRetries := 10 + 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(250 * 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..8b67881 --- /dev/null +++ b/spm/go.mod @@ -0,0 +1,7 @@ +module weforge.xyz/Spitfire/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 new file mode 100644 index 0000000..a8d8521 --- /dev/null +++ b/spm/install.go @@ -0,0 +1,304 @@ +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..9f73aea --- /dev/null +++ b/spm/installed_pacakges.go @@ -0,0 +1,214 @@ +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) + + // Save arch if different from current runtime architecture + if pkg.Arch != runtime.GOARCH { + section.Key("A").SetValue(pkg.Arch) + } + + // Save OS if different from current runtime OS + if pkg.OS != runtime.GOOS { + section.Key("p").SetValue(pkg.OS) + } + } + + return cfg.SaveTo(installedFile) +} + +// isNewerVersion compares two version strings and determines if `newVer` is newer than `oldVer`. +func isNewerVersion(oldVer, newVer string) bool { + // Handle date-based versions (e.g., nightly: YYYY.MM.DD) + if isDateVersion(oldVer) && isDateVersion(newVer) { + return strings.Compare(newVer, oldVer) > 0 + } + + // Handle semantic versions (e.g., stable: v1.0.1) + if isSemVer(oldVer) && isSemVer(newVer) { + return compareSemVer(oldVer, newVer) > 0 + } + + // Fallback to lexicographical comparison for unknown formats + return strings.Compare(newVer, oldVer) > 0 +} + +// isDateVersion checks if a version string is in the format YYYY.MM.DD. +func isDateVersion(version string) bool { + parts := strings.Split(version, ".") + if len(parts) != 3 { + return false + } + for _, part := range parts { + if _, err := strconv.Atoi(part); err != nil { + return false + } + } + return true +} + +// isSemVer checks if a version string is in the format vMAJOR.MINOR.PATCH. +func isSemVer(version string) bool { + if !strings.HasPrefix(version, "v") { + return false + } + parts := strings.Split(strings.TrimPrefix(version, "v"), ".") + if len(parts) != 3 { + return false + } + for _, part := range parts { + if _, err := strconv.Atoi(part); err != nil { + return false + } + } + return true +} + +// compareSemVer compares two semantic version strings (vMAJOR.MINOR.PATCH). +// Returns: +// - 1 if `v2` is newer than `v1` +// - 0 if `v1` and `v2` are equal +// - -1 if `v1` is newer than `v2` +func compareSemVer(v1, v2 string) int { + parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".") + parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".") + + for i := 0; i < len(parts1); i++ { + num1, _ := strconv.Atoi(parts1[i]) + num2, _ := strconv.Atoi(parts2[i]) + if num1 > num2 { + return 1 + } + if num1 < num2 { + return -1 + } + } + return 0 +} + +// IsUpdateNeeded checks if the given package version is newer than what's installed. +func IsUpdateNeeded(installDir, name, release, newVersion, arch, osName string) (bool, error) { + installed, err := loadInstalledPackages(installDir) + if err != nil { + return false, err + } + + for _, pkg := range installed { + if pkg.Name == name && pkg.Release == release && pkg.Arch == arch && pkg.OS == osName { + fmt.Printf("Found installed package: %v\n", pkg) + if isNewerVersion(pkg.Version, newVersion) { + fmt.Println("Update is needed") + return true, nil + } + fmt.Println("No update needed") + return false, nil + } + } + + fmt.Println("Package not installed, update needed") + return true, nil +} + +// UpdateInstalledPackage writes/updates the new package version in installed.ini. +func UpdateInstalledPackage(installDir string, pkg AppIndexEntry) error { + installed, err := loadInstalledPackages(installDir) + if err != nil { + return err + } + + updated := false + for i, p := range installed { + if p.Name == pkg.Name && p.Release == pkg.Release && p.Arch == pkg.Arch && p.OS == pkg.OS { + installed[i].Version = pkg.Version + updated = true + break + } + } + if !updated { + installed = append(installed, pkg) + } + + return saveInstalledPackages(installDir, installed) +} + +// // DebugInstalled prints installed packages for debugging. +// func DebugInstalled(installDir string) { +// pkgs, err := loadInstalledPackages(installDir) +// if err != nil { +// fmt.Println("DebugInstalled error:", err) +// return +// } +// for _, p := range pkgs { +// fmt.Printf("Installed: %s v%s [%s] (arch=%s, os=%s)\n", p.Name, p.Version, p.Release, p.Arch, p.OS) +// } +// } diff --git a/spm/progress.go b/spm/progress.go new file mode 100644 index 0000000..c72e84e --- /dev/null +++ b/spm/progress.go @@ -0,0 +1,31 @@ +package spm + +import ( + "fmt" + "sync" +) + +type Progress struct { + mu sync.Mutex + percentage int + task string +} + +var progress = Progress{} + +// UpdateProgress sets the current percentage and task. +func UpdateProgress(percentage int, task string) { + progress.mu.Lock() + defer progress.mu.Unlock() + progress.percentage = percentage + progress.task = task + fmt.Printf("\r[%3d%%] %s", percentage, task) // Print progress to the terminal + // Next line on 100% ? +} + +// GetProgress returns the current progress state. +func GetProgress() (int, string) { + progress.mu.Lock() + defer progress.mu.Unlock() + return progress.percentage, progress.task +} diff --git a/spm/register_unix.go b/spm/register_unix.go new file mode 100644 index 0000000..f9e35c9 --- /dev/null +++ b/spm/register_unix.go @@ -0,0 +1,37 @@ +// run_default.go +//go:build !windows +// +build !windows + +package spm + +import ( + "fmt" + "os" + "path/filepath" +) + +// RegisterApp is not supported on non-Windows platforms. +func RegisterApp() error { + return fmt.Errorf("[WARN] RegisterApp() is only available on Windows") +} + +// UnregisterApp is not supported on non-Windows platforms. +func UnregisterApp() error { + return fmt.Errorf("[WARN] UnregisterApp() is only available on Windows") +} + +// IsRegistered returns true if the application is detected as installed. +// On Linux, we assume it is installed if the main executable exists in the install directory. +func IsRegistered() bool { + installDir, err := GetInstallDir() + if err != nil { + return false + } + + // Assume the executable is named "spitfire" and is located in installDir. + exePath := filepath.Join(installDir, "browser", "spitfire") + if _, err := os.Stat(exePath); err == nil { + return true + } + return false +} diff --git a/spm/register_win.go b/spm/register_win.go new file mode 100644 index 0000000..206f0ae --- /dev/null +++ b/spm/register_win.go @@ -0,0 +1,147 @@ +// run_windows.go +//go:build windows +// +build windows + +package spm + +import ( + "fmt" + + "golang.org/x/sys/windows/registry" +) + +// RegisterApp writes the necessary registry keys, making it appear as officially installed app +func RegisterApp() error { + exePath, err := GetInstallDir() + if err != nil { + return fmt.Errorf("failed to get install directory: %w", err) + } + + // 1. Create Uninstall/Modify entry + uninstallKeyPath := `Software\Microsoft\Windows\CurrentVersion\Uninstall\SpitfireBrowser` + uk, _, err := registry.CreateKey(registry.LOCAL_MACHINE, uninstallKeyPath, registry.ALL_ACCESS) + if err != nil { + return fmt.Errorf("failed to create uninstall key: %w", err) + } + defer uk.Close() + + if err := uk.SetStringValue("DisplayName", "Spitfire"); err != nil { + return err + } + if err := uk.SetStringValue("UninstallString", exePath+" --uninstall"); err != nil { + return err + } + if err := uk.SetStringValue("ModifyPath", exePath+" --modify"); err != nil { + return err + } + if err := uk.SetStringValue("DisplayIcon", exePath); err != nil { + return err + } + + // 2. Register as a browser for default apps + clientKeyPath := `Software\Clients\StartMenuInternet\SpitfireBrowser` + ck, _, err := registry.CreateKey(registry.LOCAL_MACHINE, clientKeyPath, registry.ALL_ACCESS) + if err != nil { + return fmt.Errorf("failed to create client key: %w", err) + } + defer ck.Close() + + if err := ck.SetStringValue("", "Spitfire"); err != nil { + return err + } + + // Create Capabilities subkey + capabilitiesKeyPath := clientKeyPath + `\Capabilities` + capk, _, err := registry.CreateKey(registry.LOCAL_MACHINE, capabilitiesKeyPath, registry.ALL_ACCESS) + if err != nil { + return fmt.Errorf("failed to create capabilities key: %w", err) + } + defer capk.Close() + + if err := capk.SetStringValue("ApplicationName", "Spitfire"); err != nil { + return err + } + if err := capk.SetStringValue("ApplicationDescription", "A custom browser"); err != nil { + return err + } + + // Set file associations + assocKeyPath := capabilitiesKeyPath + `\FileAssociations` + ak, _, err := registry.CreateKey(registry.LOCAL_MACHINE, assocKeyPath, registry.ALL_ACCESS) + if err != nil { + return fmt.Errorf("failed to create file associations key: %w", err) + } + defer ak.Close() + + associations := map[string]string{ + ".html": "SpitfireBrowserHTML", + "HTTP": "SpitfireBrowserHTML", + "HTTPS": "SpitfireBrowserHTML", + } + for ext, progID := range associations { + if err := ak.SetStringValue(ext, progID); err != nil { + return err + } + } + + return nil +} + +// UnregisterApp removes the registry entries created by registerApp. +func UnregisterApp() error { + // Remove the Uninstall/Modify entry. + uninstallKeyPath := `Software\Microsoft\Windows\CurrentVersion\Uninstall\SpitfireBrowser` + if err := deleteRegistryTree(registry.LOCAL_MACHINE, uninstallKeyPath); err != nil { + return fmt.Errorf("failed to delete uninstall key: %w", err) + } + + // Remove the browser registration entry. + clientKeyPath := `Software\Clients\StartMenuInternet\SpitfireBrowser` + if err := deleteRegistryTree(registry.LOCAL_MACHINE, clientKeyPath); err != nil { + return fmt.Errorf("failed to delete client key: %w", err) + } + + return nil +} + +// deleteRegistryTree recursively deletes a registry key and all its subkeys. +func deleteRegistryTree(root registry.Key, path string) error { + // Open the key with ALL_ACCESS permissions. + key, err := registry.OpenKey(root, path, registry.ALL_ACCESS) + if err != nil { + // If the key does not exist, there's nothing to do. + if err == registry.ErrNotExist { + return nil + } + return err + } + // Read the names of all subkeys. + subKeys, err := key.ReadSubKeyNames(-1) + key.Close() // Close the key so it can be deleted later. + if err != nil { + return err + } + + // Recursively delete each subkey. + for _, subKey := range subKeys { + subKeyPath := path + `\` + subKey + if err := deleteRegistryTree(root, subKeyPath); err != nil { + return err + } + } + + // Finally, delete the (now empty) key. + return registry.DeleteKey(root, path) +} + +// IsRegistered returns true if the application is registered (installed) in the registry. +func IsRegistered() bool { + // Try to open the uninstall key with read-only access. + key, err := registry.OpenKey(registry.LOCAL_MACHINE, `Software\Microsoft\Windows\CurrentVersion\Uninstall\SpitfireBrowser`, registry.READ) + if err != nil { + // If the key cannot be opened, assume the app is not registered. + return false + } + defer key.Close() + return true +} diff --git a/spm/run_unix.go b/spm/run_unix.go new file mode 100644 index 0000000..aa580b2 --- /dev/null +++ b/spm/run_unix.go @@ -0,0 +1,70 @@ +// run_unix.go +//go:build !windows +// +build !windows + +package spm + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "syscall" +) + +// 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") + + 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") + if _, err := os.Stat(exePath); err != nil { + return fmt.Errorf("browser executable not found at %s: %w", exePath, err) + } + + cmd := exec.Command(exePath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Start the process in a new process group + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } + + fmt.Printf("Starting browser: %s\n", exePath) + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start browser: %w", err) + } + + // Print PID and PGID for debugging + pgid, err := syscall.Getpgid(cmd.Process.Pid) + if err == nil { + fmt.Printf("Browser process started with PID %d (PGID %d)\n", cmd.Process.Pid, pgid) + } else { + fmt.Printf("Browser process started with PID %d\n", cmd.Process.Pid) + } + + if err := cmd.Wait(); err != nil { + return fmt.Errorf("browser exited with error: %w", err) + } + + fmt.Println("Browser exited successfully.") + return nil +} diff --git a/spm/run_win.go b/spm/run_win.go new file mode 100644 index 0000000..a108d7e --- /dev/null +++ b/spm/run_win.go @@ -0,0 +1,64 @@ +// run_windows.go +//go:build windows +// +build windows + +package spm + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "syscall" +) + +// 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") + + 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 _, err := os.Stat(exePath); err != nil { + return fmt.Errorf("browser executable not found at %s: %w", exePath, err) + } + + cmd := exec.Command(exePath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Use CREATE_NEW_PROCESS_GROUP flag for Windows + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP, + } + + fmt.Printf("Starting browser: %s\n", exePath) + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start browser: %w", err) + } + + fmt.Printf("Browser process started with PID %d\n", cmd.Process.Pid) + + if err := cmd.Wait(); err != nil { + return fmt.Errorf("browser exited with error: %w", err) + } + + fmt.Println("Browser exited successfully.") + return nil +} diff --git a/spm/search.go b/spm/search.go new file mode 100644 index 0000000..2cada63 --- /dev/null +++ b/spm/search.go @@ -0,0 +1,242 @@ +package spm + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" +) + +// Addon struct capturing fields from AMO search results (v5) +type Addon struct { + ID int `json:"id"` + Name map[string]string `json:"name"` + Slug string `json:"slug"` + URL string `json:"url"` + + CurrentVersion struct { + Version string `json:"version"` + ReleaseNotes map[string]string `json:"release_notes"` + Files []struct { + URL string `json:"url"` + } `json:"files"` + } `json:"current_version"` + + Author struct { + Name string `json:"name"` + } `json:"author"` + + IconURL string `json:"icon_url"` + Summary map[string]string `json:"summary"` + Tags []string `json:"tags"` + License struct { + Name string `json:"name"` + } `json:"license"` + + // Make sure we match the real AMO JSON: "image_url" is where + // the full-size preview is stored (not "url") + Previews []struct { + ID int `json:"id"` + Caption string `json:"caption"` + ImageURL string `json:"image_url"` + ThumbnailURL string `json:"thumbnail_url"` + } `json:"previews"` + + ThemeData struct { + Images struct { + Header string `json:"header"` + } `json:"images"` + } `json:"theme_data"` +} + +// AMOResponse is the structure returned by the AMO search endpoint (v5). +type AMOResponse struct { + Count int `json:"count"` + Results []Addon `json:"results"` +} + +func searchMozillaAddons(query, addonType string) ([]AppIndexEntry, error) { + baseURL := "https://addons.mozilla.org/api/v5/addons/search/" + q := url.QueryEscape(query) + apiURL := fmt.Sprintf("%s?app=firefox&q=%s", baseURL, q) + if addonType != "" && addonType != "all" { + apiURL += "&type=" + url.QueryEscape(addonType) + } + + log.Printf("AMO API request: %s", apiURL) + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed building request: %w", err) + } + req.Header.Set("User-Agent", "MyCustomBrowser/1.0") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to query AMO: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("AMO returned status: %d", resp.StatusCode) + } + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed reading AMO response: %w", err) + } + + var amoResp AMOResponse + if err := json.Unmarshal(bodyBytes, &amoResp); err != nil { + return nil, fmt.Errorf("failed parsing AMO JSON: %w", err) + } + + var entries []AppIndexEntry + for _, a := range amoResp.Results { + // Determine display name. + var addonName string + if val, ok := a.Name["en-US"]; ok && val != "" { + addonName = val + } else { + for _, v := range a.Name { + addonName = v + break + } + if addonName == "" { + addonName = "Unknown Add-on" + } + } + + finalType := addonType + if finalType == "" || finalType == "all" { + finalType = "addon" + } + + // Pick download URL from current version if present. + downloadURL := a.URL + if len(a.CurrentVersion.Files) > 0 && a.CurrentVersion.Files[0].URL != "" { + downloadURL = a.CurrentVersion.Files[0].URL + } + + // Gather screenshot URLs from Previews using preview.ImageURL. + var screenshots []string + for _, preview := range a.Previews { + screenshots = append(screenshots, preview.ImageURL) + } + + // If this is a theme, override screenshots to first preview. + if strings.EqualFold(finalType, "theme") || strings.EqualFold(finalType, "statictheme") || + strings.EqualFold(addonType, "theme") || strings.EqualFold(addonType, "statictheme") { + if len(a.Previews) > 0 { + log.Printf("Using first preview for theme %s: %s", a.Slug, a.Previews[0].ImageURL) + screenshots = []string{a.Previews[0].ImageURL} + } else { + log.Printf("No preview images available for theme %s", a.Slug) + } + } + + // Release notes + releaseNotes := "" + if rn, ok := a.CurrentVersion.ReleaseNotes["en-US"]; ok && rn != "" { + releaseNotes = rn + } else { + for _, rn := range a.CurrentVersion.ReleaseNotes { + releaseNotes = rn + break + } + } + + // Description + description := "" + if d, ok := a.Summary["en-US"]; ok && d != "" { + description = d + } else { + for _, d := range a.Summary { + description = d + break + } + } + + entries = append(entries, AppIndexEntry{ + Name: addonName, + Version: a.CurrentVersion.Version, + Type: finalType, + DownloadURL: downloadURL, + Maintainer: a.Author.Name, + Icon: a.IconURL, + Screenshots: screenshots, + Tags: a.Tags, + Description: description, + URL: a.URL, + License: a.License.Name, + Notes: releaseNotes, + }) + } + + log.Printf("AMO results: %d", len(entries)) + return entries, nil +} + +// Search in local SPM index by type +func searchLocalByType(typeFilter string) ([]AppIndexEntry, error) { + allEntries, err := GetIndex() + if err != nil { + return nil, err + } + var filtered []AppIndexEntry + for _, e := range allEntries { + if strings.EqualFold(e.Type, typeFilter) { + filtered = append(filtered, e) + } + } + return filtered, nil +} + +// Search in local SPM index for all, optionally filtered by name substring. +func searchLocalAll(query string) ([]AppIndexEntry, error) { + allEntries, err := GetIndex() + if err != nil { + return nil, err + } + if query == "" { + return allEntries, nil + } + var result []AppIndexEntry + for _, e := range allEntries { + if strings.Contains(strings.ToLower(e.Name), strings.ToLower(query)) { + result = append(result, e) + } + } + return result, nil +} + +// SearchPackages coordinates between local and AMO-based searches depending on filter +func SearchPackages(query, filter string) ([]AppIndexEntry, error) { + switch filter { + case "addon": + return searchMozillaAddons(query, "extension") + case "theme": + return searchMozillaAddons(query, "statictheme") + case "layout", "bundle", "config": + return searchLocalByType(filter) + case "", "all": + localAll, err := searchLocalAll(query) + if err != nil { + return nil, fmt.Errorf("local search error: %w", err) + } + mozExt, err := searchMozillaAddons(query, "extension") + if err != nil { + return nil, fmt.Errorf("mozilla (extension) error: %w", err) + } + mozTheme, err := searchMozillaAddons(query, "statictheme") + if err != nil { + return nil, fmt.Errorf("mozilla (theme) error: %w", err) + } + return append(append(localAll, mozExt...), mozTheme...), nil + default: + return nil, fmt.Errorf("unrecognized filter: %s", filter) + } +} diff --git a/spm/utils.go b/spm/utils.go new file mode 100644 index 0000000..607b6f6 --- /dev/null +++ b/spm/utils.go @@ -0,0 +1,26 @@ +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 +} diff --git a/store.go b/store.go new file mode 100644 index 0000000..f0abd14 --- /dev/null +++ b/store.go @@ -0,0 +1,59 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" +) + +// AMOResponse is the structure returned by the AMO search endpoint. +type AMOResponse struct { + Count int `json:"count"` + Results []Addon `json:"results"` +} + +// Addon holds basic information from the search endpoint. +type Addon struct { + ID int `json:"id"` + Name map[string]string `json:"name"` + Slug string `json:"slug"` + URL string `json:"url"` + CurrentVersion CurrentVersion `json:"current_version,omitempty"` +} + +// CurrentVersion holds details about the add-on's current version. +type CurrentVersion struct { + Files []AddonFile `json:"files"` + File *AddonFile `json:"file"` +} + +// AddonFile represents a file (typically the XPI) associated with an add-on. +type AddonFile struct { + URL string `json:"url"` +} + +// fetchAddonDetails retrieves detailed information for a specific add-on, including file details. +func fetchAddonDetails(slug string) (Addon, error) { + apiURL := fmt.Sprintf("https://addons.mozilla.org/api/v5/addons/addon/%s/?include=files", slug) + client := &http.Client{} + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return Addon{}, err + } + req.Header.Set("User-Agent", "MyCustomBrowser/1.0") + resp, err := client.Do(req) + if err != nil { + return Addon{}, err + } + defer resp.Body.Close() + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return Addon{}, err + } + var addon Addon + if err := json.Unmarshal(bodyBytes, &addon); err != nil { + return Addon{}, err + } + return addon, nil +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..af56e80 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,25 @@ + + + + + Spitfire Mod Store - Search + + +

Spitfire Mod Store

+
+ +

+ + +

+ +
+ + diff --git a/templates/store.html b/templates/store.html new file mode 100644 index 0000000..50121fb --- /dev/null +++ b/templates/store.html @@ -0,0 +1,54 @@ + + + + + Search Results + + + +

Search Results

+
    + {{range .}} + {{if eq .type "addon"}} + +
  • + {{.name}} icon + {{.name}} [{{.type}}] + + View +
  • + {{else if eq .type "statictheme"}} + +
  • + {{.name}} [{{.type}}] + View +
  • + {{else}} + +
  • + {{.name}} [{{.release}} - {{.type}}] + Download + ({{.os}}/{{.arch}}) +
  • + {{end}} + {{else}} +
  • No add-ons found.
  • + {{end}} +
+ + Back to Search + +