// Package installer provides cross-platform installation functionality package installer import ( "context" "errors" "fmt" "os/exec" "path/filepath" "runtime" "strings" "sync" "time" "gitgud.io/mike/mpv-manager/internal/packagequery" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/log" "gitgud.io/mike/mpv-manager/pkg/platform" ) // 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" } // DetectionState distinguishes a proven installation from a proven absence // and an inconclusive probe. Automatic discovery is conservative: a missing // tool, timeout, permission error, or incomplete search is Unknown, never an // instruction to delete durable application state. type DetectionState string const ( DetectionFound DetectionState = "found" DetectionNotFound DetectionState = "not-found" DetectionUnknown DetectionState = "unknown" ) // DetectionObservation is one identity-scoped discovery result. NotFound is // only actionable when InstallPath identifies an exact installation; method- // wide absence is intentionally non-destructive because several installations // can share one method. type DetectionObservation struct { State DetectionState App DetectedApp } // DetectInstalledMPVApps concurrently detects all installed MPV applications // Checks Flatpak and system package managers for mpv and celluloid on Linux, // well-known install locations for mpv.exe on Windows, and application bundles // in /Applications on macOS. Discovery never executes a detected application. func DetectInstalledMPVApps() []DetectedApp { if runtime.GOOS == "windows" { log.Info("[Detection] Starting Windows MPV detection...") apps := DetectWindowsMPVInstalls() log.Info(fmt.Sprintf("[Detection] Total detected apps: %d", len(apps))) return apps } if runtime.GOOS == "darwin" { return detectMacAppBundles("/Applications") } 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 } // DetectInstalledMPVAppObservations returns every proven installation plus an // Unknown marker for supported methods that produced no result. The current // heterogeneous OS probes cannot prove that an unobserved custom/package // install is absent, so they never synthesize NotFound. func DetectInstalledMPVAppObservations() []DetectionObservation { apps := DetectInstalledMPVApps() observations := make([]DetectionObservation, 0, len(apps)+4) foundMethods := make(map[string]bool) for _, app := range apps { observations = append(observations, DetectionObservation{State: DetectionFound, App: app}) foundMethods[app.MethodID] = true } var supportedMethods []string switch runtime.GOOS { case "darwin": supportedMethods = []string{constants.MethodMPVApp, constants.MethodIINA} case "windows": supportedMethods = []string{constants.MethodMPVBinary} case "linux": supportedMethods = []string{ constants.MethodMPVFlatpak, constants.MethodCelluloidFlatpak, constants.MethodMPVPackage, constants.MethodCelluloidPackage, } } for _, methodID := range supportedMethods { if !foundMethods[methodID] { observations = append(observations, DetectionObservation{ State: DetectionUnknown, App: DetectedApp{MethodID: methodID}, }) } } return observations } // 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 { var methodID string switch appID { case constants.FlatpakMPV: methodID = constants.MethodMPVFlatpak case constants.FlatpakCelluloid: methodID = constants.MethodCelluloidFlatpak default: return "" } version, err := packagequery.Installed(context.Background(), methodID) if err != nil { return "" } return version } // detectPackageManagerFamily detects the system package manager family. // Delegates os-release parsing to the canonical implementation in the // platform package, with a fallback to checking which package manager exists. func detectPackageManagerFamily() string { switch family := platform.GetLinuxDistroFamily(); family { case constants.PMFamilyDebian, constants.PMFamilyRHEL, constants.PMFamilyArch, constants.PMFamilySUSE: return family } // 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) { version, err := packagequery.InstalledLinux(context.Background(), family, packageName) if err != nil { return "", "" } path, _ = exec.LookPath(packageName) if path == "" && packageName == "celluloid" { path, _ = exec.LookPath("gnome-mpv") } return version, path } // SyncInstalledApps adds or refreshes proven detections. Absence from this // slice is not evidence of uninstallation, so existing records are preserved. // Call SyncInstalledAppObservations with an exact NotFound observation only // when a caller has independently proven that one installation disappeared. func SyncInstalledApps(detected []DetectedApp) (added int, removed int) { observations := make([]DetectionObservation, 0, len(detected)) for _, app := range detected { observations = append(observations, DetectionObservation{State: DetectionFound, App: app}) } return SyncInstalledAppObservations(observations) } // SyncInstalledAppObservations reconciles discovery by canonical method/path // identity. Multiple paths using the same install method remain independent; // Unknown never mutates state, and NotFound requires an exact non-empty path. func SyncInstalledAppObservations(observations []DetectionObservation) (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 } // Collapse only duplicate observations of the exact same method/path. A // proven Found result takes precedence over Unknown or NotFound regardless // of input order. byIdentity := make(map[string]DetectionObservation) for _, observation := range observations { identity := detectedAppIdentity(observation.App) if current, exists := byIdentity[identity]; exists && detectionStateRank(current.State) >= detectionStateRank(observation.State) { continue } byIdentity[identity] = observation } for _, observation := range byIdentity { app := observation.App switch observation.State { case DetectionUnknown: continue case DetectionNotFound: if strings.TrimSpace(app.InstallPath) == "" { log.Debug(fmt.Sprintf("[Detection] Ignoring method-wide absence for %s without an exact path", app.MethodID)) continue } tracked, resolveErr := config.ResolveInstalledApp("", "", app.MethodID, app.InstallPath) if errors.Is(resolveErr, config.ErrInstalledAppNotFound) { continue } if resolveErr != nil { log.Warn(fmt.Sprintf("[Detection] Failed to resolve absent app %s at %s: %v", app.MethodID, app.InstallPath, resolveErr)) continue } if didRemove, err := config.RemoveInstalledAppIfUnchanged(tracked); err != nil { log.Warn(fmt.Sprintf("[Detection] Failed to remove app %s: %v", tracked.ID, err)) } else if didRemove { removed++ } continue case DetectionFound: // Continue below. default: log.Warn(fmt.Sprintf("[Detection] Ignoring invalid state %q for %s", observation.State, app.MethodID)) continue } // Earlier versions stored package-manager labels instead of discovered // locations. Reconcile only one known placeholder with proven metadata. if app.InstallPath != "" { var legacy []config.InstalledApp for _, tracked := range config.GetInstalledApps() { if tracked.Managed && tracked.InstallMethod == app.MethodID && legacyPackagePath(app.MethodID, tracked.InstallPath) { legacy = append(legacy, tracked) } } foundPaths := 0 for _, candidate := range byIdentity { if candidate.State == DetectionFound && candidate.App.MethodID == app.MethodID { foundPaths++ } } if len(legacy) == 1 && foundPaths == 1 { if _, err := config.ReconcileInstalledAppPathIfUnchanged(legacy[0], app.InstallPath); err != nil { log.Warn(fmt.Sprintf("[Detection] Legacy path reconciliation failed: %v", err)) continue } } } tracked, resolveErr := config.ResolveInstalledApp("", "", app.MethodID, app.InstallPath) if resolveErr == nil { // Refresh version by stable ID without replacing Managed/UI metadata. if app.Version != "" && tracked.AppVersion != app.Version && !retainMacReleaseVersion(tracked, app.Version) { log.Info(fmt.Sprintf("[Detection] Updating version for %s (%s): %s -> %s", app.MethodID, tracked.ID, tracked.AppVersion, app.Version)) if _, err := config.UpdateInstalledAppVersionByID(tracked.ID, app.Version); err != nil { log.Warn(fmt.Sprintf("[Detection] Failed to update app %s: %v", tracked.ID, err)) } } continue } if !errors.Is(resolveErr, config.ErrInstalledAppNotFound) { log.Warn(fmt.Sprintf("[Detection] Failed to resolve app %s at %s: %v", app.MethodID, app.InstallPath, resolveErr)) continue } log.Info(fmt.Sprintf("[Detection] Adding newly detected app: %s (%s at %s)", app.AppName, app.MethodID, app.InstallPath)) newApp := config.InstalledApp{ AppName: app.AppName, AppType: "app", InstallMethod: app.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", app.MethodID, err)) } else { added++ } } return added, removed } func detectionStateRank(state DetectionState) int { switch state { case DetectionFound: return 3 case DetectionUnknown: return 2 case DetectionNotFound: return 1 default: return 0 } } func detectedAppIdentity(app DetectedApp) string { path := strings.TrimSpace(app.InstallPath) if path != "" { isWindowsPath := runtime.GOOS == "windows" || (len(path) >= 2 && path[1] == ':') || strings.HasPrefix(path, `\\`) if isWindowsPath { path = strings.ToLower(filepath.Clean(strings.ReplaceAll(path, `\`, "/"))) } else { path = filepath.Clean(path) } } return strings.ToLower(strings.TrimSpace(app.MethodID)) + "\x00" + path } // DetectAndSyncApps is a convenience function that detects and syncs in one call func DetectAndSyncApps() (added int, removed int) { return SyncInstalledAppObservations(DetectInstalledMPVAppObservations()) } func legacyPackagePath(methodID, path string) bool { switch methodID { case constants.MethodMPVPackage, constants.MethodCelluloidPackage: switch path { case "pacman", "apt", "dnf", "package-manager": return true } case constants.MethodMPVFlatpak, constants.MethodCelluloidFlatpak: return path == "/var/lib/flatpak/app" case constants.MethodMPVBrew: return path == "brew" } return false } // Official mpv release bundles can retain a -dev-g Info.plist // version. Keep the release tag recorded by a successful managed installation // when that metadata describes the same tag, avoiding an endless update offer. func retainMacReleaseVersion(tracked config.InstalledApp, detected string) bool { if !tracked.Managed || tracked.InstallMethod != constants.MethodMPVApp || tracked.AppVersion == "" { return false } prefix := strings.TrimPrefix(tracked.AppVersion, "v") + "-dev-g" detected = strings.TrimPrefix(detected, "v") if !strings.HasPrefix(detected, prefix) { return false } hash := strings.TrimPrefix(detected, prefix) if hash == "" { return false } for _, c := range hash { if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { return false } } return true }