package installer import ( "bytes" "context" "errors" "fmt" "os" "os/exec" "path/filepath" "runtime" "time" "gitgud.io/mike/mpv-manager/internal/process" "gitgud.io/mike/mpv-manager/internal/fileops" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/constants" ) // ArchitectureInfo contains architecture details and URLs type ArchitectureInfo struct { Arch string URL string FFmpegURL string } const maxConfigBackups = 50 // GetArchitectureInfo returns architecture details for Windows install func GetArchitectureInfo(platformArch string, isV3 bool, releaseInfo ReleaseInfo) (ArchitectureInfo, error) { arch, err := windowsBinaryArchitecture(platformArch, isV3) if err != nil { return ArchitectureInfo{}, err } info := ArchitectureInfo{Arch: arch} switch arch { case constants.ArchX8664V3: info.URL, info.FFmpegURL = releaseInfo.Windows.X8664v3.URL, releaseInfo.FFmpeg.X8664v3.URL case constants.ArchX8664: info.URL, info.FFmpegURL = releaseInfo.Windows.X8664.URL, releaseInfo.FFmpeg.X8664.URL case constants.ArchAarch64: info.URL, info.FFmpegURL = releaseInfo.Windows.Aarch64.URL, releaseInfo.FFmpeg.Aarch64.URL } if info.URL == "" { return ArchitectureInfo{}, fmt.Errorf("release manifest unavailable or missing a download URL for %s - check your internet connection and use Retry, then try again", info.Arch) } return info, nil } func windowsBinaryArchitecture(platformArch string, isV3 bool) (string, error) { switch platformArch { case constants.ArchAMD64, constants.ArchX8664: if isV3 { return constants.ArchX8664V3, nil } return constants.ArchX8664, nil case constants.ArchARM64: return constants.ArchAarch64, nil default: return "", fmt.Errorf("unsupported architecture: %s (i686/32-bit builds are no longer available)", platformArch) } } // GetMPVConfigPath returns the path to mpv.conf (Unix) func GetMPVConfigPath() (string, error) { return config.GetMPVConfigFilePath() } // GetMPVConfigDir returns the path to mpv config directory func GetMPVConfigDir() (string, error) { dir := config.GetMPVConfigPath() if dir == "" { return "", fmt.Errorf("MPV config directory is empty") } return dir, nil } // GetConfigBackupDir returns the path to config backups directory func GetConfigBackupDir() (string, error) { mpvDir, err := GetMPVConfigDir() if err != nil { return "", err } return filepath.Join(mpvDir, constants.ConfigBackupsDir), nil } // CreateFullBackup creates a backup in the conf_backups directory with full timestamp func CreateFullBackup(fs FileSystem, filePath string, cr *CommandRunner) (string, error) { data, err := fs.ReadFile(filePath) if errors.Is(err, os.ErrNotExist) { return "", fmt.Errorf("config file not found: %s", filePath) } if err != nil { return "", fmt.Errorf("read config for backup: %w", err) } backupDir, err := GetConfigBackupDir() if err != nil { return "", err } if err := os.MkdirAll(backupDir, constants.DirPermission); err != nil { return "", err } mode := os.FileMode(constants.FilePermission) if info, statErr := fs.Stat(filePath); statErr == nil && info.Mode().IsRegular() { mode = info.Mode().Perm() } pattern := time.Now().Format(constants.BackupTimestampUnique) + "-*" + constants.BackupDirFilePrefix backupPath, err := fileops.WriteUnique(backupDir, pattern, data, mode) if err != nil { return "", fmt.Errorf("write durable config backup: %w", err) } if err := fileops.PruneBackups(backupDir, "*"+constants.BackupDirFilePrefix, maxConfigBackups); err != nil { return "", fmt.Errorf("prune old config backups: %w", err) } if cr != nil { cr.outputChan <- fmt.Sprintf("Created backup: %s", backupPath) cr.outputChan <- "Backup created successfully!" } return backupPath, nil } // RestoreBackup restores a backup file to the config location func RestoreBackup(fs FileSystem, backupPath, configPath string, cr *CommandRunner) error { return fileops.WithLock(configPath, func() error { candidate, err := config.ReadBackupFile(backupPath) if err != nil { return fmt.Errorf("read validated backup: %w", err) } configDir := filepath.Dir(configPath) if err := os.MkdirAll(configDir, constants.DirPermission); err != nil { return fmt.Errorf("create config directory: %w", err) } backupInfo, err := os.Lstat(backupPath) if err != nil { return fmt.Errorf("inspect validated backup metadata: %w", err) } if !backupInfo.Mode().IsRegular() { return fmt.Errorf("validated backup is not regular: %s", backupPath) } fallbackMode := backupInfo.Mode().Perm() recoveryPath := "" if _, statErr := os.Stat(configPath); statErr == nil { recoveryPath, err = CreateFullBackup(fs, configPath, cr) if err != nil { return fmt.Errorf("create pre-restore recovery backup: %w", err) } } else if !os.IsNotExist(statErr) { return fmt.Errorf("inspect current config before restore: %w", statErr) } if recoveryPath != "" && cr != nil { cr.outputChan <- fmt.Sprintf("Current config recovery snapshot: %s", recoveryPath) } if err := fileops.AtomicWritePreserve(configPath, candidate, fallbackMode); 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 config differs from staged backup") } return rollbackConfigReplacement(configPath, recoveryPath, err) } return nil }) } func rollbackConfigReplacement(configPath, recoveryPath string, replaceErr error) error { if recoveryPath == "" { return replaceErr } recovery, err := os.ReadFile(recoveryPath) if err != nil { return errors.Join(replaceErr, fmt.Errorf("read recovery snapshot: %w", err)) } snapshotInfo, err := os.Lstat(recoveryPath) if err != nil { return errors.Join(replaceErr, fmt.Errorf("inspect recovery snapshot metadata: %w", err)) } if !snapshotInfo.Mode().IsRegular() { return errors.Join(replaceErr, fmt.Errorf("recovery snapshot is not regular: %s", recoveryPath)) } if err := fileops.AtomicWritePreserve(configPath, recovery, snapshotInfo.Mode().Perm()); err != nil { return errors.Join(replaceErr, fmt.Errorf("restore recovery snapshot: %w", err)) } return replaceErr } // CommandExists checks if a command is available in PATH func CommandExists(name string) bool { _, err := exec.LookPath(name) return err == nil } // GetPrivilegePrefix returns sudo prefix if needed for Linux func GetPrivilegePrefix() []string { if os.Geteuid() == 0 { return []string{} } if CommandExists(constants.CommandSudo) { return []string{constants.CommandSudo} } return []string{} } // GetMethodDisplayName returns the display name for a method ID func GetMethodDisplayName(methodID string) string { if name, ok := constants.MethodIDToName[methodID]; ok { return name } return methodID } // EnsureDirectory creates a directory if it doesn't exist func EnsureDirectory(path string, cr *CommandRunner) error { if err := os.MkdirAll(path, constants.DirPermission); err != nil { if cr != nil { cr.outputChan <- fmt.Sprintf("Error: Failed to create directory: %s", path) } return err } if cr != nil { cr.outputChan <- fmt.Sprintf("Created directory: %s", path) } return nil } // InstallMPVConfigSafely installs MPV config and returns any failure after // recording it in the command output. func InstallMPVConfigSafely(installer *Installer, cr *CommandRunner) error { if err := installer.InstallMPVConfigWithOutput(cr); err != nil { cr.outputChan <- fmt.Sprintf("Warning: Failed to install MPV config: %v", err) return fmt.Errorf("install MPV config: %w", err) } return nil } // InstallUISafely installs the specified UI type with safe error handling // Also sets the appropriate osc= setting in mpv.conf // - UITypeUOSC: installs uOSC and sets osc=yes // - UITypeModernZ: installs ModernZ and sets osc=no // - UITypeNone: removes all UI files and osc= setting func InstallUISafely(installer *Installer, cr *CommandRunner, configDir string, uiType string) error { prepared, err := installer.PrepareUI(cr, configDir, uiType) if err != nil { return err } defer prepared.Close() return prepared.Commit(cr) } // PrepareUI downloads and verifies a UI before an operation changes live files. // The caller owns the result and must close it after commit or cancellation. func (installer *Installer) PrepareUI(cr *CommandRunner, configDir, uiType string) (*PreparedUI, error) { if uiType != constants.UITypeUOSC && uiType != constants.UITypeNone { uiType = constants.UITypeModernZ } return installer.stageUI(cr, configDir, uiType, true, func(stageDir string) error { switch uiType { case constants.UITypeModernZ: return installer.installModernZWithOutput(cr, stageDir, nil) case constants.UITypeUOSC: return installer.installUOSCWithOutput(cr, stageDir, false) default: cr.outputChan <- "UI type set to none - using MPV's default interface" return nil } }) } var managedUIPaths = []string{ filepath.Join(".mpv-manager", "ui-baselines"), constants.MPVConfigFileName, filepath.Join(constants.ScriptsDir, "uosc.lua"), filepath.Join(constants.ScriptsDir, "uosc"), filepath.Join(constants.ScriptOptsDir, "uosc.conf"), filepath.Join(constants.FontsDir, "uosc"), filepath.Join(constants.FontsDir, constants.UOSCIconsFontFile), filepath.Join(constants.FontsDir, constants.UOSCTexturesFontFile), filepath.Join(constants.ScriptsDir, "modernz.lua"), filepath.Join(constants.ScriptOptsDir, "modernz.conf"), filepath.Join(constants.FontsDir, constants.ModernZFontFile), filepath.Join(constants.FontsDir, "material-design-icons.ttf"), filepath.Join(constants.FontsDir, "fluent-system-icons.ttf"), filepath.Join(constants.ScriptsDir, "thumbfast.lua"), } var transientUIPaths = []string{ "uosc.zip", "uosc.conf", "modernz.lua.download", constants.ModernZFontFile + ".download", "modernz.conf.download", } type uiTransaction struct { configDir string backupDir string backedUp []string fs FileSystem journal *installerTransaction } func beginUITransaction(fs FileSystem, configDir string) (*uiTransaction, error) { if err := os.MkdirAll(configDir, constants.DirPermission); err != nil { return nil, err } backupDir, err := os.MkdirTemp(filepath.Dir(configDir), ".mpv-manager-ui-backup-*") if err != nil { return nil, err } rollbackPaths := append(append([]string(nil), managedUIPaths...), transientUIPaths...) journal, err := beginInstallerTransaction(installerTransactionUI, configDir, backupDir, rollbackPaths, nil) if err != nil { _ = os.RemoveAll(backupDir) return nil, err } transaction := &uiTransaction{configDir: configDir, backupDir: backupDir, fs: fs, journal: journal} for _, relativePath := range managedUIPaths { source := filepath.Join(configDir, relativePath) if _, err := os.Lstat(source); errors.Is(err, os.ErrNotExist) { continue } else if err != nil { _ = transaction.journal.rolledBack() return nil, err } if err := copyInstallerPath(fs, source, filepath.Join(backupDir, relativePath), false); err != nil { _ = transaction.journal.rolledBack() return nil, err } transaction.backedUp = append(transaction.backedUp, relativePath) } if err := syncInstallerTree(backupDir, false); err != nil { _ = transaction.journal.rolledBack() return nil, fmt.Errorf("sync UI rollback snapshot: %w", err) } if err := transaction.journal.activateUI(transaction.backedUp); err != nil { _ = transaction.journal.rolledBack() return nil, err } return transaction, nil } func (t *uiTransaction) commit() error { if err := syncInstallerPaths(t.configDir, append(append([]string(nil), managedUIPaths...), transientUIPaths...)); err != nil { return errors.Join(fmt.Errorf("sync installed UI: %w", err), t.rollback()) } if err := t.journal.commit(); err != nil { if t.journal.journal.State == "applying" { return errors.Join(err, t.rollback()) } return err } return nil } func syncInstallerPaths(root string, relativePaths []string) error { var syncErr error for _, relative := range relativePaths { path := filepath.Join(root, relative) if _, err := os.Lstat(path); os.IsNotExist(err) { continue } else if err != nil { syncErr = errors.Join(syncErr, err) continue } if err := syncInstallerTree(path, false); err != nil { syncErr = errors.Join(syncErr, err) } } return errors.Join(syncErr, syncInstallerDirectory(root)) } func (t *uiTransaction) rollback() error { // Reuse the crash-recovery path, including backup preflight and metadata. if err := rollbackInstallerJournal(t.journal.journal); err != nil { t.journal.retain() return fmt.Errorf("restore previous UI: %w", err) } return t.journal.rolledBack() } func syncInstallerTree(root string, allowInternalLinks bool) error { return filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error { if walkErr != nil { return walkErr } if allowInternalLinks && info.Mode()&os.ModeSymlink != 0 { return validateInternalInstallerLink(root, path) } if info.Mode().IsRegular() { flags := os.O_RDONLY if runtime.GOOS == "windows" { // FlushFileBuffers requires a handle opened with GENERIC_WRITE. flags = os.O_RDWR } file, err := os.OpenFile(path, flags, 0) if err != nil { return err } err = file.Sync() return errors.Join(err, file.Close()) } if info.IsDir() { return syncInstallerDirectory(path) } return fmt.Errorf("unsupported installer transaction path %s", path) }) } func copyInstallerPath(fs FileSystem, source, destination string, allowInternalLinks bool) error { info, err := os.Lstat(source) if err != nil { return err } if info.Mode()&os.ModeSymlink != 0 || (!info.Mode().IsRegular() && !info.IsDir()) { return fmt.Errorf("refusing to replace unsupported UI path %s", source) } if info.Mode().IsRegular() { if err := os.MkdirAll(filepath.Dir(destination), constants.DirPermission); err != nil { return err } return fs.CopyFile(source, destination, info.Mode().Perm()) } if allowInternalLinks && runtime.GOOS == "darwin" { // ditto preserves framework links, extended attributes and resource forks. if err := syncInstallerTree(source, true); err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() if err := process.Run(ctx, exec.Command("ditto", source, destination)); err != nil { return fmt.Errorf("restore application bundle: %w", err) } return nil } return filepath.Walk(source, func(path string, entry os.FileInfo, walkErr error) error { if walkErr != nil { return walkErr } if entry.Mode()&os.ModeSymlink != 0 { if !allowInternalLinks { return fmt.Errorf("refusing to replace unsupported UI path %s", path) } if err := validateInternalInstallerLink(source, path); err != nil { return err } link, err := os.Readlink(path) if err != nil { return err } relative, err := filepath.Rel(source, path) if err != nil { return err } return os.Symlink(link, filepath.Join(destination, relative)) } if !entry.Mode().IsRegular() && !entry.IsDir() { return fmt.Errorf("refusing to replace unsupported installer path %s", path) } relativePath, err := filepath.Rel(source, path) if err != nil { return err } target := filepath.Join(destination, relativePath) if entry.IsDir() { return os.MkdirAll(target, entry.Mode().Perm()) } if err := os.MkdirAll(filepath.Dir(target), constants.DirPermission); err != nil { return err } return fs.CopyFile(path, target, entry.Mode().Perm()) }) } // RemoveUIFiles removes UI files for the specified UI type // This should be called before switching to a different UI type // - UITypeUOSC: removes the uosc script, config, and current/legacy uOSC fonts // - UITypeModernZ: removes modernz.lua, modernz.conf, fonts/modernz-icons.ttf // Also cleans up legacy font files (material-design-icons.ttf, fluent-system-icons.ttf) from pre-0.3.1 installs func RemoveUIFiles(configDir string, uiType string) error { scriptsDir := filepath.Join(configDir, constants.ScriptsDir) scriptOptsDir := filepath.Join(configDir, constants.ScriptOptsDir) fontsDir := filepath.Join(configDir, constants.FontsDir) switch uiType { case constants.UITypeUOSC: // Remove uOSC files filesToRemove := []string{ filepath.Join(scriptsDir, "uosc.lua"), filepath.Join(scriptOptsDir, "uosc.conf"), filepath.Join(fontsDir, constants.UOSCIconsFontFile), filepath.Join(fontsDir, constants.UOSCTexturesFontFile), } dirsToRemove := []string{ filepath.Join(scriptsDir, "uosc"), filepath.Join(fontsDir, "uosc"), } for _, f := range filesToRemove { if err := os.Remove(f); err != nil && !os.IsNotExist(err) { return err } } for _, d := range dirsToRemove { if err := os.RemoveAll(d); err != nil { return err } } case constants.UITypeModernZ: // Remove ModernZ files (current) filesToRemove := []string{ filepath.Join(scriptsDir, "modernz.lua"), filepath.Join(scriptOptsDir, "modernz.conf"), filepath.Join(fontsDir, constants.ModernZFontFile), // Legacy font files from pre-0.3.1 ModernZ releases filepath.Join(fontsDir, "material-design-icons.ttf"), filepath.Join(fontsDir, "fluent-system-icons.ttf"), } for _, f := range filesToRemove { if err := os.Remove(f); err != nil && !os.IsNotExist(err) { return err } } } return nil } // RemoveAllUIFiles removes all known UI files (both uOSC and ModernZ) // This is useful when switching UI types to ensure a clean slate func RemoveAllUIFiles(configDir string) error { if err := RemoveUIFiles(configDir, constants.UITypeUOSC); err != nil { return err } if err := RemoveUIFiles(configDir, constants.UITypeModernZ); err != nil { return err } return nil } // RemoveThumbfast removes the thumbfast.lua script from the scripts directory // This should be called when switching to "No UI" since thumbfast is only needed for UOSC/ModernZ func RemoveThumbfast(configDir string) error { scriptsDir := filepath.Join(configDir, constants.ScriptsDir) thumbfastPath := filepath.Join(scriptsDir, "thumbfast.lua") return os.Remove(thumbfastPath) // Ignore error if file doesn't exist } // GetMPVConfigDirFromHome returns the MPV config directory path from home directory func GetMPVConfigDirFromHome(homeDir string) string { configPaths := constants.GetConfigPaths(homeDir) return configPaths.MPVConfigDir }