Init
This commit is contained in:
commit
d0187f94d7
23 changed files with 2489 additions and 0 deletions
15
go.mod
Normal file
15
go.mod
Normal file
|
@ -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
|
||||||
|
)
|
13
go.sum
Normal file
13
go.sum
Normal file
|
@ -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=
|
107
headers.go
Normal file
107
headers.go
Normal file
|
@ -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)
|
||||||
|
}
|
15
main.go
Normal file
15
main.go
Normal file
|
@ -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))
|
||||||
|
}
|
382
spm/appindex.go
Normal file
382
spm/appindex.go
Normal file
|
@ -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
|
||||||
|
}
|
252
spm/auto.go
Normal file
252
spm/auto.go
Normal file
|
@ -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
|
||||||
|
}
|
143
spm/decompress.go
Normal file
143
spm/decompress.go
Normal file
|
@ -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\<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, 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
|
||||||
|
}
|
144
spm/dirs.go
Normal file
144
spm/dirs.go
Normal file
|
@ -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
|
||||||
|
}
|
128
spm/download.go
Normal file
128
spm/download.go
Normal file
|
@ -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
|
||||||
|
}
|
7
spm/go.mod
Normal file
7
spm/go.mod
Normal file
|
@ -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
|
10
spm/go.sum
Normal file
10
spm/go.sum
Normal file
|
@ -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=
|
304
spm/install.go
Normal file
304
spm/install.go
Normal file
|
@ -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:<logFile>: 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)
|
||||||
|
}
|
214
spm/installed_pacakges.go
Normal file
214
spm/installed_pacakges.go
Normal file
|
@ -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)
|
||||||
|
// }
|
||||||
|
// }
|
31
spm/progress.go
Normal file
31
spm/progress.go
Normal 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
|
||||||
|
}
|
37
spm/register_unix.go
Normal file
37
spm/register_unix.go
Normal file
|
@ -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
|
||||||
|
}
|
147
spm/register_win.go
Normal file
147
spm/register_win.go
Normal file
|
@ -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
|
||||||
|
}
|
70
spm/run_unix.go
Normal file
70
spm/run_unix.go
Normal file
|
@ -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
|
||||||
|
}
|
64
spm/run_win.go
Normal file
64
spm/run_win.go
Normal file
|
@ -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
|
||||||
|
}
|
242
spm/search.go
Normal file
242
spm/search.go
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
26
spm/utils.go
Normal file
26
spm/utils.go
Normal file
|
@ -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
|
||||||
|
}
|
59
store.go
Normal file
59
store.go
Normal file
|
@ -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
|
||||||
|
}
|
25
templates/index.html
Normal file
25
templates/index.html
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Spitfire Mod Store - Search</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Spitfire Mod Store</h1>
|
||||||
|
<form action="/search" method="GET">
|
||||||
|
<input type="text" name="q" placeholder="Search add-ons" required>
|
||||||
|
<br><br>
|
||||||
|
<label for="filter">Search Type:</label>
|
||||||
|
<select name="filter" id="filter">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="addon">Extension</option>
|
||||||
|
<option value="theme">Theme</option>
|
||||||
|
<option value="bundle">Bundle</option>
|
||||||
|
<option value="layout">Layout</option>
|
||||||
|
<option value="config">Configuration</option>
|
||||||
|
</select>
|
||||||
|
<br><br>
|
||||||
|
<input type="submit" value="Search">
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
54
templates/store.html
Normal file
54
templates/store.html
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Search Results</title>
|
||||||
|
<style>
|
||||||
|
.theme-background {
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: rgb(37, 37, 37);
|
||||||
|
}
|
||||||
|
.addon-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Search Results</h1>
|
||||||
|
<ul>
|
||||||
|
{{range .}}
|
||||||
|
{{if eq .type "addon"}}
|
||||||
|
<!-- AMO add-on / extension: icon shown before the name -->
|
||||||
|
<li>
|
||||||
|
<img src="{{.icon}}" alt="{{.name}} icon" class="addon-icon">
|
||||||
|
<strong>{{.name}}</strong> [{{.type}}]
|
||||||
|
<!-- <a href="/install?slug={{.slug}}">Install</a> -->
|
||||||
|
<a href="{{.url}}" target="_blank">View</a>
|
||||||
|
</li>
|
||||||
|
{{else if eq .type "statictheme"}}
|
||||||
|
<!-- Theme: background set to the last screenshot -->
|
||||||
|
<li class="theme-background" style="background-image: url('{{last .screenshots}}');">
|
||||||
|
<strong>{{.name}}</strong> [{{.type}}]
|
||||||
|
<a href="{{.url}}" target="_blank">View</a>
|
||||||
|
</li>
|
||||||
|
{{else}}
|
||||||
|
<!-- SPM items -->
|
||||||
|
<li>
|
||||||
|
<strong>{{.name}}</strong> [{{.release}} - {{.type}}]
|
||||||
|
<a href="{{.downloadURL}}" target="_blank">Download</a>
|
||||||
|
({{.os}}/{{.arch}})
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<li>No add-ons found.</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<a href="/">Back to Search</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Add table
Add a link
Reference in a new issue