package installer import ( "errors" "fmt" "os" "path/filepath" "runtime" "strings" "time" "unicode" "gitgud.io/mike/mpv-manager/internal/fileops" "gitgud.io/mike/mpv-manager/pkg/constants" ) type uiConfigSnapshot struct { path string data []byte mode os.FileMode } func readUIConfigSnapshot(path string) (*uiConfigSnapshot, error) { info, err := os.Lstat(path) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("inspect UI config %q: %w", path, err) } if !info.Mode().IsRegular() { return nil, fmt.Errorf("UI config %q is not a regular file", path) } data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read UI config %q: %w", path, err) } return &uiConfigSnapshot{path: path, data: data, mode: info.Mode().Perm()}, nil } func (snapshot *uiConfigSnapshot) writeTo(path string) error { if snapshot == nil { return nil } mode := snapshot.mode if mode == 0 { mode = constants.FilePermission } if err := fileops.AtomicWrite(path, snapshot.data, mode); err != nil { return fmt.Errorf("restore preserved UI config: %w", err) } return nil } func (snapshot *uiConfigSnapshot) createDurableBackup() (string, error) { if snapshot == nil { return "", nil } if err := os.MkdirAll(filepath.Dir(snapshot.path), constants.DirPermission); err != nil { return "", err } pattern := filepath.Base(snapshot.path) + ".pre-update-" + time.Now().Format("20060102-150405.000000000") + ".*.bak" mode := snapshot.mode if mode == 0 { mode = constants.FilePermission } return fileops.WriteUnique(filepath.Dir(snapshot.path), pattern, snapshot.data, mode) } func uiConfigRelativePath(uiType string) string { switch uiType { case constants.UITypeModernZ: return filepath.Join(constants.ScriptOptsDir, "modernz.conf") case constants.UITypeUOSC: return filepath.Join(constants.ScriptOptsDir, "uosc.conf") default: return "" } } func stagedUIPaths(uiType string) []string { common := []string{ filepath.Join(constants.ScriptsDir, "thumbfast.lua"), filepath.Join(".mpv-manager", "ui-baselines"), } switch uiType { case constants.UITypeModernZ: return append(common, filepath.Join(constants.ScriptsDir, "modernz.lua"), filepath.Join(constants.ScriptOptsDir, "modernz.conf"), filepath.Join(constants.FontsDir, constants.ModernZFontFile), ) case constants.UITypeUOSC: return append(common, 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), ) default: return common } } // updateUIWithPreservedConfig stages and validates a complete UI update before // touching live files. A successful update keeps the user's config byte for // byte and leaves a timestamped durable backup beside it. func (i *Installer) updateUIWithPreservedConfig(cr *CommandRunner, installDir, uiType string, installStaged func(string) error) error { if uiConfigRelativePath(uiType) == "" { return fmt.Errorf("unsupported UI type %q", uiType) } return i.replaceUI(cr, installDir, uiType, false, installStaged) } // PreparedUI owns a verified private UI tree. Call Close after Commit or when // abandoning preparation. It does not snapshot or alter live configuration. type PreparedUI struct { attempted bool installer *Installer stageDir string installDir string uiType string replaceAll bool } func (p *PreparedUI) Close() error { if p == nil || p.stageDir == "" { return nil } err := os.RemoveAll(p.stageDir) if err == nil { p.stageDir = "" } return err } func (i *Installer) replaceUI(cr *CommandRunner, installDir, uiType string, replaceAll bool, installStaged func(string) error) error { prepared, err := i.stageUI(cr, installDir, uiType, replaceAll, installStaged) if err != nil { return err } defer prepared.Close() return prepared.Commit(cr) } func (i *Installer) stageUI(cr *CommandRunner, installDir, uiType string, replaceAll bool, installStaged func(string) error) (*PreparedUI, error) { stageParent := filepath.Dir(installDir) if runtime.GOOS == "windows" && strings.EqualFold(filepath.Base(installDir), constants.PortableConfigDir) { // A fresh MPV install must stay free of unowned entries while its // ownership manifest is prepared. Stage beside the installation root. stageParent = filepath.Dir(stageParent) } if err := os.MkdirAll(stageParent, constants.DirPermission); err != nil { return nil, fmt.Errorf("prepare UI update staging parent: %w", err) } stageDir, err := os.MkdirTemp(stageParent, ".mpv-manager-ui-update-*") if err != nil { return nil, fmt.Errorf("create UI update staging directory: %w", err) } prepared := &PreparedUI{installer: i, stageDir: stageDir, installDir: installDir, uiType: uiType, replaceAll: replaceAll} if err := installStaged(stageDir); err != nil { return nil, errors.Join(fmt.Errorf("stage %s update: %w", uiType, err), prepared.Close()) } if err := cr.Context().Err(); err != nil { return nil, errors.Join(fmt.Errorf("%s update cancelled before commit: %w", uiType, err), prepared.Close()) } return prepared, nil } // Commit reads the latest live configs under editor locks, then replaces the // UI files and version metadata in one recoverable transaction. func (p *PreparedUI) Commit(cr *CommandRunner) error { if p == nil || p.stageDir == "" || p.attempted { return fmt.Errorf("UI preparation is closed or already consumed") } p.attempted = true i, stageDir, installDir, uiType, replaceAll := p.installer, p.stageDir, p.installDir, p.uiType, p.replaceAll if err := cr.Context().Err(); err != nil { return err } if err := RecoverInstallerTransactions(installDir); err != nil { return fmt.Errorf("recover interrupted UI transaction: %w", err) } for _, relative := range managedUIPaths { if err := validateOverlayRecoveryParents(installDir, filepath.Join(installDir, relative)); err != nil { return fmt.Errorf("unsafe UI configuration parent: %w", err) } } return withUIConfigLocks(installDir, func() error { var snapshot *uiConfigSnapshot if relative := uiConfigRelativePath(uiType); relative != "" { var err error snapshot, err = readUIConfigSnapshot(filepath.Join(installDir, relative)) if err != nil { return err } if err := snapshot.writeTo(filepath.Join(stageDir, relative)); err != nil { return err } } paths := stagedUIPaths(uiType) if replaceAll { // Edit the staged mpv.conf, avoiding reentrant acquisition of the // live editor lock and retaining every unrelated user setting. mpvConfig, err := readUIConfigSnapshot(filepath.Join(installDir, constants.MPVConfigFileName)) if err != nil { return err } stagedConfig := filepath.Join(stageDir, constants.MPVConfigFileName) if err := mpvConfig.writeTo(stagedConfig); err != nil { return err } osc := "yes" if uiType == constants.UITypeModernZ { osc = "no" } if err := i.SetOSCConfig(cr, stagedConfig, osc); err != nil { return err } paths = append(paths, constants.MPVConfigFileName) } if err := cr.BeginCommit(); err != nil { return err } transaction, err := beginUITransaction(i.fileSystem(), installDir) if err != nil { return fmt.Errorf("prepare UI update rollback: %w", err) } defer transaction.journal.retain() backupPath, err := snapshot.createDurableBackup() if err != nil { return errors.Join(err, transaction.rollback()) } if backupPath != "" { cr.outputChan <- "Backed up existing UI config: " + backupPath } commitErr := func() error { if replaceAll { if err := RemoveAllUIFiles(installDir); err != nil { return err } } else if err := RemoveUIFiles(installDir, uiType); err != nil { return err } if err := RemoveThumbfast(installDir); err != nil && !os.IsNotExist(err) { return err } for _, relative := range paths { source := filepath.Join(stageDir, relative) if _, err := os.Lstat(source); os.IsNotExist(err) { continue } else if err != nil { return err } if err := copyInstallerPath(i.fileSystem(), source, filepath.Join(installDir, relative), false); err != nil { return fmt.Errorf("install staged UI path %q: %w", relative, err) } } versions := map[string]string{} if replaceAll { versions[constants.UITypeUOSC], versions[constants.UITypeModernZ] = "", "" } switch uiType { case constants.UITypeUOSC: versions[uiType] = i.ReleaseInfo.UOSC.AppVersion case constants.UITypeModernZ: versions[uiType] = i.ReleaseInfo.ModernZ.AppVersion } return transaction.journal.publishUIVersions(versions) }() if commitErr != nil { return errors.Join(commitErr, transaction.rollback()) } if err := transaction.commit(); err != nil { return err } if snapshot != nil { cr.outputChan <- "Preserved existing " + uiDisplayName(uiType) + " configuration" } return nil }) } // Each editor owns one of these locks. UI replacement/recovery takes all three // in this fixed order; downloads never hold them. func withUIConfigLocks(configDir string, fn func() error) error { return fileops.WithLock(filepath.Join(configDir, constants.MPVConfigFileName), func() error { return fileops.WithLock(filepath.Join(configDir, constants.ScriptOptsDir, "modernz.conf"), func() error { return fileops.WithLock(filepath.Join(configDir, constants.ScriptOptsDir, "uosc.conf"), fn) }) }) } func uiDisplayName(uiType string) string { if uiType == constants.UITypeModernZ { return "ModernZ" } return "uOSC" } func baselineFileName(uiType, version string) string { version = strings.TrimSpace(version) if version == "" { version = "unknown" } version = strings.Map(func(r rune) rune { if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '.' || r == '-' || r == '_' { return r } return '-' }, version) return uiType + "-" + version + ".conf" } // storeUIConfigBaseline retains the verified clean template used by an // installation. Future migrations can compare it with the user's file rather // than guessing which values were customized. func storeUIConfigBaseline(installDir, uiType, version, configPath string) error { content, err := os.ReadFile(configPath) if err != nil { return err } baselinePath := filepath.Join(installDir, ".mpv-manager", "ui-baselines", baselineFileName(uiType, version)) if err := os.MkdirAll(filepath.Dir(baselinePath), constants.DirPermission); err != nil { return err } return fileops.AtomicWrite(baselinePath, content, constants.FilePermission) }