285 lines
7.7 KiB
Go
285 lines
7.7 KiB
Go
|
|
// Package updater provides auto-update functionality for BugSETI.
|
||
|
|
package updater
|
||
|
|
|
||
|
|
import (
|
||
|
|
"fmt"
|
||
|
|
"os"
|
||
|
|
"os/exec"
|
||
|
|
"path/filepath"
|
||
|
|
"runtime"
|
||
|
|
"syscall"
|
||
|
|
)
|
||
|
|
|
||
|
|
// InstallResult contains the result of an installation.
|
||
|
|
type InstallResult struct {
|
||
|
|
Success bool `json:"success"`
|
||
|
|
OldPath string `json:"oldPath"`
|
||
|
|
NewPath string `json:"newPath"`
|
||
|
|
BackupPath string `json:"backupPath"`
|
||
|
|
RestartNeeded bool `json:"restartNeeded"`
|
||
|
|
Error string `json:"error,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// Installer handles installing updates and restarting the application.
|
||
|
|
type Installer struct {
|
||
|
|
executablePath string
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewInstaller creates a new installer.
|
||
|
|
func NewInstaller() (*Installer, error) {
|
||
|
|
execPath, err := os.Executable()
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to get executable path: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Resolve symlinks to get the real path
|
||
|
|
execPath, err = filepath.EvalSymlinks(execPath)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to resolve executable path: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return &Installer{
|
||
|
|
executablePath: execPath,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Install replaces the current binary with the new one.
|
||
|
|
func (i *Installer) Install(newBinaryPath string) (*InstallResult, error) {
|
||
|
|
result := &InstallResult{
|
||
|
|
OldPath: i.executablePath,
|
||
|
|
NewPath: newBinaryPath,
|
||
|
|
RestartNeeded: true,
|
||
|
|
}
|
||
|
|
|
||
|
|
// Verify the new binary exists and is executable
|
||
|
|
if _, err := os.Stat(newBinaryPath); err != nil {
|
||
|
|
result.Error = fmt.Sprintf("new binary not found: %v", err)
|
||
|
|
return result, fmt.Errorf("new binary not found: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create backup of current binary
|
||
|
|
backupPath := i.executablePath + ".bak"
|
||
|
|
result.BackupPath = backupPath
|
||
|
|
|
||
|
|
// Platform-specific installation
|
||
|
|
var err error
|
||
|
|
switch runtime.GOOS {
|
||
|
|
case "windows":
|
||
|
|
err = i.installWindows(newBinaryPath, backupPath)
|
||
|
|
default:
|
||
|
|
err = i.installUnix(newBinaryPath, backupPath)
|
||
|
|
}
|
||
|
|
|
||
|
|
if err != nil {
|
||
|
|
result.Error = err.Error()
|
||
|
|
return result, err
|
||
|
|
}
|
||
|
|
|
||
|
|
result.Success = true
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// installUnix performs the installation on Unix-like systems.
|
||
|
|
func (i *Installer) installUnix(newBinaryPath, backupPath string) error {
|
||
|
|
// Remove old backup if exists
|
||
|
|
os.Remove(backupPath)
|
||
|
|
|
||
|
|
// Rename current binary to backup
|
||
|
|
if err := os.Rename(i.executablePath, backupPath); err != nil {
|
||
|
|
return fmt.Errorf("failed to backup current binary: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Copy new binary to target location
|
||
|
|
// We use copy instead of rename in case they're on different filesystems
|
||
|
|
if err := copyFile(newBinaryPath, i.executablePath); err != nil {
|
||
|
|
// Try to restore backup
|
||
|
|
os.Rename(backupPath, i.executablePath)
|
||
|
|
return fmt.Errorf("failed to install new binary: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Make executable
|
||
|
|
if err := os.Chmod(i.executablePath, 0755); err != nil {
|
||
|
|
// Try to restore backup
|
||
|
|
os.Remove(i.executablePath)
|
||
|
|
os.Rename(backupPath, i.executablePath)
|
||
|
|
return fmt.Errorf("failed to make binary executable: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// installWindows performs the installation on Windows.
|
||
|
|
// On Windows, we can't replace a running executable, so we use a different approach:
|
||
|
|
// 1. Rename current executable to .old
|
||
|
|
// 2. Copy new executable to target location
|
||
|
|
// 3. On next start, clean up the .old file
|
||
|
|
func (i *Installer) installWindows(newBinaryPath, backupPath string) error {
|
||
|
|
// Remove old backup if exists
|
||
|
|
os.Remove(backupPath)
|
||
|
|
|
||
|
|
// On Windows, we can rename the running executable
|
||
|
|
if err := os.Rename(i.executablePath, backupPath); err != nil {
|
||
|
|
return fmt.Errorf("failed to backup current binary: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Copy new binary to target location
|
||
|
|
if err := copyFile(newBinaryPath, i.executablePath); err != nil {
|
||
|
|
// Try to restore backup
|
||
|
|
os.Rename(backupPath, i.executablePath)
|
||
|
|
return fmt.Errorf("failed to install new binary: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Restart restarts the application with the new binary.
|
||
|
|
func (i *Installer) Restart() error {
|
||
|
|
args := os.Args
|
||
|
|
env := os.Environ()
|
||
|
|
|
||
|
|
switch runtime.GOOS {
|
||
|
|
case "windows":
|
||
|
|
return i.restartWindows(args, env)
|
||
|
|
default:
|
||
|
|
return i.restartUnix(args, env)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// restartUnix restarts the application on Unix-like systems using exec.
|
||
|
|
func (i *Installer) restartUnix(args []string, env []string) error {
|
||
|
|
// Use syscall.Exec to replace the current process
|
||
|
|
// This is the cleanest way to restart on Unix
|
||
|
|
return syscall.Exec(i.executablePath, args, env)
|
||
|
|
}
|
||
|
|
|
||
|
|
// restartWindows restarts the application on Windows.
|
||
|
|
func (i *Installer) restartWindows(args []string, env []string) error {
|
||
|
|
// On Windows, we can't use exec to replace the process
|
||
|
|
// Instead, we start a new process and exit the current one
|
||
|
|
cmd := exec.Command(i.executablePath, args[1:]...)
|
||
|
|
cmd.Env = env
|
||
|
|
cmd.Stdout = os.Stdout
|
||
|
|
cmd.Stderr = os.Stderr
|
||
|
|
cmd.Stdin = os.Stdin
|
||
|
|
|
||
|
|
if err := cmd.Start(); err != nil {
|
||
|
|
return fmt.Errorf("failed to start new process: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Exit current process
|
||
|
|
os.Exit(0)
|
||
|
|
return nil // Never reached
|
||
|
|
}
|
||
|
|
|
||
|
|
// RestartLater schedules a restart for when the app next starts.
|
||
|
|
// This is useful when the user wants to continue working and restart later.
|
||
|
|
func (i *Installer) RestartLater() error {
|
||
|
|
// Create a marker file that indicates a restart is pending
|
||
|
|
markerPath := filepath.Join(filepath.Dir(i.executablePath), ".bugseti-restart-pending")
|
||
|
|
return os.WriteFile(markerPath, []byte("restart"), 0644)
|
||
|
|
}
|
||
|
|
|
||
|
|
// CheckPendingRestart checks if a restart was scheduled.
|
||
|
|
func (i *Installer) CheckPendingRestart() bool {
|
||
|
|
markerPath := filepath.Join(filepath.Dir(i.executablePath), ".bugseti-restart-pending")
|
||
|
|
_, err := os.Stat(markerPath)
|
||
|
|
return err == nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ClearPendingRestart clears the pending restart marker.
|
||
|
|
func (i *Installer) ClearPendingRestart() error {
|
||
|
|
markerPath := filepath.Join(filepath.Dir(i.executablePath), ".bugseti-restart-pending")
|
||
|
|
return os.Remove(markerPath)
|
||
|
|
}
|
||
|
|
|
||
|
|
// CleanupBackup removes the backup binary after a successful update.
|
||
|
|
func (i *Installer) CleanupBackup() error {
|
||
|
|
backupPath := i.executablePath + ".bak"
|
||
|
|
if _, err := os.Stat(backupPath); err == nil {
|
||
|
|
return os.Remove(backupPath)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Rollback restores the previous version from backup.
|
||
|
|
func (i *Installer) Rollback() error {
|
||
|
|
backupPath := i.executablePath + ".bak"
|
||
|
|
|
||
|
|
// Check if backup exists
|
||
|
|
if _, err := os.Stat(backupPath); err != nil {
|
||
|
|
return fmt.Errorf("backup not found: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Remove current binary
|
||
|
|
if err := os.Remove(i.executablePath); err != nil {
|
||
|
|
return fmt.Errorf("failed to remove current binary: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Restore backup
|
||
|
|
if err := os.Rename(backupPath, i.executablePath); err != nil {
|
||
|
|
return fmt.Errorf("failed to restore backup: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetExecutablePath returns the path to the current executable.
|
||
|
|
func (i *Installer) GetExecutablePath() string {
|
||
|
|
return i.executablePath
|
||
|
|
}
|
||
|
|
|
||
|
|
// copyFile copies a file from src to dst.
|
||
|
|
func copyFile(src, dst string) error {
|
||
|
|
sourceFile, err := os.Open(src)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
defer sourceFile.Close()
|
||
|
|
|
||
|
|
// Get source file info for permissions
|
||
|
|
sourceInfo, err := sourceFile.Stat()
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
destFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, sourceInfo.Mode())
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
defer destFile.Close()
|
||
|
|
|
||
|
|
_, err = destFile.ReadFrom(sourceFile)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// CanSelfUpdate checks if the application has permission to update itself.
|
||
|
|
func CanSelfUpdate() bool {
|
||
|
|
execPath, err := os.Executable()
|
||
|
|
if err != nil {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
execPath, err = filepath.EvalSymlinks(execPath)
|
||
|
|
if err != nil {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check if we can write to the executable's directory
|
||
|
|
dir := filepath.Dir(execPath)
|
||
|
|
testFile := filepath.Join(dir, ".bugseti-update-test")
|
||
|
|
|
||
|
|
f, err := os.Create(testFile)
|
||
|
|
if err != nil {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
f.Close()
|
||
|
|
os.Remove(testFile)
|
||
|
|
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
// NeedsElevation returns true if the update requires elevated privileges.
|
||
|
|
func NeedsElevation() bool {
|
||
|
|
return !CanSelfUpdate()
|
||
|
|
}
|