//go:build darwin package installer import ( "fmt" "os" "os/exec" "path/filepath" "gitgud.io/mike/mpv-manager/internal/assets" "gitgud.io/mike/mpv-manager/pkg/constants" ) // CreateMacOSWebUIShortcutWithOutput creates a macOS app bundle for the Web UI func CreateMacOSWebUIShortcutWithOutput(cr *CommandRunner) error { execPath, err := os.Executable() if err != nil { return fmt.Errorf("failed to get executable path: %w", err) } cr.outputChan <- "Creating Web UI app bundle..." appsDir := "/Applications" appPath := filepath.Join(appsDir, "MPV Manager.app") if _, err := os.Stat(appPath); err == nil { cr.outputChan <- "⚠ App bundle already exists: " + appPath return nil } contentsDir := filepath.Join(appPath, "Contents") macosDir := filepath.Join(contentsDir, "MacOS") resourcesDir := filepath.Join(contentsDir, "Resources") for _, dir := range []string{contentsDir, macosDir, resourcesDir} { if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("failed to create directory: %w", err) } } wrapperScript := "#!/bin/sh\nexec " + posixShellQuote(execPath) + " -m web\n" wrapperPath := filepath.Join(macosDir, "mpv-manager") if err := os.WriteFile(wrapperPath, []byte(wrapperScript), 0755); err != nil { return fmt.Errorf("failed to write wrapper script: %w", err) } infoPlistContent := ` CFBundleExecutable mpv-manager CFBundleIdentifier com.mpv.manager CFBundleName MPV Manager CFBundleDisplayName MPV Manager CFBundleVersion 0.1.0 CFBundlePackageType APPL CFBundleIconFile AppIcon ` infoPlistPath := filepath.Join(contentsDir, "Info.plist") if err := os.WriteFile(infoPlistPath, []byte(infoPlistContent), 0644); err != nil { return fmt.Errorf("failed to write Info.plist: %w", err) } // Create macOS installer instance to install icon mi := &MacOSInstaller{} if err := mi.InstallIconWithOutput(cr); err != nil { cr.outputChan <- "Warning: Failed to prepare icon: " + err.Error() } // Copy icon to Resources homeDir, _ := os.UserHomeDir() icnsPath := filepath.Join(homeDir, constants.MacOSTempIconDir, constants.MacOSIconFileName) if _, err := os.Stat(icnsPath); err == nil { destIconPath := filepath.Join(resourcesDir, "AppIcon.icns") if data, err := os.ReadFile(icnsPath); err == nil { if err := os.WriteFile(destIconPath, data, 0644); err == nil { cr.outputChan <- "✓ Icon installed: " + destIconPath } else { cr.outputChan <- "Warning: Failed to copy icon: " + err.Error() } } } else { // Fallback: use PNG directly (some macOS versions don't support iconutil) pngPath := filepath.Join(homeDir, constants.MacOSTempIconDir, "icon-128.png") if _, err := os.Stat(pngPath); err == nil { destIconPath := filepath.Join(resourcesDir, "AppIcon.png") if data, err := os.ReadFile(pngPath); err == nil { if err := os.WriteFile(destIconPath, data, 0644); err == nil { cr.outputChan <- "✓ Icon installed (PNG fallback): " + destIconPath } } } } // Extract the macOS uninstall script to ~/.config/mpv directory configDir := filepath.Join(homeDir, ".config", "mpv") if err := assets.ExtractUninstallMacOSSh(configDir); err != nil { cr.outputChan <- "Warning: Failed to extract uninstall script: " + err.Error() } else { cr.outputChan <- "✓ Uninstall script installed: " + filepath.Join(configDir, "uninstall-macos.sh") } cr.outputChan <- "✓ App bundle created: " + appPath cr.outputChan <- "Note: You may need to allow app in System Preferences > Security & Privacy." return nil } // CreateWebUIShortcutWithOutput is the method wrapper for CreateMacOSWebUIShortcutWithOutput func (mi *MacOSInstaller) CreateWebUIShortcutWithOutput(cr *CommandRunner) error { return CreateMacOSWebUIShortcutWithOutput(cr) } // ConvertPNGToICNS converts a PNG file to ICNS format using iconutil // Creates an iconset with multiple sizes and converts to ICNS func ConvertPNGToICNS(pngPath, icnsPath string) error { // Check if iconutil is available if _, err := exec.LookPath("iconutil"); err != nil { return fmt.Errorf("iconutil not available on this system") } // Create temporary iconset directory iconsetDir := pngPath + ".iconset" if err := os.MkdirAll(iconsetDir, 0755); err != nil { return fmt.Errorf("failed to create iconset directory: %w", err) } defer os.RemoveAll(iconsetDir) // Iconset names describe point sizes; @2x files contain twice the pixels. for _, size := range []int{16, 32, 128, 256, 512} { for _, scale := range []int{1, 2} { name := fmt.Sprintf("icon_%dx%d", size, size) if scale == 2 { name += "@2x" } outPath := filepath.Join(iconsetDir, name+".png") pixels := fmt.Sprint(size * scale) cmd := exec.Command("sips", "-z", pixels, pixels, pngPath, "--out", outPath) if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("create icon %s: %w: %s", name, err, output) } } } // Convert iconset to ICNS using iconutil cmd := exec.Command("iconutil", "-c", "icns", iconsetDir, "-o", icnsPath) if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("iconutil failed: %w\nOutput: %s", err, string(output)) } return nil } // InstallIconWithOutput installs the icon for macOS app bundles func (mi *MacOSInstaller) InstallIconWithOutput(cr *CommandRunner) error { homeDir, err := os.UserHomeDir() if err != nil { return fmt.Errorf("failed to get home directory: %w", err) } // Create temp icon directory tempIconDir := filepath.Join(homeDir, constants.MacOSTempIconDir) if err := os.MkdirAll(tempIconDir, 0755); err != nil { return fmt.Errorf("failed to create temp icon directory: %w", err) } pngPath := filepath.Join(tempIconDir, "icon-128.png") icnsPath := filepath.Join(tempIconDir, constants.MacOSIconFileName) // Check if ICNS already exists if _, err := os.Stat(icnsPath); err == nil { cr.outputChan <- "Icon already converted: " + icnsPath return nil } cr.outputChan <- "Converting icon to ICNS format..." // Extract PNG from embedded assets pngData, err := assets.ReadPNGIcon() if err != nil { return fmt.Errorf("failed to read icon from assets: %w", err) } if err := os.WriteFile(pngPath, pngData, 0644); err != nil { return fmt.Errorf("failed to write PNG file: %w", err) } // Convert PNG to ICNS if err := ConvertPNGToICNS(pngPath, icnsPath); err != nil { // iconutil may not be available on all macOS versions // Fall back to using PNG directly in app bundle cr.outputChan <- "Warning: Could not convert to ICNS, will use PNG as fallback: " + err.Error() return nil } cr.outputChan <- "✓ Icon converted: " + icnsPath return nil }