From 9e5457c2ec38c178d6fda7493e98818705dfc749 Mon Sep 17 00:00:00 2001 From: partisan Date: Wed, 25 Dec 2024 10:58:31 +0100 Subject: [PATCH] added SPM --- installer.go | 109 +++++++++++++++++++++++ main.go | 230 ++++++++++++++++++++++++++++++++++++++++++++---- spm/appindex.go | 108 +++++++++++++++++++++++ spm/download.go | 91 +++++++++++++++++++ spm/install.go | 161 +++++++++++++++++++++++++++++++++ spm/progress.go | 30 +++++++ spm/utils.go | 64 ++++++++++++++ transition.go | 4 +- 8 files changed, 779 insertions(+), 18 deletions(-) create mode 100644 installer.go create mode 100644 spm/appindex.go create mode 100644 spm/download.go create mode 100644 spm/install.go create mode 100644 spm/progress.go create mode 100644 spm/utils.go diff --git a/installer.go b/installer.go new file mode 100644 index 0000000..6f58364 --- /dev/null +++ b/installer.go @@ -0,0 +1,109 @@ +package main + +import ( + "fmt" + "path/filepath" + "spitfire-installer/spm" +) + +// Installer manages the download, decompression, and installation processes. +type Installer struct { + // Progress info from SPM + Progress int + Task string + + // Internal states + IsDownloading bool + IsInstalling bool + DoneDownload bool + DoneInstall bool + LastError error + + // Paths + DownloadDir string + TempDir string +} + +// NewInstaller creates a new Installer with initial state. +func NewInstaller() *Installer { + return &Installer{} +} + +// StartDownloadDecompress starts the download and decompression in a background goroutine. +func (inst *Installer) StartDownloadDecompress() { + inst.IsDownloading = true + go func() { + defer func() { + inst.IsDownloading = false + inst.DoneDownload = (inst.LastError == nil) + }() + + // Prepare download directory + spm.UpdateProgress(0, "Preparing to download...") + inst.DownloadDir = spm.GetTempDownloadDir() + + // Download APPINDEX + appIndexPath := filepath.Join(inst.DownloadDir, "APPINDEX") + spm.UpdateProgress(0, "Downloading APPINDEX") + if err := spm.DownloadAppIndex(appIndexPath); err != nil { + inst.LastError = err + return + } + + // Download package + packageName := "spitfire-browser" + release := "nightly" + spm.UpdateProgress(0, "Downloading package...") + if err := spm.DownloadPackageFromAppIndex(appIndexPath, packageName, release, inst.DownloadDir); err != nil { + inst.LastError = err + return + } + + // Decompress + spm.UpdateProgress(0, "Decompressing...") + packagePath := filepath.Join(inst.DownloadDir, "browser-amd64-nightly-linux.tar.gz") + tempDir, err := spm.DecompressToTemp(packagePath) + if err != nil { + inst.LastError = err + return + } + + inst.TempDir = tempDir + }() +} + +// FinalInstall moves files to the final install directory in a background goroutine. +func (inst *Installer) FinalInstall() { + if !inst.DoneDownload { + inst.LastError = fmt.Errorf("Cannot install: download and decompression are not complete") + return + } + + inst.IsInstalling = true + go func() { + defer func() { + inst.IsInstalling = false + inst.DoneInstall = (inst.LastError == nil) + }() + + // Generate default install directory + installDir, err := spm.GetDefaultInstallDir() + if err != nil { + inst.LastError = fmt.Errorf("failed to determine default install directory: %w", err) + return + } + + if err := spm.MoveFilesToInstallDir(inst.TempDir, installDir); err != nil { + inst.LastError = err + return + } + + spm.UpdateProgress(100, "Installation complete!") + }() +} + +// PollProgress fetches the latest progress and task from SPM. +func (inst *Installer) PollProgress() { + p, t := spm.GetProgress() + inst.Progress, inst.Task = p, t +} diff --git a/main.go b/main.go index ba43caa..e9fe30a 100644 --- a/main.go +++ b/main.go @@ -2,18 +2,23 @@ package main import ( "fmt" - "os" rl "github.com/gen2brain/raylib-go/raylib" ) -var transition = NewTransitionManager() -var currentStep = 0 -var targetStep = 0 -var useDefault = false +// Keep your global variables for steps and transitions +var ( + transition = NewTransitionManager() + currentStep = 0 + targetStep = 0 + useDefault = false -var step1DefaultRect rl.Rectangle -var step1CustomRect rl.Rectangle + step1DefaultRect rl.Rectangle + step1CustomRect rl.Rectangle + + // Our global Installer from installer.go + installer = NewInstaller() +) func main() { monitor := rl.GetCurrentMonitor() @@ -32,6 +37,9 @@ func main() { InitBackground(rl.GetScreenWidth(), rl.GetScreenHeight()) + // Start the download+decompress in background immediately: + installer.StartDownloadDecompress() + targetStep = 0 for !rl.WindowShouldClose() { @@ -46,12 +54,24 @@ func main() { nextX := int32(screenW - 150) nextY := prevY + // Update transition oldAlpha, oldScale, oldOffsetX, newAlpha, newScale, newOffsetX := transition.Update() if !transition.IsActive() && currentStep != targetStep { currentStep = targetStep } + // Poll SPM progress for the GUI + installer.PollProgress() + + // If an unrecoverable error occurred, you could handle it here: + // (For now, we just print it in the console.) + if installer.LastError != nil { + fmt.Println("SPM Error:", installer.LastError) + // You might choose to show a popup or do something else + } + + // GUI input if !transition.IsActive() && rl.IsMouseButtonPressed(rl.MouseLeftButton) { handleInput(mousePos, screenW, screenH, buttonW, buttonH, prevX, prevY, nextX, nextY) } @@ -93,20 +113,27 @@ func main() { } } + // Draw the semi-transparent loading circle if user is downloading or installing + if installer.IsDownloading || installer.IsInstalling { + drawInstallProgress(screenW, installer.Progress, installer.Task) + } + rl.EndDrawing() } } -func startTransition(from, to int) { - targetStep = to - transition.Start(from, to) -} +// handleInput acts on mouse clicks in each step +func handleInput(mousePos rl.Vector2, screenW, screenH, buttonW, buttonH int, + prevX, prevY, nextX, nextY int32) { -func handleInput(mousePos rl.Vector2, screenW, screenH, buttonW, buttonH int, prevX, prevY, nextX, nextY int32) { if currentStep == 0 { if overRect(mousePos, step1DefaultRect) { - fmt.Println("Installation complete with default settings.") - os.Exit(0) + // user clicked "Default" => do final install if not already installing + useDefault = true + if !installer.IsInstalling && !installer.DoneInstall { + installer.FinalInstall() + } + fmt.Println("Installation started with default settings.") } if overRect(mousePos, step1CustomRect) { useDefault = false @@ -124,9 +151,12 @@ func handleInput(mousePos rl.Vector2, screenW, screenH, buttonW, buttonH int, pr selectTheme(mousePos) } else if currentStep == 2 { if overButton(mousePos, nextX, nextY, int32(buttonW), int32(buttonH)) { - fmt.Printf("Installation complete:\nDefault: %v\nColor: %s\nTheme: %s\nLayout: %s\n", + // user clicked "Finish" => final install if not already installing + if !installer.IsInstalling && !installer.DoneInstall { + installer.FinalInstall() + } + fmt.Printf("Installation started:\nDefault: %v\nColor: %s\nTheme: %s\nLayout: %s\n", useDefault, selectedColor, selectedTheme, selectedLayout) - os.Exit(0) } if overButton(mousePos, int32(prevX), int32(prevY), int32(buttonW), int32(buttonH)) { startTransition(currentStep, 1) @@ -135,6 +165,7 @@ func handleInput(mousePos rl.Vector2, screenW, screenH, buttonW, buttonH int, pr } } +// drawHeader is your original function func drawHeader(screenW int) { title := "Spitfire Browser Installer" titleFontSize := int32(30) @@ -142,3 +173,170 @@ func drawHeader(screenW int) { rl.DrawText(title, (int32(screenW)-titleWidth)/2, 20, titleFontSize, rl.RayWhite) rl.DrawLine(50, 60, int32(screenW)-50, 60, rl.Fade(rl.White, 0.5)) } + +// drawInstallProgress displays a simple white circle with partial alpha, +// plus the current task text below it, in the top-right area. +func drawInstallProgress(screenW int, progress int, task string) { + circleX := float32(screenW - 80) + circleY := float32(100) + radius := float32(30) + + // Colors for the circle + bgColor := rl.Color{R: 255, G: 255, B: 255, A: 80} + fillColor := rl.Color{R: 255, G: 255, B: 255, A: 200} + + // Draw background circle + rl.DrawCircle(int32(circleX), int32(circleY), radius, bgColor) + + // Draw progress arc + angle := float32(progress) / 100.0 * 360.0 + rl.DrawCircleSector( + rl.Vector2{X: circleX, Y: circleY}, + radius, + 0, + angle, + 40, + fillColor, + ) + + // Print numeric progress + txt := fmt.Sprintf("%3d%%", progress) + rl.DrawText(txt, int32(circleX)-rl.MeasureText(txt, 18)/2, int32(circleY)-10, 18, rl.White) + + // Draw wrapped task text below the circle + drawTextWrapped(task, int32(circleX)-100, int32(circleY)+40, 200, 18, rl.White) +} + +// Custom function to draw text with wrapping +func drawTextWrapped(text string, x, y int32, maxWidth, fontSize int32, color rl.Color) { + words := splitIntoWords(text) + line := "" + offsetY := int32(0) + + for _, word := range words { + testLine := line + word + " " + if rl.MeasureText(testLine, fontSize) > int32(maxWidth) { + rl.DrawText(line, x, y+offsetY, fontSize, color) + line = word + " " + offsetY += fontSize + 2 + } else { + line = testLine + } + } + + // Draw the last line + if line != "" { + rl.DrawText(line, x, y+offsetY, fontSize, color) + } +} + +// Helper function to split text into words +func splitIntoWords(text string) []string { + words := []string{} + word := "" + for _, char := range text { + if char == ' ' || char == '\n' { + if word != "" { + words = append(words, word) + word = "" + } + if char == '\n' { + words = append(words, "\n") + } + } else { + word += string(char) + } + } + if word != "" { + words = append(words, word) + } + return words +} + +// startTransition is your existing function +func startTransition(from, to int) { + targetStep = to + transition.Start(from, to) +} + +// SPM example + +// package main + +// import ( +// "fmt" +// "os" +// "path/filepath" +// "spitfire-installer/spm" +// "time" +// ) + +// func main() { +// // Start a goroutine to display progress updates +// done := make(chan bool) +// go func() { +// for { +// select { +// case <-done: +// return +// default: +// percentage, task := spm.GetProgress() +// fmt.Printf("\r[%3d%%] %s", percentage, task) +// time.Sleep(500 * time.Millisecond) +// } +// } +// }() + +// // Set up the download directory +// downloadDir := spm.GetTempDownloadDir() +// fmt.Println("\nTemporary download directory:", downloadDir) + +// // Download the APPINDEX +// appIndexPath := filepath.Join(downloadDir, "APPINDEX") +// spm.UpdateProgress(0, "Starting APPINDEX download") +// if err := spm.DownloadAppIndex(appIndexPath); err != nil { +// fmt.Println("\nError downloading APPINDEX:", err) +// done <- true +// os.Exit(1) +// } + +// // Download the desired package version (e.g., nightly) +// packageName := "spitfire-browser" +// release := "nightly" + +// spm.UpdateProgress(0, "Starting package download") +// if err := spm.DownloadPackageFromAppIndex(appIndexPath, packageName, release, downloadDir); err != nil { +// fmt.Println("\nError downloading package:", err) +// done <- true +// os.Exit(1) +// } + +// // Decompress and install +// packagePath := filepath.Join(downloadDir, "browser-amd64-nightly-linux.tar.gz") +// spm.UpdateProgress(0, "Starting decompression") +// tempDir, err := spm.DecompressToTemp(packagePath) +// if err != nil { +// fmt.Println("\nError decompressing package:", err) +// done <- true +// os.Exit(1) +// } +// fmt.Println("\nDecompressed package to:", tempDir) + +// // Generate default install directory +// installDir, err := spm.GetDefaultInstallDir() +// if err != nil { +// inst.LastError = fmt.Errorf("failed to determine default install directory: %w", err) +// return +// } + +// spm.UpdateProgress(0, "Starting installation") +// if err := spm.MoveFilesToInstallDir(tempDir, installDir); err != nil { +// fmt.Println("\nError installing package:", err) +// done <- true +// os.Exit(1) +// } + +// // Notify progress display to stop and finalize +// done <- true +// fmt.Printf("\nSuccessfully installed %s (%s) to %s\n", packageName, release, installDir) +// } diff --git a/spm/appindex.go b/spm/appindex.go new file mode 100644 index 0000000..debdf28 --- /dev/null +++ b/spm/appindex.go @@ -0,0 +1,108 @@ +package spm + +import ( + "bufio" + "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 + Arch string + OS string + 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:") { + if entry.Name != "" { + entries = append(entries, entry) + entry = AppIndexEntry{} + } + } + parts := strings.SplitN(line, ":", 2) + if len(parts) < 2 { + continue + } + + switch parts[0] { + case "P": + entry.Name = parts[1] + case "R": + entry.Release = parts[1] + case "V": + entry.Version = parts[1] + case "A": + entry.Arch = parts[1] + case "p": + entry.OS = parts[1] + case "d": + entry.DownloadURL = parts[1] + } + } + + if entry.Name != "" { + entries = append(entries, entry) + } + + return entries, scanner.Err() +} diff --git a/spm/download.go b/spm/download.go new file mode 100644 index 0000000..fe6dd32 --- /dev/null +++ b/spm/download.go @@ -0,0 +1,91 @@ +package spm + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" +) + +type Package struct { + Name string + Arch string + OS string + DownloadURL string +} + +func DownloadPackage(pkg Package, destDir string) error { + UpdateProgress(0, fmt.Sprintf("Downloading %s", pkg.Name)) + resp, err := http.Get(pkg.DownloadURL) + if err != nil { + return err + } + defer resp.Body.Close() + + filePath := filepath.Join(destDir, filepath.Base(pkg.DownloadURL)) + out, err := os.Create(filePath) + 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, fmt.Sprintf("Downloading %s", pkg.Name)) + if _, err := out.Write(buf[:n]); err != nil { + return err + } + } + if err == io.EOF { + break + } + if err != nil { + return err + } + } + + UpdateProgress(100, fmt.Sprintf("%s downloaded", pkg.Name)) + return nil +} + +func FindPackage(entries []AppIndexEntry, name, release, arch, os string) (*AppIndexEntry, error) { + for _, entry := range entries { + if entry.Name == name && entry.Release == release && entry.Arch == arch && entry.OS == os { + return &entry, nil + } + } + return nil, errors.New("package not found") +} + +func DownloadPackageFromAppIndex(appIndexPath, packageName, release, destDir string) error { + arch := runtime.GOARCH + osName := runtime.GOOS + + entries, err := ParseAppIndex(appIndexPath) + if err != nil { + return err + } + + entry, err := FindPackage(entries, packageName, release, arch, osName) + if err != nil { + return err + } + + return DownloadPackage(Package{ + Name: entry.Name, + Arch: entry.Arch, + OS: entry.OS, + DownloadURL: entry.DownloadURL, + }, destDir) +} diff --git a/spm/install.go b/spm/install.go new file mode 100644 index 0000000..5879747 --- /dev/null +++ b/spm/install.go @@ -0,0 +1,161 @@ +package spm + +import ( + "archive/tar" + "compress/gzip" + "io" + "os" + "path/filepath" +) + +func DecompressToTemp(filePath string) (string, error) { + UpdateProgress(0, "Decompressing package") + tempDir, err := os.MkdirTemp("", "spm_decompress") + if err != nil { + return "", err + } + + 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) + var totalFiles, processedFiles int + // Count total files + for { + _, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return "", err + } + totalFiles++ + } + + file.Seek(0, io.SeekStart) + gzr.Reset(file) + tarReader = tar.NewReader(gzr) + + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return "", err + } + + targetPath := filepath.Join(tempDir, 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 { + return "", err + } + outFile.Close() + } + + processedFiles++ + UpdateProgress(int(float64(processedFiles)/float64(totalFiles)*100), "Decompressing package") + } + + UpdateProgress(100, "Package decompressed") + return tempDir, nil +} + +func MoveFilesToInstallDir(tempDir, installDir string) error { + // 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()) +} diff --git a/spm/progress.go b/spm/progress.go new file mode 100644 index 0000000..8c87f2c --- /dev/null +++ b/spm/progress.go @@ -0,0 +1,30 @@ +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 +} + +// 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/utils.go b/spm/utils.go new file mode 100644 index 0000000..b71ba27 --- /dev/null +++ b/spm/utils.go @@ -0,0 +1,64 @@ +package spm + +import ( + "fmt" + "os" + "path/filepath" + "runtime" +) + +func GetTempDownloadDir() string { + dir, err := os.MkdirTemp("", "spm_downloads") + if err != nil { + panic(err) + } + return dir +} + +func SetDownloadFolder(customDir string) (string, error) { + if err := os.MkdirAll(customDir, os.ModePerm); err != nil { + return "", err + } + return customDir, nil +} + +// GetDefaultInstallDir generates the default installation directory based on the OS and environment. +func GetDefaultInstallDir() (string, error) { + var installDir string + + switch runtime.GOOS { + case "windows": + // Use %APPDATA% or Program Files on Windows + appData := os.Getenv("APPDATA") + if appData != "" { + installDir = filepath.Join(appData, "Spitfire") + } else { + 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 or /opt 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) + } + + return installDir, nil +} diff --git a/transition.go b/transition.go index 954688e..21066c3 100644 --- a/transition.go +++ b/transition.go @@ -122,9 +122,9 @@ func (t *TransitionManager) Update() ( oldAlpha, oldScale, oldOffsetX = 1, 1, 0 newAlpha, newScale, newOffsetX = 1, 1, 0 - slideDir := float32(-1) + slideDir := float32(1) if t.direction == DirectionBackward { - slideDir = 1 + slideDir = -1 } p := t.accumSec / t.totalSec