package web import ( "context" "errors" "fmt" "runtime" "strings" "sync" "time" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/installer" "gitgud.io/mike/mpv-manager/pkg/log" "gitgud.io/mike/mpv-manager/pkg/version" ) type VersionCache struct { LastChecked time.Time UpdateMap map[string]version.UpdateItem // installed-app ID; method ID for components/legacy fixtures PackageVersionMap map[string]AppVersionInfo // methodID -> {current, available} } type AppVersionInfo struct { CurrentVersion string AvailableVersion string } // PackageVersionResult holds result of a package version check type PackageVersionResult struct { App config.InstalledApp CurrentVersion string AvailableVersion string NotInstalled bool // true ONLY if the package manager definitively reported the package as not installed } var packageVersionCheckTimeout = 10 * time.Second func updateCacheKey(update version.UpdateItem) string { if update.AppID != "" { return update.AppID } return update.MethodID } // versionCacheSnapshot copies the cached update items and package versions // under the read lock, then releases it. Callers must convert/render from the // returned copies only — never call helpers that acquire versionCacheMux while // holding it (recursive RLock deadlocks once a writer queues; see Go RWMutex // writer-preference semantics). func (s *Server) versionCacheSnapshot() (updateItems []version.UpdateItem, updateMap map[string]version.UpdateItem, pkgVersions map[string]AppVersionInfo) { s.versionCacheMux.RLock() defer s.versionCacheMux.RUnlock() updateMap = make(map[string]version.UpdateItem) pkgVersions = make(map[string]AppVersionInfo) if s.versionCache == nil { return nil, updateMap, pkgVersions } for methodID, update := range s.versionCache.UpdateMap { updateItems = append(updateItems, update) updateMap[methodID] = update } for methodID, info := range s.versionCache.PackageVersionMap { pkgVersions[methodID] = info } return updateItems, updateMap, pkgVersions } // cleanAppVersion removes package name prefix from version string for display // e.g., "mpv 1:0.41.0-2.1" → "1:0.41.0-2.1" // // "celluloid 0.29-1.1" → "0.29-1.1" func cleanAppVersion(version string) string { if version == "" { return "" } // Try to extract version after package name // Common formats: "pkg 1.0.0", "pkg 1:0.41.0-2.1" fields := strings.Fields(version) if len(fields) > 1 { // Return everything after the first field (package name) return strings.Join(fields[1:], " ") } return version } // compareVersions compares two version strings and returns true only when the // available version has greater precedence. A differing older version is not // an update. func compareVersions(current, available string) bool { // Empty versions mean no update available if current == "" || available == "" { return false } // Package manager messages mean no update available if available == "Check via Package Manager" || available == "Unknown" { return false } // If current is "Unknown", we can't determine if update is available // (available might be a placeholder like "Check via Package Manager") if current == "Unknown" { return false } return version.IsVersionUpdateAvailable(current, available) } // getPackageManagerType returns the package manager type for a given method ID func getPackageManagerType(methodID string) string { switch { case strings.Contains(methodID, "flatpak"): return "Flatpak" case strings.Contains(methodID, "package"): return "Package Manager" case strings.Contains(methodID, "brew"): return "Brew" default: return "" } } // convertClientUpdates converts update items to client update info. // It is LOCK-FREE: pkgVersions must be a snapshot previously copied under // versionCacheMux (see versionCacheSnapshot). It must never acquire // versionCacheMux itself — callers hold it while copying, and a recursive // RLock deadlocks once a writer (refreshVersionCache) queues. func convertClientUpdates(updates []version.UpdateItem, pkgVersions map[string]AppVersionInfo) []ClientUpdateInfo { var result []ClientUpdateInfo for _, item := range updates { logo := getLogoForApp(item.MethodID, item.AppName) // Check if this is a package manager app versionInfo, isPackageManager := pkgVersions[item.MethodID] var updateAvailable bool var currentVersion, availableVersion string var packageManager string if isPackageManager { // For package manager apps, compare versions currentVersion = versionInfo.CurrentVersion availableVersion = versionInfo.AvailableVersion updateAvailable = compareVersions(currentVersion, availableVersion) packageManager = getPackageManagerType(item.MethodID) } else { // For release-based apps, update is available if UpdateType is "update" currentVersion = item.CurrentVersion availableVersion = item.AvailableVersion updateAvailable = item.UpdateType == "update" } // Only add apps that actually have updates available // This prevents showing "Up to Date" buttons in the Available Updates section if !updateAvailable { continue } result = append(result, ClientUpdateInfo{ AppID: item.AppID, AppName: item.AppName, InstallPath: item.InstallPath, CurrentVersion: cleanAppVersion(currentVersion), AvailableVersion: cleanAppVersion(availableVersion), MethodID: item.MethodID, Logo: logo, UpdateAvailable: true, PackageManager: packageManager, }) } return result } // concurrentlyCheckPackageVersions checks all package manager versions concurrently // Returns versions and the exact observed records that were definitively absent func (s *Server) concurrentlyCheckPackageVersions(ctx context.Context, installedApps []config.InstalledApp) (map[string]AppVersionInfo, []config.InstalledApp) { // Collect package manager apps var pmApps []config.InstalledApp for _, app := range installedApps { if constants.IsPackageManaged(app.InstallMethod) { pmApps = append(pmApps, app) } } // If no package manager apps, return empty map if len(pmApps) == 0 { return make(map[string]AppVersionInfo), nil } // Create channel for results with buffer resultChan := make(chan PackageVersionResult, len(pmApps)) ctx, cancel := context.WithTimeout(ctx, packageVersionCheckTimeout) var workers sync.WaitGroup defer func() { cancel(); workers.Wait() }() installedProbe := getPackageVersionProbe availableProbe := getAvailableVersionProbe // Start goroutines for concurrent version checking for _, app := range pmApps { workers.Add(1) go func(app config.InstalledApp) { defer workers.Done() currentVersion, verr := installedProbe(ctx, app.InstallMethod) availableVersion := availableProbe(ctx, app.InstallMethod) // Only a DEFINITIVE "not installed" marks the app as stale. // Any probe error or uncertainty keeps the app in config — this // prevents silent data loss on transient package manager errors // and on unsupported distros. notInstalled := errors.Is(verr, ErrPackageNotInstalled) if verr != nil && !notInstalled { log.Warn(fmt.Sprintf("Could not determine installed version for %s (%s): %v — keeping app in config", app.AppName, app.InstallMethod, verr)) } if currentVersion == "" { currentVersion = "Unknown" } if availableVersion == "" { availableVersion = "Check via Package Manager" } result := PackageVersionResult{ App: app, CurrentVersion: currentVersion, AvailableVersion: availableVersion, NotInstalled: notInstalled, } select { case resultChan <- result: case <-ctx.Done(): } }(app) } // Collect results with one deadline for the entire probe batch. versionMap := make(map[string]AppVersionInfo) var staleApps []config.InstalledApp for i := 0; i < len(pmApps); i++ { select { case result := <-resultChan: versionMap[result.App.InstallMethod] = AppVersionInfo{ CurrentVersion: result.CurrentVersion, AvailableVersion: result.AvailableVersion, } if result.NotInstalled { staleApps = append(staleApps, result.App) } case <-ctx.Done(): log.Warn("Timeout while checking package versions, some results may be missing") // Fill in remaining apps as unknown for _, app := range pmApps { if _, ok := versionMap[app.InstallMethod]; !ok { versionMap[app.InstallMethod] = AppVersionInfo{ CurrentVersion: "Unknown", AvailableVersion: "Check via Package Manager", } } } return versionMap, staleApps } } return versionMap, staleApps } // refreshVersionCache refreshes the version check cache func (s *Server) refreshVersionCache(ctx context.Context) { log.Info("Refreshing version check cache...") // Detect and sync installations on every supported platform. if runtime.GOOS == "linux" || runtime.GOOS == "windows" || runtime.GOOS == "darwin" { added, removed := installer.DetectAndSyncApps() if added > 0 { log.Info(fmt.Sprintf("Added %d newly detected apps to config", added)) } if removed > 0 { log.Info(fmt.Sprintf("Removed %d stale apps from config", removed)) } } // Get installed apps (now includes any newly detected) installedApps := config.GetInstalledApps() appUpdates := version.CheckForAppUpdatesContext(ctx, s.releaseSnapshot(), installedApps) // Build the replacement without holding the cache lock so requests can keep // reading the previous complete snapshot during slow package-manager probes. newCache := &VersionCache{ LastChecked: time.Now(), UpdateMap: make(map[string]version.UpdateItem), PackageVersionMap: make(map[string]AppVersionInfo), } for _, update := range appUpdates.AppsToUpdate { newCache.UpdateMap[updateCacheKey(update)] = update } // Query package manager versions for package manager apps concurrently // Also detects and removes stale apps (no longer installed) versionMap, staleApps := s.concurrentlyCheckPackageVersions(ctx, installedApps) for methodID, versionInfo := range versionMap { newCache.PackageVersionMap[methodID] = versionInfo } if ctx.Err() != nil { return } for _, observed := range staleApps { removed, err := config.RemoveInstalledAppIfUnchanged(observed) if err != nil { log.Warn(fmt.Sprintf("Failed to reconcile absent app %s: %v", observed.ID, err)) } else if removed { delete(newCache.UpdateMap, observed.ID) log.Info("Removed unchanged absent app from config: " + observed.ID) } } s.versionCacheMux.Lock() s.versionCache = newCache s.versionCacheMux.Unlock() log.Info(fmt.Sprintf("Version check cache refreshed: %d updates, %d package versions", len(newCache.UpdateMap), len(newCache.PackageVersionMap))) }