From 6d50c30e1dccaa64c29a31f9f39c579d9022784e Mon Sep 17 00:00:00 2001
From: partisan <none@noone.no>
Date: Tue, 25 Feb 2025 20:15:39 +0100
Subject: [PATCH] Init

---
 appindex.go           | 123 +++++++++++++++++
 auto.go               | 261 ++++++++++++++++++++++++++++++++++++
 decompress.go         | 139 +++++++++++++++++++
 dirs.go               | 144 ++++++++++++++++++++
 download.go           | 128 ++++++++++++++++++
 go.mod                |   7 +
 go.sum                |  10 ++
 install.go            | 304 ++++++++++++++++++++++++++++++++++++++++++
 installed_pacakges.go | 204 ++++++++++++++++++++++++++++
 progress.go           |  31 +++++
 register_unix.go      |  17 +++
 register_win.go       | 135 +++++++++++++++++++
 run_unix.go           |  74 ++++++++++
 run_win.go            |  68 ++++++++++
 utils.go              |  26 ++++
 15 files changed, 1671 insertions(+)
 create mode 100644 appindex.go
 create mode 100644 auto.go
 create mode 100644 decompress.go
 create mode 100644 dirs.go
 create mode 100644 download.go
 create mode 100644 go.mod
 create mode 100644 go.sum
 create mode 100644 install.go
 create mode 100644 installed_pacakges.go
 create mode 100644 progress.go
 create mode 100644 register_unix.go
 create mode 100644 register_win.go
 create mode 100644 run_unix.go
 create mode 100644 run_win.go
 create mode 100644 utils.go

