commit d9dae02ffc0b5a71222b42ae93efdbe58be18aab
Author: partisan <none@noone.no>
Date:   Mon Feb 3 15:52:19 2025 +0100

    Init

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7787b5e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+downloads/
+spitfire-luncher.exe
+spitfire-luncher
\ No newline at end of file
diff --git a/background.go b/background.go
new file mode 100644
index 0000000..f78221f
--- /dev/null
+++ b/background.go
@@ -0,0 +1,59 @@
+package main
+
+import (
+	"math/rand"
+	"time"
+
+	rl "github.com/gen2brain/raylib-go/raylib"
+)
+
+type Particle struct {
+	Pos  rl.Vector2
+	Vel  rl.Vector2
+	Size float32
+}
+
+var particles []Particle
+var topColor = rl.Color{R: 0x20, G: 0x0F, B: 0x3C, A: 0xFF}      // #200F3C top
+var bottomColor = rl.Color{R: 0x3B, G: 0x0B, B: 0x42, A: 0xFF}   // #3B0B42 bottom
+var particleColor = rl.Color{R: 0xD4, G: 0xB0, B: 0xB5, A: 0x80} // D4B0B5 with some transparency
+
+var rng = rand.New(rand.NewSource(time.Now().UnixNano())) // Local RNG
+
+func InitBackground(width, height int) {
+	particles = make([]Particle, 20)
+	for i := range particles {
+		particles[i].Pos = rl.Vector2{X: float32(rng.Intn(width)), Y: float32(rng.Intn(height))}
+		particles[i].Vel = rl.Vector2{X: (rng.Float32() - 0.5) * 0.2, Y: (rng.Float32() - 0.5) * 0.2}
+		particles[i].Size = rng.Float32()*1.5 + 0.5 // Particles size ~0.5-2.0
+	}
+}
+
+func UpdateBackground(screenWidth, screenHeight int) {
+	deltaTime := rl.GetFrameTime() // Time in seconds since the last frame
+	for i := range particles {
+		particles[i].Pos.X += particles[i].Vel.X * deltaTime * 60 // Adjust for frame rate
+		particles[i].Pos.Y += particles[i].Vel.Y * deltaTime * 60
+
+		// Wrap around screen
+		if particles[i].Pos.X < 0 {
+			particles[i].Pos.X += float32(screenWidth)
+		} else if particles[i].Pos.X > float32(screenWidth) {
+			particles[i].Pos.X -= float32(screenWidth)
+		}
+		if particles[i].Pos.Y < 0 {
+			particles[i].Pos.Y += float32(screenHeight)
+		} else if particles[i].Pos.Y > float32(screenHeight) {
+			particles[i].Pos.Y -= float32(screenHeight)
+		}
+	}
+}
+
+func DrawBackground(screenWidth, screenHeight int) {
+	// Draw vertical gradient
+	rl.DrawRectangleGradientV(0, 0, int32(screenWidth), int32(screenHeight), topColor, bottomColor)
+	// Draw particles
+	for _, p := range particles {
+		rl.DrawCircleV(p.Pos, p.Size, particleColor)
+	}
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..9020865
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,16 @@
+module spitfire-luncher
+
+go 1.23.4
+
+require github.com/gen2brain/raylib-go/raylib v0.0.0-20250109172833-6dbba4f81a9b
+
+require (
+	github.com/ebitengine/purego v0.7.1 // indirect
+	golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
+	golang.org/x/sys v0.20.0 // indirect
+	gopkg.in/ini.v1 v1.67.0 // indirect
+)
+
+replace spitfire-luncher/spm => ./spm
+
+require spitfire-luncher/spm v0.0.0
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..fcea25d
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,10 @@
+github.com/ebitengine/purego v0.7.1 h1:6/55d26lG3o9VCZX8lping+bZcmShseiqlh2bnUDiPA=
+github.com/ebitengine/purego v0.7.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
+github.com/gen2brain/raylib-go/raylib v0.0.0-20250109172833-6dbba4f81a9b h1:JJfspevP3YOXcSKVABizYOv++yMpTJIdPUtoDzF/RWw=
+github.com/gen2brain/raylib-go/raylib v0.0.0-20250109172833-6dbba4f81a9b/go.mod h1:BaY76bZk7nw1/kVOSQObPY1v1iwVE1KHAGMfvI6oK1Q=
+golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
+golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
+golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
diff --git a/gui.go b/gui.go
new file mode 100644
index 0000000..c7a34bb
--- /dev/null
+++ b/gui.go
@@ -0,0 +1,119 @@
+package main
+
+import (
+	"fmt"
+	"path/filepath"
+	"time"
+
+	rl "github.com/gen2brain/raylib-go/raylib"
+)
+
+// ShowUpdateWindow displays the update GUI.
+func ShowUpdateWindow() {
+	screenW := 300
+	screenH := 450
+	rl.SetConfigFlags(rl.FlagWindowUndecorated)
+	rl.InitWindow(int32(screenW), int32(screenH), "Updating Spitfire")
+
+	// 1) Use actual monitor refresh rate
+	monitor := rl.GetCurrentMonitor()
+	refreshRate := rl.GetMonitorRefreshRate(monitor)
+	rl.SetTargetFPS(int32(refreshRate))
+
+	InitBackground(screenW, screenH)
+
+	logoPath := filepath.Join(getSpmDir(), "logo.png")
+	logo := rl.LoadTexture(logoPath)
+	defer rl.UnloadTexture(logo)
+
+	var displayed float32
+	scale := float32(0.5)
+
+	var sf StateFile
+
+	sf = ReadState()
+
+	// 2) Update state asynchronously in a separate goroutine
+	go func() {
+		for {
+			sf = ReadState()
+			time.Sleep(1 * time.Second)
+		}
+	}()
+
+	for {
+		tgt := float32(sf.Progress)
+
+		if rl.WindowShouldClose() {
+			break
+		}
+
+		// Smooth interpolation for displayed progress
+		displayed += (tgt - displayed) * 0.15
+
+		rl.BeginDrawing()
+		rl.ClearBackground(rl.Black)
+
+		// 2 px inside border for visibility
+		rl.DrawRectangleLinesEx(
+			rl.Rectangle{X: 1, Y: 1, Width: float32(screenW - 2), Height: float32(screenH - 2)},
+			2,
+			rl.White,
+		)
+
+		UpdateBackground(screenW, screenH)
+		DrawBackground(screenW, screenH)
+
+		// Draw logo scaled
+		lw := float32(logo.Width) * scale
+		lx := float32(screenW)/2 - lw/2
+		ly := float32(20)
+		rl.DrawTextureEx(logo, rl.Vector2{X: lx, Y: ly}, 0, scale, rl.White)
+
+		// Progress bar
+		barW := float32(screenW - 60)
+		barH := float32(20)
+		barX := float32(30)
+		barY := float32(screenH/2 + 40)
+		frac := displayed / 100.0
+		if frac < 0 {
+			frac = 0
+		} else if frac > 1 {
+			frac = 1
+		}
+
+		fillRect := rl.Rectangle{X: barX, Y: barY, Width: barW * frac, Height: barH}
+		fullRect := rl.Rectangle{X: barX, Y: barY, Width: barW, Height: barH}
+		corner := float32(0.4)
+
+		rl.DrawRectangleRounded(fillRect, corner, 6, rl.RayWhite)
+		rl.DrawRectangleRoundedLines(fullRect, corner, 6, rl.White)
+
+		// Display status text
+		msg := updateStatusMsg
+		if !sf.IsUpdating {
+			msg = "Update complete!"
+		}
+		fontSize := int32(20)
+		txtW := rl.MeasureText(msg, fontSize)
+		txtX := (int32(screenW) - txtW) / 2
+		txtY := int32(barY) - 30
+		rl.DrawText(msg, txtX, txtY, fontSize, rl.White)
+
+		// Display numeric progress
+		progStr := fmt.Sprintf("%.0f%%", displayed)
+		pw := rl.MeasureText(progStr, 20)
+		px := (int32(screenW) - pw) / 2
+		py := int32(barY) + 30
+		rl.DrawText(progStr, px, py, 20, rl.White)
+
+		rl.EndDrawing()
+
+		// If no longer updating => break window loop
+		if !sf.IsUpdating {
+			break
+		}
+	}
+
+	rl.CloseWindow()
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..86ce9f8
--- /dev/null
+++ b/main.go
@@ -0,0 +1,263 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	// Import your SPM-based installer code
+	"spitfire-luncher/spm"
+)
+
+// -------------------------------------------------------------------
+// GLOBALS
+// -------------------------------------------------------------------
+var (
+	copyingInProgress int32
+	updateStatusMsg   string
+	browserCmd        *exec.Cmd
+	browserMutex      sync.Mutex // Protects the lock file map
+	isBackgroundMode  bool
+)
+
+// StateFile holds the update state from INI
+type StateFile struct {
+	IsUpdating bool
+	Progress   int32
+	Stage      string // "Idle","Downloading","Decompressing","ReadyToInstall","Installing","Complete","Failed"
+}
+
+// -------------------------------------------------------------------
+// LOG INIT
+// -------------------------------------------------------------------
+func init() {
+	log.SetFlags(0)
+	log.SetPrefix("[UPDATER] ")
+}
+
+// -------------------------------------------------------------------
+// INI Helpers
+// -------------------------------------------------------------------
+func getSpmDir() string {
+	installDir, err := spm.GetDefaultInstallDir()
+	if err != nil {
+		log.Println("Warning: Using '.' for spmDir =>", err)
+		return "."
+	}
+	spmDir := filepath.Join(installDir, "spm")
+	_ = os.MkdirAll(spmDir, 0755)
+	return spmDir
+}
+
+func getStateFilePath() string {
+	return filepath.Join(getSpmDir(), "update_state.ini")
+}
+
+func WriteState(isUpdating bool, progress int32, stage string) {
+	content := "[UpdateState]\n" +
+		fmt.Sprintf("IsUpdating=%v\n", isUpdating) +
+		fmt.Sprintf("Progress=%d\n", progress) +
+		fmt.Sprintf("Stage=%s\n", stage)
+	_ = os.WriteFile(getStateFilePath(), []byte(content), 0644)
+}
+
+func ReadState() StateFile {
+	sf := StateFile{}
+	data, err := os.ReadFile(getStateFilePath())
+	if err != nil {
+		return sf
+	}
+
+	lines := strings.Split(string(data), "\n")
+	for _, line := range lines {
+		line = strings.TrimSpace(line)
+		if line == "" || strings.HasPrefix(line, "[") {
+			continue
+		}
+		parts := strings.SplitN(line, "=", 2)
+		if len(parts) != 2 {
+			continue
+		}
+		key := strings.TrimSpace(parts[0])
+		val := strings.TrimSpace(parts[1])
+		switch key {
+		case "IsUpdating":
+			sf.IsUpdating = (val == "true")
+		case "Progress":
+			if i, errAtoi := strconv.Atoi(val); errAtoi == nil {
+				sf.Progress = int32(i)
+			}
+		case "Stage":
+			sf.Stage = val
+		}
+	}
+	return sf
+}
+
+// -------------------------------------------------------------------
+// BROWSER
+// -------------------------------------------------------------------
+// LaunchBrowser starts the browser and creates a unique lock file in the spmdir.
+func LaunchBrowser() {
+	spmDir := getSpmDir()
+
+	// Generate a unique lock file name (based on timestamp)
+	lockFileName := fmt.Sprintf("browser_lock_%d.lock", time.Now().UnixNano())
+	lockFilePath := filepath.Join(spmDir, lockFileName)
+
+	// Create the lock file
+	file, err := os.Create(lockFilePath)
+	if err != nil {
+		fmt.Println("Failed to create lock file:", err)
+		return
+	}
+	file.Close()
+
+	// Run the browser process and wait for it to finish
+	fmt.Println("Starting browser...")
+	err = spm.RunAndWait()
+	if err != nil {
+		fmt.Println("Browser process encountered an error:", err)
+	}
+
+	// Remove the lock file after the browser finishes
+	fmt.Printf("Removing lock file: %s\n", lockFilePath)
+	err = os.Remove(lockFilePath)
+	if err != nil {
+		fmt.Println("Failed to remove lock file:", err)
+	} else {
+		fmt.Println("Lock file removed successfully.")
+	}
+}
+
+// IsBrowserRunning checks if there are any browser lock files in the spmdir.
+func IsBrowserRunning() bool {
+	spmDir := getSpmDir()
+
+	// Look for lock files in the spmdir
+	lockFiles, err := filepath.Glob(filepath.Join(spmDir, "browser_lock_*.lock"))
+	if err != nil {
+		fmt.Println("Error checking for lock files:", err)
+		return false
+	}
+
+	// Return true if at least one lock file is found
+	return len(lockFiles) > 0
+}
+
+// -------------------------------------------------------------------
+// BACKGROUND SERVICE
+// -------------------------------------------------------------------
+func runBackgroundUpdater() {
+	// Delete the update_state.ini file on startup
+	stateFilePath := getStateFilePath()
+	if _, err := os.Stat(stateFilePath); err == nil {
+		err = os.Remove(stateFilePath)
+		if err != nil {
+			log.Printf("Failed to delete state file %s: %v\n", stateFilePath, err)
+		} else {
+			log.Printf("Deleted state file: %s\n", stateFilePath)
+		}
+	} else {
+		log.Printf("State file does not exist, no need to delete: %s\n", stateFilePath)
+	}
+
+	// Remove all lock files in the spmdir
+	spmDir := getSpmDir()
+	lockFilePattern := filepath.Join(spmDir, "browser_lock_*.lock")
+	lockFiles, err := filepath.Glob(lockFilePattern)
+	if err != nil {
+		log.Printf("Failed to scan for lock files in %s: %v\n", spmDir, err)
+	} else {
+		for _, lockFile := range lockFiles {
+			err := os.Remove(lockFile)
+			if err != nil {
+				log.Printf("Failed to delete lock file %s: %v\n", lockFile, err)
+			} else {
+				log.Printf("Deleted lock file: %s\n", lockFile)
+			}
+		}
+	}
+
+	log.Println("Background updater started (no GUI).")
+
+	// Start a separate goroutine to periodically update the progress in update_state.ini
+	go func() {
+		for {
+			// Get current progress and task from spm
+			progress, task := spm.GetProgress()
+
+			// Determine if the updater is in "Copying files to install directory" stage
+			isUpdating := task == "Copying files to install directory"
+
+			// Write the state to the update_state.ini file
+			WriteState(isUpdating, int32(progress), task)
+
+			// Sleep for 1 second before updating the progress again
+			time.Sleep(1 * time.Second)
+		}
+	}()
+
+	// Main loop for periodic AutoDownloadUpdates and AutoInstallUpdates
+	for {
+		// Run AutoDownloadUpdates every 10 seconds
+		err := spm.AutoDownloadUpdates()
+		if err != nil {
+			log.Printf("AutoDownloadUpdates failed: %v\n", err)
+			WriteState(false, 0, fmt.Sprintf("Update failed: %v", err))
+			time.Sleep(10 * time.Second)
+			continue
+		}
+
+		// Check if the browser is running
+		if IsBrowserRunning() {
+			log.Println("Browser is running. Waiting before installing updates.")
+			time.Sleep(10 * time.Second)
+			continue
+		}
+
+		// If the browser is not running, run AutoInstallUpdates
+		log.Println("\nBrowser is not running. Starting installation of updates.")
+		err = spm.AutoInstallUpdates()
+		if err != nil {
+			log.Printf("AutoInstallUpdates failed: %v\n", err)
+			WriteState(false, 0, fmt.Sprintf("Installation failed: %v", err))
+			time.Sleep(10 * time.Second)
+			continue
+		}
+
+		// Write to update_state.ini indicating updates are complete
+		WriteState(false, 100, "Complete")
+		log.Println("Updates installed successfully. Waiting before next check.")
+
+		// Wait 10 seconds before checking again
+		time.Sleep(10 * time.Second)
+	}
+}
+
+// -------------------------------------------------------------------
+// MAIN
+// -------------------------------------------------------------------
+func main() {
+	if len(os.Args) > 1 && os.Args[1] == "--update-service" {
+		isBackgroundMode = true
+		runBackgroundUpdater()
+		return
+	}
+
+	log.Println("Launcher started (foreground mode).")
+	sf := ReadState()
+	if sf.IsUpdating == true {
+		log.Println("Stage=Installing => show update window.")
+		ShowUpdateWindow()
+	} else {
+		LaunchBrowser()
+	}
+	fmt.Println("Exiting launcher.")
+}
diff --git a/spm/appindex.go b/spm/appindex.go
new file mode 100644
index 0000000..14a77be
--- /dev/null
+++ b/spm/appindex.go
@@ -0,0 +1,123 @@
+package spm
+
+import (
+	"bufio"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"strings"
+)
+
+const appIndexURL = "https://downloads.sourceforge.net/project/spitfire-browser/APPINDEX"
+
+func DownloadAppIndex(dest string) error {
+	UpdateProgress(0, "Downloading APPINDEX")
+	resp, err := http.Get(appIndexURL)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	out, err := os.Create(dest)
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+
+	totalSize := resp.ContentLength
+	var downloaded int64
+
+	// Track progress as bytes are downloaded
+	buf := make([]byte, 1024)
+	for {
+		n, err := resp.Body.Read(buf)
+		if n > 0 {
+			downloaded += int64(n)
+			percentage := int(float64(downloaded) / float64(totalSize) * 100)
+			UpdateProgress(percentage, "Downloading APPINDEX")
+			if _, err := out.Write(buf[:n]); err != nil {
+				return err
+			}
+		}
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return err
+		}
+	}
+
+	UpdateProgress(100, "APPINDEX downloaded")
+	return nil
+}
+
+type AppIndexEntry struct {
+	Name        string
+	Version     string
+	Release     string // "nightly" / "stable" / etc.
+	Arch        string // e.g. "amd64", "386"
+	OS          string // e.g. "windows", "linux"
+	Type        string // "browser", "addon", "theme", etc.
+	DownloadURL string
+}
+
+func ParseAppIndex(filePath string) ([]AppIndexEntry, error) {
+	file, err := os.Open(filePath)
+	if err != nil {
+		return nil, err
+	}
+	defer file.Close()
+
+	var entries []AppIndexEntry
+	scanner := bufio.NewScanner(file)
+	entry := AppIndexEntry{}
+
+	for scanner.Scan() {
+		line := scanner.Text()
+		if strings.HasPrefix(line, "C:") {
+			// Start of a new entry
+			if entry.Name != "" {
+				entries = append(entries, entry)
+				entry = AppIndexEntry{}
+			}
+		}
+
+		parts := strings.SplitN(line, ":", 2)
+		if len(parts) < 2 {
+			continue
+		}
+
+		key, value := parts[0], parts[1]
+		switch key {
+		case "P":
+			entry.Name = value
+		case "R":
+			entry.Release = value
+		case "V":
+			entry.Version = value
+		case "A":
+			entry.Arch = value
+		case "p":
+			entry.OS = value
+		case "d":
+			entry.DownloadURL = value
+		case "o":
+			entry.Type = value
+		}
+	}
+
+	// Append the last entry if any
+	if entry.Name != "" {
+		entries = append(entries, entry)
+	}
+
+	// Log all parsed entries
+	fmt.Printf("[INFO] Total parsed entries: %d\n", len(entries))
+	for _, e := range entries {
+		fmt.Printf(" - Name: %s, Release: %s, Type: %s, OS: %s, Arch: %s, Version: %s, URL: %s\n",
+			e.Name, e.Release, e.Type, e.OS, e.Arch, e.Version, e.DownloadURL)
+	}
+
+	return entries, scanner.Err()
+}
diff --git a/spm/auto.go b/spm/auto.go
new file mode 100644
index 0000000..51de9f4
--- /dev/null
+++ b/spm/auto.go
@@ -0,0 +1,157 @@
+package spm
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+)
+
+// pendingUpdates holds info about packages that have been downloaded/decompressed
+// but not yet moved to the final install location.
+var pendingUpdates []AppIndexEntry
+
+// AutoDownloadUpdates downloads the APPINDEX file, parses it, compares against
+// currently installed packages, and if it finds a newer version, downloads
+// and decompresses it into a temporary folder. The result is stored in pendingUpdates.
+func AutoDownloadUpdates() error {
+	// 1) Download the APPINDEX file to a temporary location
+	appIndexPath := filepath.Join(os.TempDir(), "APPINDEX")
+	fmt.Println("[INFO] Starting APPINDEX download to:", appIndexPath)
+	err := DownloadAppIndex(appIndexPath)
+	if err != nil {
+		return fmt.Errorf("[ERROR] Failed to download APPINDEX: %w", err)
+	}
+	fmt.Println("[INFO] APPINDEX downloaded successfully")
+
+	// 2) Parse the APPINDEX file
+	fmt.Println("[INFO] Parsing APPINDEX file:", appIndexPath)
+	entries, err := ParseAppIndex(appIndexPath)
+	if err != nil {
+		return fmt.Errorf("[ERROR] Failed to parse APPINDEX: %w", err)
+	}
+	fmt.Printf("[INFO] Parsed APPINDEX successfully, found %d entries\n", len(entries))
+
+	// 3) Load installed packages
+	fmt.Println("[INFO] Loading installed packages")
+	installDir, err := GetInstallDir()
+	if err != nil {
+		return err
+	}
+	fmt.Println("[INFO] Install directory:", installDir)
+
+	installedPkgs, err := loadInstalledPackages(installDir)
+	if err != nil {
+		return fmt.Errorf("[ERROR] Failed to load installed packages: %w", err)
+	}
+	fmt.Printf("[INFO] Loaded %d installed packages\n", len(installedPkgs))
+
+	// 4) Process entries for installed packages only
+	for _, installed := range installedPkgs {
+		fmt.Printf("[INFO] Checking updates for installed package: %+v\n", installed)
+
+		// Filter APPINDEX entries that match the installed package's attributes
+		var matchingEntry *AppIndexEntry
+		for _, entry := range entries {
+			if entry.Name == installed.Name &&
+				entry.Release == installed.Release &&
+				entry.Type == installed.Type &&
+				entry.OS == installed.OS &&
+				entry.Arch == installed.Arch {
+				matchingEntry = &entry
+				break
+			}
+		}
+
+		if matchingEntry == nil {
+			fmt.Printf("[WARN] No matching APPINDEX entry found for installed package: %s (%s)\n", installed.Name, installed.Release)
+			continue
+		}
+
+		fmt.Printf("[INFO] Found matching APPINDEX entry: %+v\n", *matchingEntry)
+
+		// Determine if an update is needed
+		updateNeeded, err := IsUpdateNeeded(installDir, matchingEntry.Name, matchingEntry.Release, matchingEntry.Version, matchingEntry.Arch, matchingEntry.OS)
+		if err != nil {
+			return fmt.Errorf("[ERROR] Failed to check if update is needed for %s: %w", matchingEntry.Name, err)
+		}
+
+		if !updateNeeded {
+			fmt.Printf("[INFO] No update needed for package '%s'\n", matchingEntry.Name)
+			continue
+		}
+
+		// 5) Download the package into a temporary download folder
+		downloadDir := GetTempDir()
+		fmt.Printf("[INFO] Downloading package '%s' to temporary folder: %s\n", matchingEntry.Name, downloadDir)
+
+		err = DownloadPackageFromAppIndex(appIndexPath, matchingEntry.Name, matchingEntry.Release, matchingEntry.Type, downloadDir)
+		if err != nil {
+			return fmt.Errorf("[ERROR] Failed to download package '%s': %w", matchingEntry.Name, err)
+		}
+
+		fmt.Printf("[INFO] Package '%s' downloaded successfully to: %s\n", matchingEntry.Name, downloadDir)
+
+		// 6) Decompress the package into another temp folder
+		fmt.Printf("[INFO] Decompressing package '%s'\n", matchingEntry.Name)
+		tempDir, err := DecompressPackage(downloadDir, matchingEntry.Name, matchingEntry.Arch, matchingEntry.OS, matchingEntry.Type, matchingEntry.Release, matchingEntry.Version)
+		if err != nil {
+			return fmt.Errorf("[ERROR] Failed to decompress package '%s': %w", matchingEntry.Name, err)
+		}
+		fmt.Printf("[INFO] Package '%s' decompressed successfully to: %s\n", matchingEntry.Name, tempDir)
+
+		// 7) Store in pendingUpdates so that AutoInstallUpdates can finish the job
+		fmt.Printf("[INFO] Adding '%s' to pending updates\n", matchingEntry.Name)
+		pendingUpdates = append(pendingUpdates, AppIndexEntry{
+			Name:    matchingEntry.Name,
+			Version: matchingEntry.Version,
+			Release: matchingEntry.Release,
+			Arch:    matchingEntry.Arch,
+			OS:      matchingEntry.OS,
+			Type:    matchingEntry.Type,
+		})
+	}
+
+	fmt.Println("[INFO] AutoDownloadUpdates completed successfully")
+	return nil
+}
+
+// AutoInstallUpdates installs any packages that were decompressed by AutoDownloadUpdates.
+// It moves files from their temp directories to the final location and updates installed.ini.
+func AutoInstallUpdates() error {
+	installDir, err := GetDefaultInstallDir()
+	if err != nil {
+		return err
+	}
+
+	for _, entry := range pendingUpdates {
+		// 1) Construct the same .tar.gz name we used when decompressing
+		fileName := fmt.Sprintf("%s@%s@%s@%s@%s@%s",
+			entry.Name,    // no 'packageName'
+			entry.Arch,    // matches 'arch'
+			entry.OS,      // matches 'os'
+			entry.Type,    // matches 'type'
+			entry.Release, // matches 'release'
+			entry.Version, // matches 'version'
+		)
+
+		// 3) Combine with your global temp dir
+		tempBase := GetTempDir() // e.g. C:\Users\YourUser\AppData\Local\Temp\spm_temp_164326
+		decompressedDir := filepath.Join(tempBase, fileName)
+
+		// 4) Move files from that decompressedDir
+		fmt.Printf("[INFO] Installing %s from %s\n", entry.Name, decompressedDir)
+		err := MoveFilesToInstallDir(decompressedDir, installDir, entry.Type)
+		if err != nil {
+			return fmt.Errorf("failed to move files for %s: %w", entry.Name, err)
+		}
+
+		// 5) Finalize
+		err = FinalizeInstall(entry.Name, entry.Release, entry.Version, entry.Arch, entry.OS)
+		if err != nil {
+			return fmt.Errorf("failed to finalize install for %s: %w", entry.Name, err)
+		}
+	}
+
+	pendingUpdates = nil
+	return nil
+}
diff --git a/spm/download.go b/spm/download.go
new file mode 100644
index 0000000..8609823
--- /dev/null
+++ b/spm/download.go
@@ -0,0 +1,220 @@
+package spm
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+	"runtime"
+	"time"
+)
+
+// func DownloadPackage(pkg Package, destDir string, version, release, pkgType string) error {
+// 	client := &http.Client{}
+
+// 	var resp *http.Response
+// 	var err error
+// 	for i := 0; i < 3; i++ { // Retry up to 3 times
+// 		fmt.Printf("[INFO] Attempting to download package from URL: %s (Attempt %d)\n", pkg.DownloadURL, i+1)
+// 		resp, err = client.Get(pkg.DownloadURL)
+// 		if err == nil && resp.StatusCode == http.StatusOK {
+// 			break
+// 		}
+// 		if err != nil {
+// 			fmt.Printf("[ERROR] Attempt %d failed: %v\n", i+1, err)
+// 		}
+// 		if resp != nil && resp.StatusCode != http.StatusOK {
+// 			fmt.Printf("[ERROR] Server returned status: %d\n", resp.StatusCode)
+// 		}
+// 		if i < 2 {
+// 			time.Sleep(2 * time.Second) // Delay between retries
+// 		}
+// 	}
+// 	if err != nil {
+// 		return fmt.Errorf("[ERROR] Failed to download %s after 3 retries: %w", pkg.Name, err)
+// 	}
+// 	defer resp.Body.Close()
+
+// 	// Check content type
+// 	contentType := resp.Header.Get("Content-Type")
+// 	if contentType != "application/gzip" && contentType != "application/x-tar" {
+// 		return fmt.Errorf("[ERROR] Invalid content type: %s. Expected a .tar.gz file.", contentType)
+// 	}
+
+// 	// Generate the filename using the desired format
+// 	filename := fmt.Sprintf("%s@%s@%s@%s@%s@%s.tar.gz",
+// 		pkg.Name, // Name of the package
+// 		pkg.Arch, // Architecture (e.g., amd64)
+// 		pkg.OS,   // Operating System (e.g., windows, linux)
+// 		pkgType,  // Type of the package (e.g., nightly, stable)
+// 		release,  // Release (e.g., nightly, stable)
+// 		version,  // Version of the package
+// 	)
+
+// 	// Construct the full file path
+// 	filePath := filepath.Join(destDir, filename)
+// 	fmt.Printf("[INFO] Saving package to: %s\n", filePath)
+
+// 	// Create the destination directory if it doesn't exist
+// 	err = os.MkdirAll(destDir, 0755)
+// 	if err != nil {
+// 		return fmt.Errorf("[ERROR] Failed to create destination directory %s: %w", destDir, err)
+// 	}
+
+// 	// Create the file to save the download
+// 	out, err := os.Create(filePath)
+// 	if err != nil {
+// 		return fmt.Errorf("[ERROR] Failed to create file %s: %w", filePath, err)
+// 	}
+// 	defer out.Close()
+
+// 	// Track download progress
+// 	totalSize := resp.ContentLength
+// 	var downloaded int64
+// 	buf := make([]byte, 1024)
+// 	for {
+// 		n, err := resp.Body.Read(buf)
+// 		if n > 0 {
+// 			downloaded += int64(n)
+// 			percentage := int(float64(downloaded) / float64(totalSize) * 100)
+// 			UpdateProgress(percentage, fmt.Sprintf("Downloading %s", pkg.Name))
+// 			if _, err := out.Write(buf[:n]); err != nil {
+// 				return fmt.Errorf("[ERROR] Failed to write to file %s: %w", filePath, err)
+// 			}
+// 		}
+// 		if err == io.EOF {
+// 			break
+// 		}
+// 		if err != nil {
+// 			return fmt.Errorf("[ERROR] Error reading response body: %w", err)
+// 		}
+// 	}
+
+// 	UpdateProgress(100, fmt.Sprintf("%s downloaded", pkg.Name))
+// 	fmt.Printf("[INFO] Package %s downloaded successfully to: %s\n", pkg.Name, filePath)
+
+// 	// Validate that the file is a valid gzip or tar file
+// 	if _, err := os.Stat(filePath); err != nil {
+// 		return fmt.Errorf("[ERROR] Downloaded file does not exist: %w", err)
+// 	}
+
+// 	return nil
+// }
+
+// DownloadPackageFromAppIndex selects and downloads the correct package from the APPINDEX.
+func DownloadPackageFromAppIndex(appIndexPath string, packageName string, release string, pkgType string, destDir string) error {
+	// Parse the APPINDEX
+	entries, err := ParseAppIndex(appIndexPath)
+	if err != nil {
+		return fmt.Errorf("failed to parse APPINDEX: %w", err)
+	}
+
+	// Find the right entry
+	var selected *AppIndexEntry
+	for _, e := range entries {
+		if e.Name == packageName &&
+			e.Release == release &&
+			e.Type == pkgType &&
+			e.OS == runtime.GOOS &&
+			e.Arch == runtime.GOARCH {
+			selected = &e
+			break
+		}
+	}
+
+	// Handle no matching entry
+	if selected == nil {
+		return fmt.Errorf("package not found in APPINDEX: %s (release: %s, type: %s, os: %s, arch: %s)", packageName, release, pkgType, runtime.GOOS, runtime.GOARCH)
+	}
+
+	// Check if the package is already installed and up-to-date
+	installDir, err := GetDefaultInstallDir()
+	if err != nil {
+		return fmt.Errorf("failed to get install directory: %w", err)
+	}
+	needsUpdate, err := IsUpdateNeeded(installDir, packageName, release, selected.Version, selected.Arch, selected.OS)
+	if err != nil {
+		return fmt.Errorf("failed to check update status: %w", err)
+	}
+	if !needsUpdate {
+		UpdateProgress(0, "Already up-to-date, skipping download.")
+		return nil // Skip download
+	}
+
+	// Download the package
+	UpdateProgress(0, fmt.Sprintf("Downloading %s %s (%s)...", packageName, selected.Version, selected.Type))
+	resp, err := http.Get(selected.DownloadURL)
+	if err != nil {
+		return fmt.Errorf("failed to download package: %w", err)
+	}
+	defer resp.Body.Close()
+
+	// Save the downloaded file
+	downloadedFileName := filepath.Base(selected.DownloadURL)
+	downloadedFilePath := filepath.Join(destDir, downloadedFileName)
+
+	out, err := os.OpenFile(downloadedFilePath, os.O_CREATE|os.O_WRONLY, 0644)
+	if err != nil {
+		return fmt.Errorf("failed to create output file: %w", err)
+	}
+	defer out.Close()
+
+	totalSize := resp.ContentLength
+	var downloaded int64
+	buf := make([]byte, 32*1024) // Use a larger buffer for efficiency
+
+	for {
+		n, errRead := resp.Body.Read(buf)
+		if n > 0 {
+			downloaded += int64(n)
+			percentage := int(float64(downloaded) / float64(totalSize) * 100)
+			UpdateProgress(percentage, fmt.Sprintf("Downloading %s %s (%s)...", packageName, selected.Version, selected.Type))
+			if _, errWrite := out.Write(buf[:n]); errWrite != nil {
+				return fmt.Errorf("failed to write to output file: %w", errWrite)
+			}
+		}
+		if errRead == io.EOF {
+			break
+		}
+		if errRead != nil {
+			return fmt.Errorf("error while reading response: %w", errRead)
+		}
+	}
+
+	// Ensure the file handle is closed before renaming
+	out.Close()
+
+	// Construct the expected filename
+	expectedFileName := fmt.Sprintf("%s@%s@%s@%s@%s@%s.tar.gz",
+		packageName, selected.Arch, selected.OS, selected.Type, selected.Release, selected.Version)
+
+	expectedFilePath := filepath.Join(destDir, expectedFileName)
+
+	// I dont know why is this happening, I dont want to know but sometimes some process is helding up the donwloaded files so thats why it retries here
+	maxRetries := 5
+	for i := 0; i < maxRetries; i++ {
+		err = os.Rename(downloadedFilePath, expectedFilePath)
+		if err == nil {
+			break
+		}
+
+		// Check if file is in use
+		f, checkErr := os.Open(downloadedFilePath)
+		if checkErr != nil {
+			return fmt.Errorf("file is locked by another process: %w", checkErr)
+		}
+		f.Close()
+
+		if i < maxRetries-1 {
+			time.Sleep(500 * time.Millisecond) // Wait before retrying
+		}
+	}
+
+	if err != nil {
+		return fmt.Errorf("failed to rename downloaded file after retries: %w", err)
+	}
+
+	UpdateProgress(100, fmt.Sprintf("Downloaded %s %s (%s).", packageName, selected.Version, selected.Type))
+	return nil
+}
diff --git a/spm/go.mod b/spm/go.mod
new file mode 100644
index 0000000..a12e946
--- /dev/null
+++ b/spm/go.mod
@@ -0,0 +1,5 @@
+module spm
+
+go 1.23.4
+
+require gopkg.in/ini.v1 v1.67.0 // indirect
diff --git a/spm/go.sum b/spm/go.sum
new file mode 100644
index 0000000..a8937af
--- /dev/null
+++ b/spm/go.sum
@@ -0,0 +1,2 @@
+gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
diff --git a/spm/install.go b/spm/install.go
new file mode 100644
index 0000000..053be30
--- /dev/null
+++ b/spm/install.go
@@ -0,0 +1,204 @@
+package spm
+
+import (
+	"archive/tar"
+	"compress/gzip"
+	"fmt"
+	"io"
+	"math/rand"
+	"os"
+	"path/filepath"
+)
+
+func DecompressToTemp(filePath string) (string, error) {
+	UpdateProgress(0, "Decompressing package")
+
+	// 1) Base temp dir
+	baseTempDir := GetTempDir()
+
+	// 2) Create a unique subfolder inside the base temp dir
+	subfolderName := fmt.Sprintf("spm_decompress_%d", rand.Intn(1000000))
+	decompressDir := filepath.Join(baseTempDir, subfolderName)
+	if err := os.MkdirAll(decompressDir, 0755); err != nil {
+		return "", fmt.Errorf("failed to create decompress dir: %w", err)
+	}
+
+	// 3) Open the tar.gz file
+	file, err := os.Open(filePath)
+	if err != nil {
+		return "", err
+	}
+	defer file.Close()
+
+	gzr, err := gzip.NewReader(file)
+	if err != nil {
+		return "", err
+	}
+	defer gzr.Close()
+
+	tarReader := tar.NewReader(gzr)
+
+	// 4) Count total files
+	var totalFiles, processedFiles int
+	for {
+		_, err := tarReader.Next()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return "", err
+		}
+		totalFiles++
+	}
+
+	// 5) Reset file position and tar reader
+	if _, err := file.Seek(0, io.SeekStart); err != nil {
+		return "", err
+	}
+	if err := gzr.Reset(file); err != nil {
+		return "", err
+	}
+	tarReader = tar.NewReader(gzr)
+
+	// 6) Extract into `decompressDir`
+	for {
+		header, err := tarReader.Next()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return "", err
+		}
+
+		targetPath := filepath.Join(decompressDir, header.Name)
+		switch header.Typeflag {
+		case tar.TypeDir:
+			if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil {
+				return "", err
+			}
+		case tar.TypeReg:
+			outFile, err := os.Create(targetPath)
+			if err != nil {
+				return "", err
+			}
+			if _, err := io.Copy(outFile, tarReader); err != nil {
+				outFile.Close()
+				return "", err
+			}
+			outFile.Close()
+		}
+
+		processedFiles++
+		UpdateProgress(int(float64(processedFiles)/float64(totalFiles)*100), "Decompressing package")
+	}
+
+	UpdateProgress(100, "Package decompressed")
+	return decompressDir, nil
+}
+
+func MoveFilesToInstallDir(tempDir, installDir, pkgType string) error {
+	// Ensure tempDir exists before processing
+	if _, err := os.Stat(tempDir); os.IsNotExist(err) {
+		return fmt.Errorf("tempDir does not exist: %s", tempDir)
+	}
+
+	// If the package type is "browser", set the subdirectory to "browser"
+	if pkgType == "browser" {
+		installDir = filepath.Join(installDir, "browser")
+	}
+
+	// Count total files to copy
+	var totalFiles, copiedFiles int
+	err := filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		if !info.IsDir() {
+			totalFiles++
+		}
+		return nil
+	})
+	if err != nil {
+		return err
+	}
+
+	// Copy files and track progress
+	err = filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+
+		relPath, err := filepath.Rel(tempDir, path)
+		if err != nil {
+			return err
+		}
+
+		targetPath := filepath.Join(installDir, relPath)
+		if info.IsDir() {
+			// Create directories in the install directory
+			if err := os.MkdirAll(targetPath, os.ModePerm); err != nil {
+				return err
+			}
+		} else {
+			// Copy files to the install directory
+			if err := copyFile(path, targetPath); err != nil {
+				return err
+			}
+			copiedFiles++
+			UpdateProgress(int(float64(copiedFiles)/float64(totalFiles)*100), "Copying files to install directory")
+		}
+		return nil
+	})
+
+	if err != nil {
+		return err
+	}
+
+	// Clean up temporary directory
+	UpdateProgress(100, "Cleaning up temporary files")
+	return os.RemoveAll(tempDir)
+}
+
+// copyFile copies the contents of the source file to the destination file.
+func copyFile(src, dst string) error {
+	sourceFile, err := os.Open(src)
+	if err != nil {
+		return err
+	}
+	defer sourceFile.Close()
+
+	// Create the destination file
+	destinationFile, err := os.Create(dst)
+	if err != nil {
+		return err
+	}
+	defer destinationFile.Close()
+
+	// Copy the file content
+	if _, err := io.Copy(destinationFile, sourceFile); err != nil {
+		return err
+	}
+
+	// Preserve file permissions
+	info, err := sourceFile.Stat()
+	if err != nil {
+		return err
+	}
+	return os.Chmod(dst, info.Mode())
+}
+
+// FinalizeInstall finalizes the installation by updating installed.ini.
+func FinalizeInstall(packageName, release, version, arch, osName string) error {
+	installDir, err := GetDefaultInstallDir()
+	if err != nil {
+		return err
+	}
+	pkgInfo := AppIndexEntry{
+		Name:    packageName,
+		Version: version,
+		Release: release,
+		Arch:    arch,
+		OS:      osName,
+	}
+	return UpdateInstalledPackage(installDir, pkgInfo)
+}
diff --git a/spm/installed_pacakges.go b/spm/installed_pacakges.go
new file mode 100644
index 0000000..1f89eae
--- /dev/null
+++ b/spm/installed_pacakges.go
@@ -0,0 +1,204 @@
+package spm
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strconv"
+	"strings"
+
+	"gopkg.in/ini.v1"
+)
+
+// getInstalledPackagesPath determines the path for the installed.ini file.
+func getInstalledPackagesPath(installDir string) string {
+	spmDir := filepath.Join(installDir, "spm")
+	_ = os.MkdirAll(spmDir, 0755)
+	return filepath.Join(spmDir, "installed.ini")
+}
+
+// loadInstalledPackages reads the installed.ini file and parses it.
+func loadInstalledPackages(installDir string) ([]AppIndexEntry, error) {
+	installedFile := getInstalledPackagesPath(installDir)
+
+	cfg, err := ini.LoadSources(ini.LoadOptions{AllowNonUniqueSections: true}, installedFile)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return []AppIndexEntry{}, nil // Return empty slice if file doesn't exist
+		}
+		return nil, err
+	}
+
+	var installed []AppIndexEntry
+	for _, section := range cfg.Sections() {
+		if section.Name() == "DEFAULT" {
+			continue
+		}
+
+		// Read fields
+		name := section.Key("P").String()
+		version := section.Key("V").String()
+		release := section.Key("R").String()
+		typeVal := section.Key("o").String()
+		arch := section.Key("A").MustString(runtime.GOARCH) // Default to system arch
+		osName := section.Key("p").MustString(runtime.GOOS) // Default to system OS
+
+		// Append to slice
+		installed = append(installed, AppIndexEntry{
+			Name:    name,
+			Version: version,
+			Release: release,
+			Type:    typeVal,
+			Arch:    arch,
+			OS:      osName,
+		})
+	}
+
+	return installed, nil
+}
+
+// saveInstalledPackages writes the installed packages into installed.ini.
+func saveInstalledPackages(installDir string, pkgs []AppIndexEntry) error {
+	installedFile := getInstalledPackagesPath(installDir)
+
+	cfg := ini.Empty()
+	for _, pkg := range pkgs {
+		section, err := cfg.NewSection(pkg.Name)
+		if err != nil {
+			return err
+		}
+		section.Key("P").SetValue(pkg.Name)
+		section.Key("V").SetValue(pkg.Version)
+		section.Key("R").SetValue(pkg.Release)
+		section.Key("o").SetValue(pkg.Type)
+	}
+
+	return cfg.SaveTo(installedFile)
+}
+
+// isNewerVersion compares two version strings and determines if `newVer` is newer than `oldVer`.
+func isNewerVersion(oldVer, newVer string) bool {
+	// Handle date-based versions (e.g., nightly: YYYY.MM.DD)
+	if isDateVersion(oldVer) && isDateVersion(newVer) {
+		return strings.Compare(newVer, oldVer) > 0
+	}
+
+	// Handle semantic versions (e.g., stable: v1.0.1)
+	if isSemVer(oldVer) && isSemVer(newVer) {
+		return compareSemVer(oldVer, newVer) > 0
+	}
+
+	// Fallback to lexicographical comparison for unknown formats
+	return strings.Compare(newVer, oldVer) > 0
+}
+
+// isDateVersion checks if a version string is in the format YYYY.MM.DD.
+func isDateVersion(version string) bool {
+	parts := strings.Split(version, ".")
+	if len(parts) != 3 {
+		return false
+	}
+	for _, part := range parts {
+		if _, err := strconv.Atoi(part); err != nil {
+			return false
+		}
+	}
+	return true
+}
+
+// isSemVer checks if a version string is in the format vMAJOR.MINOR.PATCH.
+func isSemVer(version string) bool {
+	if !strings.HasPrefix(version, "v") {
+		return false
+	}
+	parts := strings.Split(strings.TrimPrefix(version, "v"), ".")
+	if len(parts) != 3 {
+		return false
+	}
+	for _, part := range parts {
+		if _, err := strconv.Atoi(part); err != nil {
+			return false
+		}
+	}
+	return true
+}
+
+// compareSemVer compares two semantic version strings (vMAJOR.MINOR.PATCH).
+// Returns:
+// - 1 if `v2` is newer than `v1`
+// - 0 if `v1` and `v2` are equal
+// - -1 if `v1` is newer than `v2`
+func compareSemVer(v1, v2 string) int {
+	parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
+	parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
+
+	for i := 0; i < len(parts1); i++ {
+		num1, _ := strconv.Atoi(parts1[i])
+		num2, _ := strconv.Atoi(parts2[i])
+		if num1 > num2 {
+			return 1
+		}
+		if num1 < num2 {
+			return -1
+		}
+	}
+	return 0
+}
+
+// IsUpdateNeeded checks if the given package version is newer than what's installed.
+func IsUpdateNeeded(installDir, name, release, newVersion, arch, osName string) (bool, error) {
+	installed, err := loadInstalledPackages(installDir)
+	if err != nil {
+		return false, err
+	}
+
+	for _, pkg := range installed {
+		if pkg.Name == name && pkg.Release == release && pkg.Arch == arch && pkg.OS == osName {
+			fmt.Printf("Found installed package: %v\n", pkg)
+			if isNewerVersion(pkg.Version, newVersion) {
+				fmt.Println("Update is needed")
+				return true, nil
+			}
+			fmt.Println("No update needed")
+			return false, nil
+		}
+	}
+
+	fmt.Println("Package not installed, update needed")
+	return true, nil
+}
+
+// UpdateInstalledPackage writes/updates the new package version in installed.ini.
+func UpdateInstalledPackage(installDir string, pkg AppIndexEntry) error {
+	installed, err := loadInstalledPackages(installDir)
+	if err != nil {
+		return err
+	}
+
+	updated := false
+	for i, p := range installed {
+		if p.Name == pkg.Name && p.Release == pkg.Release && p.Arch == pkg.Arch && p.OS == pkg.OS {
+			installed[i].Version = pkg.Version
+			updated = true
+			break
+		}
+	}
+	if !updated {
+		installed = append(installed, pkg)
+	}
+
+	return saveInstalledPackages(installDir, installed)
+}
+
+// // DebugInstalled prints installed packages for debugging.
+// func DebugInstalled(installDir string) {
+// 	pkgs, err := loadInstalledPackages(installDir)
+// 	if err != nil {
+// 		fmt.Println("DebugInstalled error:", err)
+// 		return
+// 	}
+// 	for _, p := range pkgs {
+// 		fmt.Printf("Installed: %s v%s [%s] (arch=%s, os=%s)\n", p.Name, p.Version, p.Release, p.Arch, p.OS)
+// 	}
+// }
diff --git a/spm/progress.go b/spm/progress.go
new file mode 100644
index 0000000..c72e84e
--- /dev/null
+++ b/spm/progress.go
@@ -0,0 +1,31 @@
+package spm
+
+import (
+	"fmt"
+	"sync"
+)
+
+type Progress struct {
+	mu         sync.Mutex
+	percentage int
+	task       string
+}
+
+var progress = Progress{}
+
+// UpdateProgress sets the current percentage and task.
+func UpdateProgress(percentage int, task string) {
+	progress.mu.Lock()
+	defer progress.mu.Unlock()
+	progress.percentage = percentage
+	progress.task = task
+	fmt.Printf("\r[%3d%%] %s", percentage, task) // Print progress to the terminal
+	// Next line on 100% ?
+}
+
+// GetProgress returns the current progress state.
+func GetProgress() (int, string) {
+	progress.mu.Lock()
+	defer progress.mu.Unlock()
+	return progress.percentage, progress.task
+}
diff --git a/spm/run.go b/spm/run.go
new file mode 100644
index 0000000..d83619f
--- /dev/null
+++ b/spm/run.go
@@ -0,0 +1,70 @@
+package spm
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"runtime"
+)
+
+// Run locates and starts the installed Spitfire browser without waiting for it to exit.
+func Run() error {
+	installDir, err := GetInstallDir()
+	if err != nil {
+		return err
+	}
+
+	exePath := filepath.Join(installDir, "browser", "spitfire.exe")
+	if runtime.GOOS != "windows" {
+		exePath = filepath.Join(installDir, "browser", "spitfire")
+	}
+
+	cmd := exec.Command(exePath)
+	return cmd.Start()
+}
+
+// RunAndWait locates and starts the installed Spitfire browser and waits for it to exit.
+func RunAndWait() error {
+	installDir, err := GetInstallDir()
+	if err != nil {
+		return fmt.Errorf("failed to get install directory: %w", err)
+	}
+
+	// Construct the browser executable path
+	exePath := filepath.Join(installDir, "browser", "spitfire.exe")
+	if runtime.GOOS != "windows" {
+		exePath = filepath.Join(installDir, "browser", "spitfire")
+	}
+
+	// Check if the browser executable exists
+	if _, err := os.Stat(exePath); err != nil {
+		return fmt.Errorf("browser executable not found at %s: %w", exePath, err)
+	}
+
+	// Create the command
+	cmd := exec.Command(exePath)
+
+	// Attach standard output and error for debugging
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+
+	// Start the browser process
+	fmt.Printf("Starting browser: %s\n", exePath)
+	err = cmd.Start()
+	if err != nil {
+		return fmt.Errorf("failed to start browser: %w", err)
+	}
+
+	// Print the PID for debugging
+	fmt.Printf("Browser process started with PID %d\n", cmd.Process.Pid)
+
+	// Wait for the process to exit
+	err = cmd.Wait()
+	if err != nil {
+		return fmt.Errorf("browser exited with error: %w", err)
+	}
+
+	fmt.Println("Browser exited successfully.")
+	return nil
+}
diff --git a/spm/tempdir.go b/spm/tempdir.go
new file mode 100644
index 0000000..7b7644c
--- /dev/null
+++ b/spm/tempdir.go
@@ -0,0 +1,32 @@
+package spm
+
+import (
+	"fmt"
+	"math/rand"
+	"os"
+	"path/filepath"
+	"sync"
+	"time"
+)
+
+var (
+	tempDirOnce sync.Once
+	tempDirPath string
+)
+
+// GetTempDir generates or retrieves a unique temp directory.
+func GetTempDir() string {
+	tempDirOnce.Do(func() {
+		// Generate a unique temp directory name
+		rand.Seed(time.Now().UnixNano())
+		tempDirPath = filepath.Join(os.TempDir(), fmt.Sprintf("spm_temp_%d", rand.Intn(1000000)))
+
+		// Ensure the directory exists
+		if err := os.MkdirAll(tempDirPath, os.ModePerm); err != nil {
+			fmt.Printf("[ERROR] Failed to create temp directory: %v\n", err)
+		} else {
+			fmt.Printf("[INFO] Using temp directory: %s\n", tempDirPath)
+		}
+	})
+	return tempDirPath
+}
diff --git a/spm/utils.go b/spm/utils.go
new file mode 100644
index 0000000..4a03b31
--- /dev/null
+++ b/spm/utils.go
@@ -0,0 +1,224 @@
+package spm
+
+import (
+	"archive/tar"
+	"compress/gzip"
+	"fmt"
+	"io"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"sync"
+)
+
+// global mutable variable to store the chosen install directory
+var (
+	mu           sync.Mutex
+	installedDir string
+	envVar       = "SPITFIRE_INSTALL_DIR" // Environment variable name
+)
+
+// SetInstallDir sets the global install directory variable and updates the persistent environment variable.
+func SetInstallDir(path string) error {
+	mu.Lock()
+	defer mu.Unlock()
+
+	installedDir = path
+
+	// Persist the environment variable on Windows
+	if runtime.GOOS == "windows" {
+		err := persistSystemEnvVar(envVar, path)
+		if err != nil {
+			return err
+		}
+	} else {
+		// For non-Windows platforms, just set it in the current process environment
+		err := os.Setenv(envVar, path)
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// GetInstallDir returns the currently set install directory if available;
+// otherwise, it calls GetDefaultInstallDir() and sets that.
+func GetInstallDir() (string, error) {
+	mu.Lock()
+	defer mu.Unlock()
+
+	// If already set, return it
+	if installedDir != "" {
+		return installedDir, nil
+	}
+
+	// Check if it's stored in the system environment variable
+	if envDir := os.Getenv(envVar); envDir != "" {
+		installedDir = envDir
+		return installedDir, nil
+	}
+
+	// Compute and store the default directory if not already set
+	defDir, err := GetDefaultInstallDir()
+	if err != nil {
+		return "", err
+	}
+	installedDir = defDir
+
+	// Persist the default directory as an environment variable on Windows
+	if runtime.GOOS == "windows" {
+		_ = persistSystemEnvVar(envVar, defDir)
+	} else {
+		_ = os.Setenv(envVar, defDir)
+	}
+
+	return defDir, nil
+}
+
+// persistSystemEnvVar sets a persistent environment variable on Windows using the `setx` command.
+func persistSystemEnvVar(key, value string) error {
+	cmd := exec.Command("cmd", "/C", "setx", key, value)
+	err := cmd.Run()
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+// GetDefaultInstallDir generates the default installation directory
+// based on the OS and environment, then also sets it via SetInstallDir.
+func GetDefaultInstallDir() (string, error) {
+	var installDir string
+
+	switch runtime.GOOS {
+	case "windows":
+		programFiles := os.Getenv("ProgramFiles")
+		if programFiles == "" {
+			return "", fmt.Errorf("unable to determine default install directory on Windows")
+		}
+		installDir = filepath.Join(programFiles, "Spitfire")
+
+	case "darwin":
+		// Use ~/Library/Application Support on macOS
+		homeDir, err := os.UserHomeDir()
+		if err != nil {
+			return "", fmt.Errorf("unable to determine home directory on macOS: %w", err)
+		}
+		installDir = filepath.Join(homeDir, "Library", "Application Support", "Spitfire")
+
+	case "linux":
+		// Use ~/.local/share/Spitfire on Linux
+		homeDir, err := os.UserHomeDir()
+		if err != nil {
+			return "", fmt.Errorf("unable to determine home directory on Linux: %w", err)
+		}
+		installDir = filepath.Join(homeDir, ".local", "share", "Spitfire")
+
+	default:
+		return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
+	}
+
+	// Also store it globally so future calls to GetInstallDir() return the same
+	SetInstallDir(installDir)
+	return installDir, nil
+}
+
+// SetDownloadFolder ensures customDir exists, returns it
+func SetDownloadFolder(customDir string) (string, error) {
+	if err := os.MkdirAll(customDir, os.ModePerm); err != nil {
+		return "", err
+	}
+	return customDir, nil
+}
+
+// IsMatchingEntry checks if a package entry matches the requested specs
+func IsMatchingEntry(e AppIndexEntry, name, release, arch, osName, pkgType string) bool {
+	return e.Name == name &&
+		e.Release == release &&
+		e.Arch == arch &&
+		e.OS == osName &&
+		e.Type == pkgType
+}
+
+// DecompressPackage determines the package format and decompresses it
+// DecompressPackage: uses a consistent folder name based on "expectedFileName".
+func DecompressPackage(downloadDir, packageName, arch, osName, pkgType, release, version string) (string, error) {
+	// 1) Construct the .tar.gz name
+	expectedFileName := fmt.Sprintf(
+		"%s@%s@%s@%s@%s@%s.tar.gz",
+		packageName, arch, osName, pkgType, release, version,
+	)
+	packagePath := filepath.Join(downloadDir, expectedFileName)
+
+	// Check that file exists
+	if _, err := os.Stat(packagePath); os.IsNotExist(err) {
+		return "", fmt.Errorf("package file not found: %s", packagePath)
+	}
+
+	// 2) Build the folder path (minus ".tar.gz")
+	folderName := strings.TrimSuffix(expectedFileName, ".tar.gz")
+	tempDir := GetTempDir() // e.g. C:\Users\<User>\AppData\Local\Temp\spm_temp_164326
+	decompressDir := filepath.Join(tempDir, folderName)
+
+	// Ensure the folder
+	if err := os.MkdirAll(decompressDir, 0755); err != nil {
+		return "", fmt.Errorf("failed to create decompressDir: %w", err)
+	}
+
+	// 3) Decompress everything into `decompressDir`
+	if err := decompressTarGz(packagePath, decompressDir); err != nil {
+		return "", fmt.Errorf("failed to decompress: %w", err)
+	}
+
+	// Return the folder path we used
+	return decompressDir, nil
+}
+
+func decompressTarGz(srcFile, destDir string) error {
+	f, err := os.Open(srcFile)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+
+	gzr, err := gzip.NewReader(f)
+	if err != nil {
+		return err
+	}
+	defer gzr.Close()
+
+	tarReader := tar.NewReader(gzr)
+	for {
+		header, err := tarReader.Next()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return err
+		}
+
+		outPath := filepath.Join(destDir, header.Name)
+		switch header.Typeflag {
+		case tar.TypeDir:
+			if err := os.MkdirAll(outPath, os.FileMode(header.Mode)); err != nil {
+				return err
+			}
+		case tar.TypeReg:
+			outFile, err := os.Create(outPath)
+			if err != nil {
+				return err
+			}
+			_, err = io.Copy(outFile, tarReader)
+			outFile.Close()
+			if err != nil {
+				return err
+			}
+		default:
+			// handle symlinks etc. if needed
+		}
+	}
+	return nil
+}