package version import ( "bytes" "context" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "gitgud.io/mike/mpv-manager/pkg/log" "gitgud.io/mike/mpv-manager/pkg/releasemanifest" "lukechampine.com/blake3" ) const ( ProductID = "mpv-manager" ComponentID = "manager-portable" developmentVersion = "1.3.1" ReleasesURL = "https://mpv.rocks/api/releases/stable.json" ReleaseCandidatesURL = "https://mpv.rocks/api/releases/rc.json" UpdateCheckTimeout = 10 * time.Second selfUpdateTimeout = 15 * time.Minute maxSelfUpdateBytes = 512 << 20 // postUpdateCheckTimeout bounds the --version sanity check run against a // freshly installed binary before an update is accepted. postUpdateCheckTimeout = 10 * time.Second maxIdentityOutputBytes = 64 << 10 ) var ( // CurrentVersion is a variable so release builds can replace the // development default with -X at link time. CurrentVersion = developmentVersion BuildTime string GitCommit string SelfUpdateDisabled string // Set via ldflags: -X 'gitgud.io/mike/mpv-manager/pkg/version.SelfUpdateDisabled=true' // ManifestPublicKeys is a comma-separated key ring populated in release // builds: "key-id=base64-ed25519-public-key". Empty trust fails closed. ManifestPublicKeys string // ManifestKeyValidity optionally binds each key ID to an authenticated // publication epoch: "key-id=not-before-unix:not-after-unix". ManifestKeyValidity string // ManifestRevokedKeyIDs is a comma-separated denylist embedded in new // builds for emergency key revocation. ManifestRevokedKeyIDs string ) // BinaryIdentity is the stable, machine-readable identity reported by // --version --json and verified before a self-update is committed. type BinaryIdentity struct { Product string `json:"product"` Component string `json:"component"` Version string `json:"version"` GOOS string `json:"goos"` GOARCH string `json:"goarch"` BuildTime string `json:"build_time,omitempty"` GitCommit string `json:"git_commit,omitempty"` } // GetBinaryIdentity returns the identity of the running executable. func GetBinaryIdentity() BinaryIdentity { return BinaryIdentity{ Product: ProductID, Component: ComponentID, Version: normalizeVersion(CurrentVersion), GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, BuildTime: BuildTime, GitCommit: GitCommit, } } func normalizeVersion(value string) string { return strings.TrimPrefix(strings.TrimSpace(value), "v") } var ( selfUpdateHTTPClient = &http.Client{Timeout: selfUpdateTimeout} selfUpdateTotalTimeout = 15 * time.Minute ) // ReleaseInfo aliases the shared producer/consumer release manifest schema. type ReleaseInfo = releasemanifest.Manifest 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 AssetSize int64 ManifestKeyID string Error error authenticated *authenticatedUpdateSelection } // authenticatedUpdateSelection is an opaque capability created only after a // manifest has passed embedded-key verification and its selected asset has // been bound to the public DTO fields. Callers may inspect/copy a check result, // but cannot manufacture this proof or mutate the selection after checking. type authenticatedUpdateSelection struct { fingerprint string manifestHash string channel string publishedAt string manifestKeyID string } type updateSelectionFingerprint struct { CurrentVersion string `json:"current_version"` LatestVersion string `json:"latest_version"` UpdateAvailable bool `json:"update_available"` URL string `json:"url"` BLAKE3 string `json:"blake3"` AssetSize int64 `json:"asset_size"` ManifestKeyID string `json:"manifest_key_id"` } func fingerprintUpdateSelection(result *VersionCheckResult) string { if result == nil { return "" } payload, err := json.Marshal(updateSelectionFingerprint{ CurrentVersion: result.CurrentVersion, LatestVersion: result.LatestVersion, UpdateAvailable: result.UpdateAvailable, URL: result.URL, BLAKE3: result.BLAKE3, AssetSize: result.AssetSize, ManifestKeyID: result.ManifestKeyID, }) if err != nil { return "" } digest := blake3.Sum256(payload) return hex.EncodeToString(digest[:]) } func authenticateUpdateSelection(result *VersionCheckResult, release *ReleaseInfo) error { if result == nil || release == nil || release.Signature == nil { return fmt.Errorf("cannot authenticate an incomplete update selection") } payload, err := release.CanonicalPayload() if err != nil { return fmt.Errorf("bind authenticated release manifest: %w", err) } manifestDigest := blake3.Sum256(payload) result.authenticated = &authenticatedUpdateSelection{ fingerprint: fingerprintUpdateSelection(result), manifestHash: hex.EncodeToString(manifestDigest[:]), channel: release.Channel, publishedAt: release.PublishedAt, manifestKeyID: release.Signature.KeyID, } return nil } func validateAuthenticatedUpdateSelection(result *VersionCheckResult) error { if result == nil || result.authenticated == nil { return fmt.Errorf("update selection is not backed by a verified release manifest") } if result.authenticated.fingerprint == "" || result.authenticated.fingerprint != fingerprintUpdateSelection(result) { return fmt.Errorf("authenticated update selection was modified after verification") } if result.authenticated.manifestHash == "" || result.authenticated.channel != releasemanifest.ChannelForVersion(CurrentVersion) || result.authenticated.publishedAt == "" || result.authenticated.manifestKeyID != result.ManifestKeyID { return fmt.Errorf("authenticated update selection proof is incomplete") } return nil } // SelfUpdatePhase is a stable update lifecycle state shared by the Web job, // TUI, logs, and future desktop host. type SelfUpdatePhase string const ( SelfUpdateChecking SelfUpdatePhase = "checking" SelfUpdateDownloading SelfUpdatePhase = "downloading" SelfUpdateVerifying SelfUpdatePhase = "verifying" SelfUpdateReadyToRestart SelfUpdatePhase = "ready_to_restart" SelfUpdateRestarting SelfUpdatePhase = "restarting" SelfUpdateCommitted SelfUpdatePhase = "committed" SelfUpdateRolledBack SelfUpdatePhase = "rolled_back" SelfUpdateFailed SelfUpdatePhase = "failed" ) // SelfUpdateProgress reports a structured update phase and optional byte // progress. Total is zero when the current phase is not byte-oriented. type SelfUpdateProgress struct { Phase SelfUpdatePhase Written int64 Total int64 Message string } // SelfUpdateProgressCallback receives structured lifecycle events. type SelfUpdateProgressCallback func(SelfUpdateProgress) func GetCurrentVersion() string { return CurrentVersion } // CheckForUpdate checks the configured feed using the default request timeout. func CheckForUpdate() *VersionCheckResult { return CheckForUpdateContext(context.Background()) } // CheckForUpdateContext checks the configured feed with caller cancellation. func CheckForUpdateContext(ctx context.Context) *VersionCheckResult { return checkForUpdateFrom(ctx, releaseFeedURL()) } // checkForUpdateFrom queries the release manifest at url and fills in a // VersionCheckResult for the current platform. It is the testable core of // CheckForUpdateContext; the exported wrapper selects the build channel feed. func checkForUpdateFrom(ctx context.Context, url string) *VersionCheckResult { log.Info("Checking for manager updates...") release, err := fetchReleaseInfoFrom(ctx, url) if err != nil { log.Error("Failed to check for updates: " + err.Error()) return &VersionCheckResult{ CurrentVersion: CurrentVersion, Error: fmt.Errorf("failed to check for updates: %w", err), } } return CheckForUpdateFromReleaseInfo(release) } // CheckForUpdateFromReleaseInfo derives the manager update selection from one // manifest, verifying its signature and trust lifecycle before minting an // update selection. Startup callers use this to avoid fetching // the same document separately for installer metadata and manager updates. func CheckForUpdateFromReleaseInfo(release *ReleaseInfo) *VersionCheckResult { result := &VersionCheckResult{CurrentVersion: CurrentVersion} if err := verifyReleaseManifest(release); err != nil { result.Error = err return result } selfUpdateDisabled := SelfUpdateDisabled == "true" 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 if release.Signature != nil { result.ManifestKeyID = release.Signature.KeyID } result.LatestVersion = release.Version if CompareVersions(CurrentVersion, release.MinimumUpdaterVersion) < 0 { result.Error = fmt.Errorf("release %s requires updater %s or later", release.Version, release.MinimumUpdaterVersion) return result } // 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 } if err := selectManagerAsset(result, release, runtime.GOOS, runtime.GOARCH); err != nil { result.Error = err } else if err := authenticateUpdateSelection(result, release); err != nil { result.Error = err } return result } // selectManagerAsset fills in the platform-specific download URL and BLAKE3 // hash and decides whether an update can be offered. A platform without a // release asset never sees an update, even when a newer version exists. func selectManagerAsset(result *VersionCheckResult, release *ReleaseInfo, goos, goarch string) error { component, asset, err := release.SelectAsset(ComponentID, goos, goarch) if err != nil { result.UpdateAvailable = false return err } result.LatestVersion = component.Version result.URL = asset.URL result.BLAKE3 = asset.BLAKE3 result.AssetSize = asset.Size result.UpdateAvailable = CompareVersions(CurrentVersion, component.Version) < 0 return nil } func CompareVersions(v1, v2 string) int { left := parseComparableVersion(v1) right := parseComparableVersion(v2) maxCoreParts := max(len(left.core), len(right.core)) for index := range maxCoreParts { leftPart := "0" rightPart := "0" if index < len(left.core) { leftPart = left.core[index] } if index < len(right.core) { rightPart = right.core[index] } if comparison := compareNumericIdentifier(leftPart, rightPart); comparison != 0 { return comparison } } // A stable release has higher precedence than a prerelease with the same // core version. Build metadata never affects precedence. if len(left.prerelease) == 0 && len(right.prerelease) > 0 { return 1 } if len(left.prerelease) > 0 && len(right.prerelease) == 0 { return -1 } for index := 0; index < min(len(left.prerelease), len(right.prerelease)); index++ { leftPart := left.prerelease[index] rightPart := right.prerelease[index] leftNumeric := isDecimalIdentifier(leftPart) rightNumeric := isDecimalIdentifier(rightPart) switch { case leftNumeric && rightNumeric: if comparison := compareNumericIdentifier(leftPart, rightPart); comparison != 0 { return comparison } case leftNumeric: return -1 case rightNumeric: return 1 case leftPart < rightPart: return -1 case leftPart > rightPart: return 1 } } return compareInt(len(left.prerelease), len(right.prerelease)) } type comparableVersion struct { core []string prerelease []string } func parseComparableVersion(value string) comparableVersion { value = normalizeVersion(value) value, _, _ = strings.Cut(value, "+") core, prerelease, hasPrerelease := strings.Cut(value, "-") parsed := comparableVersion{core: strings.Split(core, ".")} if hasPrerelease { parsed.prerelease = strings.Split(prerelease, ".") } return parsed } func compareNumericIdentifier(left, right string) int { left = strings.TrimLeft(left, "0") right = strings.TrimLeft(right, "0") if left == "" { left = "0" } if right == "" { right = "0" } if comparison := compareInt(len(left), len(right)); comparison != 0 { return comparison } switch { case left < right: return -1 case left > right: return 1 default: return 0 } } func isDecimalIdentifier(value string) bool { if value == "" { return false } for _, character := range value { if character < '0' || character > '9' { return false } } return true } func compareInt(left, right int) int { switch { case left < right: return -1 case left > right: return 1 default: return 0 } } func IsVersionUpdateAvailable(current, latest string) bool { return CompareVersions(current, latest) < 0 } func emitSelfUpdateProgress(callback SelfUpdateProgressCallback, progress SelfUpdateProgress) { if callback != nil { callback(progress) } } func downloadFileWithProgressContext(ctx context.Context, url, dest string, progressCallback func(int64, int64), maxRetries int) error { ctx, cancel := context.WithTimeout(ctx, selfUpdateTotalTimeout) defer cancel() log.Debug(fmt.Sprintf("Downloading file from: %s (max retries: %d)", url, maxRetries)) var lastErr error for attempt := 1; attempt <= maxRetries; attempt++ { if err := ctx.Err(); err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err } resp, err := selfUpdateHTTPClient.Do(req) if err != nil { if ctx.Err() != nil { return ctx.Err() } lastErr = err log.Error(fmt.Sprintf("Download attempt %d/%d failed: %s", attempt, maxRetries, err.Error())) if attempt < maxRetries { if err := waitForRetry(ctx, time.Duration(attempt)*time.Second); err != nil { return err } } 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 { if err := waitForRetry(ctx, time.Duration(attempt)*time.Second); err != nil { return err } } continue } if resp.ContentLength > maxSelfUpdateBytes { resp.Body.Close() return fmt.Errorf("self-update exceeds %d-byte limit", maxSelfUpdateBytes) } 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 } var writer io.Writer = out if progressCallback != nil { writer = &progressWriter{ total: resp.ContentLength, callback: progressCallback, underlying: out, lastUpdate: time.Now(), } } var written int64 written, err = io.Copy(writer, io.LimitReader(resp.Body, maxSelfUpdateBytes+1)) bodyCloseErr := resp.Body.Close() outputCloseErr := out.Close() if err == nil { err = outputCloseErr } if err == nil { err = bodyCloseErr } if err != nil || written > maxSelfUpdateBytes { if written > maxSelfUpdateBytes { err = fmt.Errorf("self-update exceeds %d-byte limit", maxSelfUpdateBytes) } lastErr = err log.Error(fmt.Sprintf("Download attempt %d/%d failed: %s", attempt, maxRetries, err.Error())) os.Remove(dest) if attempt < maxRetries { if err := waitForRetry(ctx, time.Duration(attempt)*time.Second); err != nil { return err } } 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) } func waitForRetry(ctx context.Context, delay time.Duration) error { timer := time.NewTimer(delay) defer timer.Stop() select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return nil } } 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 } // verifySelfUpdate verifies the downloaded self-update binary before it is // installed. Unlike app downloads, self-update fails closed: a missing // BLAKE3 hash in the release info aborts the update. func verifySelfUpdate(filePath, expectedHash string) error { if expectedHash == "" { return fmt.Errorf("no BLAKE3 hash available for this update, aborting self-update") } return VerifyBLAKE3(filePath, expectedHash) } // VerifyBLAKE3 verifies file BLAKE3 hash against expected value func VerifyBLAKE3(filePath, expectedHash string) error { computedHash, err := fileBLAKE3(filePath) if err != nil { return err } if computedHash != expectedHash { return fmt.Errorf("BLAKE3 hash mismatch: expected %s, got %s", expectedHash, computedHash) } return nil } func fileBLAKE3(filePath string) (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) } return "blake3:" + hex.EncodeToString(hash.Sum(nil)), nil } // newBinaryPath returns the path a freshly downloaded update is staged at // before it replaces the binary at executablePath. func newBinaryPath(executablePath string) string { return executablePath + ".new" } // backupBinaryPath returns the path the previous binary is kept at while an // update swaps in the new one. func backupBinaryPath(executablePath string) string { return executablePath + ".backup" } // CleanupStaleUpdateFiles repairs the executable directory after an // interrupted self-update and removes leftovers from a completed one. It is // meant to run once at startup; all failures are logged, never fatal. func CleanupStaleUpdateFiles(executablePath string) { if executablePath == "" { return } RecoverSelfUpdateTransactions(executablePath) newPath := newBinaryPath(executablePath) backupPath := backupBinaryPath(executablePath) _, exeErr := os.Stat(executablePath) if os.IsNotExist(exeErr) { // A kill or power loss between the backup rename and the new-binary // rename left no executable: restore the previous version. if _, err := os.Stat(backupPath); err == nil { if err := os.Rename(backupPath, executablePath); err != nil { log.Error("Failed to restore previous version from update backup: " + err.Error()) } else { log.Info("Restored previous version from update backup") } } } else if _, err := os.Stat(backupPath); err == nil { // A leftover backup means the previous update completed. On Windows it // may still be locked by the exiting process, so a failure here is // expected and retried on the next start. if err := os.Remove(backupPath); err != nil { log.Debug("Could not remove stale update backup (will retry next start): " + err.Error()) } } // Orphaned staged downloads may be partial; never promote them. if _, err := os.Stat(newPath); err == nil { if err := os.Remove(newPath); err != nil { log.Debug("Could not remove orphaned update file: " + err.Error()) } } } // verifyUpdatedBinary proves that a freshly installed binary is the exact // product, component, version, OS, and architecture selected by the update. func verifyUpdatedBinary(executablePath string, expected BinaryIdentity) error { return verifyUpdatedBinaryContext(context.Background(), executablePath, expected) } func verifyUpdatedBinaryContext(parent context.Context, executablePath string, expected BinaryIdentity) error { actual, err := readBinaryIdentityContext(parent, executablePath) if err != nil { return err } return validateBinaryIdentity(actual, expected) } func readBinaryIdentity(executablePath string) (BinaryIdentity, error) { return readBinaryIdentityContext(context.Background(), executablePath) } func readBinaryIdentityContext(parent context.Context, executablePath string) (BinaryIdentity, error) { ctx, cancel := context.WithTimeout(parent, postUpdateCheckTimeout) defer cancel() cmd := exec.CommandContext(ctx, executablePath, "--version", "--json") configureIdentityCommand(cmd) cmd.WaitDelay = 2 * time.Second var output boundedIdentityOutput var stderr boundedIdentityOutput cmd.Stdout = &output cmd.Stderr = &stderr err := cmd.Run() if parent.Err() != nil { return BinaryIdentity{}, parent.Err() } if ctx.Err() == context.DeadlineExceeded { return BinaryIdentity{}, fmt.Errorf("updated binary did not respond to --version --json within %s", postUpdateCheckTimeout) } if output.exceeded || stderr.exceeded { return BinaryIdentity{}, fmt.Errorf("updated binary identity output exceeded %d bytes", maxIdentityOutputBytes) } if err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) { return BinaryIdentity{}, fmt.Errorf("updated binary failed --version --json check: %w", err) } return BinaryIdentity{}, fmt.Errorf("could not start updated binary for --version --json check: %w", err) } var actual BinaryIdentity decoder := json.NewDecoder(bytes.NewReader(output.bytes())) if err := decoder.Decode(&actual); err != nil { return BinaryIdentity{}, fmt.Errorf("updated binary returned invalid version identity: %w", err) } if err := ensureJSONEOF(decoder); err != nil { return BinaryIdentity{}, fmt.Errorf("updated binary returned invalid version identity: %w", err) } return actual, nil } type boundedIdentityOutput struct { buffer bytes.Buffer exceeded bool } func (output *boundedIdentityOutput) Write(data []byte) (int, error) { originalLength := len(data) remaining := maxIdentityOutputBytes - output.buffer.Len() if remaining <= 0 { output.exceeded = output.exceeded || originalLength > 0 return originalLength, nil } if len(data) > remaining { _, _ = output.buffer.Write(data[:remaining]) output.exceeded = true return originalLength, nil } _, _ = output.buffer.Write(data) return originalLength, nil } func (output *boundedIdentityOutput) bytes() []byte { return output.buffer.Bytes() } func validateBinaryIdentity(actual, expected BinaryIdentity) error { checks := []struct { name string actual string expected string }{ {name: "product", actual: actual.Product, expected: expected.Product}, {name: "component", actual: actual.Component, expected: expected.Component}, {name: "version", actual: normalizeVersion(actual.Version), expected: normalizeVersion(expected.Version)}, {name: "GOOS", actual: actual.GOOS, expected: expected.GOOS}, {name: "GOARCH", actual: actual.GOARCH, expected: expected.GOARCH}, } for _, check := range checks { if check.actual != check.expected { return fmt.Errorf("updated binary %s mismatch: got %q, want %q", check.name, check.actual, check.expected) } } return nil } func ensureJSONEOF(decoder *json.Decoder) error { var extra any if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { if err == nil { return fmt.Errorf("multiple JSON values") } return err } return nil }