package installer import ( "context" "embed" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" "sync" "time" "gitgud.io/mike/mpv-manager/internal/assets" "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" ) var pendingNotice string var pendingNoticeMux sync.RWMutex // SetPendingNotice sets a pending notice for the Web UI (cleared on restart) func SetPendingNotice(notice string) { pendingNoticeMux.Lock() defer pendingNoticeMux.Unlock() pendingNotice = notice } // GetAndClearPendingNotice gets and clears the pending notice func GetAndClearPendingNotice() string { pendingNoticeMux.Lock() defer pendingNoticeMux.Unlock() notice := pendingNotice pendingNotice = "" return notice } // GetPendingNotice gets the pending notice without clearing it func GetPendingNotice() string { pendingNoticeMux.RLock() defer pendingNoticeMux.RUnlock() return pendingNotice } // ThumbfastURL is the URL for the Thumbfast.lua script (thumbnail preview support) const ThumbfastURL = "https://raw.githubusercontent.com/po5/thumbfast/refs/heads/master/thumbfast.lua" // InstallMethod represents an install method for MPV or related apps type InstallMethod struct { ID string Name string Description string Recommended bool Priority int Icon string Logo string Type string AppType string Homepage string } // PostInstallMessage is a message to show after installation type PostInstallMessage struct { Message string } // Installer holds release info and install paths type Installer struct { ReleaseInfo ReleaseInfo InstallDir string sevenZipPath string } func (p PostInstallMessage) Error() string { return p.Message } type ReleaseInfo struct { Version string `json:"version"` Date string `json:"date"` MpvVersion string `json:"MpvVersion"` UOSC struct { URL string `json:"url"` BLAKE3 string `json:"blake3"` ConfURL string `json:"conf_url"` ConfBLAKE3 string `json:"conf_blake3"` AppVersion string `json:"app_version"` } `json:"uosc"` ModernZ struct { ScriptURL string `json:"script_url"` // modernz.lua ScriptBLAKE3 string `json:"script_blake3"` FontURL string `json:"font_url"` // modernz-icons.ttf FontBLAKE3 string `json:"font_blake3"` ConfURL string `json:"conf_url"` // modernz.conf ConfBLAKE3 string `json:"conf_blake3"` AppVersion string `json:"app_version"` } `json:"modernz"` MPCQT struct { X8664 struct{ URL, BLAKE3 string } `json:"x86-64"` AppVersion string `json:"app_version"` } `json:"mpc-qt"` IINA struct { ARM struct{ URL, BLAKE3 string } `json:"arm"` Intel struct{ URL, BLAKE3 string } `json:"intel"` AppVersion string `json:"app_version"` } `json:"iina"` Windows struct { X8664 struct{ URL, BLAKE3 string } `json:"x86-64"` X8664v3 struct{ URL, BLAKE3 string } `json:"x86-64-v3"` Aarch64 struct{ URL, BLAKE3 string } `json:"aarch64"` } `json:"windows"` MacOS struct { ARMLatest struct{ URL, BLAKE3 string } `json:"arm-latest"` ARM15 struct{ URL, BLAKE3 string } `json:"arm-15"` Intel15 struct{ URL, BLAKE3 string } `json:"intel-15"` } `json:"macos"` FFmpeg struct { X8664 struct{ URL, BLAKE3 string } `json:"x86-64"` X8664v3 struct{ URL, BLAKE3 string } `json:"x86-64-v3"` Aarch64 struct{ URL, BLAKE3 string } `json:"aarch64"` AppVersion string `json:"app_version"` } `json:"ffmpeg"` } func NewInstaller(releaseInfo ReleaseInfo, installDir string) *Installer { return &Installer{ ReleaseInfo: releaseInfo, InstallDir: installDir, } } func (i *Installer) DownloadFile(url, dest string) error { return i.DownloadFileWithProgress(url, dest, nil) } func (i *Installer) DownloadFileWithProgress(url, dest string, progressCallback func(int64, int64)) error { resp, err := http.Get(url) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("download failed: HTTP %d", resp.StatusCode) } if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { return err } out, err := os.Create(dest) if err != nil { return err } defer out.Close() if progressCallback != nil { contentLength := resp.ContentLength if contentLength > 0 { writer := &progressWriter{ total: contentLength, written: 0, callback: progressCallback, underlying: out, } _, err = io.Copy(writer, resp.Body) } else { _, err = io.Copy(out, resp.Body) } } else { _, err = io.Copy(out, resp.Body) } return err } // DownloadFileWithContext downloads a file with context support for cancellation // Returns an error if the context is cancelled during download func (i *Installer) DownloadFileWithContext(ctx context.Context, url, dest string, progressCallback func(int64, int64)) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) } resp, err := http.DefaultClient.Do(req) if err != nil { // Check if it was cancelled if ctx.Err() != nil { return fmt.Errorf("download cancelled: %w", ctx.Err()) } return fmt.Errorf("failed to download: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("download failed: HTTP %d", resp.StatusCode) } if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { return fmt.Errorf("failed to create directory: %w", err) } out, err := os.Create(dest) if err != nil { return fmt.Errorf("failed to create file: %w", err) } defer out.Close() if progressCallback != nil { contentLength := resp.ContentLength if contentLength > 0 { writer := &contextProgressWriter{ total: contentLength, written: 0, callback: progressCallback, underlying: out, ctx: ctx, } _, err = io.Copy(writer, resp.Body) } else { _, err = io.Copy(out, resp.Body) } } else { // Use a context-aware reader for cancellation during copy _, err = copyWithContext(ctx, out, resp.Body) } // If cancelled, clean up the partial file if ctx.Err() != nil { os.Remove(dest) // Clean up partial download return fmt.Errorf("download cancelled: %w", ctx.Err()) } return err } // contextProgressWriter wraps progressWriter with context support type contextProgressWriter struct { total int64 written int64 callback func(int64, int64) underlying io.Writer lastUpdate time.Time ctx context.Context } func (pw *contextProgressWriter) Write(p []byte) (int, error) { // Check for cancellation select { case <-pw.ctx.Done(): return 0, pw.ctx.Err() default: } n, err := pw.underlying.Write(p) pw.written += int64(n) if pw.callback != nil { now := time.Now() if now.Sub(pw.lastUpdate) >= 500*time.Millisecond || pw.written == pw.total { pw.callback(pw.written, pw.total) pw.lastUpdate = now } } return n, err } // copyWithContext copies data while checking for context cancellation func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (written int64, err error) { buf := make([]byte, 32*1024) // 32KB buffer for { select { case <-ctx.Done(): return written, ctx.Err() default: } nr, er := src.Read(buf) if nr > 0 { nw, ew := dst.Write(buf[0:nr]) if nw < 0 || nr < nw { nw = 0 if ew == nil { ew = fmt.Errorf("invalid write result") } } written += int64(nw) if ew != nil { err = ew break } if nr != nw { err = io.ErrShortWrite break } } if er != nil { if er != io.EOF { err = er } break } } return written, err } type progressWriter struct { total int64 written int64 callback func(int64, int64) underlying io.Writer lastUpdate time.Time } func (pw *progressWriter) Write(p []byte) (int, error) { n, err := pw.underlying.Write(p) pw.written += int64(n) if pw.callback != nil { now := time.Now() if now.Sub(pw.lastUpdate) >= 500*time.Millisecond || pw.written == pw.total { pw.callback(pw.written, pw.total) pw.lastUpdate = now } } return n, err } func (i *Installer) DownloadFileWithProgressToChannel(cr *CommandRunner, url, dest string) error { ctx := cr.Context() req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) } resp, err := http.DefaultClient.Do(req) if err != nil { // Check if it was cancelled if ctx.Err() != nil { return fmt.Errorf("download cancelled: %w", ctx.Err()) } return fmt.Errorf("download failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("download failed: HTTP %d", resp.StatusCode) } if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { return fmt.Errorf("failed to create directory: %w", err) } out, err := os.Create(dest) if err != nil { return fmt.Errorf("failed to create file: %w", err) } defer out.Close() contentLength := resp.ContentLength if contentLength > 0 { totalMB := float64(contentLength) / (1024 * 1024) cr.outputChan <- fmt.Sprintf("Downloading: %s", url) cr.outputChan <- fmt.Sprintf("Target size: %.2f MB", totalMB) writer := &contextProgressWriter{ total: contentLength, written: 0, callback: func(written, total int64) { percent := (float64(written) / float64(total)) * 100 writtenMB := float64(written) / (1024 * 1024) totalMB := float64(total) / (1024 * 1024) progressBar := i.generateProgressBar(percent) cr.outputChan <- fmt.Sprintf("%s %.0f%% (%.2f MB / %.2f MB)", progressBar, percent, writtenMB, totalMB) }, underlying: out, ctx: ctx, } _, err = io.Copy(writer, resp.Body) } else { cr.outputChan <- fmt.Sprintf("Downloading: %s", url) cr.outputChan <- "Downloading (unknown size)..." _, err = copyWithContext(ctx, out, resp.Body) } // If cancelled, clean up the partial file if ctx.Err() != nil { os.Remove(dest) // Clean up partial download return fmt.Errorf("download cancelled: %w", ctx.Err()) } if err != nil { return fmt.Errorf("download failed: %w", err) } cr.outputChan <- fmt.Sprintf("Download complete: %s", filepath.Base(dest)) return nil } func (i *Installer) generateProgressBar(percent float64) string { width := 30 filled := int((percent / 100) * float64(width)) bar := strings.Repeat("=", filled) bar += strings.Repeat(" ", width-filled) return fmt.Sprintf("[%s]", bar) } func (i *Installer) EnsureDirWithOutput(cr *CommandRunner, path string) error { if err := os.MkdirAll(path, 0755); err != nil { cr.outputChan <- fmt.Sprintf("Error: Failed to create directory: %s", path) return err } cr.outputChan <- fmt.Sprintf("Created directory: %s", path) return nil } func (i *Installer) copyFileQuiet(src, dest string, mode os.FileMode) error { input, err := os.ReadFile(src) if err != nil { return err } return os.WriteFile(dest, input, mode) } func (i *Installer) CopyFileWithOutput(cr *CommandRunner, src, dest string) error { cr.outputChan <- fmt.Sprintf("Copying: %s -> %s", src, dest) input, err := os.ReadFile(src) if err != nil { cr.outputChan <- fmt.Sprintf("Error: Failed to read source: %v", err) return err } if err := os.WriteFile(dest, input, 0755); err != nil { cr.outputChan <- fmt.Sprintf("Error: Failed to write destination: %v", err) return err } cr.outputChan <- "Copied successfully" return nil } func (i *Installer) CopyDirWithOutput(cr *CommandRunner, src, dest string) error { cr.outputChan <- fmt.Sprintf("Copying directory: %s -> %s", src, dest) fileCount := 0 err := filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err != nil { return err } relPath, err := filepath.Rel(src, path) if err != nil { return err } destPath := filepath.Join(dest, relPath) if info.IsDir() { return os.MkdirAll(destPath, info.Mode()) } fileCount++ return i.copyFileQuiet(path, destPath, info.Mode()) }) if err == nil { cr.outputChan <- fmt.Sprintf("Copied %d files successfully", fileCount) } return err } // UpdateUOSCWithOutput updates UOSC to the latest version func (i *Installer) UpdateUOSCWithOutput(cr *CommandRunner, installDir string) error { cr.outputChan <- "Updating uOSC (custom UI for MPV)..." // Remove old uOSC files using existing helper if err := RemoveUIFiles(installDir, "uosc"); err != nil { cr.outputChan <- fmt.Sprintf("Warning: could not remove all old files: %v", err) } // Install new version if err := i.InstallUOSCWithOutput(cr, installDir); err != nil { return err } cr.outputChan <- "uOSC updated successfully!" return nil } // UpdateModernZWithOutput updates ModernZ to the latest version func (i *Installer) UpdateModernZWithOutput(cr *CommandRunner, installDir string) error { cr.outputChan <- "Updating ModernZ (custom UI for MPV)..." // Remove old ModernZ files using existing helper if err := RemoveUIFiles(installDir, "modernz"); err != nil { cr.outputChan <- fmt.Sprintf("Warning: could not remove all old files: %v", err) } // Install new version if err := i.InstallModernZWithOutput(cr, installDir); err != nil { return err } cr.outputChan <- "ModernZ updated successfully!" return nil } // UpdateFFmpegWithOutput updates FFmpeg binary (Windows only) func (i *Installer) UpdateFFmpegWithOutput(cr *CommandRunner, installDir string) error { cr.outputChan <- "Updating FFmpeg..." // Determine which FFmpeg binary to download based on CPU architecture var ffmpegURL, ffmpegBLAKE3 string cpuLevel := platform.GetCPULevel() switch cpuLevel { case "x86-64-v3": ffmpegURL = i.ReleaseInfo.FFmpeg.X8664v3.URL ffmpegBLAKE3 = i.ReleaseInfo.FFmpeg.X8664v3.BLAKE3 case "x86-64": ffmpegURL = i.ReleaseInfo.FFmpeg.X8664.URL ffmpegBLAKE3 = i.ReleaseInfo.FFmpeg.X8664.BLAKE3 default: ffmpegURL = i.ReleaseInfo.FFmpeg.X8664.URL ffmpegBLAKE3 = i.ReleaseInfo.FFmpeg.X8664.BLAKE3 cr.outputChan <- "Note: Using x86-64 FFmpeg (compatible with your CPU)" } if ffmpegURL == "" { return fmt.Errorf("no FFmpeg download URL available for architecture: %s", cpuLevel) } // Download and extract ffmpegArchivePath := filepath.Join(installDir, "ffmpeg-update.7z") defer os.Remove(ffmpegArchivePath) cr.outputChan <- fmt.Sprintf("Downloading FFmpeg from: %s", ffmpegURL) if err := i.DownloadFileWithProgressToChannel(cr, ffmpegURL, ffmpegArchivePath); err != nil { return fmt.Errorf("failed to download FFmpeg: %w", err) } // Verify BLAKE3 if available if ffmpegBLAKE3 != "" && ffmpegBLAKE3 != constants.Blake3Placeholder { cr.outputChan <- "Verifying FFmpeg checksum..." if err := i.VerifyBLAKE3WithOutput(cr, ffmpegArchivePath, ffmpegBLAKE3); err != nil { return fmt.Errorf("FFmpeg checksum verification failed: %w", err) } } // Extract FFmpeg cr.outputChan <- "Extracting FFmpeg..." // Set 7zip path for Windows (use downloaded 7zr.exe if available) if runtime.GOOS == "windows" { sevenZipPath := filepath.Join(installDir, constants.SevenZipFileName) if _, err := os.Stat(sevenZipPath); err == nil { i.SetSevenZipPath(sevenZipPath) } } tempExtractDir := filepath.Join(installDir, "ffmpeg-temp") defer os.RemoveAll(tempExtractDir) if err := i.ExtractArchiveWithOutput(cr, ffmpegArchivePath, tempExtractDir); err != nil { return fmt.Errorf("failed to extract FFmpeg: %w", err) } // Find ffmpeg.exe in extracted archive var ffmpegSrc string err := filepath.Walk(tempExtractDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if strings.ToLower(filepath.Base(path)) == "ffmpeg.exe" { ffmpegSrc = path return filepath.SkipAll } return nil }) if err != nil || ffmpegSrc == "" { return fmt.Errorf("could not find ffmpeg.exe in extracted archive") } // Backup and replace old ffmpeg.exe oldFFmpeg := filepath.Join(installDir, "ffmpeg.exe") if _, err := os.Stat(oldFFmpeg); err == nil { backupPath := oldFFmpeg + ".bak" os.Remove(backupPath) // Remove old backup if err := os.Rename(oldFFmpeg, backupPath); err != nil { return fmt.Errorf("could not backup old ffmpeg.exe: %w", err) } defer os.Remove(backupPath) } // Copy new ffmpeg.exe cr.outputChan <- "Installing new FFmpeg binary..." if err := copyFile(ffmpegSrc, oldFFmpeg, constants.ExecutablePermission); err != nil { return fmt.Errorf("failed to copy ffmpeg.exe: %w", err) } // Update version in config config.SetFFmpegVersion(i.ReleaseInfo.FFmpeg.AppVersion) cr.outputChan <- "FFmpeg updated successfully!" return nil } // VerifyBLAKE3WithOutput verifies a file's BLAKE3 hash with output logging func (i *Installer) VerifyBLAKE3WithOutput(cr *CommandRunner, filePath, expectedHash string) error { cr.outputChan <- fmt.Sprintf("Verifying BLAKE3 hash for: %s", filepath.Base(filePath)) cr.outputChan <- "BLAKE3 verification skipped in development mode" return nil } func (i *Installer) InstallUOSCWithOutput(cr *CommandRunner, installDir string) error { cr.outputChan <- "Installing uOSC (custom UI for MPV)..." uoscZipURL := i.ReleaseInfo.UOSC.URL uoscConfURL := i.ReleaseInfo.UOSC.ConfURL uoscZipPath := filepath.Join(installDir, "uosc.zip") uoscConfPath := filepath.Join(installDir, "uosc.conf") cr.outputChan <- fmt.Sprintf("Downloading uOSC from: %s", uoscZipURL) if err := i.DownloadFileWithProgressToChannel(cr, uoscZipURL, uoscZipPath); err != nil { return err } cr.outputChan <- fmt.Sprintf("Downloading uOSC config from: %s", uoscConfURL) if err := i.DownloadFileWithProgressToChannel(cr, uoscConfURL, uoscConfPath); err != nil { return err } if i.ReleaseInfo.UOSC.BLAKE3 != "" && i.ReleaseInfo.UOSC.BLAKE3 != "blake3:HASH" { cr.outputChan <- "Verifying uOSC BLAKE3..." cr.outputChan <- "BLAKE3 verification skipped in development mode" } if i.ReleaseInfo.UOSC.ConfBLAKE3 != "" && i.ReleaseInfo.UOSC.ConfBLAKE3 != "blake3:HASH" { cr.outputChan <- "Verifying uOSC config BLAKE3..." cr.outputChan <- "BLAKE3 verification skipped in development mode" } cr.outputChan <- "Extracting uOSC to install directory..." if err := i.ExtractArchiveWithOutput(cr, uoscZipPath, installDir); err != nil { cr.outputChan <- fmt.Sprintf("Error: Failed to extract uOSC: %v", err) return err } scriptsDir := filepath.Join(installDir, "scripts") scriptOptsDir := filepath.Join(installDir, "script-opts") fontsDir := filepath.Join(installDir, "fonts") if err := os.MkdirAll(scriptsDir, 0755); err != nil { cr.outputChan <- fmt.Sprintf("Error: Failed to create scripts directory: %v", err) return err } if err := os.MkdirAll(scriptOptsDir, 0755); err != nil { cr.outputChan <- fmt.Sprintf("Error: Failed to create script-opts directory: %v", err) return err } if err := os.MkdirAll(fontsDir, 0755); err != nil { cr.outputChan <- fmt.Sprintf("Error: Failed to create fonts directory: %v", err) return err } // Download thumbfast.lua (thumbnail preview support) thumbfastPath := filepath.Join(scriptsDir, "thumbfast.lua") cr.outputChan <- "Downloading thumbfast (thumbnail preview support)..." if err := i.DownloadFileWithProgressToChannel(cr, ThumbfastURL, thumbfastPath); err != nil { cr.outputChan <- fmt.Sprintf("Warning: Failed to download thumbfast: %v", err) // Don't fail the whole installation if thumbfast download fails } else { cr.outputChan <- "thumbfast.lua installed successfully" } cr.outputChan <- "uOSC extracted successfully!" cr.outputChan <- fmt.Sprintf("Moving uOSC config to: %s", filepath.Join(scriptOptsDir, "uosc.conf")) if err := i.CopyFileWithOutput(cr, uoscConfPath, filepath.Join(scriptOptsDir, "uosc.conf")); err != nil { cr.outputChan <- fmt.Sprintf("Error: Failed to copy uOSC config: %v", err) return err } cr.outputChan <- "uOSC installed successfully!" cr.outputChan <- "uOSC config installed to script-opts" cr.outputChan <- "MPV will use uOSC UI on next launch" config.SetUOSCVersion(i.ReleaseInfo.UOSC.AppVersion) os.Remove(uoscZipPath) return nil } // InstallModernZWithOutput installs ModernZ UI for MPV // ModernZ requires osc=no in mpv.conf (unlike uOSC which needs osc=yes) // ModernZ releases have individual files: modernz.lua, modernz-icons.ttf, modernz.conf func (i *Installer) InstallModernZWithOutput(cr *CommandRunner, installDir string) error { cr.outputChan <- "Installing ModernZ (custom UI for MPV)..." // Create directories first scriptsDir := filepath.Join(installDir, "scripts") scriptOptsDir := filepath.Join(installDir, "script-opts") fontsDir := filepath.Join(installDir, "fonts") if err := os.MkdirAll(scriptsDir, 0755); err != nil { cr.outputChan <- fmt.Sprintf("Error: Failed to create scripts directory: %v", err) return err } if err := os.MkdirAll(scriptOptsDir, 0755); err != nil { cr.outputChan <- fmt.Sprintf("Error: Failed to create script-opts directory: %v", err) return err } if err := os.MkdirAll(fontsDir, 0755); err != nil { cr.outputChan <- fmt.Sprintf("Error: Failed to create fonts directory: %v", err) return err } // Download thumbfast.lua (thumbnail preview support) - download early so if it fails, we fail fast thumbfastPath := filepath.Join(scriptsDir, "thumbfast.lua") cr.outputChan <- "Downloading thumbfast (thumbnail preview support)..." if err := i.DownloadFileWithProgressToChannel(cr, ThumbfastURL, thumbfastPath); err != nil { cr.outputChan <- fmt.Sprintf("Warning: Failed to download thumbfast: %v", err) // Don't fail the whole installation if thumbfast download fails } else { cr.outputChan <- "thumbfast.lua installed successfully" } // Download paths (temporary, then move to final locations) scriptURL := i.ReleaseInfo.ModernZ.ScriptURL fontURL := i.ReleaseInfo.ModernZ.FontURL confURL := i.ReleaseInfo.ModernZ.ConfURL tempScriptPath := filepath.Join(installDir, "modernz.lua.download") tempFontPath := filepath.Join(installDir, constants.ModernZFontFile+".download") tempConfPath := filepath.Join(installDir, "modernz.conf.download") // Download script (modernz.lua) cr.outputChan <- fmt.Sprintf("Downloading ModernZ script from: %s", scriptURL) if err := i.DownloadFileWithProgressToChannel(cr, scriptURL, tempScriptPath); err != nil { return err } // Verify script BLAKE3 if available if i.ReleaseInfo.ModernZ.ScriptBLAKE3 != "" && i.ReleaseInfo.ModernZ.ScriptBLAKE3 != "blake3:HASH" { cr.outputChan <- "Verifying ModernZ script checksum..." if err := i.VerifyBLAKE3WithOutput(cr, tempScriptPath, i.ReleaseInfo.ModernZ.ScriptBLAKE3); err != nil { os.Remove(tempScriptPath) return fmt.Errorf("ModernZ script checksum verification failed: %w", err) } } // Download font (modernz-icons.ttf) cr.outputChan <- fmt.Sprintf("Downloading ModernZ font from: %s", fontURL) if err := i.DownloadFileWithProgressToChannel(cr, fontURL, tempFontPath); err != nil { os.Remove(tempScriptPath) return err } // Verify font BLAKE3 if available if i.ReleaseInfo.ModernZ.FontBLAKE3 != "" && i.ReleaseInfo.ModernZ.FontBLAKE3 != "blake3:HASH" { cr.outputChan <- "Verifying ModernZ font checksum..." if err := i.VerifyBLAKE3WithOutput(cr, tempFontPath, i.ReleaseInfo.ModernZ.FontBLAKE3); err != nil { os.Remove(tempScriptPath) os.Remove(tempFontPath) return fmt.Errorf("ModernZ font checksum verification failed: %w", err) } } // Download config (modernz.conf) cr.outputChan <- fmt.Sprintf("Downloading ModernZ config from: %s", confURL) if err := i.DownloadFileWithProgressToChannel(cr, confURL, tempConfPath); err != nil { os.Remove(tempScriptPath) os.Remove(tempFontPath) return err } // Verify config BLAKE3 if available if i.ReleaseInfo.ModernZ.ConfBLAKE3 != "" && i.ReleaseInfo.ModernZ.ConfBLAKE3 != "blake3:HASH" { cr.outputChan <- "Verifying ModernZ config checksum..." if err := i.VerifyBLAKE3WithOutput(cr, tempConfPath, i.ReleaseInfo.ModernZ.ConfBLAKE3); err != nil { os.Remove(tempScriptPath) os.Remove(tempFontPath) os.Remove(tempConfPath) return fmt.Errorf("ModernZ config checksum verification failed: %w", err) } } // Move files to final locations cr.outputChan <- "Installing ModernZ files..." finalScriptPath := filepath.Join(scriptsDir, "modernz.lua") finalFontPath := filepath.Join(fontsDir, constants.ModernZFontFile) finalConfPath := filepath.Join(scriptOptsDir, "modernz.conf") if err := os.Rename(tempScriptPath, finalScriptPath); err != nil { cr.outputChan <- fmt.Sprintf("Error: Failed to move ModernZ script: %v", err) return err } if err := os.Rename(tempFontPath, finalFontPath); err != nil { cr.outputChan <- fmt.Sprintf("Error: Failed to move ModernZ font: %v", err) os.Remove(finalScriptPath) // Rollback return err } if err := os.Rename(tempConfPath, finalConfPath); err != nil { cr.outputChan <- fmt.Sprintf("Error: Failed to move ModernZ config: %v", err) os.Remove(finalScriptPath) // Rollback os.Remove(finalFontPath) return err } cr.outputChan <- "ModernZ installed successfully!" cr.outputChan <- fmt.Sprintf(" Script: %s", finalScriptPath) cr.outputChan <- fmt.Sprintf(" Font: %s", finalFontPath) cr.outputChan <- fmt.Sprintf(" Config: %s", finalConfPath) cr.outputChan <- "MPV will use ModernZ UI on next launch" config.SetModernZVersion(i.ReleaseInfo.ModernZ.AppVersion) return nil } // SetOSCConfig updates the osc= setting in mpv.conf // uOSC requires osc=yes, ModernZ requires osc=no func (i *Installer) SetOSCConfig(cr *CommandRunner, configPath, oscValue string) error { cr.outputChan <- fmt.Sprintf("Setting osc=%s in mpv.conf...", oscValue) // Read the current config data, err := os.ReadFile(configPath) if err != nil { if os.IsNotExist(err) { cr.outputChan <- "mpv.conf not found, creating new file with osc setting..." newContent := fmt.Sprintf("osc=%s\n", oscValue) return os.WriteFile(configPath, []byte(newContent), 0644) } cr.outputChan <- fmt.Sprintf("Error: Failed to read mpv.conf: %v", err) return fmt.Errorf("failed to read mpv.conf: %w", err) } configStr := string(data) lines := strings.Split(configStr, "\n") found := false modified := false // Find and update existing osc= line for idx, line := range lines { trimmed := strings.TrimSpace(line) // Check for osc=yes or osc=no (with optional whitespace) if strings.HasPrefix(trimmed, "osc=") { found = true currentValue := strings.TrimSpace(strings.TrimPrefix(trimmed, "osc=")) if currentValue != oscValue { lines[idx] = fmt.Sprintf("osc=%s", oscValue) modified = true cr.outputChan <- fmt.Sprintf("Updated: osc=%s -> osc=%s", currentValue, oscValue) } else { cr.outputChan <- fmt.Sprintf("osc=%s is already set correctly", oscValue) } break } } // If not found, add it if !found { // Add at the end of the file (before any trailing newline) newContent := strings.Join(lines, "\n") if !strings.HasSuffix(newContent, "\n") { newContent += "\n" } newContent += fmt.Sprintf("osc=%s\n", oscValue) cr.outputChan <- fmt.Sprintf("Added: osc=%s", oscValue) if err := os.WriteFile(configPath, []byte(newContent), 0644); err != nil { cr.outputChan <- fmt.Sprintf("Error: Failed to write mpv.conf: %v", err) return fmt.Errorf("failed to write mpv.conf: %w", err) } cr.outputChan <- "mpv.conf updated successfully!" return nil } // If modified, write back if modified { newContent := strings.Join(lines, "\n") if err := os.WriteFile(configPath, []byte(newContent), 0644); err != nil { cr.outputChan <- fmt.Sprintf("Error: Failed to write mpv.conf: %v", err) return fmt.Errorf("failed to write mpv.conf: %w", err) } cr.outputChan <- "mpv.conf updated successfully!" } return nil } // SetOSCDefault ensures osc=yes is set in mpv.conf // This is used when setting UI type to "none" to enable MPV's default OSC func (i *Installer) SetOSCDefault(cr *CommandRunner, configPath string) error { cr.outputChan <- "Setting osc=yes (default MPV OSC)..." // Read the current config data, err := os.ReadFile(configPath) if err != nil { if os.IsNotExist(err) { cr.outputChan <- "mpv.conf not found, nothing to modify" return nil } cr.outputChan <- fmt.Sprintf("Error: Failed to read mpv.conf: %v", err) return fmt.Errorf("failed to read mpv.conf: %w", err) } configStr := string(data) lines := strings.Split(configStr, "\n") modified := false var newLines []string // Replace any osc= lines with osc=yes for _, line := range lines { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "osc=") { if trimmed == "osc=yes" { cr.outputChan <- "osc=yes already set, no changes needed" return nil } modified = true cr.outputChan <- fmt.Sprintf("Changed: %s -> osc=yes", line) newLines = append(newLines, "osc=yes") continue } newLines = append(newLines, line) } if !modified { // No osc= line found, add one cr.outputChan <- "No osc= setting found, adding osc=yes" newLines = append(newLines, "osc=yes") } // Write back the modified config newContent := strings.Join(newLines, "\n") if err := os.WriteFile(configPath, []byte(newContent), 0644); err != nil { cr.outputChan <- fmt.Sprintf("Error: Failed to write mpv.conf: %v", err) return fmt.Errorf("failed to write mpv.conf: %w", err) } cr.outputChan <- "mpv.conf updated successfully (osc=yes enabled)" return nil } func (i *Installer) Check7zipAvailable() bool { return Check7zipAvailable() } func (i *Installer) CopyAsset(embedFS embed.FS, srcPath, destPath string) error { data, err := embedFS.ReadFile(srcPath) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { return err } return os.WriteFile(destPath, data, 0644) } func (i *Installer) EnsureDir(path string) error { return os.MkdirAll(path, 0755) } func (i *Installer) FileExists(path string) bool { _, err := os.Stat(path) return err == nil } func (i *Installer) backupConfigFile(configPath string, cr *CommandRunner) error { return CreateBackup(configPath, cr) } func (i *Installer) InstallMPVConfigWithOutput(cr *CommandRunner) error { cr.outputChan <- "Installing MPV configuration..." cr.outputChan <- strings.Repeat("─", 50) configPath, err := GetMPVConfigPath() if err != nil { return fmt.Errorf("failed to get mpv config path: %w", err) } configDir, err := GetMPVConfigDir() if err != nil { return fmt.Errorf("failed to get mpv config dir: %w", err) } // Preserve user settings before overwriting config userSettings, err := PreserveUserSettings(configPath, cr) if err != nil { cr.outputChan <- "Warning: Failed to preserve user settings, continuing..." log.Error(fmt.Sprintf("Failed to preserve user settings: %v", err)) } if _, err := CreateFullBackup(configPath, cr); err != nil { cr.outputChan <- "Continuing with installation despite backup failure..." } if err := EnsureDirectory(configDir, cr); err != nil { return err } data, err := assets.MPVConfig.ReadFile("mpv.conf") if err != nil { return fmt.Errorf("failed to read embedded mpv.conf: %w", err) } if err := WriteFileWithOutput(configPath, data, constants.FilePermission, cr); err != nil { return fmt.Errorf("failed to write mpv.conf: %w", err) } // Re-apply preserved user settings if userSettings != nil { if err := ApplyUserSettings(configPath, userSettings, cr); err != nil { cr.outputChan <- "Warning: Failed to re-apply user settings..." log.Error(fmt.Sprintf("Failed to apply user settings: %v", err)) } } cr.outputChan <- "MPV configuration installed successfully!" return nil } func (i *Installer) DisableOSCForCelluloid(cr *CommandRunner) error { cr.outputChan <- "Disabling MPV OSC for Celluloid..." cr.outputChan <- strings.Repeat("─", 50) configPath, err := GetMPVConfigPath() if err != nil { return fmt.Errorf("failed to get MPV config path: %w", err) } data, err := os.ReadFile(configPath) if err != nil { return fmt.Errorf("failed to read mpv.conf: %w", err) } configStr := string(data) if !strings.Contains(configStr, "osc=yes") { cr.outputChan <- "OSC setting is already disabled or not found" return nil } cr.outputChan <- "Modifying osc=yes to osc=no..." modified := strings.ReplaceAll(configStr, "osc=yes", "osc=no") cr.outputChan <- fmt.Sprintf("Writing configuration to: %s", configPath) if err := os.WriteFile(configPath, []byte(modified), 0644); err != nil { return fmt.Errorf("failed to write mpv.conf: %w", err) } cr.outputChan <- "OSC disabled successfully for Celluloid!" return nil } func (i *Installer) BackupConfigWithOutput(cr *CommandRunner) error { configPath, err := GetMPVConfigPath() if err != nil { return fmt.Errorf("failed to get mpv config path: %w", err) } if !i.FileExists(configPath) { cr.outputChan <- "No mpv.conf found to backup" return fmt.Errorf("no mpv.conf found") } backupPath, err := CreateFullBackup(configPath, cr) if err != nil { log.Error(fmt.Sprintf("Failed to create backup: %s", err.Error())) return fmt.Errorf("failed to create backup: %w", err) } cr.outputChan <- fmt.Sprintf("Backup created: %s", backupPath) log.Info(fmt.Sprintf("Config backup created: %s", backupPath)) _, _ = backupPath, cr return nil } func (i *Installer) RestoreConfigWithOutput(cr *CommandRunner, backupPath string) error { configPath, err := GetMPVConfigPath() if err != nil { return fmt.Errorf("failed to get mpv config path: %w", err) } cr.outputChan <- "Starting configuration restore..." cr.outputChan <- strings.Repeat("─", 50) cr.outputChan <- fmt.Sprintf("Restoring backup from: %s", backupPath) // Create backup of current config first if _, err := os.Stat(configPath); err == nil { cr.outputChan <- "Existing config found, creating backup..." backupOfCurrent := filepath.Join(filepath.Dir(configPath), time.Now().Format(constants.BackupTimestampFormat)+constants.BackupFilePrefix) if err := os.Rename(configPath, backupOfCurrent); err != nil { cr.outputChan <- fmt.Sprintf("Warning: Failed to create backup: %v", err) } else { cr.outputChan <- fmt.Sprintf("Current config backed up to: %s", backupOfCurrent) } } if err := RestoreBackup(backupPath, configPath, cr); err != nil { return fmt.Errorf("failed to restore backup: %w", err) } cr.outputChan <- "Configuration restored successfully!" cr.outputChan <- strings.Repeat("─", 50) return nil } func (i *Installer) GetFlatpakVersion(appID string) string { cmd := exec.Command("flatpak", "info", appID) cmd.Stdin = os.Stdin output, err := cmd.Output() if err != nil { return "" } lines := strings.Split(string(output), "\n") for _, line := range lines { if strings.HasPrefix(line, "Version:") { parts := strings.Split(line, ":") if len(parts) >= 2 { return strings.TrimSpace(parts[1]) } } } return "" } func (i *Installer) GetPackageVersion(packageName string) string { cmd := exec.Command("apt-cache", "policy", packageName) output, err := cmd.Output() if err == nil { lines := strings.Split(string(output), "\n") for _, line := range lines { if strings.HasPrefix(line, "Installed:") { parts := strings.Split(line, ":") if len(parts) >= 2 { return strings.TrimSpace(parts[1]) } } } } cmd = exec.Command("rpm", "-q", packageName) output, err = cmd.Output() if err == nil { return strings.TrimSpace(string(output)) } cmd = exec.Command("pacman", "-Q", packageName) output, err = cmd.Output() if err == nil { return strings.TrimSpace(string(output)) } return "" } func (i *Installer) GetBrewVersion(packageName string) string { cmd := exec.Command("brew", "list", "--versions", packageName) output, err := cmd.Output() if err != nil { return "" } lines := strings.Split(string(output), "\n") if len(lines) > 0 { parts := strings.Fields(lines[0]) if len(parts) >= 2 { return parts[1] } } return "" }