367 lines
8.7 KiB
Go
367 lines
8.7 KiB
Go
package core
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
githubReleasesAPI = "https://api.github.com/repos/chenhg5/cc-connect/releases"
|
|
giteeReleasesAPI = "https://gitee.com/api/v5/repos/cg33/cc-connect/releases"
|
|
githubDownload = "https://github.com/chenhg5/cc-connect/releases/download"
|
|
giteeDownload = "https://gitee.com/cg33/cc-connect/releases/download"
|
|
)
|
|
|
|
type ReleaseInfo struct {
|
|
TagName string `json:"tag_name"`
|
|
Name string `json:"name"`
|
|
Body string `json:"body"`
|
|
Prerelease bool `json:"prerelease"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// CheckForUpdate queries GitHub/Gitee for newer releases.
|
|
// If preferGitee is true, tries Gitee first (faster in China); otherwise GitHub first.
|
|
func CheckForUpdate(currentVersion string, preferGitee bool) (*ReleaseInfo, error) {
|
|
releases, err := fetchReleases(preferGitee)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(releases) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// Find the newest release by semver comparison
|
|
var best *ReleaseInfo
|
|
for i := range releases {
|
|
r := &releases[i]
|
|
if r.TagName == "" {
|
|
continue
|
|
}
|
|
if best == nil || semverCompare(r.TagName, best.TagName) > 0 {
|
|
best = r
|
|
}
|
|
}
|
|
|
|
if best == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
cur := normalizeVersion(currentVersion)
|
|
latest := normalizeVersion(best.TagName)
|
|
if cur == latest || semverCompare(best.TagName, currentVersion) <= 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
return best, nil
|
|
}
|
|
|
|
func fetchReleases(preferGitee bool) ([]ReleaseInfo, error) {
|
|
type source struct {
|
|
name string
|
|
url string
|
|
}
|
|
sources := []source{
|
|
{"github", githubReleasesAPI + "?per_page=20"},
|
|
{"gitee", giteeReleasesAPI + "?per_page=20&direction=desc&sort=created"},
|
|
}
|
|
if preferGitee {
|
|
sources[0], sources[1] = sources[1], sources[0]
|
|
}
|
|
|
|
releases, err := fetchReleasesFrom(sources[0].url)
|
|
if err == nil && len(releases) > 0 {
|
|
return releases, nil
|
|
}
|
|
slog.Debug("updater: primary source failed, trying fallback", "primary", sources[0].name, "error", err)
|
|
|
|
releases, err = fetchReleasesFrom(sources[1].url)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("check updates failed (both sources): %w", err)
|
|
}
|
|
return releases, nil
|
|
}
|
|
|
|
func fetchReleasesFrom(apiURL string) ([]ReleaseInfo, error) {
|
|
client := &http.Client{Timeout: 15 * time.Second}
|
|
req, err := http.NewRequest("GET", apiURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("User-Agent", "cc-connect-updater")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("API returned %d", resp.StatusCode)
|
|
}
|
|
|
|
var releases []ReleaseInfo
|
|
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
|
return nil, err
|
|
}
|
|
return releases, nil
|
|
}
|
|
|
|
// SelfUpdate downloads and installs the given release version.
|
|
// If preferGitee is true, tries Gitee download first.
|
|
func SelfUpdate(tag string, preferGitee bool) error {
|
|
goos := runtime.GOOS
|
|
goarch := runtime.GOARCH
|
|
|
|
ext := ".tar.gz"
|
|
if goos == "windows" {
|
|
ext = ".zip"
|
|
}
|
|
filename := fmt.Sprintf("cc-connect-%s-%s-%s%s", tag, goos, goarch, ext)
|
|
|
|
giteeURL := fmt.Sprintf("%s/%s/%s", giteeDownload, tag, filename)
|
|
githubURL := fmt.Sprintf("%s/%s/%s", githubDownload, tag, filename)
|
|
urls := []string{githubURL, giteeURL}
|
|
if preferGitee {
|
|
urls = []string{giteeURL, githubURL}
|
|
}
|
|
|
|
var data []byte
|
|
var lastErr error
|
|
for _, u := range urls {
|
|
slog.Info("updater: downloading", "url", u)
|
|
data, lastErr = downloadFile(u)
|
|
if lastErr == nil {
|
|
break
|
|
}
|
|
slog.Debug("updater: download failed, trying next", "error", lastErr)
|
|
}
|
|
if lastErr != nil && data == nil {
|
|
return fmt.Errorf("download failed from all sources: %w", lastErr)
|
|
}
|
|
|
|
var binary []byte
|
|
var err error
|
|
if goos == "windows" {
|
|
binary, err = extractBinaryFromZip(data)
|
|
} else {
|
|
binary, err = extractBinaryFromTarGz(data)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("extract binary: %w", err)
|
|
}
|
|
|
|
return replaceBinary(binary)
|
|
}
|
|
|
|
func downloadFile(url string) ([]byte, error) {
|
|
client := &http.Client{
|
|
Timeout: 5 * time.Minute,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
if len(via) > 10 {
|
|
return fmt.Errorf("too many redirects")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("User-Agent", "cc-connect-updater")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("HTTP %d for %s", resp.StatusCode, url)
|
|
}
|
|
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
func extractBinaryFromTarGz(data []byte) ([]byte, error) {
|
|
r := bytes.NewReader(data)
|
|
gr, err := gzip.NewReader(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer gr.Close()
|
|
|
|
tr := tar.NewReader(gr)
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
name := filepath.Base(hdr.Name)
|
|
if strings.HasPrefix(name, "cc-connect") && hdr.Typeflag == tar.TypeReg {
|
|
return io.ReadAll(tr)
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("cc-connect binary not found in archive")
|
|
}
|
|
|
|
func extractBinaryFromZip(data []byte) ([]byte, error) {
|
|
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, f := range r.File {
|
|
name := filepath.Base(f.Name)
|
|
if strings.HasPrefix(name, "cc-connect") && !f.FileInfo().IsDir() {
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rc.Close()
|
|
return io.ReadAll(rc)
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("cc-connect binary not found in zip archive")
|
|
}
|
|
|
|
func replaceBinary(newBinary []byte) error {
|
|
execPath, err := os.Executable()
|
|
if err != nil {
|
|
return fmt.Errorf("get executable path: %w", err)
|
|
}
|
|
execPath, err = filepath.EvalSymlinks(execPath)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve symlinks: %w", err)
|
|
}
|
|
|
|
dir := filepath.Dir(execPath)
|
|
tmpFile, err := os.CreateTemp(dir, "cc-connect-update-*")
|
|
if err != nil {
|
|
return fmt.Errorf("create temp file: %w", err)
|
|
}
|
|
tmpPath := tmpFile.Name()
|
|
|
|
if _, err := tmpFile.Write(newBinary); err != nil {
|
|
tmpFile.Close()
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("write new binary: %w", err)
|
|
}
|
|
tmpFile.Close()
|
|
|
|
if err := os.Chmod(tmpPath, 0o755); err != nil {
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("chmod: %w", err)
|
|
}
|
|
|
|
oldPath := execPath + ".old"
|
|
os.Remove(oldPath)
|
|
|
|
if err := os.Rename(execPath, oldPath); err != nil {
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("backup old binary: %w", err)
|
|
}
|
|
|
|
if err := os.Rename(tmpPath, execPath); err != nil {
|
|
// Try to restore
|
|
if restoreErr := os.Rename(oldPath, execPath); restoreErr != nil {
|
|
slog.Error("updater: failed to restore old binary after install failed", "error", restoreErr)
|
|
}
|
|
return fmt.Errorf("install new binary: %w", err)
|
|
}
|
|
|
|
// Don't remove .old file on Linux - the running process may still need it
|
|
// for os.Executable() to work correctly after restart.
|
|
// The .old file will be overwritten on next update.
|
|
|
|
slog.Info("updater: binary replaced successfully", "path", execPath)
|
|
return nil
|
|
}
|
|
|
|
// --- semver comparison ---
|
|
|
|
var semverRe = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)(?:-(.+))?$`)
|
|
|
|
type semver struct {
|
|
major, minor, patch int
|
|
pre string
|
|
preNum int
|
|
}
|
|
|
|
func parseSemver(v string) semver {
|
|
m := semverRe.FindStringSubmatch(strings.TrimSpace(v))
|
|
if m == nil {
|
|
return semver{}
|
|
}
|
|
major, _ := strconv.Atoi(m[1])
|
|
minor, _ := strconv.Atoi(m[2])
|
|
patch, _ := strconv.Atoi(m[3])
|
|
pre := m[4]
|
|
preNum := 0
|
|
if idx := strings.LastIndex(pre, "."); idx >= 0 {
|
|
preNum, _ = strconv.Atoi(pre[idx+1:])
|
|
}
|
|
return semver{major: major, minor: minor, patch: patch, pre: pre, preNum: preNum}
|
|
}
|
|
|
|
func normalizeVersion(v string) string {
|
|
v = strings.TrimSpace(v)
|
|
if !strings.HasPrefix(v, "v") {
|
|
v = "v" + v
|
|
}
|
|
return v
|
|
}
|
|
|
|
// semverCompare returns >0 if a > b, <0 if a < b, 0 if equal.
|
|
func semverCompare(a, b string) int {
|
|
sa := parseSemver(a)
|
|
sb := parseSemver(b)
|
|
|
|
if d := sa.major - sb.major; d != 0 {
|
|
return d
|
|
}
|
|
if d := sa.minor - sb.minor; d != 0 {
|
|
return d
|
|
}
|
|
if d := sa.patch - sb.patch; d != 0 {
|
|
return d
|
|
}
|
|
// No pre-release > has pre-release (1.0.0 > 1.0.0-beta.1)
|
|
if sa.pre == "" && sb.pre != "" {
|
|
return 1
|
|
}
|
|
if sa.pre != "" && sb.pre == "" {
|
|
return -1
|
|
}
|
|
// Both have pre-release: compare lexicographically, then by number
|
|
if sa.pre != sb.pre {
|
|
if d := strings.Compare(sa.pre, sb.pre); d != 0 {
|
|
// "beta" prefix comparison; if same prefix, compare numbers
|
|
aPre := strings.TrimRight(sa.pre, "0123456789.")
|
|
bPre := strings.TrimRight(sb.pre, "0123456789.")
|
|
if aPre == bPre {
|
|
return sa.preNum - sb.preNum
|
|
}
|
|
return d
|
|
}
|
|
}
|
|
return 0
|
|
}
|