Files
G-Launcher/internal/git-service/launcher.go
T

247 lines
8.2 KiB
Go

package gitService
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"g-launcher/pkg/constant"
"g-launcher/pkg/models"
"github.com/minio/selfupdate"
)
type GitService struct{}
type launcherManifest struct {
Version string `json:"version"`
SHA256 string `json:"sha256"`
}
var launcherVersionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`)
func (g *GitService) GetLatestLauncherVersion() (bool, string, string) {
// First, attempt to check via latest.json manifest
if constant.LauncherManifestURL != "" {
manifest, err := fetchLauncherManifest(constant.LauncherManifestURL)
if err == nil && manifest.Version != "" {
return true, manifest.Version, ""
}
}
// Fallback to Gitea/Forgejo/GitHub Releases API
if constant.LauncherGitUrl != "" {
resp, err := http.Get(constant.LauncherGitUrl)
if err == nil {
defer resp.Body.Close()
body, readErr := io.ReadAll(resp.Body)
if readErr == nil {
var releases []models.ReleaseType
if json.Unmarshal(body, &releases) == nil && len(releases) > 0 {
return true, releases[0].TagName, ""
}
}
}
}
return false, "", "no launcher updates found"
}
func (g *GitService) UpdateLauncherProgress(version string) (bool, string) {
if !constant.LauncherUpdatesEnabled {
return false, "launcher updates are disabled in development builds"
}
// Mode 1: Manifest-based update with SHA-256 integrity verification
if constant.LauncherManifestURL != "" && constant.LauncherDownloadURL != "" {
manifest, err := fetchLauncherManifest(constant.LauncherManifestURL)
if err == nil && manifest.Version == version && manifest.SHA256 != "" {
return g.applyManifestUpdate(manifest)
}
}
// Mode 2: Release API fallback
if constant.LauncherGitUrl != "" {
asset, ok := g.getReleaseAsset(version, constant.LauncherGitUrl, constant.LauncherFile)
if ok && asset.BrowserDownloadURL != "" {
resp, err := http.Get(asset.BrowserDownloadURL)
if err != nil {
return false, err.Error()
}
defer resp.Body.Close()
err = selfupdate.Apply(resp.Body, selfupdate.Options{})
if err != nil {
return false, err.Error()
}
return true, ""
}
}
// Mode 3: Build from source inside a temporary workspace, replace binary, and delete temp
return g.BuildAndUpdateFromSource(constant.LauncherBranch)
}
func (g *GitService) BuildAndUpdateFromSource(branch string) (bool, string) {
if branch == "" {
branch = constant.LauncherBranch
}
gitExe, err := ensureGit()
if err != nil {
return false, fmt.Sprintf("git is required for source update: %v", err)
}
// 1. Create clean temporary directory
tempDir, err := os.MkdirTemp("", "g-launcher-build-*")
if err != nil {
return false, fmt.Sprintf("create temp build directory: %v", err)
}
defer os.RemoveAll(tempDir) // Guarantee temporary directory is completely deleted
// 2. Clone repository into temp directory
cloneCmd := exec.Command(gitExe, "clone", "--depth", "1", "--branch", branch, constant.LauncherRepoURL, tempDir)
cloneCmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
if out, err := cloneCmd.CombinedOutput(); err != nil {
return false, fmt.Sprintf("clone repository to temp: %v (%s)", err, strings.TrimSpace(string(out)))
}
// 3. Build frontend inside temp
frontendDir := filepath.Join(tempDir, "frontend")
npmInstall := exec.Command("npm", "install", "--no-audit", "--no-fund")
npmInstall.Dir = frontendDir
if out, err := npmInstall.CombinedOutput(); err != nil {
return false, fmt.Sprintf("npm install in temp: %v (%s)", err, strings.TrimSpace(string(out)))
}
npmBuild := exec.Command("npm", "run", "build")
npmBuild.Dir = frontendDir
if out, err := npmBuild.CombinedOutput(); err != nil {
return false, fmt.Sprintf("npm run build in temp: %v (%s)", err, strings.TrimSpace(string(out)))
}
// 4. Build Go binary inside temp
builtExePath := filepath.Join(tempDir, "bin", "G-Launcher.exe")
if err := os.MkdirAll(filepath.Dir(builtExePath), 0755); err != nil {
return false, fmt.Sprintf("create temp bin directory: %v", err)
}
goBuild := exec.Command("go", "build", "-tags", "production", "-trimpath", "-buildvcs=false", "-ldflags=-w -s -H windowsgui", "-o", builtExePath, ".")
goBuild.Dir = tempDir
if out, err := goBuild.CombinedOutput(); err != nil {
return false, fmt.Sprintf("go build in temp: %v (%s)", err, strings.TrimSpace(string(out)))
}
// 5. Replace current running binary with newly built binary
builtFile, err := os.Open(builtExePath)
if err != nil {
return false, fmt.Sprintf("open built launcher in temp: %v", err)
}
defer builtFile.Close()
if err := selfupdate.Apply(builtFile, selfupdate.Options{}); err != nil {
return false, fmt.Sprintf("apply launcher update: %v", err)
}
return true, ""
}
func (g *GitService) applyManifestUpdate(manifest launcherManifest) (bool, string) {
client := &http.Client{Timeout: 10 * time.Minute}
response, err := client.Get(constant.LauncherDownloadURL)
if err != nil {
return false, fmt.Errorf("download launcher update: %w", err).Error()
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return false, fmt.Sprintf("download launcher update: server returned %s", response.Status)
}
temporaryFile, err := os.CreateTemp("", "g-launcher-update-*.exe")
if err != nil {
return false, fmt.Errorf("create temporary launcher update: %w", err).Error()
}
temporaryPath := temporaryFile.Name()
defer os.Remove(temporaryPath)
hash := sha256.New()
if _, err := io.Copy(io.MultiWriter(temporaryFile, hash), response.Body); err != nil {
temporaryFile.Close()
return false, fmt.Errorf("save launcher update: %w", err).Error()
}
if err := temporaryFile.Close(); err != nil {
return false, fmt.Errorf("close launcher update: %w", err).Error()
}
expectedHash, err := hex.DecodeString(manifest.SHA256)
if err != nil || subtle.ConstantTimeCompare(hash.Sum(nil), expectedHash) != 1 {
return false, "launcher update checksum mismatch"
}
updateFile, err := os.Open(temporaryPath)
if err != nil {
return false, fmt.Errorf("open launcher update: %w", err).Error()
}
defer updateFile.Close()
if err := selfupdate.Apply(updateFile, selfupdate.Options{}); err != nil {
return false, fmt.Errorf("apply launcher update: %w", err).Error()
}
return true, ""
}
func fetchLauncherManifest(manifestURL string) (launcherManifest, error) {
parsedURL, err := url.Parse(manifestURL)
if err != nil || (parsedURL.Scheme != "https" && parsedURL.Scheme != "http") {
return launcherManifest{}, fmt.Errorf("invalid launcher manifest URL")
}
query := parsedURL.Query()
query.Set("_", fmt.Sprintf("%d", time.Now().UnixNano()))
parsedURL.RawQuery = query.Encode()
request, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil)
if err != nil {
return launcherManifest{}, fmt.Errorf("create launcher manifest request: %w", err)
}
request.Header.Set("Cache-Control", "no-cache")
request.Header.Set("User-Agent", "G-Launcher")
client := &http.Client{Timeout: 20 * time.Second}
response, err := client.Do(request)
if err != nil {
return launcherManifest{}, fmt.Errorf("check launcher updates: %w", err)
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return launcherManifest{}, fmt.Errorf("check launcher updates: server returned %s", response.Status)
}
var manifest launcherManifest
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&manifest); err != nil {
return launcherManifest{}, fmt.Errorf("read launcher update manifest: %w", err)
}
if !launcherVersionPattern.MatchString(manifest.Version) {
return launcherManifest{}, fmt.Errorf("launcher update manifest has invalid version %q", manifest.Version)
}
manifest.SHA256 = strings.TrimSpace(manifest.SHA256)
if len(manifest.SHA256) != sha256.Size*2 {
return launcherManifest{}, fmt.Errorf("launcher update manifest has invalid SHA-256")
}
if _, err := hex.DecodeString(manifest.SHA256); err != nil {
return launcherManifest{}, fmt.Errorf("launcher update manifest has invalid SHA-256: %w", err)
}
manifest.SHA256 = strings.ToLower(manifest.SHA256)
return manifest, nil
}