package version import ( "context" "gitgud.io/mike/mpv-manager/internal/packagequery" "os" "path/filepath" "strings" "time" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/installer" ) type UpdateCheckResult struct { AppsToUpdate []UpdateItem NoEligibleApps bool ConfigBackupItems []ConfigBackupItem } type UpdateItem struct { AppID string AppName string InstallPath string CurrentVersion string AvailableVersion string UpdateType string PackageManager string MethodID string } type ConfigBackupItem struct { BackupName string BackupPath string BackupDate string } // CheckForAppUpdates retains the legacy combined app and backup inventory. // Deprecated: use CheckForAppUpdatesContext and request ListConfigBackups only // when the caller actually displays backups. func CheckForAppUpdates(releaseInfo *installer.ReleaseInfo, installedApps []config.InstalledApp) *UpdateCheckResult { result := CheckForAppUpdatesContext(context.Background(), releaseInfo, installedApps) result.ConfigBackupItems, _ = ListConfigBackups() return result } // CheckForAppUpdatesContext shares one bounded cancellation budget across all // package probes; unknown or interrupted queries retain the tracked app. // Backup enumeration is separate: callers that need it use ListConfigBackups. func CheckForAppUpdatesContext(ctx context.Context, releaseInfo *installer.ReleaseInfo, installedApps []config.InstalledApp) *UpdateCheckResult { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() // A manifest refresh can fail during startup. Treat that as an empty // manifest instead of dereferencing nil in the update display path. if releaseInfo == nil { releaseInfo = &installer.ReleaseInfo{} } result := &UpdateCheckResult{ AppsToUpdate: []UpdateItem{}, ConfigBackupItems: []ConfigBackupItem{}, } for _, app := range installedApps { updateItem := checkAppForUpdate(ctx, app, releaseInfo) if updateItem != nil { result.AppsToUpdate = append(result.AppsToUpdate, *updateItem) } } checkUOSCUpdate(result, releaseInfo) checkModernZUpdate(result, releaseInfo) checkFFmpegUpdate(result, releaseInfo) hasManagedApp := false for _, app := range installedApps { if isManagedApp(app.InstallMethod) { hasManagedApp = true break } } result.NoEligibleApps = !hasManagedApp && len(result.AppsToUpdate) == 0 return result } func isManagedApp(methodID string) bool { managedMethods := []string{ constants.MethodMPVBinary, constants.MethodMPVBinaryV3, constants.MethodMPVApp, constants.MethodMPCQT, constants.MethodIINA, } for _, m := range managedMethods { if methodID == m { return true } } return false } func checkAppForUpdate(ctx context.Context, app config.InstalledApp, releaseInfo *installer.ReleaseInfo) *UpdateItem { switch app.InstallMethod { case constants.MethodMPVBinary, constants.MethodMPVBinaryV3, constants.MethodMPVApp: currentVersion := app.AppVersion availableVersion := releaseInfo.MpvVersion updateAvailable := currentVersion != "" && availableVersion != "" && IsVersionUpdateAvailable(mpvReleaseBase(currentVersion), mpvReleaseBase(availableVersion)) // Determine UpdateType based on whether update is available updateType := "current-version" if updateAvailable { updateType = "update" } return &UpdateItem{ AppID: app.ID, AppName: app.AppName, InstallPath: app.InstallPath, CurrentVersion: currentVersion, AvailableVersion: availableVersion, UpdateType: updateType, MethodID: app.InstallMethod, } case constants.MethodMPCQT: currentVersion := app.AppVersion availableVersion := releaseInfo.MPCQT.AppVersion updateAvailable := IsVersionUpdateAvailable(currentVersion, availableVersion) // Determine UpdateType based on whether update is available updateType := "current-version" if updateAvailable { updateType = "update" } return &UpdateItem{ AppID: app.ID, AppName: app.AppName, InstallPath: app.InstallPath, CurrentVersion: currentVersion, AvailableVersion: availableVersion, UpdateType: updateType, MethodID: app.InstallMethod, } case constants.MethodIINA: currentVersion := app.AppVersion availableVersion := releaseInfo.IINA.AppVersion updateAvailable := IsVersionUpdateAvailable(currentVersion, availableVersion) // Determine UpdateType based on whether update is available updateType := "current-version" if updateAvailable { updateType = "update" } return &UpdateItem{ AppID: app.ID, AppName: app.AppName, InstallPath: app.InstallPath, CurrentVersion: currentVersion, AvailableVersion: availableVersion, UpdateType: updateType, MethodID: app.InstallMethod, } case constants.MethodMPVFlatpak, constants.MethodMPVPackage, constants.MethodCelluloidFlatpak, constants.MethodCelluloidPackage, constants.MethodMPVBrew: pkgManager := getPackageManagerName(app.InstallMethod) currentVersion := app.AppVersion // If no version is stored, try to query package manager if currentVersion == "" { currentVersion, _ = packagequery.Installed(ctx, app.InstallMethod) } // Always return update item for version display, even if version query failed // For package managers, show current version without update button availableVersion := "Check via " + pkgManager if currentVersion == "" { availableVersion = "Unknown" } return &UpdateItem{ AppID: app.ID, AppName: app.AppName, InstallPath: app.InstallPath, CurrentVersion: currentVersion, AvailableVersion: availableVersion, UpdateType: "package-info", PackageManager: pkgManager, MethodID: app.InstallMethod, } } return nil } // mpvReleaseBase removes a git-describe commit suffix. Such a build is after // its base tag, not a SemVer prerelease of that tag. The shared manager version // comparator must retain its normal prerelease ordering. func mpvReleaseBase(value string) string { value = strings.TrimSuffix(strings.TrimSpace(value), "-dirty") hashIndex := strings.LastIndex(value, "-g") if hashIndex < 0 || hashIndex+2 == len(value) { return value } for _, c := range value[hashIndex+2:] { if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { return value } } prefix := value[:hashIndex] countIndex := strings.LastIndex(prefix, "-") if countIndex < 0 || !isDecimalIdentifier(prefix[countIndex+1:]) { return value } return prefix[:countIndex] } func checkUOSCUpdate(result *UpdateCheckResult, releaseInfo *installer.ReleaseInfo) { installedUOSCVersion := config.GetUOSCVersion() if installedUOSCVersion == "" { return } availableUOSCVersion := releaseInfo.UOSC.AppVersion if IsVersionUpdateAvailable(installedUOSCVersion, availableUOSCVersion) { result.AppsToUpdate = append(result.AppsToUpdate, UpdateItem{ AppName: "uOSC - custom MPV UI", CurrentVersion: installedUOSCVersion, AvailableVersion: availableUOSCVersion, UpdateType: "update", MethodID: constants.MethodUOSC, }) } } func checkModernZUpdate(result *UpdateCheckResult, releaseInfo *installer.ReleaseInfo) { installedModernZVersion := config.GetModernZVersion() if installedModernZVersion == "" { return } availableModernZVersion := releaseInfo.ModernZ.AppVersion if IsVersionUpdateAvailable(installedModernZVersion, availableModernZVersion) { result.AppsToUpdate = append(result.AppsToUpdate, UpdateItem{ AppName: "ModernZ - custom MPV UI", CurrentVersion: installedModernZVersion, AvailableVersion: availableModernZVersion, UpdateType: "update", MethodID: constants.MethodModernZ, }) } } func checkFFmpegUpdate(result *UpdateCheckResult, releaseInfo *installer.ReleaseInfo) { installedFFmpegVersion := config.GetFFmpegVersion() if installedFFmpegVersion == "" { return } availableFFmpegVersion := releaseInfo.FFmpeg.AppVersion if IsVersionUpdateAvailable(installedFFmpegVersion, availableFFmpegVersion) { result.AppsToUpdate = append(result.AppsToUpdate, UpdateItem{ AppName: "FFmpeg - media utilities", CurrentVersion: installedFFmpegVersion, AvailableVersion: availableFFmpegVersion, UpdateType: "update", MethodID: constants.MethodFFmpeg, }) } } func getPackageManagerName(methodID string) string { switch methodID { case constants.MethodMPVFlatpak, constants.MethodCelluloidFlatpak: return "Flatpak" case constants.MethodMPVPackage, constants.MethodCelluloidPackage: return "Package Manager" case constants.MethodMPVBrew: return "Homebrew" } return "Unknown" } // ListConfigBackups enumerates regular configuration backups on demand. func ListConfigBackups() ([]ConfigBackupItem, error) { backupDir, err := config.GetMPVConfigBackupDir() if err != nil { return nil, err } items := []ConfigBackupItem{} if err := checkAndCollectBackups(backupDir, &items); err != nil { if os.IsNotExist(err) { return items, nil } return nil, err } return items, nil } func checkAndCollectBackups(backupDir string, items *[]ConfigBackupItem) error { files, err := os.ReadDir(backupDir) if err != nil { return err } for _, file := range files { fileName := file.Name() if strings.HasSuffix(fileName, ".conf") { backupName := strings.TrimSuffix(fileName, ".conf") backupPath := filepath.Join(backupDir, fileName) info, err := os.Lstat(backupPath) if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { continue } *items = append(*items, ConfigBackupItem{ BackupName: backupName, BackupPath: backupPath, BackupDate: info.ModTime().Format("2006-01-02 15:04"), }) } } return nil }