feat: add selfupdate module (check, apply, restart)

- 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>
This commit is contained in:
2026-05-26 12:39:38 +03:00
parent 5f4ff47ce7
commit e927fff02f

View File

@@ -1,2 +1,145 @@
// package selfupdate handles launcher auto-updates via go-selfupdate.
// 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
}