// Package packagequery owns bounded, locale-stable package-manager probes. package packagequery import ( "bufio" "context" "errors" "fmt" "os" "os/exec" "runtime" "strings" "gitgud.io/mike/mpv-manager/internal/process" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/log" "gitgud.io/mike/mpv-manager/pkg/platform" "time" ) // ErrNotInstalled is returned when the package manager definitively // reports that a package is not installed. Any other error means the installed // state could not be determined (missing package manager, transient failure, // unsupported distro) and callers must NOT treat it as "not installed". var ErrNotInstalled = errors.New("package not installed") // cleanVersionString removes package name prefix from version string // e.g., "mpv 1:0.41.0-2.1" → "1:0.41.0-2.1" func cleanVersionString(version, packageName string) string { if version == "" { return "" } // If version starts with package name followed by space, remove it prefix := packageName + " " if strings.HasPrefix(version, prefix) { return strings.TrimPrefix(version, prefix) } return version } // getPackageNameForMethod maps method ID to package name for version checking func getPackageNameForMethod(methodID string) string { switch methodID { case constants.MethodMPVPackage, constants.MethodMPVFlatpak, constants.MethodMPVBrew: return "mpv" case constants.MethodCelluloidPackage, constants.MethodCelluloidFlatpak: return "celluloid" default: return "" } } // Installed returns the installed version or ErrNotInstalled only for a // definitive absence. Every other error is an unknown state. Even callers // without a deadline are bounded to five seconds, including child processes. func Installed(ctx context.Context, methodID string) (string, error) { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() // Map method ID to package name packageName := getPackageNameForMethod(methodID) if packageName == "" { return "", fmt.Errorf("unsupported package method %q", methodID) } switch { case strings.Contains(methodID, "flatpak"): return getFlatpakPackageVersion(ctx, packageName) case strings.Contains(methodID, "brew"): return getBrewPackageVersion(ctx, packageName) case strings.Contains(methodID, "package"): return getLinuxPackageVersion(ctx, packageName) default: return "", fmt.Errorf("no package manager associated with method: %s", methodID) } } // Available returns the repository version when the package manager can // determine it within five seconds. Unknown versions remain empty. func Available(ctx context.Context, methodID string) string { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() packageName := getPackageNameForMethod(methodID) if packageName == "" { return "" } switch { case strings.Contains(methodID, "flatpak"): return getFlatpakAvailableVersion(ctx, packageName) case strings.Contains(methodID, "package"): if runtime.GOOS != "linux" { return "" } switch platform.GetLinuxDistroFamily() { case "arch": return getPacmanAvailableVersion(ctx, packageName) case "debian": return getAptAvailableVersion(ctx, packageName) case "rhel": return getDnfAvailableVersion(ctx, packageName) case "suse": return getZypperAvailableVersion(ctx, packageName) } } return "" } // getFlatpakAppID returns the full Flatpak app ID for a package func getFlatpakAppID(packageName string) string { packageName = strings.ToLower(packageName) switch packageName { case "mpv": return constants.FlatpakMPV case "celluloid": return constants.FlatpakCelluloid default: return packageName } } // getLinuxPackageVersion queries the appropriate Linux package manager for the installed version func getLinuxPackageVersion(ctx context.Context, packageName string) (string, error) { if runtime.GOOS != "linux" { return "", fmt.Errorf("linux package query unsupported on %s", runtime.GOOS) } return InstalledLinux(ctx, platform.GetLinuxDistroFamily(), packageName) } // InstalledLinux probes the selected package-manager family using the same bounded, // locale-stable parsers as version checks. Discovery may select a supported tool // family when os-release does not identify the host distribution. func InstalledLinux(ctx context.Context, family, packageName string) (string, error) { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() switch family { case constants.PMFamilyArch: return getPacmanPackageVersion(ctx, packageName) case constants.PMFamilyDebian: return getAptPackageVersion(ctx, packageName) case constants.PMFamilyRHEL: return getDnfPackageVersion(ctx, packageName) case constants.PMFamilySUSE: return getZypperPackageVersion(ctx, packageName) default: return "", fmt.Errorf("unsupported or unknown distribution %q", family) } } // tryPacmanVersion attempts to get the installed version of a package using pacman -Qi. // Returns ErrNotInstalled when pacman definitively reports the package // is not installed; any other error means the state could not be determined. // Runs with LC_ALL=C so the English "Version" label is parsed correctly on // non-English systems. func tryPacmanVersion(ctx context.Context, packageName string) (string, error) { cmd := exec.CommandContext(ctx, "pacman", "-Qi", packageName) output, err := runQuery(ctx, cmd) if err != nil { // pacman -Qi exits non-zero with "error: package 'X' was not found" // when the package is definitively not installed if strings.Contains(string(output), "was not found") { return "", ErrNotInstalled } return "", fmt.Errorf("pacman -Qi %s failed: %w", packageName, err) } // Parse output for "Version : X.Y.Z" - be more specific to avoid matching lines with "Version" in other contexts scanner := bufio.NewScanner(strings.NewReader(string(output))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) // Check for "Version" followed by colon to ensure we're parsing the correct line if strings.HasPrefix(line, "Version") && strings.Contains(line, ":") { parts := strings.SplitN(line, ":", 2) if len(parts) == 2 { version := strings.TrimSpace(parts[1]) // Additional check: version should not contain package name // e.g., "mpv 0.41.0-2.1" should only return "0.41.0-2.1" if strings.Contains(version, " ") { // If version contains space, take the last part (actual version number) versionParts := strings.Fields(version) if len(versionParts) > 0 { version = versionParts[len(versionParts)-1] } } // Clean version string by removing package name prefix (defensive measure) return cleanVersionString(version, packageName), nil } } } return "", fmt.Errorf("could not parse pacman -Qi output for %s", packageName) } // getPacmanPackageVersion queries pacman for the installed version of a package // For celluloid, tries alternative package names (celluloid-git, gnome-mpv, gnome-mpv-git) func getPacmanPackageVersion(ctx context.Context, packageName string) (string, error) { // Check if pacman is available if _, err := exec.LookPath("pacman"); err != nil { log.Info("pacman not available") return "", fmt.Errorf("pacman not available") } // Try primary package name version, err := tryPacmanVersion(ctx, packageName) if err == nil { return version, nil } if !errors.Is(err, ErrNotInstalled) { // Could not determine installed state — do NOT treat as missing return "", err } // For celluloid, try alternative package names (AUR variants, old name) if packageName == "celluloid" { alternatives := []string{"celluloid-git", "gnome-mpv", "gnome-mpv-git"} for _, alt := range alternatives { log.Info(fmt.Sprintf("Celluloid not found under primary package name, trying: %s", alt)) version, err := tryPacmanVersion(ctx, alt) if err == nil { log.Info(fmt.Sprintf("Found Celluloid under package name: %s", alt)) return version, nil } if !errors.Is(err, ErrNotInstalled) { // Uncertain result for an alternative name — do NOT treat as missing return "", err } } log.Info("Celluloid not found under any package name (celluloid, celluloid-git, gnome-mpv, gnome-mpv-git)") } // Definitively not installed under any applicable package name return "", ErrNotInstalled } // getPacmanAvailableVersion queries pacman for the available version of a package in the repos func getPacmanAvailableVersion(ctx context.Context, packageName string) string { // Check if pacman is available if _, err := exec.LookPath("pacman"); err != nil { log.Info("pacman not available") return "" } // Use pacman -Si to get available version cmd := exec.CommandContext(ctx, "pacman", "-Si", packageName) output, err := runQuery(ctx, cmd) if err != nil { log.Debug(fmt.Sprintf("Failed to get pacman available version for %s: %v", packageName, err)) return "" } // Parse output for "Version : X.Y.Z" scanner := bufio.NewScanner(strings.NewReader(string(output))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "Version") { parts := strings.SplitN(line, ":", 2) if len(parts) == 2 { version := strings.TrimSpace(parts[1]) // Clean version string by removing package name prefix (defensive measure) return cleanVersionString(version, packageName) } } } return "" } // parseAptPolicyOutput extracts the Candidate version from `apt-cache policy` // output. Returns "" with nil error when apt definitively reports no candidate // ("Candidate: (none)"); an error means the output could not be parsed at all. func parseAptPolicyOutput(out string) (string, error) { scanner := bufio.NewScanner(strings.NewReader(out)) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "Candidate:") { parts := strings.SplitN(line, ":", 2) if len(parts) == 2 { version := strings.TrimSpace(parts[1]) if version == "(none)" { // apt definitively reports no available candidate return "", nil } return version, nil } } } return "", fmt.Errorf("could not parse apt-cache policy output") } // parseDnfInfoOutput extracts the Version field from `dnf info` output. // Returns "" with nil error when dnf reports "No matching Packages"; an error // means the output could not be parsed. func parseDnfInfoOutput(out string) (string, error) { if strings.Contains(strings.ToLower(out), "no matching packages") { return "", nil } scanner := bufio.NewScanner(strings.NewReader(out)) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) // Check for "Version" followed by colon to ensure we're parsing correct line if strings.HasPrefix(line, "Version") && strings.Contains(line, ":") { parts := strings.SplitN(line, ":", 2) if len(parts) == 2 { version := strings.TrimSpace(parts[1]) // Additional check: version should not contain package name if strings.Contains(version, " ") { // If version contains space, take the last part (actual version number) versionParts := strings.Fields(version) if len(versionParts) > 0 { version = versionParts[len(versionParts)-1] } } return version, nil } } } return "", fmt.Errorf("could not parse dnf info output") } // parseZypperInfoOutput extracts the Version field from `zypper info` output. // Returns "" with nil error when zypper reports the package is "not found"; // an error means the output could not be parsed. func parseZypperInfoOutput(out string) (string, error) { if strings.Contains(strings.ToLower(out), "not found") { return "", nil } scanner := bufio.NewScanner(strings.NewReader(out)) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) // Check for "Version" followed by colon to ensure we're parsing correct line if strings.HasPrefix(line, "Version") && strings.Contains(line, ":") { parts := strings.SplitN(line, ":", 2) if len(parts) == 2 { version := strings.TrimSpace(parts[1]) // Additional check: version should not contain package name if strings.Contains(version, " ") { // If version contains space, take the last part (actual version number) versionParts := strings.Fields(version) if len(versionParts) > 0 { version = versionParts[len(versionParts)-1] } } return version, nil } } } return "", fmt.Errorf("could not parse zypper info output") } // getAptAvailableVersion queries apt for the available (candidate) version of // a package. Runs with LC_ALL=C so the English "Candidate:" label is parsed // correctly on non-English systems. func getAptAvailableVersion(ctx context.Context, packageName string) string { // Check if apt is available if _, err := exec.LookPath("apt-get"); err != nil { log.Info("apt-get not available") return "" } cmd := exec.CommandContext(ctx, "apt-cache", "policy", packageName) output, err := runQuery(ctx, cmd) if err != nil { log.Debug(fmt.Sprintf("Failed to get apt available version for %s: %v", packageName, err)) return "" } version, err := parseAptPolicyOutput(string(output)) if err != nil { log.Debug(fmt.Sprintf("Failed to parse apt available version for %s: %v", packageName, err)) return "" } // Clean version string by removing package name prefix (defensive measure) return cleanVersionString(version, packageName) } // getDnfAvailableVersion queries dnf for the available version of a package. // Runs with LC_ALL=C so the English "Version" label is parsed correctly on // non-English systems. func getDnfAvailableVersion(ctx context.Context, packageName string) string { // Check if dnf is available if _, err := exec.LookPath("dnf"); err != nil { log.Info("dnf not available") return "" } cmd := exec.CommandContext(ctx, "dnf", "info", "--available", packageName) output, err := runQuery(ctx, cmd) if err != nil { // dnf exits non-zero with "No matching Packages to list" when the // package is not available; any other failure is indeterminate if !strings.Contains(strings.ToLower(string(output)), "no matching packages") { log.Debug(fmt.Sprintf("Failed to get dnf available version for %s: %v", packageName, err)) return "" } } version, err := parseDnfInfoOutput(string(output)) if err != nil { log.Debug(fmt.Sprintf("Failed to parse dnf available version for %s: %v", packageName, err)) return "" } // Clean version string by removing package name prefix (defensive measure) return cleanVersionString(version, packageName) } // getZypperAvailableVersion queries zypper for the available version of a // package. Runs with LC_ALL=C so the English "Version" label is parsed // correctly on non-English systems. func getZypperAvailableVersion(ctx context.Context, packageName string) string { // Check if zypper is available if _, err := exec.LookPath("zypper"); err != nil { log.Info("zypper not available") return "" } cmd := exec.CommandContext(ctx, "zypper", "--non-interactive", "info", packageName) output, err := runQuery(ctx, cmd) if err != nil { // zypper exits non-zero with "Package 'X' not found." when the package // is not available; any other failure is indeterminate if !strings.Contains(strings.ToLower(string(output)), "not found") { log.Debug(fmt.Sprintf("Failed to get zypper available version for %s: %v", packageName, err)) return "" } } version, err := parseZypperInfoOutput(string(output)) if err != nil { log.Debug(fmt.Sprintf("Failed to parse zypper available version for %s: %v", packageName, err)) return "" } // Clean version string by removing package name prefix (defensive measure) return cleanVersionString(version, packageName) } // getDnfPackageVersion queries dnf for the installed version of a package. // Runs with LC_ALL=C so the English "Version" label is parsed correctly on // non-English systems. func getDnfPackageVersion(ctx context.Context, packageName string) (string, error) { // Check if dnf is available if _, err := exec.LookPath("dnf"); err != nil { log.Info("dnf not available") return "", fmt.Errorf("dnf not available") } cmd := exec.CommandContext(ctx, "dnf", "info", "--installed", packageName) output, err := runQuery(ctx, cmd) if err != nil { // dnf exits non-zero with "No matching Packages to list" when the // package is definitively not installed if strings.Contains(string(output), "No matching Packages") { return "", ErrNotInstalled } return "", fmt.Errorf("dnf info %s failed: %w", packageName, err) } scanner := bufio.NewScanner(strings.NewReader(string(output))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) // Check for "Version" followed by colon to ensure we're parsing correct line if strings.HasPrefix(line, "Version") && strings.Contains(line, ":") { parts := strings.SplitN(line, ":", 2) if len(parts) == 2 { version := strings.TrimSpace(parts[1]) // Additional check: version should not contain package name if strings.Contains(version, " ") { // If version contains space, take the last part (actual version number) versionParts := strings.Fields(version) if len(versionParts) > 0 { version = versionParts[len(versionParts)-1] } } // Clean version string by removing package name prefix (defensive measure) return cleanVersionString(version, packageName), nil } } } return "", fmt.Errorf("could not parse dnf info output for %s", packageName) } // getZypperPackageVersion queries zypper (via rpm) for the installed version of a package. // Runs with LC_ALL=C so output parsing works on non-English systems. func getZypperPackageVersion(ctx context.Context, packageName string) (string, error) { // Check if rpm is available (zypper uses rpm for package queries) if _, err := exec.LookPath("rpm"); err != nil { log.Info("rpm not available") return "", fmt.Errorf("rpm not available") } cmd := exec.CommandContext(ctx, "rpm", "-q", "--queryformat", "%{VERSION}-%{RELEASE}", packageName) output, err := runQuery(ctx, cmd) if err != nil { // rpm -q exits 1 with "package X is not installed" on stderr var exitErr *exec.ExitError if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 && strings.Contains(string(output), "is not installed") { return "", ErrNotInstalled } return "", fmt.Errorf("rpm -q %s failed: %w", packageName, err) } version := strings.TrimSpace(string(output)) if version != "" && !strings.Contains(version, "not installed") { log.Debug(fmt.Sprintf("Found zypper version for %s: %s", packageName, version)) return version, nil } return "", fmt.Errorf("could not parse rpm version output for %s", packageName) } // getAptPackageVersion queries apt for the installed version of a package. // Runs with LC_ALL=C so the English "Installed:" label is parsed correctly on // non-English systems. func getAptPackageVersion(ctx context.Context, packageName string) (string, error) { // Check if apt is available if _, err := exec.LookPath("apt-get"); err != nil { log.Info("apt-get not available") return "", fmt.Errorf("apt-get not available") } cmd := exec.CommandContext(ctx, "apt-cache", "policy", packageName) output, err := runQuery(ctx, cmd) if err != nil { return "", fmt.Errorf("apt-cache policy %s failed: %w", packageName, err) } 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 { version := strings.TrimSpace(parts[1]) if version == "(none)" { // apt definitively reports the package as not installed return "", ErrNotInstalled } // Additional check: version should not contain package name if strings.Contains(version, " ") { // If version contains space, take the last part (actual version number) versionParts := strings.Fields(version) if len(versionParts) > 0 { version = versionParts[len(versionParts)-1] } } // Clean version string by removing package name prefix (defensive measure) return cleanVersionString(version, packageName), nil } } } return "", fmt.Errorf("could not parse apt-cache policy output for %s", packageName) } // getFlatpakPackageVersion queries flatpak for the installed version of an app. // Runs with LC_ALL=C so the English "Version:" label is parsed correctly on // non-English systems. func getFlatpakPackageVersion(ctx context.Context, packageName string) (string, error) { // Check if flatpak is available if _, err := exec.LookPath("flatpak"); err != nil { log.Info("flatpak not available") return "", fmt.Errorf("flatpak not available") } // Map to proper Flatpak app ID appID := getFlatpakAppID(packageName) // Use flatpak info to get installed version cmd := exec.CommandContext(ctx, "flatpak", "info", appID) output, err := runQuery(ctx, cmd) if err != nil { // flatpak info exits non-zero when the app is definitively not installed out := string(output) if strings.Contains(out, "No such ref") || strings.Contains(out, "No such app") || strings.Contains(out, "No application found") || strings.Contains(out, "not installed") { return "", ErrNotInstalled } return "", fmt.Errorf("flatpak info %s failed: %w", appID, err) } // Parse output for "Version: X.Y.Z" (exact match, not "Runtime Version:" or "Sdk Version:") scanner := bufio.NewScanner(strings.NewReader(string(output))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) // Exact match for "Version:" line (not Runtime Version or Sdk Version) if line == "Version:" || strings.HasPrefix(line, "Version: ") { parts := strings.SplitN(line, ":", 2) if len(parts) == 2 { version := strings.TrimSpace(parts[1]) // Clean version string by removing package name prefix (defensive measure) return cleanVersionString(version, packageName), nil } } } return "", fmt.Errorf("could not parse flatpak info output for %s", appID) } // getFlatpakAvailableVersion queries flatpak remote for the available version of an app func getFlatpakAvailableVersion(ctx context.Context, packageName string) string { // Check if flatpak is available if _, err := exec.LookPath("flatpak"); err != nil { log.Info("flatpak not available") return "" } // Map to proper Flatpak app ID appID := getFlatpakAppID(packageName) // Use flathub remote directly for available version check cmd := exec.CommandContext(ctx, "flatpak", "remote-info", "flathub", appID) output, err := runQuery(ctx, cmd) if err != nil { log.Debug(fmt.Sprintf("Failed to get flatpak available version for %s: %v", appID, err)) return "" } // Parse output for "Version: X.Y.Z" scanner := bufio.NewScanner(strings.NewReader(string(output))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "Version:") { parts := strings.SplitN(line, ":", 2) if len(parts) == 2 { version := strings.TrimSpace(parts[1]) // Clean version string by removing package name prefix (defensive measure) return cleanVersionString(version, packageName) } } } return "" } // getBrewPackageVersion queries brew for the installed version of a formula. // Runs with LC_ALL=C so output parsing works on non-English systems. func getBrewPackageVersion(ctx context.Context, packageName string) (string, error) { if runtime.GOOS != "darwin" { return "", fmt.Errorf("brew is only supported on macOS") } // Check if brew is available if _, err := exec.LookPath("brew"); err != nil { log.Info("brew not available") return "", fmt.Errorf("brew not available") } cmd := exec.CommandContext(ctx, "brew", "list", "--versions", packageName) output, err := runQuery(ctx, cmd) if err != nil { // brew exits non-zero with "No such keg" when the formula is // definitively not installed out := string(output) if strings.Contains(out, "No such keg") || strings.Contains(out, "not installed") { return "", ErrNotInstalled } return "", fmt.Errorf("brew list --versions %s failed: %w", packageName, err) } scanner := bufio.NewScanner(strings.NewReader(string(output))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, packageName) { parts := strings.Fields(line) if len(parts) >= 2 { version := parts[1] // Clean version string by removing package name prefix (defensive measure) return cleanVersionString(version, packageName), nil } } } // brew list --versions succeeds with empty output for an absent formula. if strings.TrimSpace(string(output)) == "" { return "", ErrNotInstalled } return "", fmt.Errorf("could not parse brew version output for %s", packageName) } // Use one owned process boundary for every query. Discard partial timeout // output so a diagnostic printed before a hang cannot become "not installed". func runQuery(ctx context.Context, cmd *exec.Cmd) ([]byte, error) { cmd.Env = append(os.Environ(), "LC_ALL=C") stdout, err := process.Output(ctx, cmd) if ctx.Err() != nil { return nil, ctx.Err() } if errors.Is(err, process.ErrOutputLimit) { return nil, err } var exitErr *exec.ExitError if errors.As(err, &exitErr) { return append(append(stdout, '\n'), exitErr.Stderr...), err } return stdout, err }