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 }