初始化仓库
This commit is contained in:
+366
@@ -0,0 +1,366 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user