- Check: query /api/launcher/latest with os/arch, compare versions - Apply: download new binary, verify SHA-256, replace current executable (Windows .old rename) - Restart: re-launch executable and exit - Uses stdlib only (no external update library needed for direct URL downloads) Co-Authored-By: OWL <noreply@anthropic.com>
146 lines
3.2 KiB
Go
146 lines
3.2 KiB
Go
// package selfupdate handles launcher auto-updates.
|
|
package selfupdate
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
|
|
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/config"
|
|
)
|
|
|
|
// UpdateInfo describes the latest launcher version from the server.
|
|
type UpdateInfo struct {
|
|
Version string `json:"version"`
|
|
URL string `json:"url"`
|
|
SHA256 string `json:"sha256"`
|
|
}
|
|
|
|
// Check queries /api/launcher/latest to see if a newer version is available.
|
|
func Check(currentVersion string) (*UpdateInfo, error) {
|
|
s, err := config.Load()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading config: %w", err)
|
|
}
|
|
|
|
latestURL := fmt.Sprintf("%s/api/launcher/latest?os=%s&arch=%s",
|
|
s.ServerURL, runtime.GOOS, runtime.GOARCH)
|
|
|
|
resp, err := http.Get(latestURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying latest version: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading response: %w", err)
|
|
}
|
|
|
|
var info UpdateInfo
|
|
if err := json.Unmarshal(data, &info); err != nil {
|
|
return nil, fmt.Errorf("parsing update response: %w", err)
|
|
}
|
|
|
|
if info.Version == currentVersion {
|
|
return nil, nil
|
|
}
|
|
return &info, nil
|
|
}
|
|
|
|
// Apply downloads the new binary and replaces the current executable.
|
|
func Apply(info *UpdateInfo) error {
|
|
execPath, err := os.Executable()
|
|
if err != nil {
|
|
return fmt.Errorf("resolving executable path: %w", err)
|
|
}
|
|
|
|
tmpPath := execPath + ".new"
|
|
if err := downloadFile(info.URL, tmpPath); err != nil {
|
|
return fmt.Errorf("downloading update: %w", err)
|
|
}
|
|
|
|
if info.SHA256 != "" {
|
|
got, err := sha256File(tmpPath)
|
|
if err != nil {
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("hashing download: %w", err)
|
|
}
|
|
if got != info.SHA256 {
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("SHA-256 mismatch: expected %s, got %s", info.SHA256, got)
|
|
}
|
|
}
|
|
|
|
if err := os.Chmod(tmpPath, 0o755); err != nil {
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("chmod %s: %w", tmpPath, err)
|
|
}
|
|
|
|
if runtime.GOOS == "windows" {
|
|
oldPath := execPath + ".old"
|
|
_ = os.Remove(oldPath)
|
|
if err := os.Rename(execPath, oldPath); err != nil {
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("renaming current binary: %w", err)
|
|
}
|
|
}
|
|
|
|
return os.Rename(tmpPath, execPath)
|
|
}
|
|
|
|
// Restart re-launches the current executable and exits.
|
|
func Restart() error {
|
|
execPath, err := os.Executable()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
proc, err := os.StartProcess(execPath, os.Args, &os.ProcAttr{
|
|
Dir: filepath.Dir(execPath),
|
|
Env: os.Environ(),
|
|
Files: []*os.File{os.Stdin, os.Stdout, os.Stderr},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("restarting: %w", err)
|
|
}
|
|
_ = proc.Release()
|
|
os.Exit(0)
|
|
return nil
|
|
}
|
|
|
|
func downloadFile(url, dest string) error {
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
f, err := os.Create(dest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
_, err = io.Copy(f, resp.Body)
|
|
return err
|
|
}
|
|
|
|
func sha256File(path string) (string, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
|
|
h := sha256.New()
|
|
if _, err := io.Copy(h, f); err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
|
}
|