// Package uiconfig provides UI-independent discovery and migration helpers for // mpv on-screen controller configuration. package uiconfig import ( "errors" "fmt" "os" "path/filepath" "gitgud.io/mike/mpv-manager/internal/fileops" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/modernzconf" "gitgud.io/mike/mpv-manager/pkg/uoscconf" ) const ( // MigrationModernZScalingV1 identifies the one-time decision introduced // when managed ModernZ installs switched to resolution-independent sizing. MigrationModernZScalingV1 = "modernz-scaling-v1" MigrationActionApply = "apply" MigrationActionKeep = "keep" ) // Warning describes an invalid value in one of the curated settings. Missing // settings are valid because each UI supplies its own defaults. type Warning struct { Key string `json:"key"` Value string `json:"value"` Message string `json:"message"` } // Migration is an actionable, persistent configuration recommendation. type Migration struct { ID string `json:"id"` UI string `json:"ui"` Title string `json:"title"` Message string `json:"message"` CurrentValue string `json:"current_value"` RecommendedValue string `json:"recommended_value"` } // Status contains the active UI's compatibility warnings and any pending // one-time migration. type Status struct { UI string `json:"ui"` Warnings []Warning `json:"warnings"` Migration *Migration `json:"migration,omitempty"` } // ConfigDir returns the default mpv configuration directory used by the Web // and TUI settings surfaces. func ConfigDir() (string, error) { dir := config.GetMPVConfigPath() if dir == "" { return "", fmt.Errorf("MPV config directory is empty") } return dir, nil } // DetectInstalledUI returns modernz, uosc, none, or ambiguous. On-disk scripts take // precedence over legacy manager metadata; uOSC requires on-disk evidence // because old configurations used it as an implicit default. func DetectInstalledUI() string { configDir, err := ConfigDir() if err == nil { modernZScript := pathExists(filepath.Join(configDir, constants.ScriptsDir, "modernz.lua")) uoscScript := pathExists(filepath.Join(configDir, constants.ScriptsDir, "uosc.lua")) || pathExists(filepath.Join(configDir, constants.ScriptsDir, "uosc", "main.lua")) if modernZScript && uoscScript { return constants.UITypeAmbiguous } if modernZScript { return constants.UITypeModernZ } if uoscScript || pathExists(filepath.Join(configDir, constants.ScriptOptsDir, "uosc.conf")) { return constants.UITypeUOSC } } if config.GetInstalledUIType() == constants.UITypeModernZ { return constants.UITypeModernZ } return constants.UITypeNone } // Inspect evaluates the active UI without modifying its configuration. func Inspect() (Status, error) { ui := DetectInstalledUI() status := Status{UI: ui, Warnings: []Warning{}} switch ui { case constants.UITypeAmbiguous: status.Warnings = append(status.Warnings, Warning{ Key: "ui", Value: constants.UITypeModernZ + "," + constants.UITypeUOSC, Message: "Both ModernZ and uOSC scripts are installed. Choose Change MPV UI to keep one overlay before editing UI-specific settings.", }) case constants.UITypeModernZ: path, err := modernzconf.GetModernZConfPath() if err != nil { return status, err } conf, err := modernzconf.ParseModernZConf(path) if err != nil { return status, err } for _, option := range modernzconf.KnownOptions() { value := conf.GetValue(option.Key) if err := modernzconf.ValidateValue(option.Key, value); err != nil { status.Warnings = append(status.Warnings, Warning{Key: option.Key, Value: value, Message: err.Error()}) } } if config.GetUIConfigMigrationResolution(MigrationModernZScalingV1) == "" && conf.GetValue("vidscale") == "auto" { status.Migration = &Migration{ ID: MigrationModernZScalingV1, UI: constants.UITypeModernZ, Title: "Review ModernZ scaling", Message: "ModernZ currently follows mpv's window scaling. Consistent size prevents controls from shrinking in small windows or growing excessively at 4K.", CurrentValue: "auto", RecommendedValue: modernzconf.ManagedVidscaleDefault, } } case constants.UITypeUOSC: path, err := uoscconf.GetUOSCConfPath() if err != nil { return status, err } conf, err := uoscconf.ParseUOSCConf(path) if err != nil { return status, err } for _, option := range uoscconf.KnownOptions() { value := conf.GetValue(option.Key) if err := uoscconf.ValidateValue(option.Key, value); err != nil { status.Warnings = append(status.Warnings, Warning{Key: option.Key, Value: value, Message: err.Error()}) } } } return status, nil } // ResolveMigration applies or dismisses a pending migration and records the // decision only after any requested file change succeeds. expectedValue optionally // matches the last persisted scaling value on the settings page; empty requires // a currently pending recommendation. func ResolveMigration(migrationID, action, expectedValue string) error { if migrationID != MigrationModernZScalingV1 { return fmt.Errorf("unknown UI config migration %q", migrationID) } if action != MigrationActionApply && action != MigrationActionKeep { return fmt.Errorf("unknown UI config migration action %q", action) } if err := RecoverMigrationTransactions(); err != nil { return fmt.Errorf("recover prior UI config migration: %w", err) } path, err := modernzconf.GetModernZConfPath() if err != nil { return err } return fileops.WithLock(path, func() error { if resolution := config.GetUIConfigMigrationResolution(migrationID); resolution != "" { if resolution == action { return nil } return fmt.Errorf("UI config migration %q was already resolved", migrationID) } status, err := Inspect() if err != nil { return err } if status.UI != constants.UITypeModernZ { return fmt.Errorf("ModernZ is no longer the selected UI") } if expectedValue != "" { // A settings-page decision can follow an earlier queued row save. // Compare with the value that page last persisted, under the file lock, // so another editor's intervening change is never overwritten. conf, err := modernzconf.ParseModernZConf(path) if err != nil { return err } if conf.GetValue("vidscale") != expectedValue { return fmt.Errorf("ModernZ scaling changed; refresh settings before applying this decision") } } else if status.Migration == nil || status.Migration.ID != migrationID { return fmt.Errorf("UI config migration %q is not pending", migrationID) } if action == MigrationActionKeep { return config.SetUIConfigMigrationResolution(migrationID, action) } journal, err := captureMigrationJournal(path, action) if err != nil { return err } if err := writeMigrationJournal(path, journal); err != nil { return fmt.Errorf("record ModernZ migration intent: %w", err) } if err := modernzconf.SetValueFileLocked(path, "vidscale", modernzconf.ManagedVidscaleDefault); err != nil { // Atomic publication can succeed before a final durability check // fails. Retire intent only after confirming or restoring its original // snapshot; an ambiguous later edit must retain the journal. rollbackErr := restoreMigrationOriginal(path, journal) if rollbackErr == nil { rollbackErr = fileops.RemoveDurable(migrationJournalPath(path)) } return errors.Join(fmt.Errorf("apply ModernZ scaling recommendation: %w", err), rollbackErr) } if err := config.SetUIConfigMigrationResolution(migrationID, action); err != nil { rollbackErr := restoreMigrationOriginal(path, journal) if rollbackErr == nil { rollbackErr = fileops.RemoveDurable(migrationJournalPath(path)) } return errors.Join(fmt.Errorf("persist ModernZ migration resolution: %w", err), rollbackErr) } if err := fileops.RemoveDurable(migrationJournalPath(path)); err != nil { return fmt.Errorf("migration committed; intent cleanup is pending: %w", err) } return nil }) } func pathExists(path string) bool { _, err := os.Stat(path) return err == nil }