Files
2026-06-02 23:14:41 +08:00

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
}