package installer import ( "bytes" "context" "debug/pe" "embed" "encoding/hex" "fmt" "io" "os" "path/filepath" "runtime" "strings" "sync" "time" "gitgud.io/mike/mpv-manager/internal/assets" "gitgud.io/mike/mpv-manager/internal/fileops" "gitgud.io/mike/mpv-manager/internal/mpvconf" "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/modernzconf" "gitgud.io/mike/mpv-manager/pkg/platform" "gitgud.io/mike/mpv-manager/pkg/releasemanifest" "lukechampine.com/blake3" ) 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 } // 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 fs FileSystem downloader Downloader httpClient HTTPClient executor CommandExecutor } func (p PostInstallMessage) Error() string { return p.Message } // ReleaseInfo is the shared producer/consumer release manifest schema. type ReleaseInfo = releasemanifest.Manifest func NewInstaller(releaseInfo ReleaseInfo, installDir string, opts ...InstallerOption) *Installer { inst := &Installer{ ReleaseInfo: releaseInfo, InstallDir: installDir, fs: OSFileSystem{}, downloader: NewHTTPDownloader(nil), httpClient: defaultArtifactHTTPClient, } for _, opt := range opts { opt(inst) } return inst } // CloneForOperation returns an installer whose manifest and destination are // immutable for the lifetime of one operation. Injectable dependencies are // intentionally shared; production implementations are concurrency-safe and // tests retain their configured mocks. func (i *Installer) CloneForOperation(releaseInfo ReleaseInfo, installDir string) *Installer { if i == nil { return NewInstaller(releaseInfo, installDir) } return &Installer{ ReleaseInfo: releaseInfo, InstallDir: installDir, fs: i.fs, downloader: i.downloader, httpClient: i.httpClient, executor: i.executor, } } // InstallerOption configures an optional Installer dependency. Production // code uses the zero defaults; tests inject mocks via these options. type InstallerOption func(*Installer) // WithFileSystem overrides the FileSystem used for file operations. func WithFileSystem(fs FileSystem) InstallerOption { return func(i *Installer) { i.fs = fs } } // WithDownloader overrides the Downloader used for context-aware downloads. func WithDownloader(d Downloader) InstallerOption { return func(i *Installer) { i.downloader = d } } // WithHTTPClient overrides the HTTPClient used by artifact download flows. func WithHTTPClient(c HTTPClient) InstallerOption { return func(i *Installer) { i.httpClient = c } } // WithExecutor overrides platform command execution for deterministic installer tests. // Native archive extraction does not execute external tools. func WithExecutor(e CommandExecutor) InstallerOption { return func(i *Installer) { i.executor = e } } // fileSystem returns the configured FileSystem (os-backed by default). func (i *Installer) fileSystem() FileSystem { if i.fs != nil { return i.fs } return OSFileSystem{} } // downloaderOrDefault returns the configured Downloader (http-backed by default). func (i *Installer) downloaderOrDefault() Downloader { if i.downloader != nil { return i.downloader } return NewHTTPDownloader(nil) } // http returns the configured HTTPClient (a bounded production client by default). func (i *Installer) http() HTTPClient { if i.httpClient != nil { return i.httpClient } return defaultArtifactHTTPClient } // executorOr returns the injected CommandExecutor, or fallback when none was // injected (the zero default keeps the existing CommandRunner/exec behavior). func (i *Installer) executorOr(fallback CommandExecutor) CommandExecutor { if i.executor != nil { return i.executor } return fallback } // SetInstallDir updates the install directory used by the platform installers // (WindowsInstaller embeds *Installer and reads InstallDir directly). // Call it before starting an install, not concurrently with one. func (i *Installer) SetInstallDir(path string) { i.InstallDir = path } 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 { return NewHTTPDownloader(i.http()).DownloadFile(context.Background(), url, dest, progressCallback) } // 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 { return i.downloaderOrDefault().DownloadFile(ctx, url, dest, progressCallback) } // contextProgressWriter reports bounded-frequency progress and checks cancellation. 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 } func (i *Installer) DownloadFileWithProgressToChannel(cr *CommandRunner, url, dest string) error { cr.outputChan <- fmt.Sprintf("Downloading: %s", url) var announced bool err := NewHTTPDownloader(i.http()).DownloadFile(cr.Context(), url, dest, func(written, total int64) { if !announced { cr.outputChan <- fmt.Sprintf("Target size: %.2f MB", float64(total)/(1024*1024)) announced = true } percent := float64(written) / float64(total) * 100 cr.outputChan <- fmt.Sprintf("%s %.0f%% (%.2f MB / %.2f MB)", i.generateProgressBar(percent), percent, float64(written)/(1024*1024), float64(total)/(1024*1024)) }) if err != nil { return 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 := i.fileSystem().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 := i.fileSystem().ReadFile(src) if err != nil { return err } return i.fileSystem().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 := i.fileSystem().ReadFile(src) if err != nil { cr.outputChan <- fmt.Sprintf("Error: Failed to read source: %v", err) return err } if err := i.fileSystem().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 := i.fileSystem().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 i.fileSystem().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)..." if err := i.updateUIWithPreservedConfig(cr, installDir, constants.UITypeUOSC, func(stageDir string) error { return i.installUOSCWithOutput(cr, stageDir, false) }); 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)..." if err := i.updateUIWithPreservedConfig(cr, installDir, constants.UITypeModernZ, func(stageDir string) error { return i.installModernZWithOutput(cr, stageDir, nil) }); 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..." architecture, err := windowsBinaryArchitecture(runtime.GOARCH, platform.GetCPULevel() == "x86-64-v3") if err != nil { return err } ffmpegURL, ffmpegBLAKE3, machine, err := ffmpegAssetForArchitecture(architecture, i.ReleaseInfo) if err != nil { return err } if ffmpegURL == "" { return fmt.Errorf("release manifest unavailable or missing a download URL for FFmpeg (%s) - check your internet connection and use Retry, then try again", architecture) } if err := requireSevenZipHash(ffmpegBLAKE3, "FFmpeg update"); err != nil { return err } // Reserve private scratch paths before downloading. Existing installation // siblings are never adopted as temporary storage or removed during cleanup. if err := os.MkdirAll(filepath.Dir(installDir), constants.DirPermission); err != nil { return fmt.Errorf("prepare FFmpeg staging parent: %w", err) } workDir, err := os.MkdirTemp(filepath.Dir(installDir), ".mpv-manager-ffmpeg-stage-") if err != nil { return fmt.Errorf("create private FFmpeg staging: %w", err) } defer os.RemoveAll(workDir) ffmpegArchivePath := filepath.Join(workDir, "ffmpeg.7z") 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 checksum (missing and mismatched hashes fail closed). if err := i.VerifyBLAKE3WithOutput(cr, ffmpegArchivePath, ffmpegBLAKE3); err != nil { return fmt.Errorf("FFmpeg checksum verification failed: %w", err) } // Extract FFmpeg cr.outputChan <- "Extracting FFmpeg..." tempExtractDir := filepath.Join(workDir, "payload") 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") } // Stage and validate the replacement beside the live executable before the // current binary is touched. Keeping both paths on the same filesystem makes // activation a rename transaction on Windows. oldFFmpeg := filepath.Join(installDir, "ffmpeg.exe") stagedFFmpeg, err := stageRegularFileBeside(cr.Context(), ffmpegSrc, oldFFmpeg, constants.ExecutablePermission) if err != nil { return fmt.Errorf("failed to stage ffmpeg.exe: %w", err) } defer os.Remove(stagedFFmpeg) validate := func(path string) error { return validateFFmpegExecutableForMachine(path, machine) } if err := validate(stagedFFmpeg); err != nil { return fmt.Errorf("staged ffmpeg.exe is invalid: %w", err) } cr.outputChan <- "Installing new FFmpeg binary..." if err := cr.BeginCommit(); err != nil { return err } if err := transactionalRegularFileReplace( cr.Context(), stagedFFmpeg, oldFFmpeg, validate, func() error { return config.SetFFmpegVersion(i.ReleaseInfo.FFmpeg.AppVersion) }, os.Rename, ); err != nil { return fmt.Errorf("failed to replace ffmpeg.exe: %w", err) } cr.outputChan <- "FFmpeg updated successfully!" return nil } func ffmpegAssetForArchitecture(architecture string, release ReleaseInfo) (url, hash string, machine uint16, err error) { switch architecture { case constants.ArchX8664: return release.FFmpeg.X8664.URL, release.FFmpeg.X8664.BLAKE3, pe.IMAGE_FILE_MACHINE_AMD64, nil case constants.ArchX8664V3: return release.FFmpeg.X8664v3.URL, release.FFmpeg.X8664v3.BLAKE3, pe.IMAGE_FILE_MACHINE_AMD64, nil case constants.ArchAarch64: return release.FFmpeg.Aarch64.URL, release.FFmpeg.Aarch64.BLAKE3, pe.IMAGE_FILE_MACHINE_ARM64, nil default: return "", "", 0, fmt.Errorf("unsupported FFmpeg architecture %q", architecture) } } func validateFFmpegExecutableForMachine(path string, expectedMachine uint16) error { if err := validateNonEmptyRegularFile(path, false); err != nil { return err } executable, err := pe.Open(path) if err != nil { return fmt.Errorf("parse PE executable: %w", err) } defer executable.Close() if executable.FileHeader.Machine != expectedMachine { return fmt.Errorf("unexpected PE machine %#x", executable.FileHeader.Machine) } return nil } // VerifyBLAKE3File verifies a file's BLAKE3 hash against an expected value. // The expected hash has the form "blake3:" as published in the release JSON. // Implemented locally because pkg/version imports pkg/installer (import cycle). func VerifyBLAKE3File(filePath, expectedHash string) error { return verifyBLAKE3File(context.Background(), filePath, expectedHash) } func verifyBLAKE3File(ctx context.Context, filePath, expectedHash string) error { if err := ctx.Err(); err != nil { return err } file, err := os.Open(filePath) if err != nil { return fmt.Errorf("failed to open file: %w", err) } defer file.Close() hash := blake3.New(32, nil) if _, err := io.Copy(hash, contextReader{ctx: ctx, reader: file}); err != nil { return fmt.Errorf("failed to compute hash: %w", err) } computedHash := "blake3:" + hex.EncodeToString(hash.Sum(nil)) if computedHash != expectedHash { return fmt.Errorf("BLAKE3 hash mismatch for %s: expected %s, got %s", filepath.Base(filePath), expectedHash, computedHash) } return nil } // VerifyBLAKE3WithOutput verifies a file's BLAKE3 hash with output logging. // Downloaded application artifacts fail closed when the release manifest does // not provide a concrete checksum. func (i *Installer) VerifyBLAKE3WithOutput(cr *CommandRunner, filePath, expectedHash string) error { if expectedHash == "" || expectedHash == constants.Blake3Placeholder { err := fmt.Errorf("no BLAKE3 checksum available for %s; refusing to use unverified artifact", filepath.Base(filePath)) cr.outputChan <- "Error: " + err.Error() return err } cr.outputChan <- fmt.Sprintf("Verifying BLAKE3 hash for: %s", filepath.Base(filePath)) if err := verifyBLAKE3File(cr.Context(), filePath, expectedHash); err != nil { cr.outputChan <- fmt.Sprintf("Error: %v", err) return err } cr.outputChan <- "BLAKE3 verification passed" return nil } // verifyDownload verifies a freshly downloaded file, logging through the // command runner when one is available (CLI flows pass nil). func (i *Installer) verifyDownload(cr *CommandRunner, filePath, expectedHash string) error { if cr != nil { return i.VerifyBLAKE3WithOutput(cr, filePath, expectedHash) } if expectedHash == "" || expectedHash == constants.Blake3Placeholder { return fmt.Errorf("no BLAKE3 checksum available for %s; refusing to use unverified artifact", filepath.Base(filePath)) } log.Info(fmt.Sprintf("Verifying BLAKE3 hash for: %s", filepath.Base(filePath))) if err := VerifyBLAKE3File(filePath, expectedHash); err != nil { log.Error("BLAKE3 verification failed: " + err.Error()) return err } log.Info("BLAKE3 verification passed") return nil } func (i *Installer) InstallUOSCWithOutput(cr *CommandRunner, installDir string) error { return InstallUISafely(i, cr, installDir, constants.UITypeUOSC) } func (i *Installer) installUOSCWithOutput(cr *CommandRunner, installDir string, durableArchiveCommit bool) error { cr.outputChan <- "Installing uOSC (custom UI for MPV)..." uoscZipURL := i.ReleaseInfo.UOSC.URL uoscConfURL := i.ReleaseInfo.UOSC.ConfURL if err := os.MkdirAll(filepath.Dir(installDir), constants.DirPermission); err != nil { return fmt.Errorf("prepare uOSC staging parent: %w", err) } workDir, err := os.MkdirTemp(filepath.Dir(installDir), ".mpv-manager-uosc-download-*") if err != nil { return fmt.Errorf("create private uOSC download directory: %w", err) } defer os.RemoveAll(workDir) uoscZipPath := filepath.Join(workDir, "uosc.zip") uoscConfPath := filepath.Join(workDir, "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 } // Verify uOSC checksums (missing and mismatched hashes fail closed). if err := i.VerifyBLAKE3WithOutput(cr, uoscZipPath, i.ReleaseInfo.UOSC.BLAKE3); err != nil { os.Remove(uoscZipPath) os.Remove(uoscConfPath) return fmt.Errorf("uOSC checksum verification failed: %w", err) } if err := i.VerifyBLAKE3WithOutput(cr, uoscConfPath, i.ReleaseInfo.UOSC.ConfBLAKE3); err != nil { os.Remove(uoscZipPath) os.Remove(uoscConfPath) return fmt.Errorf("uOSC config checksum verification failed: %w", err) } cr.outputChan <- "Extracting uOSC to install directory..." if err := i.extractUOSCArchive(cr, uoscZipPath, installDir, durableArchiveCommit); 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 } // Install the reviewed Thumbfast source embedded in this binary. Thumbfast // remains optional, so a local write failure does not fail the whole UI install. cr.outputChan <- "Installing bundled thumbfast (thumbnail preview support)..." if err := installEmbeddedThumbfast(installDir); err != nil { cr.outputChan <- fmt.Sprintf("Warning: Failed to install thumbfast: %v", err) } 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" if err := storeUIConfigBaseline(installDir, constants.UITypeUOSC, i.ReleaseInfo.UOSC.AppVersion, filepath.Join(scriptOptsDir, "uosc.conf")); err != nil { return fmt.Errorf("store uOSC config baseline: %w", err) } 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 { return InstallUISafely(i, cr, installDir, constants.UITypeModernZ) } func (i *Installer) installModernZWithOutput(cr *CommandRunner, installDir string, preservedScaling map[string]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 } // Install the reviewed Thumbfast source embedded in this binary. Thumbfast // remains optional, so a local write failure does not fail the whole UI install. cr.outputChan <- "Installing bundled thumbfast (thumbnail preview support)..." if err := installEmbeddedThumbfast(installDir); err != nil { cr.outputChan <- fmt.Sprintf("Warning: Failed to install thumbfast: %v", err) } 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 (missing and mismatched hashes fail closed). 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 (missing and mismatched hashes fail closed). 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 (missing and mismatched hashes fail closed). 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) } // Apply manager-owned defaults only after the upstream config passes its // checksum and while it is still staged. The live config is replaced later // by the surrounding UI transaction, so an error here leaves it untouched. if err := modernzconf.ApplyManagedInstallDefaults(tempConfPath, preservedScaling); err != nil { os.Remove(tempScriptPath) os.Remove(tempFontPath) os.Remove(tempConfPath) return fmt.Errorf("failed to apply recommended ModernZ scaling defaults: %w", err) } if len(preservedScaling) == 0 { cr.outputChan <- "Prepared recommended ModernZ scaling defaults (consistent size at 100%)" } else { cr.outputChan <- "Preserved existing ModernZ scaling settings" } // 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" if err := storeUIConfigBaseline(installDir, constants.UITypeModernZ, i.ReleaseInfo.ModernZ.AppVersion, finalConfPath); err != nil { return fmt.Errorf("store ModernZ config baseline: %w", err) } 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) if oscValue != "yes" && oscValue != "no" { return fmt.Errorf("invalid osc value %q", oscValue) } return setOSCConfigValue(cr, configPath, oscValue, true) } // 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)..." return setOSCConfigValue(cr, configPath, "yes", false) } func setOSCConfigValue(cr *CommandRunner, configPath, oscValue string, createMissing bool) error { return fileops.WithLock(configPath, func() error { data, err := os.ReadFile(configPath) if err != nil { if !os.IsNotExist(err) { cr.outputChan <- fmt.Sprintf("Error: Failed to read mpv.conf: %v", err) return fmt.Errorf("failed to read mpv.conf: %w", err) } if !createMissing { cr.outputChan <- "mpv.conf not found, nothing to modify" return nil } cr.outputChan <- "mpv.conf not found, creating new file with osc setting..." if err := os.MkdirAll(filepath.Dir(configPath), constants.DirPermission); err != nil { return fmt.Errorf("create mpv.conf directory: %w", err) } data = nil } updated, change := mpvconf.Set(string(data), "osc", oscValue, true) found, previous, duplicates := change.Found, change.Previous, change.Duplicates changed := updated != string(data) if !changed { if createMissing { cr.outputChan <- fmt.Sprintf("osc=%s is already set correctly", oscValue) } else { cr.outputChan <- fmt.Sprintf("osc=%s already set, no changes needed", oscValue) } return nil } switch { case !found && !createMissing: cr.outputChan <- fmt.Sprintf("No osc= setting found, adding osc=%s", oscValue) case !found: cr.outputChan <- fmt.Sprintf("Added: osc=%s", oscValue) case duplicates > 0: cr.outputChan <- fmt.Sprintf("Normalized %d duplicate osc setting(s); effective value %s -> %s", duplicates, previous, oscValue) default: cr.outputChan <- fmt.Sprintf("Updated: osc=%s -> osc=%s", previous, oscValue) } if err := fileops.AtomicWritePreserve(configPath, []byte(updated), constants.FilePermission); 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 }) } func (i *Installer) CopyAsset(embedFS embed.FS, srcPath, destPath string) error { data, err := embedFS.ReadFile(srcPath) if err != nil { return err } if err := i.fileSystem().MkdirAll(filepath.Dir(destPath), 0755); err != nil { return err } return i.fileSystem().WriteFile(destPath, data, 0644) } func (i *Installer) EnsureDir(path string) error { return i.fileSystem().MkdirAll(path, 0755) } func (i *Installer) FileExists(path string) bool { return i.fileSystem().Exists(path) } func (i *Installer) InstallMPVConfigWithOutput(cr *CommandRunner) error { return i.installMPVConfigWithOutput(cr, nil) } func (i *Installer) installMPVConfigWithOutput(cr *CommandRunner, transform func([]byte) ([]byte, error)) error { configPath, err := GetMPVConfigPath() if err != nil { return fmt.Errorf("failed to get mpv config path: %w", err) } return i.installMPVConfigAtPath(cr, configPath, transform) } func (i *Installer) installMPVConfigAtPath(cr *CommandRunner, configPath string, transform func([]byte) ([]byte, error)) error { cr.outputChan <- "Installing MPV configuration..." cr.outputChan <- strings.Repeat("─", 50) configDir := filepath.Dir(configPath) if err := fileops.WithLock(configPath, func() error { if err := EnsureDirectory(configDir, cr); err != nil { return err } // Preservation and recovery are preconditions for replacement. A read or // backup failure leaves the live file untouched and aborts the operation. userSettings, err := PreserveUserSettings(configPath, cr) if err != nil { return fmt.Errorf("preserve user settings: %w", err) } hadConfig := false if _, err := os.Stat(configPath); err == nil { hadConfig = true } else if !os.IsNotExist(err) { return fmt.Errorf("inspect current mpv.conf: %w", err) } recoveryPath := "" if hadConfig { recoveryPath, err = CreateFullBackup(i.fileSystem(), configPath, cr) if err != nil { return fmt.Errorf("create recovery backup: %w", err) } if recoveryPath == "" { return fmt.Errorf("create recovery backup: no backup was produced") } } data, err := assets.MPVConfig.ReadFile("mpv.conf") if err != nil { return fmt.Errorf("read embedded mpv.conf: %w", err) } candidate, err := applyUserSettingsContent(data, userSettings, cr) if err != nil { return fmt.Errorf("render preserved user settings: %w", err) } if transform != nil { candidate, err = transform(candidate) if err != nil { return fmt.Errorf("render platform defaults: %w", err) } } cr.outputChan <- fmt.Sprintf("Writing configuration to: %s", configPath) if err := fileops.AtomicWritePreserve(configPath, candidate, constants.FilePermission); err != nil { return rollbackConfigReplacement(configPath, recoveryPath, err) } committed, err := os.ReadFile(configPath) if err != nil || !bytes.Equal(committed, candidate) { if err == nil { err = fmt.Errorf("committed mpv.conf differs from staged candidate") } return rollbackConfigReplacement(configPath, recoveryPath, err) } return nil }); err != nil { return fmt.Errorf("install recommended mpv.conf transaction: %w", 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) } return fileops.WithLock(configPath, func() error { data, err := os.ReadFile(configPath) if err != nil { return fmt.Errorf("failed to read mpv.conf: %w", err) } modified, _ := mpvconf.Set(string(data), "osc", "no", true) if err := fileops.AtomicWritePreserve(configPath, []byte(modified), constants.FilePermission); 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(i.fileSystem(), 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) if err := RestoreBackup(i.fileSystem(), 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 }