package version import ( "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "runtime" "strings" "time" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/log" "lukechampine.com/blake3" ) const ( CurrentVersion = "1.2.0" ReleasesURL = "https://mpv.rocks/api/releases.json" UpdateCheckTimeout = 10 * time.Second ) var ( BuildTime string GitCommit string SelfUpdateDisabled string // Set via ldflags: -X 'gitgud.io/mike/mpv-manager/pkg/version.SelfUpdateDisabled=true' ) type ReleaseInfo struct { Version string `json:"version"` Date string `json:"date"` MpvVersion string `json:"MpvVersion"` UOSC struct { URL string `json:"url"` BLAKE3 string `json:"blake3"` ConfURL string `json:"conf_url"` ConfBLAKE3 string `json:"conf_blake3"` AppVersion string `json:"app_version"` } `json:"uosc"` ModernZ struct { ScriptURL string `json:"script_url"` // modernz.lua ScriptBLAKE3 string `json:"script_blake3"` FontURL string `json:"font_url"` // modernz-icons.ttf FontBLAKE3 string `json:"font_blake3"` ConfURL string `json:"conf_url"` // modernz.conf ConfBLAKE3 string `json:"conf_blake3"` AppVersion string `json:"app_version"` } `json:"modernz"` MPCQT struct { X8664 struct{ URL, BLAKE3 string } `json:"x86-64"` AppVersion string `json:"app_version"` } `json:"mpc-qt"` IINA struct { ARM struct{ URL, BLAKE3 string } `json:"arm"` Intel struct{ URL, BLAKE3 string } `json:"intel"` AppVersion string `json:"app_version"` } `json:"iina"` FFmpeg struct { X8664 struct{ URL, BLAKE3 string } `json:"x86-64"` X8664v3 struct{ URL, BLAKE3 string } `json:"x86-64-v3"` Aarch64 struct{ URL, BLAKE3 string } `json:"aarch64"` AppVersion string `json:"app_version"` } `json:"ffmpeg"` Manager struct { LinuxAMD64 struct{ URL, BLAKE3 string } `json:"linux-amd64"` LinuxARM64 struct{ URL, BLAKE3 string } `json:"linux-arm64"` WinX86_64 struct{ URL, BLAKE3 string } `json:"win-x86_64"` WinARM64 struct{ URL, BLAKE3 string } `json:"win-arm64"` MacosIntel struct{ URL, BLAKE3 string } `json:"macos-intel"` MacosARM struct{ URL, BLAKE3 string } `json:"macos-arm"` } `json:"manager"` } type VersionCheckResult struct { CurrentVersion string LatestVersion string MpvVersion string UOSCLatestVersion string ModernZLatestVersion string FFmpegLatestVersion string MPQTLLatestVersion string INALatestVersion string UpdateAvailable bool URL string BLAKE3 string Error error } func GetCurrentVersion() string { return CurrentVersion } func CheckForUpdate() *VersionCheckResult { result := &VersionCheckResult{ CurrentVersion: CurrentVersion, } selfUpdateDisabled := SelfUpdateDisabled == "true" client := &http.Client{ Timeout: UpdateCheckTimeout, } log.Info("Checking for manager updates...") resp, err := client.Get(ReleasesURL) if err != nil { log.Error("Failed to check for updates: " + err.Error()) result.Error = fmt.Errorf("failed to check for updates: %w", err) return result } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { log.Error(fmt.Sprintf("Failed to check for updates: HTTP %d", resp.StatusCode)) result.Error = fmt.Errorf("failed to check for updates: HTTP %d", resp.StatusCode) return result } body, err := io.ReadAll(resp.Body) if err != nil { log.Error("Failed to read update response: " + err.Error()) result.Error = fmt.Errorf("failed to read response: %w", err) return result } var release ReleaseInfo if err := json.Unmarshal(body, &release); err != nil { log.Error("Failed to parse release info: " + err.Error()) result.Error = fmt.Errorf("failed to parse release info: %w", err) return result } log.Info(fmt.Sprintf("Current version: %s, Latest version: %s", CurrentVersion, release.Version)) result.MpvVersion = release.MpvVersion result.UOSCLatestVersion = release.UOSC.AppVersion result.ModernZLatestVersion = release.ModernZ.AppVersion result.FFmpegLatestVersion = release.FFmpeg.AppVersion result.MPQTLLatestVersion = release.MPCQT.AppVersion result.INALatestVersion = release.IINA.AppVersion result.LatestVersion = release.Version // When self-update is disabled (e.g., installed via package manager), // populate the latest version for display but never indicate an update // is available. MPV app updates are still checked via CheckForAppUpdates(). if selfUpdateDisabled { log.Info("Self-update is disabled (installed via package manager), latest version fetched for display only") result.UpdateAvailable = false return result } var managerURL, managerBLAKE3 string osName := runtime.GOOS archName := runtime.GOARCH switch { case osName == "linux" && archName == "amd64": managerURL = release.Manager.LinuxAMD64.URL managerBLAKE3 = release.Manager.LinuxAMD64.BLAKE3 case osName == "linux" && archName == "arm64": managerURL = release.Manager.LinuxARM64.URL managerBLAKE3 = release.Manager.LinuxARM64.BLAKE3 case osName == "windows" && archName == "amd64": managerURL = release.Manager.WinX86_64.URL managerBLAKE3 = release.Manager.WinX86_64.BLAKE3 case osName == "windows" && archName == "arm64": managerURL = release.Manager.WinARM64.URL managerBLAKE3 = release.Manager.WinARM64.BLAKE3 case osName == "darwin" && archName == "amd64": managerURL = release.Manager.MacosIntel.URL managerBLAKE3 = release.Manager.MacosIntel.BLAKE3 case osName == "darwin" && archName == "arm64": managerURL = release.Manager.MacosARM.URL managerBLAKE3 = release.Manager.MacosARM.BLAKE3 default: managerURL = "" managerBLAKE3 = "" } result.URL = managerURL result.BLAKE3 = managerBLAKE3 result.UpdateAvailable = CompareVersions(CurrentVersion, release.Version) < 0 return result } func CompareVersions(v1, v2 string) int { v1Parts := strings.Split(strings.TrimPrefix(v1, "v"), ".") v2Parts := strings.Split(strings.TrimPrefix(v2, "v"), ".") maxLen := len(v1Parts) if len(v2Parts) > maxLen { maxLen = len(v2Parts) } for i := 0; i < maxLen; i++ { var v1Num, v2Num int if i < len(v1Parts) { fmt.Sscanf(v1Parts[i], "%d", &v1Num) } if i < len(v2Parts) { fmt.Sscanf(v2Parts[i], "%d", &v2Num) } if v1Num < v2Num { return -1 } else if v1Num > v2Num { return 1 } } return 0 } func IsVersionUpdateAvailable(current, latest string) bool { return CompareVersions(current, latest) < 0 } func UpdateSelf(executablePath string) error { if SelfUpdateDisabled == "true" { return fmt.Errorf("self-update is disabled in this build (installed via package manager)") } log.Info("Starting manager self-update...") check := CheckForUpdate() if check.Error != nil { log.Error("Failed to check for update: " + check.Error.Error()) return check.Error } if !check.UpdateAvailable { log.Info("No update available, manager is up to date") return fmt.Errorf("no update available") } log.Info("Update available: " + check.LatestVersion) tempPath := executablePath + ".new" if err := downloadFile(check.URL, tempPath); err != nil { log.Error("Failed to download update: " + err.Error()) return err } if check.BLAKE3 != "" { if err := VerifyBLAKE3(tempPath, check.BLAKE3); err != nil { log.Error("BLAKE3 verification failed: " + err.Error()) os.Remove(tempPath) return err } log.Info("BLAKE3 verification passed") } backupPath := executablePath + ".backup" if err := os.Rename(executablePath, backupPath); err != nil { log.Error("Failed to create backup: " + err.Error()) os.Remove(tempPath) return err } log.Info("Backup created: " + backupPath) if err := os.Rename(tempPath, executablePath); err != nil { log.Error("Failed to replace executable: " + err.Error()) os.Rename(backupPath, executablePath) return err } // Set executable permissions on the new binary if err := os.Chmod(executablePath, 0755); err != nil { log.Error("Failed to set executable permissions: " + err.Error()) return fmt.Errorf("failed to set executable permissions: %w", err) } os.Remove(backupPath) log.Info("Self-update completed successfully") // Update secondary installation if configured (Windows PATH or Linux self-install) if err := updateSecondaryInstallation(check.URL, check.BLAKE3, executablePath); err != nil { log.Error("Failed to update secondary installation: " + err.Error()) // Don't return error - the main update succeeded } return nil } // updateSecondaryInstallation updates the manager binary at the configured path // This handles both Windows PATH installations and Linux self-installations func updateSecondaryInstallation(downloadURL, blake3Hash, executablePath string) error { binPath := config.GetManagerBinPath() if binPath == "" { return nil } // Resolve symlinks for comparison realExecPath, err := filepath.EvalSymlinks(executablePath) if err != nil { realExecPath = executablePath } realBinPath, err := filepath.EvalSymlinks(binPath) if err != nil { // If binPath doesn't exist, that's OK - nothing to update return nil } if realBinPath == realExecPath { // Same file, no need to update return nil } log.Info("Updating installation at: " + binPath) tempPath := binPath + ".new" if err := downloadFile(downloadURL, tempPath); err != nil { return fmt.Errorf("failed to download update: %w", err) } if blake3Hash != "" { if err := VerifyBLAKE3(tempPath, blake3Hash); err != nil { os.Remove(tempPath) return fmt.Errorf("verification failed: %w", err) } } input, err := os.ReadFile(tempPath) if err != nil { os.Remove(tempPath) return fmt.Errorf("failed to read downloaded file: %w", err) } if err := os.WriteFile(binPath, input, 0755); err != nil { os.Remove(tempPath) return fmt.Errorf("failed to write to %s: %w", binPath, err) } if err := os.Chmod(binPath, 0755); err != nil { os.Remove(tempPath) return fmt.Errorf("failed to set executable permissions: %w", err) } os.Remove(tempPath) log.Info("Installation updated at: " + binPath) return nil } func UpdateSelfWithProgress(executablePath string, progressCallback func(int64, int64)) error { if SelfUpdateDisabled == "true" { return fmt.Errorf("self-update is disabled in this build (installed via package manager)") } log.Info("Starting manager self-update with progress...") check := CheckForUpdate() if check.Error != nil { log.Error("Failed to check for update: " + check.Error.Error()) return check.Error } if !check.UpdateAvailable { log.Info("No update available, manager is up to date") return fmt.Errorf("no update available") } log.Info("Update available: " + check.LatestVersion) tempPath := executablePath + ".new" if err := downloadFileWithProgress(check.URL, tempPath, progressCallback, 3); err != nil { log.Error("Failed to download update: " + err.Error()) return err } if check.BLAKE3 != "" { if err := VerifyBLAKE3(tempPath, check.BLAKE3); err != nil { log.Error("BLAKE3 verification failed: " + err.Error()) os.Remove(tempPath) return err } log.Info("BLAKE3 verification passed") } backupPath := executablePath + ".backup" if err := os.Rename(executablePath, backupPath); err != nil { log.Error("Failed to create backup: " + err.Error()) os.Remove(tempPath) return err } log.Info("Backup created: " + backupPath) if err := os.Rename(tempPath, executablePath); err != nil { log.Error("Failed to replace executable: " + err.Error()) os.Rename(backupPath, executablePath) return err } // Set executable permissions on the new binary if err := os.Chmod(executablePath, 0755); err != nil { log.Error("Failed to set executable permissions: " + err.Error()) return fmt.Errorf("failed to set executable permissions: %w", err) } os.Remove(backupPath) log.Info("Self-update completed successfully") // Update secondary installation if configured (Windows PATH or Linux self-install) if err := updateSecondaryInstallationWithProgress(check.URL, check.BLAKE3, executablePath, progressCallback); err != nil { log.Error("Failed to update secondary installation: " + err.Error()) // Don't return error - the main update succeeded } return nil } // updateSecondaryInstallationWithProgress updates the manager binary at the configured path with progress callback // This handles both Windows PATH installations and Linux self-installations func updateSecondaryInstallationWithProgress(downloadURL, blake3Hash, executablePath string, progressCallback func(int64, int64)) error { binPath := config.GetManagerBinPath() if binPath == "" { return nil } // Resolve symlinks for comparison realExecPath, err := filepath.EvalSymlinks(executablePath) if err != nil { realExecPath = executablePath } realBinPath, err := filepath.EvalSymlinks(binPath) if err != nil { // If binPath doesn't exist, that's OK - nothing to update return nil } if realBinPath == realExecPath { // Same file, no need to update return nil } log.Info("Updating installation at: " + binPath) tempPath := binPath + ".new" if err := downloadFileWithProgress(downloadURL, tempPath, progressCallback, 3); err != nil { return fmt.Errorf("failed to download update: %w", err) } if blake3Hash != "" { if err := VerifyBLAKE3(tempPath, blake3Hash); err != nil { os.Remove(tempPath) return fmt.Errorf("verification failed: %w", err) } } input, err := os.ReadFile(tempPath) if err != nil { os.Remove(tempPath) return fmt.Errorf("failed to read downloaded file: %w", err) } if err := os.WriteFile(binPath, input, 0755); err != nil { os.Remove(tempPath) return fmt.Errorf("failed to write to %s: %w", binPath, err) } if err := os.Chmod(binPath, 0755); err != nil { os.Remove(tempPath) return fmt.Errorf("failed to set executable permissions: %w", err) } os.Remove(tempPath) log.Info("Installation updated at: " + binPath) return nil } func downloadFile(url, dest string) error { log.Debug("Downloading file from: " + url) resp, err := http.Get(url) if err != nil { log.Error("Failed to download from " + url + ": " + err.Error()) return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { log.Error(fmt.Sprintf("Download failed with HTTP %d for %s", resp.StatusCode, url)) return fmt.Errorf("download failed: HTTP %d", resp.StatusCode) } out, err := os.Create(dest) if err != nil { log.Error("Failed to create file " + dest + ": " + err.Error()) return err } defer out.Close() _, err = io.Copy(out, resp.Body) if err != nil { log.Error("Failed to write to " + dest + ": " + err.Error()) return err } log.Debug("Download completed successfully: " + dest) return nil } func downloadFileWithProgress(url, dest string, progressCallback func(int64, int64), maxRetries int) error { log.Debug(fmt.Sprintf("Downloading file from: %s (max retries: %d)", url, maxRetries)) var lastErr error for attempt := 1; attempt <= maxRetries; attempt++ { resp, err := http.Get(url) if err != nil { lastErr = err log.Error(fmt.Sprintf("Download attempt %d/%d failed: %s", attempt, maxRetries, err.Error())) if attempt < maxRetries { time.Sleep(time.Duration(attempt) * time.Second) } continue } if resp.StatusCode != http.StatusOK { resp.Body.Close() lastErr = fmt.Errorf("download failed: HTTP %d", resp.StatusCode) log.Error(fmt.Sprintf("Download attempt %d/%d failed: HTTP %d", attempt, maxRetries, resp.StatusCode)) if attempt < maxRetries { time.Sleep(time.Duration(attempt) * time.Second) } continue } if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { resp.Body.Close() log.Error("Failed to create directory " + filepath.Dir(dest) + ": " + err.Error()) return err } out, err := os.Create(dest) if err != nil { resp.Body.Close() log.Error("Failed to create file " + dest + ": " + err.Error()) return err } if progressCallback != nil { contentLength := resp.ContentLength writer := &progressWriter{ total: contentLength, written: 0, callback: progressCallback, underlying: out, lastUpdate: time.Now(), } _, err = io.Copy(writer, resp.Body) resp.Body.Close() out.Close() if err != nil { lastErr = err log.Error(fmt.Sprintf("Download attempt %d/%d failed: %s", attempt, maxRetries, err.Error())) os.Remove(dest) if attempt < maxRetries { time.Sleep(time.Duration(attempt) * time.Second) } continue } log.Debug("Download completed successfully: " + dest) return nil } _, err = io.Copy(out, resp.Body) resp.Body.Close() out.Close() if err != nil { lastErr = err log.Error(fmt.Sprintf("Download attempt %d/%d failed: %s", attempt, maxRetries, err.Error())) os.Remove(dest) if attempt < maxRetries { time.Sleep(time.Duration(attempt) * time.Second) } continue } log.Debug("Download completed successfully: " + dest) return nil } log.Error(fmt.Sprintf("Download failed after %d attempts: %s", maxRetries, lastErr.Error())) return fmt.Errorf("download failed after %d attempts: %w", maxRetries, lastErr) } type progressWriter struct { total int64 written int64 callback func(int64, int64) underlying io.Writer lastUpdate time.Time } func (pw *progressWriter) Write(p []byte) (int, error) { n, err := pw.underlying.Write(p) pw.written += int64(n) if pw.callback != nil { now := time.Now() if now.Sub(pw.lastUpdate) >= 500*time.Millisecond || pw.written == pw.total { pw.callback(pw.written, pw.total) pw.lastUpdate = now } } return n, err } // VerifyBLAKE3 verifies file BLAKE3 hash against expected value func VerifyBLAKE3(filePath, expectedHash string) error { file, err := os.Open(filePath) if err != nil { return fmt.Errorf("failed to open file: %w", err) } defer file.Close() hash := blake3.New(32, nil) if _, err := io.Copy(hash, file); err != nil { return fmt.Errorf("failed to compute hash: %w", err) } computedHash := "blake3:" + hex.EncodeToString(hash.Sum(nil)) if computedHash != expectedHash { return fmt.Errorf("BLAKE3 hash mismatch: expected %s, got %s", expectedHash, computedHash) } return nil }