github.com/kelleygo/clashcore@v1.0.2/hub/updater/updater.go (about)

     1  package updater
     2  
     3  import (
     4  	"archive/zip"
     5  	"compress/gzip"
     6  	"context"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"os"
    11  	"os/exec"
    12  	"path/filepath"
    13  	"runtime"
    14  	"strings"
    15  	"sync"
    16  	"time"
    17  
    18  	yiclashcoreHttp "github.com/kelleygo/clashcore/component/http"
    19  	"github.com/kelleygo/clashcore/constant"
    20  	C "github.com/kelleygo/clashcore/constant"
    21  	"github.com/kelleygo/clashcore/log"
    22  
    23  	"github.com/klauspost/cpuid/v2"
    24  )
    25  
    26  // modify from https://github.com/AdguardTeam/AdGuardHome/blob/595484e0b3fb4c457f9bb727a6b94faa78a66c5f/internal/updater/updater.go
    27  // Updater is the yiclashcore updater.
    28  var (
    29  	goarm           string
    30  	gomips          string
    31  	amd64Compatible string
    32  
    33  	workDir string
    34  
    35  	// mu protects all fields below.
    36  	mu sync.Mutex
    37  
    38  	currentExeName string // 当前可执行文件
    39  	updateDir      string // 更新目录
    40  	packageName    string // 更新压缩文件
    41  	backupDir      string // 备份目录
    42  	backupExeName  string // 备份文件名
    43  	updateExeName  string // 更新后的可执行文件
    44  
    45  	baseURL       string = "https://github.com/kelleygo/clashcore/releases/download/Prerelease-Alpha/yiclashcore"
    46  	versionURL    string = "https://github.com/kelleygo/clashcore/releases/download/Prerelease-Alpha/version.txt"
    47  	packageURL    string
    48  	latestVersion string
    49  )
    50  
    51  func init() {
    52  	if runtime.GOARCH == "amd64" && cpuid.CPU.X64Level() < 3 {
    53  		amd64Compatible = "-compatible"
    54  	}
    55  }
    56  
    57  type updateError struct {
    58  	Message string
    59  }
    60  
    61  func (e *updateError) Error() string {
    62  	return fmt.Sprintf("update error: %s", e.Message)
    63  }
    64  
    65  // Update performs the auto-updater.  It returns an error if the updater failed.
    66  // If firstRun is true, it assumes the configuration file doesn't exist.
    67  func Update(execPath string) (err error) {
    68  	mu.Lock()
    69  	defer mu.Unlock()
    70  
    71  	latestVersion, err = getLatestVersion()
    72  	if err != nil {
    73  		return err
    74  	}
    75  
    76  	log.Infoln("current version %s, latest version %s", constant.Version, latestVersion)
    77  
    78  	if latestVersion == constant.Version {
    79  		err := &updateError{Message: "already using latest version"}
    80  		return err
    81  	}
    82  
    83  	updateDownloadURL()
    84  
    85  	defer func() {
    86  		if err != nil {
    87  			log.Errorln("updater: failed: %v", err)
    88  		} else {
    89  			log.Infoln("updater: finished")
    90  		}
    91  	}()
    92  
    93  	workDir = filepath.Dir(execPath)
    94  
    95  	err = prepare(execPath)
    96  	if err != nil {
    97  		return fmt.Errorf("preparing: %w", err)
    98  	}
    99  
   100  	defer clean()
   101  
   102  	err = downloadPackageFile()
   103  	if err != nil {
   104  		return fmt.Errorf("downloading package file: %w", err)
   105  	}
   106  
   107  	err = unpack()
   108  	if err != nil {
   109  		return fmt.Errorf("unpacking: %w", err)
   110  	}
   111  
   112  	err = backup()
   113  	if err != nil {
   114  		return fmt.Errorf("backuping: %w", err)
   115  	}
   116  
   117  	err = replace()
   118  	if err != nil {
   119  		return fmt.Errorf("replacing: %w", err)
   120  	}
   121  
   122  	return nil
   123  }
   124  
   125  // prepare fills all necessary fields in Updater object.
   126  func prepare(exePath string) (err error) {
   127  	updateDir = filepath.Join(workDir, "meta-update")
   128  	currentExeName = exePath
   129  	_, pkgNameOnly := filepath.Split(packageURL)
   130  	if pkgNameOnly == "" {
   131  		return fmt.Errorf("invalid PackageURL: %q", packageURL)
   132  	}
   133  
   134  	packageName = filepath.Join(updateDir, pkgNameOnly)
   135  	//log.Infoln(packageName)
   136  	backupDir = filepath.Join(workDir, "meta-backup")
   137  
   138  	if runtime.GOOS == "windows" {
   139  		updateExeName = "yiclashcore" + "-" + runtime.GOOS + "-" + runtime.GOARCH + amd64Compatible + ".exe"
   140  	} else if runtime.GOOS == "android" && runtime.GOARCH == "arm64" {
   141  		updateExeName = "yiclashcore-android-arm64-v8"
   142  	} else {
   143  		updateExeName = "yiclashcore" + "-" + runtime.GOOS + "-" + runtime.GOARCH + amd64Compatible
   144  	}
   145  
   146  	log.Infoln("updateExeName: %s ", updateExeName)
   147  
   148  	backupExeName = filepath.Join(backupDir, filepath.Base(exePath))
   149  	updateExeName = filepath.Join(updateDir, updateExeName)
   150  
   151  	log.Infoln(
   152  		"updater: updating using url: %s",
   153  		packageURL,
   154  	)
   155  
   156  	currentExeName = exePath
   157  	_, err = os.Stat(currentExeName)
   158  	if err != nil {
   159  		return fmt.Errorf("checking %q: %w", currentExeName, err)
   160  	}
   161  
   162  	return nil
   163  }
   164  
   165  // unpack extracts the files from the downloaded archive.
   166  func unpack() error {
   167  	var err error
   168  	_, pkgNameOnly := filepath.Split(packageURL)
   169  
   170  	log.Infoln("updater: unpacking package")
   171  	if strings.HasSuffix(pkgNameOnly, ".zip") {
   172  		_, err = zipFileUnpack(packageName, updateDir)
   173  		if err != nil {
   174  			return fmt.Errorf(".zip unpack failed: %w", err)
   175  		}
   176  
   177  	} else if strings.HasSuffix(pkgNameOnly, ".gz") {
   178  		_, err = gzFileUnpack(packageName, updateDir)
   179  		if err != nil {
   180  			return fmt.Errorf(".gz unpack failed: %w", err)
   181  		}
   182  
   183  	} else {
   184  		return fmt.Errorf("unknown package extension")
   185  	}
   186  
   187  	return nil
   188  }
   189  
   190  // backup makes a backup of the current executable file
   191  func backup() (err error) {
   192  	log.Infoln("updater: backing up current ExecFile:%s to %s", currentExeName, backupExeName)
   193  	_ = os.Mkdir(backupDir, 0o755)
   194  
   195  	err = os.Rename(currentExeName, backupExeName)
   196  	if err != nil {
   197  		return err
   198  	}
   199  
   200  	return nil
   201  }
   202  
   203  // replace moves the current executable with the updated one
   204  func replace() error {
   205  	var err error
   206  
   207  	log.Infoln("replacing: %s to %s", updateExeName, currentExeName)
   208  	if runtime.GOOS == "windows" {
   209  		// rename fails with "File in use" error
   210  		err = copyFile(updateExeName, currentExeName)
   211  	} else {
   212  		err = os.Rename(updateExeName, currentExeName)
   213  	}
   214  	if err != nil {
   215  		return err
   216  	}
   217  
   218  	log.Infoln("updater: renamed: %s to %s", updateExeName, currentExeName)
   219  
   220  	return nil
   221  }
   222  
   223  // clean removes the temporary directory itself and all it's contents.
   224  func clean() {
   225  	_ = os.RemoveAll(updateDir)
   226  }
   227  
   228  // MaxPackageFileSize is a maximum package file length in bytes. The largest
   229  // package whose size is limited by this constant currently has the size of
   230  // approximately 9 MiB.
   231  const MaxPackageFileSize = 32 * 1024 * 1024
   232  
   233  // Download package file and save it to disk
   234  func downloadPackageFile() (err error) {
   235  	ctx, cancel := context.WithTimeout(context.Background(), time.Second*90)
   236  	defer cancel()
   237  	resp, err := yiclashcoreHttp.HttpRequest(ctx, packageURL, http.MethodGet, http.Header{"User-Agent": {C.UA}}, nil)
   238  	if err != nil {
   239  		return fmt.Errorf("http request failed: %w", err)
   240  	}
   241  
   242  	defer func() {
   243  		closeErr := resp.Body.Close()
   244  		if closeErr != nil && err == nil {
   245  			err = closeErr
   246  		}
   247  	}()
   248  
   249  	var r io.Reader
   250  	r, err = LimitReader(resp.Body, MaxPackageFileSize)
   251  	if err != nil {
   252  		return fmt.Errorf("http request failed: %w", err)
   253  	}
   254  
   255  	log.Debugln("updater: reading http body")
   256  	// This use of ReadAll is now safe, because we limited body's Reader.
   257  	body, err := io.ReadAll(r)
   258  	if err != nil {
   259  		return fmt.Errorf("io.ReadAll() failed: %w", err)
   260  	}
   261  
   262  	log.Debugln("updateDir %s", updateDir)
   263  	err = os.Mkdir(updateDir, 0o755)
   264  	if err != nil {
   265  		return fmt.Errorf("mkdir error: %w", err)
   266  	}
   267  
   268  	log.Debugln("updater: saving package to file %s", packageName)
   269  	err = os.WriteFile(packageName, body, 0o644)
   270  	if err != nil {
   271  		return fmt.Errorf("os.WriteFile() failed: %w", err)
   272  	}
   273  	return nil
   274  }
   275  
   276  // Unpack a single .gz file to the specified directory
   277  // Existing files are overwritten
   278  // All files are created inside outDir, subdirectories are not created
   279  // Return the output file name
   280  func gzFileUnpack(gzfile, outDir string) (string, error) {
   281  	f, err := os.Open(gzfile)
   282  	if err != nil {
   283  		return "", fmt.Errorf("os.Open(): %w", err)
   284  	}
   285  
   286  	defer func() {
   287  		closeErr := f.Close()
   288  		if closeErr != nil && err == nil {
   289  			err = closeErr
   290  		}
   291  	}()
   292  
   293  	gzReader, err := gzip.NewReader(f)
   294  	if err != nil {
   295  		return "", fmt.Errorf("gzip.NewReader(): %w", err)
   296  	}
   297  
   298  	defer func() {
   299  		closeErr := gzReader.Close()
   300  		if closeErr != nil && err == nil {
   301  			err = closeErr
   302  		}
   303  	}()
   304  	// Get the original file name from the .gz file header
   305  	originalName := gzReader.Header.Name
   306  	if originalName == "" {
   307  		// Fallback: remove the .gz extension from the input file name if the header doesn't provide the original name
   308  		originalName = filepath.Base(gzfile)
   309  		originalName = strings.TrimSuffix(originalName, ".gz")
   310  	}
   311  
   312  	outputName := filepath.Join(outDir, originalName)
   313  
   314  	// Create the output file
   315  	wc, err := os.OpenFile(
   316  		outputName,
   317  		os.O_WRONLY|os.O_CREATE|os.O_TRUNC,
   318  		0o755,
   319  	)
   320  	if err != nil {
   321  		return "", fmt.Errorf("os.OpenFile(%s): %w", outputName, err)
   322  	}
   323  
   324  	defer func() {
   325  		closeErr := wc.Close()
   326  		if closeErr != nil && err == nil {
   327  			err = closeErr
   328  		}
   329  	}()
   330  
   331  	// Copy the contents of the gzReader to the output file
   332  	_, err = io.Copy(wc, gzReader)
   333  	if err != nil {
   334  		return "", fmt.Errorf("io.Copy(): %w", err)
   335  	}
   336  
   337  	return outputName, nil
   338  }
   339  
   340  // Unpack a single file from .zip file to the specified directory
   341  // Existing files are overwritten
   342  // All files are created inside 'outDir', subdirectories are not created
   343  // Return the output file name
   344  func zipFileUnpack(zipfile, outDir string) (string, error) {
   345  	zrc, err := zip.OpenReader(zipfile)
   346  	if err != nil {
   347  		return "", fmt.Errorf("zip.OpenReader(): %w", err)
   348  	}
   349  
   350  	defer func() {
   351  		closeErr := zrc.Close()
   352  		if closeErr != nil && err == nil {
   353  			err = closeErr
   354  		}
   355  	}()
   356  	if len(zrc.File) == 0 {
   357  		return "", fmt.Errorf("no files in the zip archive")
   358  	}
   359  
   360  	// Assuming the first file in the zip archive is the target file
   361  	zf := zrc.File[0]
   362  	var rc io.ReadCloser
   363  	rc, err = zf.Open()
   364  	if err != nil {
   365  		return "", fmt.Errorf("zip file Open(): %w", err)
   366  	}
   367  
   368  	defer func() {
   369  		closeErr := rc.Close()
   370  		if closeErr != nil && err == nil {
   371  			err = closeErr
   372  		}
   373  	}()
   374  	fi := zf.FileInfo()
   375  	name := fi.Name()
   376  	outputName := filepath.Join(outDir, name)
   377  
   378  	if fi.IsDir() {
   379  		return "", fmt.Errorf("the target file is a directory")
   380  	}
   381  
   382  	var wc io.WriteCloser
   383  	wc, err = os.OpenFile(outputName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fi.Mode())
   384  	if err != nil {
   385  		return "", fmt.Errorf("os.OpenFile(): %w", err)
   386  	}
   387  
   388  	defer func() {
   389  		closeErr := wc.Close()
   390  		if closeErr != nil && err == nil {
   391  			err = closeErr
   392  		}
   393  	}()
   394  	_, err = io.Copy(wc, rc)
   395  	if err != nil {
   396  		return "", fmt.Errorf("io.Copy(): %w", err)
   397  	}
   398  
   399  	return outputName, nil
   400  }
   401  
   402  // Copy file on disk
   403  func copyFile(src, dst string) error {
   404  	d, e := os.ReadFile(src)
   405  	if e != nil {
   406  		return e
   407  	}
   408  	e = os.WriteFile(dst, d, 0o644)
   409  	if e != nil {
   410  		return e
   411  	}
   412  	return nil
   413  }
   414  
   415  func getLatestVersion() (version string, err error) {
   416  	ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
   417  	defer cancel()
   418  	resp, err := yiclashcoreHttp.HttpRequest(ctx, versionURL, http.MethodGet, http.Header{"User-Agent": {C.UA}}, nil)
   419  	if err != nil {
   420  		return "", fmt.Errorf("get Latest Version fail: %w", err)
   421  	}
   422  	defer func() {
   423  		closeErr := resp.Body.Close()
   424  		if closeErr != nil && err == nil {
   425  			err = closeErr
   426  		}
   427  	}()
   428  
   429  	body, err := io.ReadAll(resp.Body)
   430  	if err != nil {
   431  		return "", fmt.Errorf("get Latest Version fail: %w", err)
   432  	}
   433  	content := strings.TrimRight(string(body), "\n")
   434  	return content, nil
   435  }
   436  
   437  func updateDownloadURL() {
   438  	var middle string
   439  
   440  	if runtime.GOARCH == "arm" && probeGoARM() {
   441  		//-linux-armv7-alpha-e552b54.gz
   442  		middle = fmt.Sprintf("-%s-%s%s-%s", runtime.GOOS, runtime.GOARCH, goarm, latestVersion)
   443  	} else if runtime.GOARCH == "arm64" {
   444  		//-linux-arm64-alpha-e552b54.gz
   445  		if runtime.GOOS == "android" {
   446  			middle = fmt.Sprintf("-%s-%s-v8-%s", runtime.GOOS, runtime.GOARCH, latestVersion)
   447  		} else {
   448  			middle = fmt.Sprintf("-%s-%s-%s", runtime.GOOS, runtime.GOARCH, latestVersion)
   449  		}
   450  	} else if isMIPS(runtime.GOARCH) && gomips != "" {
   451  		middle = fmt.Sprintf("-%s-%s-%s-%s", runtime.GOOS, runtime.GOARCH, gomips, latestVersion)
   452  	} else {
   453  		middle = fmt.Sprintf("-%s-%s%s-%s", runtime.GOOS, runtime.GOARCH, amd64Compatible, latestVersion)
   454  	}
   455  
   456  	if runtime.GOOS == "windows" {
   457  		middle += ".zip"
   458  	} else {
   459  		middle += ".gz"
   460  	}
   461  	packageURL = baseURL + middle
   462  	//log.Infoln(packageURL)
   463  }
   464  
   465  // isMIPS returns true if arch is any MIPS architecture.
   466  func isMIPS(arch string) (ok bool) {
   467  	switch arch {
   468  	case
   469  		"mips",
   470  		"mips64",
   471  		"mips64le",
   472  		"mipsle":
   473  		return true
   474  	default:
   475  		return false
   476  	}
   477  }
   478  
   479  // linux only
   480  func probeGoARM() (ok bool) {
   481  	cmd := exec.Command("cat", "/proc/cpuinfo")
   482  	output, err := cmd.Output()
   483  	if err != nil {
   484  		log.Errorln("probe goarm error:%s", err)
   485  		return false
   486  	}
   487  	cpuInfo := string(output)
   488  	if strings.Contains(cpuInfo, "vfpv3") || strings.Contains(cpuInfo, "vfpv4") {
   489  		goarm = "v7"
   490  	} else if strings.Contains(cpuInfo, "vfp") {
   491  		goarm = "v6"
   492  	} else {
   493  		goarm = "v5"
   494  	}
   495  	return true
   496  }