//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/bash cd "$(dirname "$0")" "` + execPath + `" -m web ` 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) // Required sizes for macOS iconset sizes := []string{ "16", "32", "64", "128", "256", "512", } // Create icons for each size using sips (built into macOS) for _, size := range sizes { // Create regular and retina (2x) versions for _, scale := range []string{"1", "2"} { var targetSize string var filename string if scale == "2" { targetSize = size + "x" + size filename = fmt.Sprintf("icon_%sx%s.png", size, size) } else { // For retina, we use 2x the size baseSize := size if baseSize == "16" || baseSize == "32" || baseSize == "64" || baseSize == "128" || baseSize == "256" || baseSize == "512" { baseSizeInt := 0 fmt.Sscanf(baseSize, "%d", &baseSizeInt) baseSizeInt *= 2 targetSize = fmt.Sprintf("%dx%d", baseSizeInt, baseSizeInt) filename = fmt.Sprintf("icon_%s@2x.png", size) } } // Use sips to resize the PNG if scale == "2" { baseSizeInt := 0 fmt.Sscanf(size, "%d", &baseSizeInt) baseSizeInt *= 2 targetSize = fmt.Sprintf("%dx%d", baseSizeInt, baseSizeInt) filename = fmt.Sprintf("icon_%s@2x.png", size) } outPath := filepath.Join(iconsetDir, filename) cmd := exec.Command("sips", "-z", targetSize, targetSize, pngPath, "--out", outPath) if output, err := cmd.CombinedOutput(); err != nil { // Log warning but continue - some sizes may fail fmt.Printf("Warning: Failed to create %s: %v\nOutput: %s\n", filename, err, string(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 }