diff --git a/appindex.go b/appindex.go
new file mode 100644
index 0000000..14a77be
--- /dev/null
+++ b/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/auto.go b/auto.go
new file mode 100644
index 0000000..92e574a
--- /dev/null
+++ b/auto.go
@@ -0,0 +1,261 @@
+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, cuz Windows has this stupid file locking mechanism
+var pendingUpdates []AppIndexEntry
+
+// AutoDownloadUpdates downloads the APPINDEX file, parses it, compares against
+// currently installed packages, and if it finds a newer version, downloads
+// and decompresses it into a temporary folder. The result is stored in pendingUpdates, so it can be used by AutoInstallUpdates().
+func AutoDownloadUpdates() error {
+	// 1) Download the APPINDEX file to a temporary location
+	appIndexPath := filepath.Join(os.TempDir(), "APPINDEX")
+	fmt.Println("[INFO] Starting APPINDEX download to:", appIndexPath)
+	err := DownloadAppIndex(appIndexPath)
+	if err != nil {
+		return fmt.Errorf("[ERROR] Failed to download APPINDEX: %w", err)
+	}
+	fmt.Println("[INFO] APPINDEX downloaded successfully")
+
+	// 2) Parse the APPINDEX file
+	fmt.Println("[INFO] Parsing APPINDEX file:", appIndexPath)
+	entries, err := ParseAppIndex(appIndexPath)
+	if err != nil {
+		return fmt.Errorf("[ERROR] Failed to parse APPINDEX: %w", err)
+	}
+	fmt.Printf("[INFO] Parsed APPINDEX successfully, found %d entries\n", len(entries))
+
+	// 3) Load installed packages
+	fmt.Println("[INFO] Loading installed packages")
+	installDir, err := GetInstallDir()
+	if err != nil {
+		return err
+	}
+	fmt.Println("[INFO] Install directory:", installDir)
+
+	installedPkgs, err := loadInstalledPackages(installDir)
+	if err != nil {
+		return fmt.Errorf("[ERROR] Failed to load installed packages: %w", err)
+	}
+	fmt.Printf("[INFO] Loaded %d installed packages\n", len(installedPkgs))
+
+	// 4) Process entries for installed packages only
+	for _, installed := range installedPkgs {
+		fmt.Printf("[INFO] Checking updates for installed package: %+v\n", installed)
+
+		// Filter APPINDEX entries that match the installed package's attributes
+		var matchingEntry *AppIndexEntry
+		for _, entry := range entries {
+			if entry.Name == installed.Name &&
+				entry.Release == installed.Release &&
+				entry.Type == installed.Type &&
+				entry.OS == installed.OS &&
+				entry.Arch == installed.Arch {
+				matchingEntry = &entry
+				break
+			}
+		}
+
+		if matchingEntry == nil {
+			fmt.Printf("[WARN] No matching APPINDEX entry found for installed package: %s (%s)\n", installed.Name, installed.Release)
+			continue
+		}
+
+		fmt.Printf("[INFO] Found matching APPINDEX entry: %+v\n", *matchingEntry)
+
+		// Determine if an update is needed
+		updateNeeded, err := IsUpdateNeeded(installDir, matchingEntry.Name, matchingEntry.Release, matchingEntry.Version, matchingEntry.Arch, matchingEntry.OS)
+		if err != nil {
+			return fmt.Errorf("[ERROR] Failed to check if update is needed for %s: %w", matchingEntry.Name, err)
+		}
+
+		if !updateNeeded {
+			fmt.Printf("[INFO] No update needed for package '%s'\n", matchingEntry.Name)
+			continue
+		}
+
+		// 5) Download the package into a temporary download folder
+		downloadDir := GetTempDir()
+		fmt.Printf("[INFO] Downloading package '%s' to temporary folder: %s\n", matchingEntry.Name, downloadDir)
+
+		err = DownloadPackageFromAppIndex(appIndexPath, matchingEntry.Name, matchingEntry.Release, matchingEntry.Type, downloadDir)
+		if err != nil {
+			return fmt.Errorf("[ERROR] Failed to download package '%s': %w", matchingEntry.Name, err)
+		}
+
+		fmt.Printf("[INFO] Package '%s' downloaded successfully to: %s\n", matchingEntry.Name, downloadDir)
+
+		// 6) Decompress the package into another temp folder
+		fmt.Printf("[INFO] Decompressing package '%s'\n", matchingEntry.Name)
+		tempDir, err := DecompressPackage(downloadDir, matchingEntry.Name, matchingEntry.Arch, matchingEntry.OS, matchingEntry.Type, matchingEntry.Release, matchingEntry.Version)
+		if err != nil {
+			return fmt.Errorf("[ERROR] Failed to decompress package '%s': %w", matchingEntry.Name, err)
+		}
+		fmt.Printf("[INFO] Package '%s' decompressed successfully to: %s\n", matchingEntry.Name, tempDir)
+
+		// 7) Store in pendingUpdates so that AutoInstallUpdates can finish the job
+		fmt.Printf("[INFO] Adding '%s' to pending updates\n", matchingEntry.Name)
+		pendingUpdates = append(pendingUpdates, AppIndexEntry{
+			Name:    matchingEntry.Name,
+			Version: matchingEntry.Version,
+			Release: matchingEntry.Release,
+			Arch:    matchingEntry.Arch,
+			OS:      matchingEntry.OS,
+			Type:    matchingEntry.Type,
+		})
+	}
+
+	fmt.Println("[INFO] AutoDownloadUpdates completed successfully")
+	return nil
+}
+
+// AutoInstallUpdates installs any packages that were downloaded and decompressed by AutoDownloadUpdates.
+// It moves files from their temp directories to the final location and updates installed.ini.
+func AutoInstallUpdates() error {
+	installDir, err := GetInstallDir()
+	if err != nil {
+		return err
+	}
+
+	for _, entry := range pendingUpdates {
+		// 1) Construct the same .tar.gz name we used when decompressing
+		fileName := fmt.Sprintf("%s@%s@%s@%s@%s@%s",
+			entry.Name,    // no 'packageName'
+			entry.Arch,    // matches 'arch'
+			entry.OS,      // matches 'os'
+			entry.Type,    // matches 'type'
+			entry.Release, // matches 'release'
+			entry.Version, // matches 'version'
+		)
+
+		// 3) Combine with your global temp dir
+		tempBase := GetTempDir() // e.g. C:\Users\YourUser\AppData\Local\Temp\spm_temp_164326
+		decompressedDir := filepath.Join(tempBase, fileName)
+
+		// 4) Move files from that decompressedDir
+		fmt.Printf("[INFO] Installing %s from %s\n", entry.Name, decompressedDir)
+		err := MoveFilesToInstallDir(decompressedDir, installDir, entry.Type)
+		if err != nil {
+			return fmt.Errorf("failed to move files for %s: %w", entry.Name, err)
+		}
+
+		// 5) Finalize
+		err = FinalizeInstall(entry.Name, entry.Release, entry.Version, entry.Arch, entry.OS)
+		if err != nil {
+			return fmt.Errorf("failed to finalize install for %s: %w", entry.Name, err)
+		}
+	}
+
+	pendingUpdates = nil
+	return nil
+}
+
+func AutoDownloadSpecified(specs []AppIndexEntry) error {
+	// 1) Download the APPINDEX file to a temporary location
+	appIndexPath := filepath.Join(os.TempDir(), "APPINDEX")
+	fmt.Println("[INFO] Starting APPINDEX download to:", appIndexPath)
+	if err := DownloadAppIndex(appIndexPath); err != nil {
+		return fmt.Errorf("[ERROR] Failed to download APPINDEX: %w", err)
+	}
+	fmt.Println("[INFO] APPINDEX downloaded successfully")
+
+	// 2) Parse the APPINDEX file
+	fmt.Println("[INFO] Parsing APPINDEX file:", appIndexPath)
+	entries, err := ParseAppIndex(appIndexPath)
+	if err != nil {
+		return fmt.Errorf("[ERROR] Failed to parse APPINDEX: %w", err)
+	}
+	fmt.Printf("[INFO] Parsed APPINDEX successfully, found %d entries\n", len(entries))
+
+	// 3) Get install directory to check for updates
+	installDir, err := GetInstallDir()
+	if err != nil {
+		return err
+	}
+	fmt.Println("[INFO] Install directory:", installDir)
+
+	// 4) For each item in the passed specs, attempt to download if update is needed
+	for _, spec := range specs {
+		fmt.Printf("[INFO] Checking requested package: %+v\n", spec)
+
+		// Find matching entry from the parsed APPINDEX
+		var matchingEntry *AppIndexEntry
+		for _, e := range entries {
+			if e.Name == spec.Name &&
+				e.Release == spec.Release &&
+				e.Type == spec.Type &&
+				e.OS == spec.OS &&
+				e.Arch == spec.Arch {
+				matchingEntry = &e
+				break
+			}
+		}
+
+		if matchingEntry == nil {
+			fmt.Printf("[WARN] No matching APPINDEX entry found for package: %s (%s)\n", spec.Name, spec.Release)
+			continue
+		}
+		fmt.Printf("[INFO] Found matching APPINDEX entry: %+v\n", *matchingEntry)
+
+		// // Check if an update is needed
+		// updateNeeded, err := IsUpdateNeeded(
+		// 	installDir,
+		// 	matchingEntry.Name,
+		// 	matchingEntry.Release,
+		// 	matchingEntry.Version,
+		// 	matchingEntry.Arch,
+		// 	matchingEntry.OS,
+		// )
+		// if err != nil {
+		// 	return fmt.Errorf("[ERROR] Failed to check if update is needed for %s: %w", matchingEntry.Name, err)
+		// }
+
+		// if !updateNeeded {
+		// 	fmt.Printf("[INFO] No update needed for package '%s'\n", matchingEntry.Name)
+		// 	continue
+		// }
+
+		// 5) Download the package
+		downloadDir := GetTempDir()
+		fmt.Printf("[INFO] Downloading package '%s' to temporary folder: %s\n", matchingEntry.Name, downloadDir)
+		if err := DownloadPackageFromAppIndex(
+			appIndexPath,
+			matchingEntry.Name,
+			matchingEntry.Release,
+			matchingEntry.Type,
+			downloadDir,
+		); err != nil {
+			return fmt.Errorf("[ERROR] Failed to download package '%s': %w", matchingEntry.Name, err)
+		}
+		fmt.Printf("[INFO] Package '%s' downloaded successfully to: %s\n", matchingEntry.Name, downloadDir)
+
+		// 6) Decompress the package
+		fmt.Printf("[INFO] Decompressing package '%s'\n", matchingEntry.Name)
+		tempDir, err := DecompressPackage(
+			downloadDir,
+			matchingEntry.Name,
+			matchingEntry.Arch,
+			matchingEntry.OS,
+			matchingEntry.Type,
+			matchingEntry.Release,
+			matchingEntry.Version,
+		)
+		if err != nil {
+			return fmt.Errorf("[ERROR] Failed to decompress package '%s': %w", matchingEntry.Name, err)
+		}
+		fmt.Printf("[INFO] Package '%s' decompressed successfully to: %s\n", matchingEntry.Name, tempDir)
+
+		// 7) Store in pendingUpdates for AutoInstallUpdates
+		fmt.Printf("[INFO] Adding '%s' to pending updates\n", matchingEntry.Name)
+		pendingUpdates = append(pendingUpdates, *matchingEntry)
+	}
+
+	fmt.Println("[INFO] AutoDownloadSpecifiedPackages completed successfully")
+	return nil
+}
diff --git a/decompress.go b/decompress.go
new file mode 100644
index 0000000..7f7a207
--- /dev/null
+++ b/decompress.go
@@ -0,0 +1,139 @@
+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)
+			updateProgress(percent, fmt.Sprintf("Extracted: %s", header.Name))
+		}
+	}
+
+	// Final update: extraction complete.
+	if updateProgress != nil {
+		updateProgress(100, "Extraction complete")
+	}
+
+	return nil
+}
+
+// ProgressReader wraps an io.Reader to count bytes and update progress.
+type ProgressReader struct {
+	io.Reader
+	Total     int64
+	BytesRead int64
+	Callback  func(int, string)
+}
+
+func (pr *ProgressReader) Read(p []byte) (int, error) {
+	n, err := pr.Reader.Read(p)
+	pr.BytesRead += int64(n)
+	if pr.Callback != nil && pr.Total > 0 {
+		percent := int((pr.BytesRead * 100) / pr.Total)
+		pr.Callback(percent, "Decompressing...")
+	}
+	return n, err
+}
diff --git a/dirs.go b/dirs.go
new file mode 100644
index 0000000..ec62c24
--- /dev/null
+++ b/dirs.go
@@ -0,0 +1,144 @@
+package spm
+
+import (
+	"fmt"
+	"math/rand"
+	"os"
+	"path/filepath"
+	"runtime"
+	"sync"
+)
+
+// global sync and variable for generated temp dir
+var (
+	tempDirOnce sync.Once
+	tempDirPath string
+)
+
+// global variables for install dir
+var (
+	installMu     sync.Mutex
+	installedDir  string
+	installEnvVar = "SPITFIRE_INSTALL_DIR"
+)
+
+// GetTempDir generates or retrieves a unique temp dir.
+func GetTempDir() string {
+	tempDirOnce.Do(func() {
+		// Generate a unique temp dir name
+		tempDirPath = filepath.Join(os.TempDir(), fmt.Sprintf("spm_temp_%d", rand.Intn(1000000)))
+
+		// Ensure the dir exists
+		if err := os.MkdirAll(tempDirPath, os.ModePerm); err != nil {
+			fmt.Printf("[ERROR] Failed to create temp directory: %v\n", err)
+		} else {
+			fmt.Printf("[INFO] Using temp directory: %s\n", tempDirPath)
+		}
+	})
+	return tempDirPath
+}
+
+// GetDefaultInstallDir generates the default installation dir
+// based on the OS and environment, then also sets it via SetInstallDir.
+//
+// Please use GetInstallDir() instead of GetDefaultInstallDir() when interacting with spm.
+func GetDefaultInstallDir() (string, error) {
+	var installDir string
+
+	switch runtime.GOOS {
+	case "windows":
+		// Use C:\Program Files
+		programFiles := os.Getenv("ProgramFiles")
+		if programFiles == "" {
+			return "", fmt.Errorf("unable to determine default install directory on Windows")
+		}
+		installDir = filepath.Join(programFiles, "Spitfire")
+
+	case "darwin":
+		// Use ~/Library/Application Support on macOS
+		homeDir, err := os.UserHomeDir()
+		if err != nil {
+			return "", fmt.Errorf("unable to determine home directory on macOS: %w", err)
+		}
+		installDir = filepath.Join(homeDir, "Library", "Application Support", "Spitfire")
+
+	case "linux":
+		// Use ~/.local/share/Spitfire on Linux
+		homeDir, err := os.UserHomeDir()
+		if err != nil {
+			return "", fmt.Errorf("unable to determine home directory on Linux: %w", err)
+		}
+		installDir = filepath.Join(homeDir, ".local", "share", "Spitfire")
+
+	default:
+		return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
+	}
+
+	// Also store it globally so future calls to GetInstallDir() return the same
+	SetInstallDir(installDir)
+	return installDir, nil
+}
+
+// SetDownloadFolder ensures customDir exists, returns it
+func SetDownloadFolder(customDir string) (string, error) {
+	if err := os.MkdirAll(customDir, os.ModePerm); err != nil {
+		return "", err
+	}
+	return customDir, nil
+}
+
+// SetInstallDir sets the global install dir variable and updates the persistent environment variable.
+func SetInstallDir(path string) error {
+	installMu.Lock()
+	defer installMu.Unlock()
+
+	installedDir = path
+
+	// Persist the environment variable on Windows
+	if runtime.GOOS == "windows" {
+		err := persistSystemEnvVar(installEnvVar, path)
+		if err != nil {
+			return err
+		}
+	} else {
+		// For non-Windows platforms, just set it in the current process environment
+		err := os.Setenv(installEnvVar, path)
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// GetInstallDir returns the currently set install dir if available.
+// Otherwise, it calls GetDefaultInstallDir() and sets that.
+func GetInstallDir() (string, error) {
+
+	// If already set, return it
+	if installedDir != "" {
+		return installedDir, nil
+	}
+
+	// Check if it's stored in the system environment variable
+	if envDir := os.Getenv(installEnvVar); envDir != "" {
+		installedDir = envDir
+		return installedDir, nil
+	}
+
+	// Compute and store the default dir if not already set
+	defDir, err := GetDefaultInstallDir()
+	if err != nil {
+		return "", err
+	}
+	installedDir = defDir
+
+	// Persist the default dir as an environment variable on Windows
+	if runtime.GOOS == "windows" {
+		_ = persistSystemEnvVar(installEnvVar, defDir)
+	} else {
+		_ = os.Setenv(installEnvVar, defDir)
+	}
+
+	return defDir, nil
+}
diff --git a/download.go b/download.go
new file mode 100644
index 0000000..30d239c
--- /dev/null
+++ b/download.go
@@ -0,0 +1,128 @@
+package spm
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+	"runtime"
+	"time"
+)
+
+// DownloadPackageFromAppIndex selects and downloads the correct package from the APPINDEX.
+func DownloadPackageFromAppIndex(appIndexPath string, packageName string, release string, pkgType string, destDir string) error {
+	// Parse the APPINDEX
+	entries, err := ParseAppIndex(appIndexPath)
+	if err != nil {
+		return fmt.Errorf("failed to parse APPINDEX: %w", err)
+	}
+
+	// Find the right entry
+	var selected *AppIndexEntry
+	for _, e := range entries {
+		if e.Name == packageName &&
+			e.Release == release &&
+			e.Type == pkgType &&
+			e.OS == runtime.GOOS &&
+			e.Arch == runtime.GOARCH {
+			selected = &e
+			break
+		}
+	}
+
+	// Handle no matching entry
+	if selected == nil {
+		return fmt.Errorf("package not found in APPINDEX: %s (release: %s, type: %s, os: %s, arch: %s)", packageName, release, pkgType, runtime.GOOS, runtime.GOARCH)
+	}
+
+	// Check if the package is already installed and up-to-date
+	installDir, err := GetInstallDir()
+	if err != nil {
+		return fmt.Errorf("failed to get install directory: %w", err)
+	}
+	needsUpdate, err := IsUpdateNeeded(installDir, packageName, release, selected.Version, selected.Arch, selected.OS)
+	if err != nil {
+		return fmt.Errorf("failed to check update status: %w", err)
+	}
+	if !needsUpdate {
+		UpdateProgress(0, "Already up-to-date, skipping download.")
+		return 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/go.mod b/go.mod
new file mode 100644
index 0000000..8b67881
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,7 @@
+module weforge.xyz/Spitfire/SPM
+
+go 1.21
+
+require gopkg.in/ini.v1 v1.67.0
+
+require github.com/stretchr/testify v1.10.0 // indirect
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..be248fb
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,10 @@
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/install.go b/install.go
new file mode 100644
index 0000000..9fbf274
--- /dev/null
+++ b/install.go
@@ -0,0 +1,304 @@
+package spm
+
+import (
+	"archive/tar"
+	"bufio"
+	"compress/gzip"
+	"fmt"
+	"io"
+	"math/rand"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"regexp"
+	"runtime"
+	"sync"
+	"time"
+)
+
+func DecompressToTemp(filePath string) (string, error) {
+	UpdateProgress(0, "Decompressing package")
+
+	// 1) Base temp dir
+	baseTempDir := GetTempDir()
+
+	// 2) Create a unique subfolder inside the base temp dir
+	subfolderName := fmt.Sprintf("spm_decompress_%d", rand.Intn(1000000))
+	decompressDir := filepath.Join(baseTempDir, subfolderName)
+	if err := os.MkdirAll(decompressDir, 0755); err != nil {
+		return "", fmt.Errorf("failed to create decompress dir: %w", err)
+	}
+
+	// 3) Open the tar.gz file
+	file, err := os.Open(filePath)
+	if err != nil {
+		return "", err
+	}
+	defer file.Close()
+
+	gzr, err := gzip.NewReader(file)
+	if err != nil {
+		return "", err
+	}
+	defer gzr.Close()
+
+	tarReader := tar.NewReader(gzr)
+
+	// 4) Count total files
+	var totalFiles, processedFiles int
+	for {
+		_, err := tarReader.Next()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return "", err
+		}
+		totalFiles++
+	}
+
+	// 5) Reset file position and tar reader
+	if _, err := file.Seek(0, io.SeekStart); err != nil {
+		return "", err
+	}
+	if err := gzr.Reset(file); err != nil {
+		return "", err
+	}
+	tarReader = tar.NewReader(gzr)
+
+	// 6) Extract into `decompressDir`
+	for {
+		header, err := tarReader.Next()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return "", err
+		}
+
+		targetPath := filepath.Join(decompressDir, header.Name)
+		switch header.Typeflag {
+		case tar.TypeDir:
+			if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil {
+				return "", err
+			}
+		case tar.TypeReg:
+			outFile, err := os.Create(targetPath)
+			if err != nil {
+				return "", err
+			}
+			if _, err := io.Copy(outFile, tarReader); err != nil {
+				outFile.Close()
+				return "", err
+			}
+			outFile.Close()
+		}
+
+		processedFiles++
+		UpdateProgress(int(float64(processedFiles)/float64(totalFiles)*100), "Decompressing package")
+	}
+
+	UpdateProgress(100, "Package decompressed")
+	return decompressDir, nil
+}
+
+// tailLogFile continuously reads new lines from logFile until done is closed.
+// It prints each new line and searches for a percentage (e.g. " 47%") to call UpdateProgress.
+func tailLogFile(logFile string, done <-chan struct{}, wg *sync.WaitGroup) {
+	defer wg.Done()
+
+	var offset int64
+	re := regexp.MustCompile(`\s+(\d+)%`)
+	for {
+		select {
+		case <-done:
+			return
+		default:
+			f, err := os.Open(logFile)
+			if err != nil {
+				time.Sleep(200 * time.Millisecond)
+				continue
+			}
+			// Seek to the last known offset.
+			f.Seek(offset, io.SeekStart)
+			scanner := bufio.NewScanner(f)
+			for scanner.Scan() {
+				line := scanner.Text()
+				fmt.Printf("[ROBOPROGRESS-LOG] %s\n", line)
+				// Look for a percentage.
+				if matches := re.FindStringSubmatch(line); len(matches) == 2 {
+					var percent int
+					_, err := fmt.Sscanf(matches[1], "%d", &percent)
+					if err == nil {
+						fmt.Printf("[ROBOPROGRESS] Parsed progress: %d%%\n", percent)
+						UpdateProgress(percent, "Copying files to install directory")
+					}
+				}
+			}
+			// Update the offset.
+			newOffset, _ := f.Seek(0, io.SeekCurrent)
+			offset = newOffset
+			f.Close()
+			time.Sleep(200 * time.Millisecond)
+		}
+	}
+}
+
+// MoveFilesToInstallDir copies files from tempDir to installDir.
+// On Windows it uses robocopy with logging so that as much output as possible is printed,
+// and a separate goroutine tails the log file to update progress.
+// The log file is saved (not automatically deleted) so you can inspect it.
+func MoveFilesToInstallDir(tempDir, installDir, pkgType string) error {
+	// Ensure tempDir exists.
+	if _, err := os.Stat(tempDir); os.IsNotExist(err) {
+		return fmt.Errorf("tempDir does not exist: %s", tempDir)
+	}
+
+	// If package type is "browser", adjust installDir.
+	if pkgType == "browser" {
+		installDir = filepath.Join(installDir, "browser")
+	}
+
+	// Ensure destination exists.
+	if err := os.MkdirAll(installDir, os.ModePerm); err != nil {
+		return fmt.Errorf("failed to create installDir: %w", err)
+	}
+
+	if runtime.GOOS == "windows" {
+		// Create a temporary log file.
+		logFile := filepath.Join(os.TempDir(), fmt.Sprintf("robocopy_%d.log", time.Now().UnixNano()))
+		// Print out the log file path so you can locate it manually.
+		fmt.Printf("[INFO] Robocopy log file: %s\n", logFile)
+
+		// Build robocopy command.
+		// /E: copy subdirectories (including empty ones)
+		// /TEE: output to console as well as to the log file.
+		// /LOG:<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)
+}
diff --git a/installed_pacakges.go b/installed_pacakges.go
new file mode 100644
index 0000000..1f89eae
--- /dev/null
+++ b/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/progress.go b/progress.go
new file mode 100644
index 0000000..c72e84e
--- /dev/null
+++ b/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/register_unix.go b/register_unix.go
new file mode 100644
index 0000000..6568edd
--- /dev/null
+++ b/register_unix.go
@@ -0,0 +1,17 @@
+// run_default.go
+//go:build !windows
+// +build !windows
+
+package spm
+
+import "fmt"
+
+// RegisterApp is not supported on non-Windows platforms.
+func RegisterApp() error {
+	return fmt.Errorf("RegisterApp is only available on Windows")
+}
+
+// UnregisterApp is not supported on non-Windows platforms.
+func UnregisterApp() error {
+	return fmt.Errorf("UnregisterApp is only available on Windows")
+}
diff --git a/register_win.go b/register_win.go
new file mode 100644
index 0000000..b2cf47f
--- /dev/null
+++ b/register_win.go
@@ -0,0 +1,135 @@
+// 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 offically 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)
+}
diff --git a/run_unix.go b/run_unix.go
new file mode 100644
index 0000000..cc82b79
--- /dev/null
+++ b/run_unix.go
@@ -0,0 +1,74 @@
+// run_unix.go
+//go:build !windows
+// +build !windows
+
+package spm
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"runtime"
+	"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")
+	if runtime.GOOS != "windows" {
+		exePath = filepath.Join(installDir, "browser", "spitfire")
+	}
+
+	cmd := exec.Command(exePath)
+	cmd.Dir = filepath.Join(installDir, "browser")
+	return cmd.Start()
+}
+
+// RunAndWait locates and starts the installed Spitfire browser and waits for it to exit.
+func RunAndWait() error {
+	installDir, err := GetInstallDir()
+	if err != nil {
+		return fmt.Errorf("failed to get install directory: %w", err)
+	}
+
+	// Construct the browser executable path
+	exePath := filepath.Join(installDir, "browser", "spitfire")
+	if _, err := os.Stat(exePath); err != nil {
+		return fmt.Errorf("browser executable not found at %s: %w", exePath, err)
+	}
+
+	cmd := exec.Command(exePath)
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+
+	// Start the process in a new process group
+	cmd.SysProcAttr = &syscall.SysProcAttr{
+		Setpgid: true,
+	}
+
+	fmt.Printf("Starting browser: %s\n", exePath)
+	if err := cmd.Start(); err != nil {
+		return fmt.Errorf("failed to start browser: %w", err)
+	}
+
+	// Print PID and PGID for debugging
+	pgid, err := syscall.Getpgid(cmd.Process.Pid)
+	if err == nil {
+		fmt.Printf("Browser process started with PID %d (PGID %d)\n", cmd.Process.Pid, pgid)
+	} else {
+		fmt.Printf("Browser process started with PID %d\n", cmd.Process.Pid)
+	}
+
+	if err := cmd.Wait(); err != nil {
+		return fmt.Errorf("browser exited with error: %w", err)
+	}
+
+	fmt.Println("Browser exited successfully.")
+	return nil
+}
diff --git a/run_win.go b/run_win.go
new file mode 100644
index 0000000..d1f2b38
--- /dev/null
+++ b/run_win.go
@@ -0,0 +1,68 @@
+// run_windows.go
+//go:build windows
+// +build windows
+
+package spm
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"runtime"
+	"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")
+	if runtime.GOOS != "windows" {
+		exePath = filepath.Join(installDir, "browser", "spitfire")
+	}
+
+	cmd := exec.Command(exePath)
+	cmd.Dir = filepath.Join(installDir, "browser")
+	return cmd.Start()
+}
+
+// RunAndWait locates and starts the installed Spitfire browser and waits for it to exit.
+func RunAndWait() error {
+	installDir, err := GetInstallDir()
+	if err != nil {
+		return fmt.Errorf("failed to get install directory: %w", err)
+	}
+
+	// Construct the browser executable path
+	exePath := filepath.Join(installDir, "browser", "spitfire.exe")
+	if _, err := os.Stat(exePath); err != nil {
+		return fmt.Errorf("browser executable not found at %s: %w", exePath, err)
+	}
+
+	cmd := exec.Command(exePath)
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+
+	// Use CREATE_NEW_PROCESS_GROUP flag for Windows
+	cmd.SysProcAttr = &syscall.SysProcAttr{
+		CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP,
+	}
+
+	fmt.Printf("Starting browser: %s\n", exePath)
+	if err := cmd.Start(); err != nil {
+		return fmt.Errorf("failed to start browser: %w", err)
+	}
+
+	fmt.Printf("Browser process started with PID %d\n", cmd.Process.Pid)
+
+	if err := cmd.Wait(); err != nil {
+		return fmt.Errorf("browser exited with error: %w", err)
+	}
+
+	fmt.Println("Browser exited successfully.")
+	return nil
+}
diff --git a/utils.go b/utils.go
new file mode 100644
index 0000000..607b6f6
--- /dev/null
+++ b/utils.go
@@ -0,0 +1,26 @@
+package spm
+
+import (
+	"os/exec"
+)
+
+// persistSystemEnvVar sets a persistent environment variable on Windows using the `setx` command.
+// Perhaps support for other systems would be needed, but all of this "Launcher" thingy is probably going to end up
+// being Windows-specific, as other superior systems have their own package managers.
+func persistSystemEnvVar(key, value string) error {
+	cmd := exec.Command("cmd", "/C", "setx", key, value)
+	err := cmd.Run()
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+// IsMatchingEntry checks if a package entry matches the requested specs
+func IsMatchingEntry(e AppIndexEntry, name, release, arch, osName, pkgType string) bool {
+	return e.Name == name &&
+		e.Release == release &&
+		e.Arch == arch &&
+		e.OS == osName &&
+		e.Type == pkgType
+}