//go:build darwin package installer import ( "fmt" "os" "os/exec" "path/filepath" "strings" "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" ) type MacOSInstaller struct { *Installer Platform *platform.Platform } func NewMacOSInstaller(installer *Installer, p *platform.Platform) *MacOSInstaller { return &MacOSInstaller{ Installer: installer, Platform: p, } } func (mi *MacOSInstaller) GetAvailableMethods(filterInstalled bool) []InstallMethod { methods := []InstallMethod{} if mi.Platform.Arch == "arm64" { if filterInstalled && config.IsAppInstalled("IINA") { } else { methods = append(methods, InstallMethod{ ID: constants.MethodIINA, Name: "IINA", Description: "Easy and modern MPV player for macOS", Recommended: true, Priority: 1, Icon: constants.MethodIcon[constants.MethodIINA], Logo: constants.MethodLogo[constants.MethodIINA], Type: constants.MethodType[constants.MethodIINA], AppType: constants.MethodAppType[constants.MethodIINA], Homepage: constants.MethodHomepage[constants.MethodIINA], }) } if filterInstalled && config.IsAppInstalled("MPV") { } else { methods = append(methods, InstallMethod{ ID: constants.MethodMPVApp, Name: "MPV", Description: "Official MPV application bundle", Recommended: true, Priority: 2, Icon: constants.MethodIcon[constants.MethodMPVApp], Logo: constants.MethodLogo[constants.MethodMPVApp], Type: constants.MethodType[constants.MethodMPVApp], AppType: constants.MethodAppType[constants.MethodMPVApp], Homepage: constants.MethodHomepage[constants.MethodMPVApp], }) } if mi.CheckBrewInstalled() { if filterInstalled && config.IsAppInstalled("MPV") { } else { methods = append(methods, InstallMethod{ ID: constants.MethodMPVBrew, Name: "MPV via Brew (CLI-only)", Description: "Install MPV using Homebrew package manager (CLI-only version)", Recommended: false, Priority: 3, Icon: constants.MethodIcon[constants.MethodMPVBrew], Logo: constants.MethodLogo[constants.MethodMPVBrew], Type: constants.MethodType[constants.MethodMPVBrew], AppType: constants.MethodAppType[constants.MethodMPVBrew], Homepage: constants.MethodHomepage[constants.MethodMPVBrew], }) } } } else { if filterInstalled && config.IsAppInstalled("MPV") { } else { methods = append(methods, InstallMethod{ ID: constants.MethodMPVApp, Name: "MPV", Description: "Official MPV application bundle", Recommended: false, Priority: 2, Icon: constants.MethodIcon[constants.MethodMPVApp], Logo: constants.MethodLogo[constants.MethodMPVApp], Type: constants.MethodType[constants.MethodMPVApp], AppType: constants.MethodAppType[constants.MethodMPVApp], Homepage: constants.MethodHomepage[constants.MethodMPVApp], }) } if mi.CheckBrewInstalled() { if filterInstalled && config.IsAppInstalled("MPV") { } else { methods = append(methods, InstallMethod{ ID: constants.MethodMPVBrew, Name: "MPV via Brew (CLI-only)", Description: "Install MPV using Homebrew package manager (CLI-only version)", Recommended: false, Priority: 3, Icon: constants.MethodIcon[constants.MethodMPVBrew], Logo: constants.MethodLogo[constants.MethodMPVBrew], Type: constants.MethodType[constants.MethodMPVBrew], AppType: constants.MethodAppType[constants.MethodMPVBrew], Homepage: constants.MethodHomepage[constants.MethodMPVBrew], }) } } } return methods } func (mi *MacOSInstaller) CheckBrewInstalled() bool { _, err := exec.LookPath("brew") return err == nil } func (mi *MacOSInstaller) InstallBrew() error { log.Info("Starting Homebrew installation...") cmd := exec.Command("/bin/bash", "-c", "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)") if err := cmd.Run(); err != nil { log.Error("Homebrew installation failed: " + err.Error()) return err } log.Info("Homebrew installed successfully") return nil } func (mi *MacOSInstaller) InstallMPVViaBrew(installUOSC bool) error { if !mi.CheckBrewInstalled() { log.Error("Homebrew is not installed, cannot install MPV via brew") return fmt.Errorf("homebrew is not installed") } log.Info("Installing MPV via Homebrew...") cmd := exec.Command("brew", "install", "mpv") if err := cmd.Run(); err != nil { log.Error("MPV installation via Homebrew failed: " + err.Error()) return err } log.Info("MPV installed successfully via Homebrew") return nil } func (mi *MacOSInstaller) InstallMPVApp(installUOSC bool) error { var url string if mi.Platform.Arch == "arm64" { url = mi.ReleaseInfo.MacOS.ARMLatest.URL } else { url = mi.ReleaseInfo.MacOS.Intel15.URL } downloadDir := mi.InstallDir if err := DownloadAndExtract(mi.Installer, nil, url, downloadDir, "mpv.zip"); err != nil { log.Error("Failed to download and extract MPV: " + err.Error()) return err } appPath := filepath.Join(downloadDir, "mpv.app") if !mi.FileExists(appPath) { log.Error("mpv.app not found in extracted archive") return fmt.Errorf("mpv.app not found in extracted archive") } if err := os.MkdirAll("/Applications", 0755); err != nil { log.Error("Failed to create /Applications directory: " + err.Error()) return err } log.Info("Copying MPV.app to /Applications...") cmd := exec.Command("cp", "-R", appPath, "/Applications/") if err := cmd.Run(); err != nil { log.Error("Failed to copy MPV.app to /Applications: " + err.Error()) return err } log.Info("MPV.app installed successfully") return nil } func (mi *MacOSInstaller) InstallIINA() error { var url string if mi.Platform.Arch == "arm64" { url = mi.ReleaseInfo.IINA.ARM.URL } else { url = mi.ReleaseInfo.IINA.Intel.URL } tempDir, _ := os.MkdirTemp("", "iina-install") archivePath := filepath.Join(tempDir, "IINA.dmg") fmt.Println("Downloading IINA...") if err := mi.DownloadFileWithProgress(url, archivePath, func(written, total int64) { if total > 0 { percent := int(float64(written) / float64(total) * 100) fmt.Printf("\rProgress: %d%% (%d/%d bytes)", percent, written, total) } }); err != nil { return err } fmt.Println() fmt.Println("Mounting DMG file...") cmd := exec.Command("hdiutil", "attach", "-nobrowse", "-noautoopen", "-quiet", archivePath) output, err := cmd.CombinedOutput() if err != nil { fmt.Printf("Failed to mount DMG: %v\n%s\n", err, string(output)) os.RemoveAll(tempDir) return err } volumePath := "/Volumes/IINA" if _, err := os.Stat(volumePath); err != nil { fmt.Printf("DMG volume not found at %s\n", volumePath) os.RemoveAll(tempDir) return fmt.Errorf("DMG volume not found") } fmt.Println("Copying IINA.app to /Applications...") srcAppPath := filepath.Join(volumePath, "IINA.app") destAppPath := "/Applications/IINA.app" cmd = exec.Command("cp", "-R", srcAppPath, destAppPath) if err := cmd.Run(); err != nil { fmt.Printf("Failed to copy IINA.app: %v\n", err) mi.unmountDMG(volumePath) os.RemoveAll(tempDir) return err } fmt.Println("Unmounting DMG...") if err := mi.unmountDMG(volumePath); err != nil { fmt.Printf("Warning: Failed to unmount DMG: %v\n", err) } fmt.Println("Cleaning up...") os.RemoveAll(tempDir) fmt.Println("IINA installed successfully!") return nil } func (mi *MacOSInstaller) unmountDMG(volumePath string) error { cmd := exec.Command("hdiutil", "detach", "-force", volumePath) _, err := cmd.CombinedOutput() return err } func (mi *MacOSInstaller) CheckInstalled() bool { appPath := "/Applications/MPV.app" return mi.FileExists(appPath) } func (mi *MacOSInstaller) CanUpdate() bool { return mi.CheckInstalled() } func (mi *MacOSInstaller) GetInstalledVersion() string { cmd := exec.Command("mpv", "--version") output, err := cmd.Output() if err != nil { return "" } lines := strings.Split(string(output), "\n") if len(lines) > 0 { return strings.TrimPrefix(lines[0], "mpv ") } return "" } func (mi *MacOSInstaller) Uninstall() error { mpvAppPath := "/Applications/MPV.app" if _, err := os.Stat(mpvAppPath); err == nil { fmt.Printf("Removing %s...\n", mpvAppPath) if err := os.RemoveAll(mpvAppPath); err != nil { fmt.Printf("Warning: Failed to remove MPV.app: %v\n", err) } else { fmt.Println("Successfully removed MPV.app") } } iinaAppPath := "/Applications/IINA.app" if _, err := os.Stat(iinaAppPath); err == nil { fmt.Printf("Removing %s...\n", iinaAppPath) if err := os.RemoveAll(iinaAppPath); err != nil { fmt.Printf("Warning: Failed to remove IINA.app: %v\n", err) } else { fmt.Println("Successfully removed IINA.app") } } if _, err := os.Stat(mi.InstallDir); err == nil { fmt.Printf("Removing install directory: %s\n", mi.InstallDir) if err := os.RemoveAll(mi.InstallDir); err != nil { fmt.Printf("Warning: Failed to remove install directory: %v\n", err) } } return nil } func (mi *MacOSInstaller) UninstallFlatpak(appID string) error { cmd := exec.Command("flatpak", "uninstall", "-y", appID) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } func (mi *MacOSInstaller) UninstallBrew(appName string) error { if _, err := exec.LookPath("brew"); err != nil { return fmt.Errorf("homebrew is not installed") } cmd := exec.Command("brew", "uninstall", appName) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } func (mi *MacOSInstaller) UninstallApp(appName string) error { appPath := fmt.Sprintf("/Applications/%s.app", appName) if _, err := os.Stat(appPath); err != nil { return fmt.Errorf("%s not found: %w", appPath, err) } return os.RemoveAll(appPath) } func (mi *MacOSInstaller) InstallMPVViaBrewWithOutput(cr *CommandRunner, uiType string, isUpdate bool) error { if !mi.CheckBrewInstalled() { return fmt.Errorf("homebrew is not installed") } if isUpdate { cr.outputChan <- "Updating Homebrew packages..." cmd := cr.RunCommand("brew", "update") if cmd != nil { log.Warn("Failed to update Homebrew packages, continuing...") } cr.outputChan <- "Updating MPV via Homebrew..." cmd = cr.RunCommand("brew", "upgrade", "mpv") if cmd != nil { return cmd } cr.outputChan <- constants.MsgConfigNotOverwritten return nil } cr.outputChan <- "Installing MPV via Homebrew..." cmd := cr.RunCommand("brew", "install", "mpv") if err := cmd; err != nil { return err } configDir, err := GetMPVConfigDir() if err != nil { cr.outputChan <- fmt.Sprintf("Warning: Failed to get MPV config dir: %v", err) } else { InstallUISafely(mi.Installer, cr, configDir, uiType) } if err := InstallMPVConfigWithPlatformDefaults(mi.Installer, cr, mi.Platform.OSType, mi.Platform.GPUInfo.Brand); err != nil { cr.outputChan <- fmt.Sprintf("Warning: Failed to install MPV config: %v", err) } return nil } func (mi *MacOSInstaller) UpdateMPVAppWithOutput(cr *CommandRunner) error { var url string if mi.Platform.Arch == "arm64" { url = mi.ReleaseInfo.MacOS.ARMLatest.URL } else { url = mi.ReleaseInfo.MacOS.Intel15.URL } tempDir, _ := os.MkdirTemp("", "mpv-update") defer os.RemoveAll(tempDir) cr.outputChan <- "Downloading MPV.app update..." if err := DownloadAndExtract(mi.Installer, cr, url, tempDir, "mpv.zip"); err != nil { return err } cr.outputChan <- "Removing old MPV.app..." os.RemoveAll("/Applications/MPV.app") cr.outputChan <- "Copying MPV.app to /Applications..." cmd := cr.RunCommand("cp", "-R", filepath.Join(tempDir, "MPV.app"), "/Applications/") if err := cmd; err != nil { return err } cr.outputChan <- "MPV.app updated successfully!" cr.outputChan <- constants.MsgConfigNotOverwritten return nil } func (mi *MacOSInstaller) InstallMPVAppWithOutput(cr *CommandRunner, uiType string) error { var url string if mi.Platform.Arch == "arm64" { url = mi.ReleaseInfo.MacOS.ARMLatest.URL } else { url = mi.ReleaseInfo.MacOS.Intel15.URL } tempDir, _ := os.MkdirTemp("", "mpv-install") defer os.RemoveAll(tempDir) if err := DownloadAndExtract(mi.Installer, cr, url, tempDir, "mpv.zip"); err != nil { return err } tarGzPath := filepath.Join(tempDir, "mpv.tar.gz") if _, err := os.Stat(tarGzPath); err == nil { cr.outputChan <- "Extracting nested tar.gz archive..." if err := mi.ExtractArchiveWithOutput(cr, tarGzPath, tempDir); err != nil { return err } } appPath := filepath.Join(tempDir, "mpv.app") if _, err := os.Stat(appPath); err != nil { return fmt.Errorf("mpv.app not found in extracted archive") } // Remove existing MPV.app if present (avoids permission issues during copy) // macOS filesystem is case-insensitive, so this handles both mpv.app and MPV.app existingApp := "/Applications/mpv.app" if _, err := os.Stat(existingApp); err == nil { cr.outputChan <- "Removing existing MPV.app..." if err := os.RemoveAll(existingApp); err != nil { cr.outputChan <- fmt.Sprintf("Warning: Failed to remove existing MPV.app: %v", err) // Try using rm -rf as fallback rmCmd := exec.Command("rm", "-rf", existingApp) if rmErr := rmCmd.Run(); rmErr != nil { cr.outputChan <- fmt.Sprintf("Warning: rm -rf also failed: %v", rmErr) } } } cr.outputChan <- "Installing MPV.app to /Applications..." cmd := cr.RunCommand("cp", "-R", appPath, "/Applications/") if err := cmd; err != nil { return err } configDir, err := GetMPVConfigDir() if err != nil { cr.outputChan <- fmt.Sprintf("Warning: Failed to get MPV config dir: %v", err) } else { InstallUISafely(mi.Installer, cr, configDir, uiType) } // Install MPV config with platform-specific defaults if err := InstallMPVConfigWithPlatformDefaults(mi.Installer, cr, mi.Platform.OSType, mi.Platform.GPUInfo.Brand); err != nil { cr.outputChan <- fmt.Sprintf("Warning: Failed to install MPV config: %v", err) } cr.outputChan <- "MPV.app installed to /Applications/" return nil } func (mi *MacOSInstaller) InstallIINAWithOutput(cr *CommandRunner) error { var url string if mi.Platform.Arch == "arm64" { url = mi.ReleaseInfo.IINA.ARM.URL } else { url = mi.ReleaseInfo.IINA.Intel.URL } tempDir, _ := os.MkdirTemp("", "iina-install") defer os.RemoveAll(tempDir) archivePath := filepath.Join(tempDir, "IINA.dmg") cr.outputChan <- "Downloading IINA..." if err := mi.DownloadFileWithProgressToChannel(cr, url, archivePath); err != nil { return err } cr.outputChan <- "Mounting DMG file..." cmd := exec.Command("hdiutil", "attach", "-nobrowse", "-noautoopen", "-quiet", archivePath) output, err := cmd.CombinedOutput() if err != nil { cr.outputChan <- fmt.Sprintf("Error: Failed to mount DMG: %v", err) cr.outputChan <- string(output) return err } volumePath := "/Volumes/IINA" if _, err := os.Stat(volumePath); err != nil { cr.outputChan <- "Error: DMG volume not found at /Volumes/IINA" return fmt.Errorf("DMG volume not found") } cr.outputChan <- "Copying IINA.app to /Applications..." srcAppPath := filepath.Join(volumePath, "IINA.app") destAppPath := "/Applications/IINA.app" // Remove existing IINA.app if present (avoids permission issues during copy) if _, err := os.Stat(destAppPath); err == nil { cr.outputChan <- "Removing existing IINA.app..." if err := os.RemoveAll(destAppPath); err != nil { cr.outputChan <- fmt.Sprintf("Warning: Failed to remove existing IINA.app: %v", err) } } cmd = exec.Command("cp", "-R", srcAppPath, destAppPath) if err := cmd.Run(); err != nil { cr.outputChan <- fmt.Sprintf("Error: Failed to copy IINA.app: %v", err) mi.unmountDMG(volumePath) return err } cr.outputChan <- "Unmounting DMG..." if err := mi.unmountDMG(volumePath); err != nil { cr.outputChan <- fmt.Sprintf("Warning: Failed to unmount DMG: %v", err) } cr.outputChan <- "IINA installed successfully!" return nil } func (mi *MacOSInstaller) GetDefaultInstallPath() string { homeDir, _ := os.UserHomeDir() return filepath.Join(homeDir, "Downloads") } func (mi *MacOSInstaller) NormalizePath(path string) string { return filepath.Clean(path) } func (mi *MacOSInstaller) UninstallFlatpakWithOutput(cr *CommandRunner, appID string) error { cr.outputChan <- fmt.Sprintf("Uninstalling %s via Flatpak...", appID) cmd := cr.RunCommand("flatpak", "uninstall", "-y", appID) if err := cmd; err != nil { return fmt.Errorf("flatpak uninstall failed: %w", err) } return nil } func (mi *MacOSInstaller) 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 := cr.RunCommand("brew", "uninstall", appName) if err := cmd; err != nil { return fmt.Errorf("brew uninstall failed: %w", err) } return nil } func (mi *MacOSInstaller) UninstallAppWithOutput(cr *CommandRunner, appName string) error { appPath := fmt.Sprintf("/Applications/%s.app", appName) cr.outputChan <- fmt.Sprintf("Uninstalling %s...", appName) if _, err := os.Stat(appPath); err != nil { return fmt.Errorf("%s not found: %w", appPath, err) } cr.outputChan <- fmt.Sprintf("Removing: %s", appPath) if err := os.RemoveAll(appPath); err != nil { return fmt.Errorf("failed to remove %s: %w", appPath, err) } cr.outputChan <- fmt.Sprintf("Successfully removed %s", appName) return nil } func (mi *MacOSInstaller) InstallCelluloidViaPackageWithOutput(cr *CommandRunner, isUpdate bool) error { return fmt.Errorf("Celluloid is not supported on macOS") } func (mi *MacOSInstaller) InstallMPCQTWithOutput(cr *CommandRunner, method string) error { return fmt.Errorf("MPC-QT is not supported on macOS") } func (mi *MacOSInstaller) InstallMPVWithOutput(cr *CommandRunner, method string, uiType string) error { return fmt.Errorf("MPV binary installation is not supported on macOS") } func (mi *MacOSInstaller) UpdateMPVWithOutput(cr *CommandRunner, method string) error { return fmt.Errorf("MPV binary update is not supported on macOS") } func (mi *MacOSInstaller) UninstallViaPackageWithOutput(cr *CommandRunner, appName string) error { return fmt.Errorf("Package managers are not supported on macOS") } func (mi *MacOSInstaller) SetupFileAssociationsWithOutput(cr *CommandRunner) error { return fmt.Errorf("File associations are not supported on macOS") } func (mi *MacOSInstaller) CreateInstallerShortcutWithOutput(cr *CommandRunner) error { cr.outputChan <- "Installer shortcut creation not needed on macOS" return nil } func (mi *MacOSInstaller) CreateInstallerShortcutToBinaryWithOutput(cr *CommandRunner, binaryPath string) error { cr.outputChan <- "Installer shortcut creation not needed on macOS" return nil } func (mi *MacOSInstaller) InstallFlatpakWithOutput(cr *CommandRunner, flatpakID string, uiType string, isUpdate bool) error { return fmt.Errorf("Flatpak is not supported on macOS") } func (mi *MacOSInstaller) InstallMPVViaPackageWithOutput(cr *CommandRunner, uiType string, isUpdate bool) error { return fmt.Errorf("Package manager installation is not supported on macOS") } // UninstallWithOutput is a no-op on macOS func (mi *MacOSInstaller) UninstallWithOutput(cr *CommandRunner) error { return fmt.Errorf("MPV binary uninstall is not supported on macOS") } // UninstallMPCQTWithOutput is a no-op on macOS (MPC-QT is Windows-only) func (mi *MacOSInstaller) UninstallMPCQTWithOutput(cr *CommandRunner) error { return fmt.Errorf("MPC-QT uninstall is not supported on macOS") } // RemoveFileAssociationsWithOutput is a no-op on macOS func (mi *MacOSInstaller) RemoveFileAssociationsWithOutput(cr *CommandRunner) error { return fmt.Errorf("File associations are not supported on macOS") } // CreateMPVShortcut is a no-op on macOS (shortcuts are not needed) func (mi *MacOSInstaller) CreateMPVShortcut(cr *CommandRunner) error { return fmt.Errorf("MPV shortcuts are not supported on macOS") }