// Package installer provides cross-platform installation functionality package installer import ( "bufio" "fmt" "os" "os/exec" "runtime" "strings" "sync" "time" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/log" ) // DetectedApp represents a detected MPV application installation type DetectedApp struct { AppName string // "MPV", "Celluloid" MethodID string // "mpv-flatpak", "celluloid-package", etc. Version string // Installed version InstallPath string // Path to binary if known InstallType string // "flatpak", "package" } // DetectInstalledMPVApps concurrently detects all installed MPV applications // Checks Flatpak and system package managers for mpv and celluloid func DetectInstalledMPVApps() []DetectedApp { if runtime.GOOS != "linux" { return nil } log.Info("[Detection] Starting concurrent MPV app detection...") var wg sync.WaitGroup resultsChan := make(chan []DetectedApp, 2) // 2 detection types // Detect Flatpak apps wg.Add(1) go func() { defer wg.Done() apps := detectFlatpakApps() if len(apps) > 0 { log.Info(fmt.Sprintf("[Detection] Found %d Flatpak apps", len(apps))) } resultsChan <- apps }() // Detect Package Manager apps wg.Add(1) go func() { defer wg.Done() apps := detectPackageApps() if len(apps) > 0 { log.Info(fmt.Sprintf("[Detection] Found %d Package Manager apps", len(apps))) } resultsChan <- apps }() // Wait for all goroutines and collect results go func() { wg.Wait() close(resultsChan) }() // Collect all detected apps var allApps []DetectedApp for apps := range resultsChan { allApps = append(allApps, apps...) } log.Info(fmt.Sprintf("[Detection] Total detected apps: %d", len(allApps))) return allApps } // detectFlatpakApps checks for MPV and Celluloid installed via Flatpak func detectFlatpakApps() []DetectedApp { var apps []DetectedApp // Check if flatpak is available if _, err := exec.LookPath("flatpak"); err != nil { log.Debug("[Detection] Flatpak not available") return apps } // Check for MPV Flatpak if version := getFlatpakAppVersion(constants.FlatpakMPV); version != "" { apps = append(apps, DetectedApp{ AppName: "MPV", MethodID: constants.MethodMPVFlatpak, Version: version, InstallPath: "flatpak run " + constants.FlatpakMPV, InstallType: "flatpak", }) log.Debug(fmt.Sprintf("[Detection] Found MPV Flatpak: %s", version)) } // Check for Celluloid Flatpak if version := getFlatpakAppVersion(constants.FlatpakCelluloid); version != "" { apps = append(apps, DetectedApp{ AppName: "Celluloid", MethodID: constants.MethodCelluloidFlatpak, Version: version, InstallPath: "flatpak run " + constants.FlatpakCelluloid, InstallType: "flatpak", }) log.Debug(fmt.Sprintf("[Detection] Found Celluloid Flatpak: %s", version)) } return apps } // detectPackageApps checks for MPV and Celluloid installed via system package manager func detectPackageApps() []DetectedApp { var apps []DetectedApp // Detect the package manager family family := detectPackageManagerFamily() if family == "" { log.Debug("[Detection] No supported package manager found") return apps } log.Debug(fmt.Sprintf("[Detection] Detected package manager family: %s", family)) // Check for MPV package if version, path := getPackageAppVersion(family, "mpv"); version != "" { apps = append(apps, DetectedApp{ AppName: "MPV", MethodID: constants.MethodMPVPackage, Version: version, InstallPath: path, InstallType: "package", }) log.Debug(fmt.Sprintf("[Detection] Found MPV Package: %s", version)) } // Check for Celluloid package (with alternative names) celluloidNames := []string{"celluloid", "gnome-mpv"} for _, name := range celluloidNames { if version, path := getPackageAppVersion(family, name); version != "" { apps = append(apps, DetectedApp{ AppName: "Celluloid", MethodID: constants.MethodCelluloidPackage, Version: version, InstallPath: path, InstallType: "package", }) log.Debug(fmt.Sprintf("[Detection] Found Celluloid Package (%s): %s", name, version)) break // Found one, no need to check others } } return apps } // getFlatpakAppVersion gets the version of a Flatpak app func getFlatpakAppVersion(appID string) string { cmd := exec.Command("flatpak", "info", appID) output, err := cmd.CombinedOutput() if err != nil { return "" } scanner := bufio.NewScanner(strings.NewReader(string(output))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "Version:" || strings.HasPrefix(line, "Version: ") { parts := strings.SplitN(line, ":", 2) if len(parts) == 2 { return strings.TrimSpace(parts[1]) } } } return "" } // detectPackageManagerFamily detects the system package manager family func detectPackageManagerFamily() string { // Read /etc/os-release to determine distro file, err := os.Open("/etc/os-release") if err != nil { // Fallback to checking which package manager exists if _, err := exec.LookPath("pacman"); err == nil { return constants.PMFamilyArch } if _, err := exec.LookPath("dnf"); err == nil { return constants.PMFamilyRHEL } if _, err := exec.LookPath("apt-get"); err == nil { return constants.PMFamilyDebian } return "" } defer file.Close() var id, idLike string scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "ID=") { id = strings.Trim(strings.TrimPrefix(line, "ID="), `"`) } else if strings.HasPrefix(line, "ID_LIKE=") { idLike = strings.Trim(strings.TrimPrefix(line, "ID_LIKE="), `"`) } } // Map to known distro families switch id { case "ubuntu", "debian", "linuxmint", "pop", "elementary": return constants.PMFamilyDebian case "fedora", "rhel", "centos", "rocky", "almalinux": return constants.PMFamilyRHEL case "arch", "manjaro", "endeavouros", "garuda", "cachyos": return constants.PMFamilyArch case "opensuse-tumbleweed", "opensuse-leap", "opensuse": return constants.PMFamilySUSE } // Check ID_LIKE for derivative distros idLikeLower := strings.ToLower(idLike) if strings.Contains(idLikeLower, "debian") { return constants.PMFamilyDebian } if strings.Contains(idLikeLower, "fedora") || strings.Contains(idLikeLower, "rhel") { return constants.PMFamilyRHEL } if strings.Contains(idLikeLower, "arch") { return constants.PMFamilyArch } if strings.Contains(idLikeLower, "suse") { return constants.PMFamilySUSE } // Fallback to checking which package manager exists if _, err := exec.LookPath("pacman"); err == nil { return constants.PMFamilyArch } if _, err := exec.LookPath("dnf"); err == nil { return constants.PMFamilyRHEL } if _, err := exec.LookPath("apt-get"); err == nil { return constants.PMFamilyDebian } if _, err := exec.LookPath("zypper"); err == nil { return constants.PMFamilySUSE } return "" } // getPackageAppVersion gets the version and path of a package-installed app func getPackageAppVersion(family, packageName string) (version string, path string) { switch family { case constants.PMFamilyDebian: return getAptPackageVersion(packageName) case constants.PMFamilyRHEL: return getDnfPackageVersion(packageName) case constants.PMFamilyArch: return getPacmanPackageVersion(packageName) case constants.PMFamilySUSE: return getZypperPackageVersion(packageName) } return "", "" } // getAptPackageVersion gets version from APT func getAptPackageVersion(packageName string) (string, string) { // Check if apt is available if _, err := exec.LookPath("apt-get"); err != nil { return "", "" } // Get version cmd := exec.Command("apt-cache", "policy", packageName) output, err := cmd.CombinedOutput() if err != nil { return "", "" } var version string scanner := bufio.NewScanner(strings.NewReader(string(output))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "Installed:") && strings.Contains(line, ":") { parts := strings.SplitN(line, ":", 2) if len(parts) == 2 { v := strings.TrimSpace(parts[1]) if v != "(none)" { version = v } } } } if version == "" { return "", "" } // Get path path, _ := exec.LookPath(packageName) return version, path } // getDnfPackageVersion gets version from DNF func getDnfPackageVersion(packageName string) (string, string) { if _, err := exec.LookPath("dnf"); err != nil { return "", "" } cmd := exec.Command("dnf", "info", "--installed", packageName) output, err := cmd.CombinedOutput() if err != nil { return "", "" } var version string scanner := bufio.NewScanner(strings.NewReader(string(output))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "Version") && strings.Contains(line, ":") { parts := strings.SplitN(line, ":", 2) if len(parts) == 2 { v := strings.TrimSpace(parts[1]) if strings.Contains(v, " ") { // Take last part if contains space versionParts := strings.Fields(v) v = versionParts[len(versionParts)-1] } version = v } } } if version == "" { return "", "" } path, _ := exec.LookPath(packageName) return version, path } // getPacmanPackageVersion gets version from Pacman func getPacmanPackageVersion(packageName string) (string, string) { if _, err := exec.LookPath("pacman"); err != nil { return "", "" } // Try primary package name version := tryPacmanVersionQuery(packageName) if version == "" && packageName == "celluloid" { // Try alternatives for celluloid alternatives := []string{"celluloid-git", "gnome-mpv", "gnome-mpv-git"} for _, alt := range alternatives { version = tryPacmanVersionQuery(alt) if version != "" { break } } } if version == "" { return "", "" } path, _ := exec.LookPath(packageName) if path == "" && packageName == "celluloid" { path, _ = exec.LookPath("gnome-mpv") // Old binary name } return version, path } // tryPacmanVersionQuery attempts to get version from pacman -Qi func tryPacmanVersionQuery(packageName string) string { cmd := exec.Command("pacman", "-Qi", packageName) output, err := cmd.CombinedOutput() if err != nil { return "" } scanner := bufio.NewScanner(strings.NewReader(string(output))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "Version") && strings.Contains(line, ":") { parts := strings.SplitN(line, ":", 2) if len(parts) == 2 { v := strings.TrimSpace(parts[1]) // Remove epoch if present (e.g., "1:0.41.0-2" -> "0.41.0-2") if strings.Contains(v, ":") { v = strings.SplitN(v, ":", 2)[1] } return v } } } return "" } // getZypperPackageVersion gets version from Zypper/RPM func getZypperPackageVersion(packageName string) (string, string) { if _, err := exec.LookPath("rpm"); err != nil { return "", "" } cmd := exec.Command("rpm", "-q", "--queryformat", "%{VERSION}-%{RELEASE}", packageName) output, err := cmd.Output() if err != nil { return "", "" } version := strings.TrimSpace(string(output)) if version == "" || strings.Contains(version, "not installed") { return "", "" } path, _ := exec.LookPath(packageName) return version, path } // SyncInstalledApps synchronizes detected apps with the config // - Adds apps that are detected but not in config // - Removes apps that are in config but no longer detected // Returns counts of added and removed apps func SyncInstalledApps(detected []DetectedApp) (added int, removed int) { // Ensure config is loaded _, err := config.Load() if err != nil { log.Error(fmt.Sprintf("[Detection] Failed to load config: %v", err)) return 0, 0 } // Get currently tracked apps trackedApps := config.GetInstalledApps() // Build maps for comparison detectedMap := make(map[string]DetectedApp) for _, app := range detected { detectedMap[app.MethodID] = app } trackedMap := make(map[string]config.InstalledApp) for _, app := range trackedApps { // Only track package manager apps (flatpak, package) if isPackageManagerMethod(app.InstallMethod) { trackedMap[app.InstallMethod] = app } } // Find apps to add (detected but not tracked) for methodID, app := range detectedMap { if _, exists := trackedMap[methodID]; !exists { log.Info(fmt.Sprintf("[Detection] Adding newly detected app: %s (%s)", app.AppName, methodID)) newApp := config.InstalledApp{ AppName: app.AppName, AppType: "app", InstallMethod: methodID, InstallPath: app.InstallPath, InstalledDate: time.Now().Format("2006-01-02"), AppVersion: app.Version, Managed: false, // Externally detected, not managed by MPV Manager } if err := config.AddInstalledApp(newApp); err != nil { log.Warn(fmt.Sprintf("[Detection] Failed to add app %s: %v", methodID, err)) } else { added++ } } else { // Update version if it changed tracked := trackedMap[methodID] if tracked.AppVersion != app.Version { log.Info(fmt.Sprintf("[Detection] Updating version for %s: %s -> %s", methodID, tracked.AppVersion, app.Version)) tracked.AppVersion = app.Version // Update by removing and re-adding config.RemoveInstalledAppByMethod(methodID) config.AddInstalledApp(tracked) } } } // Find apps to remove (tracked but not detected) for methodID := range trackedMap { if _, exists := detectedMap[methodID]; !exists { log.Info(fmt.Sprintf("[Detection] Removing stale app: %s", methodID)) if err := config.RemoveInstalledAppByMethod(methodID); err != nil { log.Warn(fmt.Sprintf("[Detection] Failed to remove app %s: %v", methodID, err)) } else { removed++ } } } return added, removed } // isPackageManagerMethod checks if a method ID is a package manager type func isPackageManagerMethod(methodID string) bool { return strings.Contains(methodID, "-flatpak") || strings.Contains(methodID, "-package") || strings.Contains(methodID, "-brew") } // DetectAndSyncApps is a convenience function that detects and syncs in one call func DetectAndSyncApps() (added int, removed int) { detected := DetectInstalledMPVApps() return SyncInstalledApps(detected) }