//go:build linux package installer import ( "context" "fmt" "os" "os/exec" "path/filepath" "strings" "time" "gitgud.io/mike/mpv-manager/internal/assets" "gitgud.io/mike/mpv-manager/internal/process" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/platform" ) type LinuxInstaller struct { *Installer Platform *platform.Platform } func NewLinuxInstaller(installer *Installer, p *platform.Platform) *LinuxInstaller { return &LinuxInstaller{ Installer: installer, Platform: p, } } // executorOr returns the injected CommandExecutor when one is configured on the // embedded Installer (tests), falling back to the provided runner otherwise. // It tolerates a nil embedded Installer (unit tests construct LinuxInstaller // directly) by falling back to the runner as well. func (li *LinuxInstaller) executorOr(cr *CommandRunner) CommandExecutor { if li.Installer != nil && li.executor != nil { return li.executor } return cr } // runSudoCommand runs a privileged command through the injected executor when // one is configured (tests); production keeps the runner's keyring-aware sudo // path, so command lines and authentication behavior are unchanged. func (li *LinuxInstaller) runSudoCommand(cr *CommandRunner, name string, args ...string) error { if li.Installer != nil && li.executor != nil { return li.executor.RunCommand(name, args...) } return cr.RunSudoCommand(name, args...) } // runQuiet runs a command without output/log wiring, using the injected // CommandExecutor when one is set (tests) and os/exec otherwise. func (li *LinuxInstaller) runQuiet(ctx context.Context, name string, args ...string) error { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if err := ctx.Err(); err != nil { return err } if li.Installer != nil && li.executor != nil { return li.executor.RunCommand(name, args...) } return process.Run(ctx, exec.CommandContext(ctx, name, args...)) } // runQuietOutput runs a command and captures its stdout without output/log // wiring, using the injected CommandExecutor when one is set (tests) and // os/exec otherwise. func (li *LinuxInstaller) runQuietOutput(ctx context.Context, name string, args ...string) ([]byte, error) { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if err := ctx.Err(); err != nil { return nil, err } if li.Installer != nil && li.executor != nil { return li.executor.RunCommandOutput(name, args...) } return process.Output(ctx, exec.CommandContext(ctx, name, args...)) } func (li *LinuxInstaller) GetAvailableMethods(filterInstalled bool) []InstallMethod { methods := []InstallMethod{} if li.Platform.IsImmutableDistro() { if filterInstalled && config.IsAppInstalled("Celluloid") { } else { methods = append(methods, InstallMethod{ ID: constants.MethodCelluloidFlatpak, Name: "Celluloid", Description: "Modern MPV frontend via Flatpak from Flathub", Recommended: true, Priority: 1, Icon: constants.MethodIcon[constants.MethodCelluloidFlatpak], Logo: constants.MethodLogo[constants.MethodCelluloidFlatpak], Type: constants.MethodType[constants.MethodCelluloidFlatpak], AppType: constants.MethodAppType[constants.MethodCelluloidFlatpak], Homepage: constants.MethodHomepage[constants.MethodCelluloidFlatpak], }) } if filterInstalled && config.IsAppInstalled("MPV") { } else { methods = append(methods, InstallMethod{ ID: constants.MethodMPVFlatpak, Name: "MPV", Description: "Official MPV via Flatpak from Flathub", Recommended: true, Priority: 2, Icon: constants.MethodIcon[constants.MethodMPVFlatpak], Logo: constants.MethodLogo[constants.MethodMPVFlatpak], Type: constants.MethodType[constants.MethodMPVFlatpak], AppType: constants.MethodAppType[constants.MethodMPVFlatpak], Homepage: constants.MethodHomepage[constants.MethodMPVFlatpak], }) } } else { family := li.Platform.DistroFamily if family == "" { family = detectLinuxFamily() } pmSupported := isPackageManagerFamilySupported(family) if !pmSupported || (filterInstalled && config.IsAppInstalled("Celluloid")) { } else { methods = append(methods, InstallMethod{ ID: constants.MethodCelluloidPackage, Name: "Celluloid", Description: "Install via package manager", Recommended: true, Priority: 1, Icon: constants.MethodIcon[constants.MethodCelluloidPackage], Logo: constants.MethodLogo[constants.MethodCelluloidPackage], Type: constants.MethodType[constants.MethodCelluloidPackage], AppType: constants.MethodAppType[constants.MethodCelluloidPackage], Homepage: constants.MethodHomepage[constants.MethodCelluloidPackage], }) } if filterInstalled && config.IsAppInstalled("Celluloid") { } else { methods = append(methods, InstallMethod{ ID: constants.MethodCelluloidFlatpak, Name: "Celluloid", Description: "Modern MPV frontend via Flatpak from Flathub", Recommended: true, Priority: 2, Icon: constants.MethodIcon[constants.MethodCelluloidFlatpak], Logo: constants.MethodLogo[constants.MethodCelluloidFlatpak], Type: constants.MethodType[constants.MethodCelluloidFlatpak], AppType: constants.MethodAppType[constants.MethodCelluloidFlatpak], Homepage: constants.MethodHomepage[constants.MethodCelluloidFlatpak], }) } if !pmSupported || (filterInstalled && config.IsAppInstalled("MPV")) { } else { methods = append(methods, InstallMethod{ ID: constants.MethodMPVPackage, Name: "MPV", Description: "Install via package manager", Recommended: true, Priority: 3, Icon: constants.MethodIcon[constants.MethodMPVPackage], Logo: constants.MethodLogo[constants.MethodMPVPackage], Type: constants.MethodType[constants.MethodMPVPackage], AppType: constants.MethodAppType[constants.MethodMPVPackage], Homepage: constants.MethodHomepage[constants.MethodMPVPackage], }) } if filterInstalled && config.IsAppInstalled("MPV") { } else { methods = append(methods, InstallMethod{ ID: constants.MethodMPVFlatpak, Name: "MPV", Description: "Official MPV via Flatpak from Flathub", Recommended: false, Priority: 4, Icon: constants.MethodIcon[constants.MethodMPVFlatpak], Logo: constants.MethodLogo[constants.MethodMPVFlatpak], Type: constants.MethodType[constants.MethodMPVFlatpak], AppType: constants.MethodAppType[constants.MethodMPVFlatpak], Homepage: constants.MethodHomepage[constants.MethodMPVFlatpak], }) } } return methods } func (li *LinuxInstaller) CheckFlatpakInstalled() bool { _, err := exec.LookPath("flatpak") return err == nil } func (li *LinuxInstaller) checkFlathubEnabled(ctx context.Context) (bool, error) { output, err := li.runQuietOutput(ctx, "flatpak", "remote-list", "--columns=name") if err != nil { return false, fmt.Errorf("inspect Flatpak remotes: %w", err) } for _, line := range strings.Split(string(output), "\n") { if strings.TrimSpace(line) == "flathub" { return true, nil } } return false, nil } func (li *LinuxInstaller) CheckInstalled() bool { _, err := exec.LookPath("mpv") return err == nil } func (li *LinuxInstaller) CanUpdate() bool { return li.CheckInstalled() } func (li *LinuxInstaller) GetInstalledVersion() string { output, err := li.runQuietOutput(context.Background(), "mpv", "--version") if err != nil { return "" } lines := strings.Split(string(output), "\n") if len(lines) > 0 { return strings.TrimPrefix(lines[0], "mpv ") } return "" } func (li *LinuxInstaller) InstallFlatpakWithOutput(cr *CommandRunner, flatpakID string, uiType string, isUpdate bool) error { runner := li.executorOr(cr) var preparedUI *PreparedUI if !isUpdate && isMPVInstall(flatpakID) && uiType != constants.UITypeNone { configDir, err := GetMPVConfigDir() if err != nil { return err } preparedUI, err = li.PrepareUI(cr, configDir, uiType) if err != nil { return err } defer preparedUI.Close() } flatpakInstalled := li.CheckFlatpakInstalled() flathubEnabled := false if flatpakInstalled && !isUpdate { var err error flathubEnabled, err = li.checkFlathubEnabled(cr.Context()) if err != nil { return err } } if err := cr.BeginCommit(); err != nil { return err } // Auto-install flatpak if not present if !flatpakInstalled { cr.outputChan <- "Flatpak is not installed. Installing automatically..." // Determine package name based on distro family var flatpakPkg string switch li.Platform.DistroFamily { case "debian": flatpakPkg = "flatpak" case "rhel": flatpakPkg = "flatpak" case "arch": flatpakPkg = "flatpak" case "suse": flatpakPkg = "flatpak" default: return fmt.Errorf("unsupported distribution family for auto-installing flatpak: %s", li.Platform.DistroFamily) } cr.outputChan <- fmt.Sprintf("Installing %s via package manager (requires sudo)...", flatpakPkg) // Build the package manager install command pm := NewPackageManager(li.Platform.DistroFamily) installCmd := pm.BuildInstallCommand(flatpakPkg, false) // false = no privilege prefix, we use RunSudoCommand if len(installCmd) == 0 { return fmt.Errorf("failed to build install command for flatpak") } // Use RunSudoCommand which handles keyring password authentication if err := li.runSudoCommand(cr, installCmd[0], installCmd[1:]...); err != nil { cr.outputChan <- fmt.Sprintf("Failed to install flatpak: %v", err) return fmt.Errorf("failed to install flatpak: %w", err) } cr.outputChan <- "✓ Flatpak installed successfully" cr.outputChan <- "" cr.outputChan <- "⚠️ NOTICE: Flatpak was just installed." cr.outputChan <- " You may need to restart your session or run 'flatpak update' for" cr.outputChan <- " application icons to appear in your desktop menu." cr.outputChan <- "" // Set pending notice for Web UI SetPendingNotice("Flatpak was just installed. You may need to restart your session or run 'flatpak update' for application icons to appear in your desktop menu.") } if isUpdate { cr.outputChan <- fmt.Sprintf("Updating %s via Flatpak...", flatpakID) cmd := runner.RunCommand("flatpak", "update", flatpakID, "-y") if cmd != nil { return cmd } if isMPVInstall(flatpakID) { cr.outputChan <- constants.MsgConfigNotOverwritten } return nil } // A just-installed Flatpak can only be queried after its package commit. if !flatpakInstalled { var err error flathubEnabled, err = li.checkFlathubEnabled(cr.Context()) if err != nil { return &PartialInstallError{Err: err} } } // Auto-enable Flathub if not present. if !flathubEnabled { cr.outputChan <- "Enabling Flathub repository..." cmd := runner.RunCommand("flatpak", "remote-add", "--if-not-exists", "flathub", "https://flathub.org/repo/flathub.flatpakrepo") if cmd != nil { return cmd } cr.outputChan <- "✓ Flathub enabled" } cr.outputChan <- fmt.Sprintf("Installing %s via Flatpak...", flatpakID) cmd := runner.RunCommand("flatpak", "install", "flathub", flatpakID, "-y") if cmd != nil { return cmd } if preparedUI != nil { if err := preparedUI.Commit(cr); err != nil { return &PartialInstallError{Err: err} } } // Configure flatpak filesystem access for user's mpv config directory if isMPVInstall(flatpakID) { if err := li.ConfigureMPVFlatpakWithOutput(cr); err != nil { cr.outputChan <- fmt.Sprintf("Warning: Failed to configure MPV Flatpak: %v", err) } } else if flatpakID == constants.FlatpakCelluloid { if err := li.ConfigureCelluloidFlatpakWithOutput(cr); err != nil { cr.outputChan <- fmt.Sprintf("Warning: Failed to configure Celluloid Flatpak: %v", err) } } return nil } func (li *LinuxInstaller) GetDefaultInstallPath() string { homeDir, _ := os.UserHomeDir() return filepath.Join(homeDir, "Downloads", "mpv") } func (li *LinuxInstaller) NormalizePath(path string) string { return filepath.Clean(path) } func (li *LinuxInstaller) UninstallFlatpakWithOutput(cr *CommandRunner, appID string) error { cr.outputChan <- fmt.Sprintf("Uninstalling %s via Flatpak...", appID) cmd := li.executorOr(cr).RunCommand("flatpak", "uninstall", "-y", appID) if err := cmd; err != nil { return fmt.Errorf("flatpak uninstall failed: %w", err) } return nil } func (li *LinuxInstaller) UninstallBrewWithOutput(cr *CommandRunner, appName string) error { if _, err := exec.LookPath("brew"); err != nil { return fmt.Errorf("homebrew is not installed") } cr.outputChan <- fmt.Sprintf("Uninstalling %s via Homebrew...", appName) cmd := li.executorOr(cr).RunCommand("brew", "uninstall", appName) if err := cmd; err != nil { return fmt.Errorf("brew uninstall failed: %w", err) } return nil } func (li *LinuxInstaller) EnsureGSettingsInstalled(cr *CommandRunner) error { if _, err := exec.LookPath(constants.CommandGsettings); err == nil { cr.outputChan <- "gsettings is already installed" return nil } cr.outputChan <- "gsettings not found, installing gsettings-desktop-schemas..." packageManager := NewPackageManager(li.Platform.DistroFamily) // Build command WITHOUT sudo prefix - RunSudoCommand handles authentication installCmd := packageManager.BuildInstallCommand("gsettings-desktop-schemas", false) if len(installCmd) == 0 { return fmt.Errorf("unsupported distro family %q: cannot build package manager install command", li.Platform.DistroFamily) } if cr != nil { cr.outputChan <- fmt.Sprintf("Running: sudo %s", strings.Join(installCmd, " ")) } return li.runSudoCommand(cr, installCmd[0], installCmd[1:]...) } func (li *LinuxInstaller) ConfigureCelluloidPackageWithOutput(cr *CommandRunner) error { cr.outputChan <- "Configuring Celluloid settings..." cr.outputChan <- strings.Repeat("─", 50) if err := li.EnsureGSettingsInstalled(cr); err != nil { cr.outputChan <- "Warning: Failed to install gsettings, skipping configuration" return nil } cr.outputChan <- "Setting mpv-config-enable to true..." cmd := li.executorOr(cr).RunCommand("gsettings", "set", "io.github.celluloid-player.Celluloid", "mpv-config-enable", "true") if err := cmd; err != nil { cr.outputChan <- "Warning: Failed to set gsettings (Celluloid may not be running)" } homeDir, _ := os.UserHomeDir() configDir := GetMPVConfigDirFromHome(homeDir) configPath := filepath.Join(configDir, "mpv.conf") cr.outputChan <- fmt.Sprintf("Setting mpv-config-file to: %s", configPath) cmd = li.executorOr(cr).RunCommand("gsettings", "set", "io.github.celluloid-player.Celluloid", "mpv-config-file", configPath) if err := cmd; err != nil { cr.outputChan <- "Warning: Failed to set gsettings (Celluloid may not be running)" } cr.outputChan <- "Celluloid configuration set!" if err := li.Installer.DisableOSCForCelluloid(cr); err != nil { cr.outputChan <- fmt.Sprintf("Warning: Failed to disable OSC: %v", err) } return nil } func (li *LinuxInstaller) ConfigureCelluloidFlatpakWithOutput(cr *CommandRunner) error { runner := li.executorOr(cr) cr.outputChan <- "Configuring Celluloid Flatpak..." cr.outputChan <- strings.Repeat("─", 50) cr.outputChan <- "Granting filesystem access to mpv config..." cmd := runner.RunCommand("flatpak", "override", "--user", "--filesystem=xdg-config/mpv:ro", constants.FlatpakCelluloid) if err := cmd; err != nil { cr.outputChan <- "Warning: Failed to set flatpak mpv dir override" } cr.outputChan <- "Setting mpv-config-enable to true..." cmd = runner.RunCommand("flatpak", "run", "--command=gsettings", constants.FlatpakCelluloid, "set", constants.GSettingsCelluloidSchema, "mpv-config-enable", "true") if err := cmd; err != nil { cr.outputChan <- "Warning: Failed to set gsettings mpv config true to flatpak" } homeDir, _ := os.UserHomeDir() configDir := GetMPVConfigDirFromHome(homeDir) configPath := filepath.Join(configDir, "mpv.conf") cr.outputChan <- fmt.Sprintf("Setting mpv-config-file to: %s", configPath) cmd = runner.RunCommand("flatpak", "run", "--command=gsettings", constants.FlatpakCelluloid, "set", constants.GSettingsCelluloidSchema, "mpv-config-file", configPath) if err := cmd; err != nil { cr.outputChan <- "Warning: Failed to set gsettings mpv file path to flatpak" } cr.outputChan <- "Celluloid Flatpak configuration set!" if err := li.Installer.DisableOSCForCelluloid(cr); err != nil { cr.outputChan <- fmt.Sprintf("Warning: Failed to disable OSC: %v", err) } return nil } func (li *LinuxInstaller) ConfigureMPVFlatpakWithOutput(cr *CommandRunner) error { cr.outputChan <- "Configuring MPV Flatpak..." cr.outputChan <- strings.Repeat("─", 50) cr.outputChan <- "Granting filesystem access to mpv config..." cmd := li.executorOr(cr).RunCommand("flatpak", "override", "--user", "--filesystem=xdg-config/mpv:ro", constants.FlatpakMPV) if err := cmd; err != nil { cr.outputChan <- "Warning: Failed to set flatpak override" } cr.outputChan <- "MPV Flatpak configuration set!" return nil } func (li *LinuxInstaller) InstallIINAWithOutput(cr *CommandRunner) error { return fmt.Errorf("IINA is not supported on Linux") } func (li *LinuxInstaller) InstallMPCQTWithOutput(cr *CommandRunner, method string) error { return fmt.Errorf("MPC-QT is not supported on Linux") } func (li *LinuxInstaller) InstallMPVAppWithOutput(cr *CommandRunner, uiType string) error { return fmt.Errorf("MPV App is not supported on Linux") } func (li *LinuxInstaller) InstallMPVViaBrewWithOutput(cr *CommandRunner, uiType string, isUpdate bool) error { return fmt.Errorf("Homebrew is not supported on Linux") } func (li *LinuxInstaller) UninstallAppWithOutput(cr *CommandRunner, appName string) error { return fmt.Errorf("App uninstallation is not supported on Linux") } func (li *LinuxInstaller) UpdateMPVAppWithOutput(cr *CommandRunner) error { return fmt.Errorf("MPV App update is not supported on Linux") } func (li *LinuxInstaller) SetupFileAssociationsWithOutput(cr *CommandRunner) error { return fmt.Errorf("File associations are not supported on Linux") } func (li *LinuxInstaller) CreateInstallerShortcutWithOutput(cr *CommandRunner) error { cr.outputChan <- "Installer shortcut creation not needed on Linux" return nil } func (li *LinuxInstaller) CreateInstallerShortcutToBinaryWithOutput(cr *CommandRunner, binaryPath string) error { cr.outputChan <- "Installer shortcut creation not needed on Linux" return nil } // InstallIcon installs the icon to ~/.local/share/icons/hicolor/128x128/apps/ func (li *LinuxInstaller) InstallIcon(cr *CommandRunner) error { homeDir, err := os.UserHomeDir() if err != nil { return fmt.Errorf("failed to get home directory: %w", err) } // Use XDG icon theme directory structure iconThemeDir := filepath.Join(homeDir, constants.LinuxIconsDir, constants.LinuxIconThemeDir) if err := os.MkdirAll(iconThemeDir, 0755); err != nil { return fmt.Errorf("failed to create icon theme directory: %w", err) } iconPath := filepath.Join(iconThemeDir, constants.LinuxIconFileName) // Check if icon already exists if _, err := os.Stat(iconPath); err == nil { cr.outputChan <- "Icon already installed: " + iconPath return nil } cr.outputChan <- "Installing icon to " + iconThemeDir + "..." // Read icon from embedded assets iconData, err := assets.ReadPNGIcon() if err != nil { return fmt.Errorf("failed to read icon from assets: %w", err) } // Write icon to destination if err := os.WriteFile(iconPath, iconData, 0644); err != nil { return fmt.Errorf("failed to write icon file: %w", err) } cr.outputChan <- "✓ Icon installed: " + iconPath // Update icon cache for desktop environments to pick up the new icon if _, err := exec.LookPath("gtk-update-icon-cache"); err == nil { iconsBaseDir := filepath.Join(homeDir, constants.LinuxIconsDir) if err := li.runQuiet(cr.Context(), "gtk-update-icon-cache", "--quiet", "--force", iconsBaseDir); err != nil { // Non-critical error, just log it cr.outputChan <- "Note: Failed to update icon cache (non-critical)" } else { cr.outputChan <- "✓ Icon cache updated" } } else if _, err := exec.LookPath("update-icon-caches"); err == nil { // Alternative command for some distros iconsBaseDir := filepath.Join(homeDir, constants.LinuxIconsDir) if err := li.runQuiet(cr.Context(), "update-icon-caches", iconsBaseDir); err != nil { // Non-critical error, just log it cr.outputChan <- "Note: Failed to update icon cache (non-critical)" } else { cr.outputChan <- "✓ Icon cache updated" } } return nil } func CreateLinuxWebUIShortcutWithOutput(cr *CommandRunner) error { execPath, err := os.Executable() if err != nil { return fmt.Errorf("failed to get executable path: %w", err) } cr.outputChan <- "Setting up MPV Manager..." homeDir, err := os.UserHomeDir() if err != nil { return fmt.Errorf("failed to get home directory: %w", err) } // Create .config/mpv directory for the binary (standard MPV config location) // Target: ~/.config/mpv/mpv-manager (the binary itself, not a directory) targetDir := filepath.Join(homeDir, ".config", "mpv") targetBinPath := filepath.Join(targetDir, constants.AppName) // Create the target directory if it doesn't exist if err := os.MkdirAll(targetDir, 0755); err != nil { return fmt.Errorf("failed to create directory %s: %w", targetDir, err) } // A shortcut only references an executable. Startup must not downgrade or // truncate a separately installed manager, or race its update transaction. targetBinPath, err = managerShortcutTarget(execPath, targetBinPath) if err != nil { return err } cr.outputChan <- "Using MPV Manager at: " + targetBinPath // Extract the Linux uninstall script to the config directory if err := assets.ExtractUninstallLinuxSh(targetDir); err != nil { cr.outputChan <- fmt.Sprintf("Warning: Failed to extract uninstall script: %v", err) } else { cr.outputChan <- "✓ Uninstall script installed: " + filepath.Join(targetDir, "uninstall-linux.sh") } // Install icon li := &LinuxInstaller{} if err := li.InstallIcon(cr); err != nil { cr.outputChan <- "Warning: Failed to install icon: " + err.Error() } // Build absolute icon path for .desktop file iconPath := filepath.Join(homeDir, constants.LinuxIconsDir, constants.LinuxIconThemeDir, constants.LinuxIconFileName) // Create .desktop file pointing to the installed binary desktopFilePath := filepath.Join(homeDir, ".local", "share", "applications", "mpv-manager.desktop") if err := os.MkdirAll(filepath.Dir(desktopFilePath), 0755); err != nil { return fmt.Errorf("failed to create applications directory: %w", err) } desktopFileContent := `[Desktop Entry] Name=MPV Manager GenericName=MPV Media Player Manager (Web UI) Comment=Install and manage MPV media player via Web UI Exec=` + desktopExecQuote(targetBinPath) + ` -m web Icon=` + iconPath + ` Terminal=false Type=Application Categories=Utility;AudioVideo; StartupNotify=true Keywords=mpv;media;player;installer; ` if err := os.WriteFile(desktopFilePath, []byte(desktopFileContent), 0644); err != nil { return fmt.Errorf("failed to write desktop file: %w", err) } desktopDir := filepath.Join(homeDir, "Desktop") desktopShortcutPath := filepath.Join(desktopDir, "mpv-manager.desktop") if err := os.Symlink(desktopFilePath, desktopShortcutPath); err != nil { // Symlink may already exist, try to remove and recreate os.Remove(desktopShortcutPath) if err := os.Symlink(desktopFilePath, desktopShortcutPath); err != nil { cr.outputChan <- "Note: Desktop symlink not created (may need to create manually)" } else { cr.outputChan <- "✓ Desktop shortcut created: " + desktopShortcutPath } } else { cr.outputChan <- "✓ Desktop shortcut created: " + desktopShortcutPath } cr.outputChan <- "✓ Application entry created: " + desktopFilePath cr.outputChan <- "Note: You may need to log out and log back in for shortcuts to appear in your menu." return nil } func (li *LinuxInstaller) CreateWebUIShortcutWithOutput(cr *CommandRunner) error { return CreateLinuxWebUIShortcutWithOutput(cr) } func (li *LinuxInstaller) InstallMPVWithOutput(cr *CommandRunner, method string, uiType string) error { return fmt.Errorf("MPV binary installation is not supported on Linux (use Flatpak or package manager)") } func (li *LinuxInstaller) UpdateMPVWithOutput(cr *CommandRunner, method string) error { return fmt.Errorf("MPV binary update is not supported on Linux (use Flatpak or package manager)") } // RemoveFileAssociationsWithOutput is a no-op on Linux func (li *LinuxInstaller) RemoveFileAssociationsWithOutput(cr *CommandRunner) error { return fmt.Errorf("file associations are not supported on Linux") } // CreateMPVShortcut is a no-op on Linux (shortcuts are not needed) func (li *LinuxInstaller) CreateMPVShortcut(cr *CommandRunner) error { return fmt.Errorf("MPV shortcuts are not supported on Linux") } // UninstallWithOutput is a no-op on Linux func (li *LinuxInstaller) UninstallWithOutput(cr *CommandRunner) error { return fmt.Errorf("MPV binary uninstall is not supported on Linux") } // UninstallMPCQTWithOutput is a no-op on Linux (MPC-QT is Windows-only) func (li *LinuxInstaller) UninstallMPCQTWithOutput(cr *CommandRunner) error { return fmt.Errorf("MPC-QT uninstall is not supported on Linux") }