//go:build darwin package installer import ( "context" "errors" "fmt" "io/fs" "os" "os/exec" "path/filepath" "strings" "time" "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/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) unmountDMG(device string) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() output, err := process.Output(ctx, exec.CommandContext(ctx, "hdiutil", "detach", "-force", device)) if err != nil { return fmt.Errorf("detach %s: %w: %s", device, err, strings.TrimSpace(string(output))) } return nil } func (mi *MacOSInstaller) mountedDMGs() ([]dmgMount, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() output, err := process.Output(ctx, exec.CommandContext(ctx, "hdiutil", "info", "-plist")) if err != nil { return nil, fmt.Errorf("inspect mounted disk images: %w", err) } return parseHDIUtilMountPlist(output, false) } func (mi *MacOSInstaller) prepareMPVAppBundle(cr *CommandRunner, url, hash, tempPrefix string) (tempDir, appPath string, resultErr error) { tempDir, err := os.MkdirTemp("", tempPrefix) if err != nil { return "", "", fmt.Errorf("create MPV download staging directory: %w", err) } defer func() { if resultErr != nil { os.RemoveAll(tempDir) } }() if err := DownloadAndExtract(mi.Installer, cr, url, tempDir, "mpv.zip", hash); err != nil { return tempDir, "", 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 tempDir, "", err } } else if !os.IsNotExist(err) { return tempDir, "", fmt.Errorf("inspect nested MPV archive: %w", err) } appPath, err = findMacAppBundle(tempDir, "mpv.app") if err != nil { return tempDir, "", err } if err := validateMacAppBundle(appPath, "mpv"); err != nil { return tempDir, "", fmt.Errorf("validate downloaded MPV.app: %w", err) } return tempDir, appPath, nil } func findMacAppBundle(root, bundleName string) (string, error) { var matches []string err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if path == root { return nil } if strings.EqualFold(entry.Name(), bundleName) { if !entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { return fmt.Errorf("%s is not a real application bundle", path) } matches = append(matches, path) return filepath.SkipDir } return nil }) if err != nil { return "", fmt.Errorf("locate %s: %w", bundleName, err) } if len(matches) == 0 { return "", fmt.Errorf("%s not found in extracted archive", bundleName) } if len(matches) != 1 { return "", fmt.Errorf("archive contains multiple %s bundles", bundleName) } return matches[0], nil } func validateMacAppBundle(bundlePath, executableName string) error { bundleInfo, err := os.Lstat(bundlePath) if err != nil { return fmt.Errorf("inspect app bundle: %w", err) } if !bundleInfo.IsDir() || bundleInfo.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("app bundle is not a real directory: %s", bundlePath) } plistPath := filepath.Join(bundlePath, "Contents", "Info.plist") if err := validateNonEmptyRegularFile(plistPath, false); err != nil { return fmt.Errorf("invalid Info.plist: %w", err) } executablePath := filepath.Join(bundlePath, "Contents", "MacOS", executableName) if err := validateNonEmptyRegularFile(executablePath, true); err != nil { return fmt.Errorf("invalid bundle executable %s: %w", executableName, err) } return nil } func validateIINAAppBundle(ctx context.Context, bundlePath, expectedArch string) error { if err := validateMacAppBundle(bundlePath, "IINA"); err != nil { return err } if ctx == nil { ctx = context.Background() } plistPath := filepath.Join(bundlePath, "Contents", "Info.plist") bundleID, err := exec.CommandContext(ctx, "plutil", "-extract", "CFBundleIdentifier", "raw", "-o", "-", plistPath).CombinedOutput() if err != nil { return fmt.Errorf("read IINA bundle identifier: %w: %s", err, strings.TrimSpace(string(bundleID))) } if actual := strings.TrimSpace(string(bundleID)); actual != "com.colliderli.iina" { return fmt.Errorf("unexpected IINA bundle identifier %q", actual) } if output, err := exec.CommandContext(ctx, "codesign", "--verify", "--deep", "--strict", "--verbose=2", bundlePath).CombinedOutput(); err != nil { return fmt.Errorf("verify IINA code signature: %w: %s", err, strings.TrimSpace(string(output))) } signatureDetails, err := exec.CommandContext(ctx, "codesign", "--display", "--verbose=4", bundlePath).CombinedOutput() if err != nil { return fmt.Errorf("read IINA code-signing identity: %w: %s", err, strings.TrimSpace(string(signatureDetails))) } teamID := codeSignDetail(signatureDetails, "TeamIdentifier") if teamID == "" || strings.EqualFold(teamID, "not set") { return fmt.Errorf("IINA signature has no TeamIdentifier") } executablePath := filepath.Join(bundlePath, "Contents", "MacOS", "IINA") architectures, err := exec.CommandContext(ctx, "lipo", "-archs", executablePath).CombinedOutput() if err != nil { return fmt.Errorf("read IINA executable architectures: %w: %s", err, strings.TrimSpace(string(architectures))) } requiredArch := expectedArch if requiredArch == "amd64" { requiredArch = "x86_64" } if requiredArch != "" && !containsWord(string(architectures), requiredArch) { return fmt.Errorf("IINA executable does not contain required architecture %s (found %s)", requiredArch, strings.TrimSpace(string(architectures))) } return nil } func codeSignDetail(output []byte, key string) string { prefix := key + "=" for _, line := range strings.Split(string(output), "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, prefix) { return strings.TrimSpace(strings.TrimPrefix(line, prefix)) } } return "" } func containsWord(value, word string) bool { for _, candidate := range strings.Fields(value) { if candidate == word { return true } } return false } // installMacAppBundle copies a validated bundle to a staging directory inside // /Applications, validates the staged copy, then performs a backup-and-swap. func (mi *MacOSInstaller) installMacAppBundle(cr *CommandRunner, sourcePath, destinationPath, executableName string) error { return mi.installMacAppBundleValidated(cr, sourcePath, destinationPath, func(path string) error { return validateMacAppBundle(path, executableName) }) } func (mi *MacOSInstaller) installMacAppBundleValidated( cr *CommandRunner, sourcePath, destinationPath string, validate func(string) error, ) error { if err := validate(sourcePath); err != nil { return err } applicationsDir := filepath.Dir(destinationPath) stageRoot, err := os.MkdirTemp(applicationsDir, ".mpv-manager-app-stage-") if err != nil { return fmt.Errorf("create staging directory beside %s: %w", destinationPath, err) } defer os.RemoveAll(stageRoot) stagedPath := filepath.Join(stageRoot, filepath.Base(destinationPath)) cr.outputChan <- fmt.Sprintf("Staging %s beside /Applications...", filepath.Base(destinationPath)) if err := cr.RunCommand("ditto", sourcePath, stagedPath); err != nil { return fmt.Errorf("stage application bundle: %w", err) } if err := validate(stagedPath); err != nil { return fmt.Errorf("validate staged application bundle: %w", err) } cr.outputChan <- fmt.Sprintf("Activating validated %s...", filepath.Base(destinationPath)) if err := cr.BeginCommit(); err != nil { return err } if err := transactionalPathReplace(cr.Context(), stagedPath, destinationPath, os.Rename); err != nil { return fmt.Errorf("replace application bundle: %w", err) } return nil } func (mi *MacOSInstaller) CheckInstalled() bool { appPath := "/Applications/MPV.app" return mi.FileExists(appPath) } func (mi *MacOSInstaller) CanUpdate() bool { return mi.CheckInstalled() } func (mi *MacOSInstaller) InstallMPVViaBrewWithOutput(cr *CommandRunner, uiType string, isUpdate bool) error { preserveConfig, err := existingMPVConfig() if err != nil { return err } if !mi.CheckBrewInstalled() { return fmt.Errorf("homebrew is not installed") } var preparedUI *PreparedUI if !isUpdate { configDir, err := GetMPVConfigDir() if err != nil { return err } preparedUI, err = mi.PrepareUI(cr, configDir, uiType) if err != nil { return err } defer preparedUI.Close() } if err := cr.BeginCommit(); err != nil { return err } 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 } if preserveConfig { cr.outputChan <- constants.MsgConfigNotOverwritten } else if err := InstallMPVConfigWithPlatformDefaults(mi.Installer, cr, mi.Platform.OSType, mi.Platform.GPUInfo); err != nil { return &PartialInstallError{Err: fmt.Errorf("install MPV config: %w", err)} } if err := preparedUI.Commit(cr); err != nil { return &PartialInstallError{Err: err} } return nil } func (mi *MacOSInstaller) UpdateMPVAppWithOutput(cr *CommandRunner) error { var url, hash string if mi.Platform.Arch == "arm64" { url = mi.ReleaseInfo.MacOS.ARMLatest.URL hash = mi.ReleaseInfo.MacOS.ARMLatest.BLAKE3 } else { url = mi.ReleaseInfo.MacOS.Intel15.URL hash = mi.ReleaseInfo.MacOS.Intel15.BLAKE3 } tempDir, appPath, err := mi.prepareMPVAppBundle(cr, url, hash, "mpv-update-") if err != nil { return err } defer os.RemoveAll(tempDir) if err := mi.installMacAppBundle(cr, appPath, "/Applications/MPV.app", "mpv"); 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, hash string if mi.Platform.Arch == "arm64" { url = mi.ReleaseInfo.MacOS.ARMLatest.URL hash = mi.ReleaseInfo.MacOS.ARMLatest.BLAKE3 } else { url = mi.ReleaseInfo.MacOS.Intel15.URL hash = mi.ReleaseInfo.MacOS.Intel15.BLAKE3 } tempDir, appPath, err := mi.prepareMPVAppBundle(cr, url, hash, "mpv-install-") if err != nil { return err } defer os.RemoveAll(tempDir) configDir, err := GetMPVConfigDir() if err != nil { return fmt.Errorf("get MPV config directory: %w", err) } // UI setup may create a minimal mpv.conf on a fresh install. Remember // whether the user already had a configuration before that setup, so a // reinstall does not replace their profiles or unrelated options. _, configErr := os.Stat(filepath.Join(configDir, constants.MPVConfigFileName)) if configErr != nil && !os.IsNotExist(configErr) { return fmt.Errorf("inspect existing MPV configuration: %w", configErr) } preserveConfig := configErr == nil preparedUI, err := mi.PrepareUI(cr, configDir, uiType) if err != nil { return err } defer preparedUI.Close() // A denied or failed bundle replacement must not change the user's live // configuration. UI downloads are staged before the bundle commit; setup // failures after it must report that the player itself was installed. if err := mi.installMacAppBundle(cr, appPath, "/Applications/MPV.app", "mpv"); err != nil { return err } if preserveConfig { cr.outputChan <- constants.MsgConfigNotOverwritten } else { if err := InstallMPVConfigWithPlatformDefaults(mi.Installer, cr, mi.Platform.OSType, mi.Platform.GPUInfo); err != nil { return &PartialInstallError{Err: fmt.Errorf("install MPV config: %w", err)} } } if err := preparedUI.Commit(cr); err != nil { return &PartialInstallError{Err: err} } cr.outputChan <- "MPV.app installed to /Applications/" return nil } func (mi *MacOSInstaller) InstallIINAWithOutput(cr *CommandRunner) (resultErr error) { var url, hash string if mi.Platform.Arch == "arm64" { url = mi.ReleaseInfo.IINA.ARM.URL hash = mi.ReleaseInfo.IINA.ARM.BLAKE3 } else { url = mi.ReleaseInfo.IINA.Intel.URL hash = mi.ReleaseInfo.IINA.Intel.BLAKE3 } tempDir, err := os.MkdirTemp("", "iina-install-") if err != nil { return fmt.Errorf("create IINA download staging directory: %w", err) } defer func() { var cleanupErr *dmgCleanupError if !errors.As(resultErr, &cleanupErr) { _ = os.RemoveAll(tempDir) } }() archivePath := filepath.Join(tempDir, "IINA.dmg") cr.outputChan <- "Downloading IINA..." if err := mi.DownloadFileWithProgressToChannel(cr, url, archivePath); err != nil { return err } if err := mi.Installer.VerifyBLAKE3WithOutput(cr, archivePath, hash); err != nil { return fmt.Errorf("IINA checksum verification failed: %w", err) } mountRoot := filepath.Join(tempDir, "mounts") if err := os.Mkdir(mountRoot, 0700); err != nil { return fmt.Errorf("create owned IINA mount root: %w", err) } mountRoot, err = filepath.EvalSymlinks(mountRoot) if err != nil { return fmt.Errorf("resolve owned IINA mount root: %w", err) } cr.outputChan <- "Mounting DMG file..." installed := false err = withOwnedDMGMounts(mountRoot, func() ([]dmgMount, error) { attachCtx, cancel := context.WithTimeout(cr.Context(), 3*time.Minute) defer cancel() output, err := process.Output(attachCtx, exec.CommandContext(attachCtx, "hdiutil", "attach", "-plist", "-nobrowse", "-noautoopen", "-readonly", "-mountroot", mountRoot, archivePath)) if err != nil { return nil, fmt.Errorf("mount IINA DMG: %w", err) } return parseHDIUtilAttachPlist(output) }, mi.mountedDMGs, mi.unmountDMG, func(mounts []dmgMount) error { selected, err := selectIINAMount(mountRoot, mounts) if err != nil { return err } validator := func(path string) error { return validateIINAAppBundle(cr.Context(), path, mi.Platform.Arch) } srcAppPath := filepath.Join(selected.MountPoint, "IINA.app") if err := validator(srcAppPath); err != nil { return fmt.Errorf("validate mounted IINA.app: %w", err) } err = mi.installMacAppBundleValidated(cr, srcAppPath, "/Applications/IINA.app", validator) installed = err == nil return err }) if err != nil { if installed { return &PartialInstallError{Err: err} } return 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") }