package sync import ( "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "runtime" "time" ) // Updater checks for and downloads client updates. type Updater struct { fetcher *Fetcher currentVer string dataDir string } // NewUpdater creates an updater. func NewUpdater(fetcher *Fetcher, currentVersion, dataDir string) *Updater { return &Updater{ fetcher: fetcher, currentVer: currentVersion, dataDir: dataDir, } } // UpdateInfo describes an available update. type UpdateInfo struct { Available bool `json:"available"` Version string `json:"version"` Changelog string `json:"changelog"` CurrentVer string `json:"current_version"` } // Check returns info about available updates. func (u *Updater) Check() (*UpdateInfo, error) { ver, err := u.fetcher.FetchVersion() if err != nil { return nil, fmt.Errorf("check update: %w", err) } return &UpdateInfo{ Available: ver.Version != u.currentVer, Version: ver.Version, Changelog: ver.Changelog, CurrentVer: u.currentVer, }, nil } // Download fetches the new binary, cleans stale configs, replaces current binary and restarts. func (u *Updater) Download() (string, error) { ver, err := u.fetcher.FetchVersion() if err != nil { return "", fmt.Errorf("fetch version: %w", err) } if ver.URL == "" { suffix := "linux-amd64" if runtime.GOOS == "windows" { suffix = "windows-amd64.exe" } ver.URL = u.fetcher.baseURL + "/releases/vpnem-" + suffix } client := &http.Client{Timeout: 5 * time.Minute} resp, err := client.Get(ver.URL) if err != nil { return "", fmt.Errorf("download: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("download: HTTP %d", resp.StatusCode) } ext := "" if runtime.GOOS == "windows" { ext = ".exe" } newBin := filepath.Join(u.dataDir, "vpnem-update"+ext) f, err := os.Create(newBin) if err != nil { return "", fmt.Errorf("create file: %w", err) } if _, err := io.Copy(f, resp.Body); err != nil { f.Close() os.Remove(newBin) return "", fmt.Errorf("write update: %w", err) } f.Close() if runtime.GOOS != "windows" { os.Chmod(newBin, 0o755) } // Clean stale configs so new version starts fresh os.Remove(filepath.Join(u.dataDir, "state.json")) os.Remove(filepath.Join(u.dataDir, "config.json")) os.Remove(filepath.Join(u.dataDir, "cache.db")) // Replace current binary and restart currentBin, _ := os.Executable() if currentBin != "" { if runtime.GOOS == "windows" { // Windows can't overwrite running exe — rename old, copy new, restart oldBin := currentBin + ".old" os.Remove(oldBin) os.Rename(currentBin, oldBin) copyFile(newBin, currentBin) // Restart: launch new binary and exit current cmd := exec.Command(currentBin) cmd.Dir = u.dataDir cmd.Start() os.Exit(0) } else { // Linux: overwrite in place, then re-exec copyFile(newBin, currentBin) os.Remove(newBin) cmd := exec.Command(currentBin) cmd.Dir = u.dataDir cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Start() os.Exit(0) } } return newBin, nil } func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(dst) if err != nil { return err } defer out.Close() _, err = io.Copy(out, in) if err != nil { return err } // Preserve executable permission on Linux if runtime.GOOS != "windows" { os.Chmod(dst, 0o755) } return nil }