package config import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "os" pathpkg "path" "path/filepath" "runtime" "strings" "sync" "time" "gitgud.io/mike/mpv-manager/internal/fileops" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/log" ) type Config struct { LastCheck string `json:"last_check"` AutoUpdate bool `json:"auto_update"` InstalledApps []InstalledApp `json:"installed_apps"` ManagerInPATH bool `json:"manager_in_path"` ManagerBinPath string `json:"manager_bin_path"` InstallPath string `json:"install_path"` MPVConfigPath string `json:"mpv_config_path"` UOSCVersion string `json:"uosc_version"` ModernZVersion string `json:"modernz_version"` FFmpegVersion string `json:"ffmpeg_version"` CurrentUIType string `json:"current_ui_type"` InstalledUIType string `json:"installed_ui_type,omitempty"` UIConfigMigrations map[string]string `json:"ui_config_migrations,omitempty"` CreateManagerShortcut bool `json:"create_manager_shortcut"` HWADecoderPreference string `json:"hwa_decoder_preference"` } type InstalledApp struct { ID string `json:"id,omitempty"` AppName string `json:"app_name"` AppType string `json:"app_type"` InstallMethod string `json:"install_method"` InstallPath string `json:"install_path"` InstalledDate string `json:"installed_date"` AppVersion string `json:"app_version"` UpdateAvailable bool `json:"update_available"` UIType string `json:"ui_type,omitempty"` // "uosc", "modernz", or empty Managed bool `json:"managed"` // true if installed/configured by MPV Manager, false if externally detected } var ( // ErrInstalledAppNotFound indicates that no installed-app record matched a // supplied stable ID or legacy identity fields. ErrInstalledAppNotFound = errors.New("installed app not found") // ErrInstalledAppAmbiguous indicates that legacy fields matched more than // one installed-app record and an ID is required for a safe mutation. ErrInstalledAppAmbiguous = errors.New("installed app identity is ambiguous") ) var ( globalConfig *Config // globalLoadErr keeps a failed read from being mistaken for an absent // config. Accessors may use the last known-good/default snapshot, but every // mutator is blocked until a subsequent load succeeds. globalLoadErr error // globalMu guards globalConfig. Web handlers spawn many goroutines that // read and mutate this state concurrently, so every exported accessor // must go through this mutex. globalMu sync.RWMutex ) // ensureLoaded loads the global config if it hasn't been loaded yet. func ensureLoaded() { globalMu.RLock() loaded := globalConfig != nil && globalLoadErr == nil globalMu.RUnlock() if !loaded { _, _ = Load() } } func newDefaultConfig() *Config { return &Config{ AutoUpdate: false, InstalledApps: []InstalledApp{}, UIConfigMigrations: map[string]string{}, } } func Load() (*Config, error) { globalMu.Lock() defer globalMu.Unlock() if globalConfig != nil && globalLoadErr == nil { return cloneConfig(globalConfig), nil } release, lockErr := fileops.AcquireAdvisory(managerConfigCoordinationPath()) if lockErr != nil { lockErr = managerCoordinationError(lockErr) if globalConfig == nil { globalConfig = newDefaultConfig() } globalLoadErr = lockErr return cloneConfig(globalConfig), lockErr } defer release() configPath, err := resolveConfigPathForLoad() if err != nil { if globalConfig == nil { globalConfig = newDefaultConfig() } globalLoadErr = err return cloneConfig(globalConfig), err } config := newDefaultConfig() if data, readErr := os.ReadFile(configPath); readErr == nil { parseErr := json.Unmarshal(data, config) if parseErr == nil { // Ensure InstalledApps is initialized (in case old config files don't have it) if config.InstalledApps == nil { config.InstalledApps = []InstalledApp{} } if config.UIConfigMigrations == nil { config.UIConfigMigrations = map[string]string{} } // Migrate apps without "managed" field - they were installed by MPV Manager // before this field existed, so they should be marked as managed needsSave := migrateManagedField(config, data) if migrateInstalledAppIdentity(config) { needsSave = true } // Save if we made any migrations if needsSave { if err := saveConfigLocked(config); err != nil { globalLoadErr = fmt.Errorf("persist config migration: %w", err) if globalConfig == nil { globalConfig = newDefaultConfig() } return cloneConfig(globalConfig), globalLoadErr } log.Info("Migrated installed-app metadata") } globalConfig = config globalLoadErr = nil return cloneConfig(config), nil } if globalConfig != nil { globalLoadErr = fmt.Errorf("parse config %s: %w", configPath, parseErr) return cloneConfig(globalConfig), globalLoadErr } // Corrupt config file: quarantine it aside instead of overwriting it // with defaults, so the original content is never silently destroyed. log.Error(fmt.Sprintf("Failed to parse config file %s: %v", configPath, parseErr)) quarantineCorruptConfig(configPath) } else if !os.IsNotExist(readErr) { loadErr := fmt.Errorf("read config %s: %w", configPath, readErr) if globalConfig == nil { globalConfig = newDefaultConfig() } globalLoadErr = loadErr return cloneConfig(globalConfig), loadErr } // Start fresh defaults in memory (no config file, unreadable file, or a // corrupt file that was just quarantined) globalConfig = config globalLoadErr = nil // Create config directory configDir := filepath.Dir(configPath) if err := os.MkdirAll(configDir, 0755); err != nil { log.Info(fmt.Sprintf("Failed to create config directory: %s", err)) } // Save default config to file if err := saveConfigLocked(config); err != nil { globalLoadErr = fmt.Errorf("create default config: %w", err) return cloneConfig(globalConfig), globalLoadErr } log.Info(fmt.Sprintf("Created default config file: %s", configPath)) return cloneConfig(globalConfig), nil } // migrateManagedField checks for apps without the "managed" field in the raw JSON // and sets Managed=true for them. This handles migration from older versions. // Returns true if any changes were made. func migrateManagedField(config *Config, rawData []byte) bool { // Parse raw JSON to check which apps have "managed" field var rawConfig struct { InstalledApps []map[string]interface{} `json:"installed_apps"` } if err := json.Unmarshal(rawData, &rawConfig); err != nil { log.Debug(fmt.Sprintf("Could not parse raw config for migration: %v", err)) return false } if len(rawConfig.InstalledApps) == 0 { return false } // Update apps that don't have the "managed" field changed := false for i := range config.InstalledApps { // Match raw records by index. Install methods are not unique: multiple // records may legitimately use different paths. hasManaged := i < len(rawConfig.InstalledApps) if hasManaged { _, hasManaged = rawConfig.InstalledApps[i]["managed"] } if !hasManaged { if !config.InstalledApps[i].Managed { config.InstalledApps[i].Managed = true changed = true log.Info(fmt.Sprintf("Migrating app %s: setting managed=true (pre-existing install)", config.InstalledApps[i].InstallMethod)) } } } return changed } // normalizeInstallPathForIdentity performs lexical normalization only. It does // not resolve symlinks or require the path to exist, so an app keeps the same // identity while temporarily unavailable. Windows paths are normalized // case-insensitively even when a config is inspected on another platform. func normalizeInstallPathForIdentity(installPath string) string { installPath = strings.TrimSpace(installPath) if installPath == "" { return "" } isWindowsPath := runtime.GOOS == "windows" || (len(installPath) >= 2 && installPath[1] == ':') || strings.HasPrefix(installPath, `\\`) if isWindowsPath { normalized := strings.ReplaceAll(installPath, `\`, "/") return strings.ToLower(pathpkg.Clean(normalized)) } return filepath.Clean(installPath) } // installedAppID returns a deterministic stable ID. Current app records are // identified by install method plus normalized path. Very old records without // a method fall back to their display name so they remain independently usable. func installedAppID(app InstalledApp) string { method := strings.ToLower(strings.TrimSpace(app.InstallMethod)) identity := method + "\x00" + normalizeInstallPathForIdentity(app.InstallPath) if method == "" { identity = "legacy\x00" + strings.ToLower(strings.TrimSpace(app.AppName)) + "\x00" + normalizeInstallPathForIdentity(app.InstallPath) } digest := sha256.Sum256([]byte(identity)) return "app-" + hex.EncodeToString(digest[:]) } // migrateInstalledAppIdentity assigns stable IDs to legacy records and // collapses duplicate method/path records. If duplicates exist, the later // record wins because AddInstalledApp historically appended the newest state. func migrateInstalledAppIdentity(config *Config) bool { changed := false migrated := make([]InstalledApp, 0, len(config.InstalledApps)) indexByID := make(map[string]int, len(config.InstalledApps)) for _, app := range config.InstalledApps { id := installedAppID(app) if app.ID != id { app.ID = id changed = true } if existingIndex, exists := indexByID[id]; exists { migrated[existingIndex] = app changed = true continue } indexByID[id] = len(migrated) migrated = append(migrated, app) } config.InstalledApps = migrated return changed } func Reload() (*Config, error) { globalMu.Lock() defer globalMu.Unlock() release, lockErr := fileops.AcquireAdvisory(managerConfigCoordinationPath()) if lockErr != nil { lockErr = managerCoordinationError(lockErr) globalLoadErr = lockErr if globalConfig == nil { globalConfig = newDefaultConfig() } return cloneConfig(globalConfig), lockErr } defer release() previous := cloneConfig(globalConfig) configPath, err := resolveConfigPathForLoad() if err != nil { globalLoadErr = err if previous == nil { previous = newDefaultConfig() globalConfig = cloneConfig(previous) } return previous, err } config := newDefaultConfig() data, err := os.ReadFile(configPath) if os.IsNotExist(err) { log.Info(fmt.Sprintf("Config file not found, using defaults: %s", configPath)) globalConfig = config globalLoadErr = nil return cloneConfig(config), nil } if err != nil { globalLoadErr = fmt.Errorf("read config %s: %w", configPath, err) if previous == nil { previous = newDefaultConfig() globalConfig = cloneConfig(previous) } return previous, globalLoadErr } if err := json.Unmarshal(data, config); err != nil { log.Error(fmt.Sprintf("Failed to parse config file: %v", err)) if previous != nil { globalConfig = previous globalLoadErr = fmt.Errorf("parse config %s: %w", configPath, err) return cloneConfig(previous), globalLoadErr } quarantineCorruptConfig(configPath) globalConfig = config globalLoadErr = nil if saveErr := saveConfigLocked(config); saveErr != nil { globalLoadErr = fmt.Errorf("save defaults after corrupt config: %w", saveErr) return cloneConfig(config), globalLoadErr } return cloneConfig(config), nil } // Apply installed-app migrations and persist them so callers always receive // stable IDs after Reload as well as after the initial Load. needsSave := migrateManagedField(config, data) if migrateInstalledAppIdentity(config) { needsSave = true } if needsSave { if err := saveConfigLocked(config); err != nil { globalLoadErr = fmt.Errorf("persist config migration after reload: %w", err) if previous != nil { globalConfig = previous return cloneConfig(previous), globalLoadErr } globalConfig = newDefaultConfig() return cloneConfig(globalConfig), globalLoadErr } } globalConfig = config globalLoadErr = nil log.Info(fmt.Sprintf("Config reloaded from: %s", configPath)) return cloneConfig(config), nil } func cloneConfig(config *Config) *Config { if config == nil { return nil } clone := *config if config.InstalledApps != nil { clone.InstalledApps = make([]InstalledApp, len(config.InstalledApps)) copy(clone.InstalledApps, config.InstalledApps) } if config.UIConfigMigrations != nil { clone.UIConfigMigrations = make(map[string]string, len(config.UIConfigMigrations)) for migrationID, resolution := range config.UIConfigMigrations { clone.UIConfigMigrations[migrationID] = resolution } } return &clone } func quarantineCorruptConfig(configPath string) { quarantinePath := fmt.Sprintf("%s.corrupt-%s", configPath, time.Now().Format("20060102-150405.000000000")) if err := fileops.RenameDurable(configPath, quarantinePath); err != nil { log.Error(fmt.Sprintf("Failed to quarantine corrupt config file %s: %v", configPath, err)) return } log.Error(fmt.Sprintf("Corrupt config file quarantined to: %s", quarantinePath)) } func Save() error { globalMu.RLock() loaded := globalConfig != nil globalMu.RUnlock() if !loaded { return fmt.Errorf("config not loaded") } return Update(func(*Config) error { return nil }) } // Update applies a mutation to an isolated candidate while holding the // configuration lock. The candidate is published to readers only after its // atomic persistence succeeds, so a rejected mutation can never leak into a // later unrelated save. func Update(mutator func(*Config) error) error { if mutator == nil { return fmt.Errorf("config mutator is nil") } ensureLoaded() globalMu.Lock() defer globalMu.Unlock() if globalLoadErr != nil { return fmt.Errorf("config mutation blocked after a failed load: %w", globalLoadErr) } release, err := fileops.AcquireAdvisory(managerConfigCoordinationPath()) if err != nil { return managerCoordinationError(err) } defer release() configPath, err := resolveConfigPathForLoad() if err != nil { globalLoadErr = err return err } latest, err := readConfigSnapshot(configPath) if err != nil { globalLoadErr = fmt.Errorf("reload config before mutation: %w", err) return globalLoadErr } candidate := cloneConfig(latest) if err := mutator(candidate); err != nil { globalConfig = latest return err } if err := saveConfigLocked(candidate); err != nil { return err } globalConfig = candidate globalLoadErr = nil return nil } func managerConfigCoordinationPath() string { // Keep the persistent advisory-lock inode outside the MPV config directory. // Install/reset flows legitimately replace or remove that directory; placing // the lock inside it both prevented those operations and could split one // logical lock across the old and newly-created directory trees. configDir := filepath.Dir(getDefaultConfigPath()) return filepath.Join(filepath.Dir(configDir), constants.ConfigCoordinationName) } func managerCoordinationError(err error) error { return fmt.Errorf("failed to create config directory or acquire coordination lock: %w", err) } func readConfigSnapshot(configPath string) (*Config, error) { data, err := os.ReadFile(configPath) if os.IsNotExist(err) { return newDefaultConfig(), nil } if err != nil { return nil, fmt.Errorf("read config %s: %w", configPath, err) } config := newDefaultConfig() if err := json.Unmarshal(data, config); err != nil { return nil, fmt.Errorf("parse config %s: %w", configPath, err) } if config.InstalledApps == nil { config.InstalledApps = []InstalledApp{} } if config.UIConfigMigrations == nil { config.UIConfigMigrations = map[string]string{} } _ = migrateManagedField(config, data) _ = migrateInstalledAppIdentity(config) return config, nil } // saveConfigLocked persists a snapshot without publishing it. Callers must // hold globalMu so competing updates cannot derive candidates from stale // state while this write is in flight. func saveConfigLocked(config *Config) error { if config == nil { return fmt.Errorf("config not loaded") } configPath := resolveConfigPathFor(config) configDir := filepath.Dir(configPath) if err := os.MkdirAll(configDir, 0755); err != nil { log.Error(fmt.Sprintf("Failed to create config directory: %s", err)) return err } data, err := json.MarshalIndent(config, "", " ") if err != nil { log.Error(fmt.Sprintf("Failed to marshal config: %s", err)) return err } if runtime.GOOS == "windows" && normalizeInstallPathForIdentity(getDefaultConfigPath()) != normalizeInstallPathForIdentity(configPath) { return publishWindowsConfig(getDefaultConfigPath(), configPath, config) } if err := writeFileAtomically(configPath, data); err != nil { return err } log.Info(fmt.Sprintf("Config file saved to: %s", configPath)) return nil } func SetInstallPath(path string) error { if path == "" { return nil } if err := Update(func(config *Config) error { config.InstallPath = path return nil }); err != nil { return fmt.Errorf("persist install path: %w", err) } log.Info(fmt.Sprintf("Setting install_path: %s", path)) return nil } // ClearInstallPath removes any custom install path, restoring the platform default. func ClearInstallPath() error { if err := Update(func(config *Config) error { config.InstallPath = "" return nil }); err != nil { return fmt.Errorf("persist default install path: %w", err) } log.Info("Clearing install_path (reset to default)") return nil } func GetInstallPath() string { ensureLoaded() globalMu.RLock() defer globalMu.RUnlock() if globalConfig.InstallPath != "" { return globalConfig.InstallPath } return getDefaultInstallPath() } // GetConfiguredInstallPath returns the explicitly configured custom install // path, or "" when none is set. Unlike GetInstallPath it does not fall back // to the platform default. func GetConfiguredInstallPath() string { ensureLoaded() globalMu.RLock() defer globalMu.RUnlock() return globalConfig.InstallPath } func SetUOSCVersion(version string) error { if err := Update(func(config *Config) error { config.UOSCVersion = version return nil }); err != nil { return fmt.Errorf("persist uosc version: %w", err) } return nil } func GetUOSCVersion() string { ensureLoaded() globalMu.RLock() defer globalMu.RUnlock() return globalConfig.UOSCVersion } func SetModernZVersion(version string) error { return Update(func(config *Config) error { config.ModernZVersion = version return nil }) } func GetModernZVersion() string { ensureLoaded() globalMu.RLock() defer globalMu.RUnlock() return globalConfig.ModernZVersion } func SetFFmpegVersion(version string) error { return Update(func(config *Config) error { config.FFmpegVersion = version return nil }) } func GetFFmpegVersion() string { ensureLoaded() globalMu.RLock() defer globalMu.RUnlock() return globalConfig.FFmpegVersion } // SetInstalledUIType saves the UI type that was chosen at install time so // that updates can reinstall the same UI instead of assuming one. func SetInstalledUIType(uiType string) error { return Update(func(config *Config) error { config.InstalledUIType = uiType return nil }) } // GetInstalledUIType returns the UI type chosen at install time. // Defaults to uOSC when no UI type was ever saved. func GetInstalledUIType() string { ensureLoaded() globalMu.RLock() defer globalMu.RUnlock() if globalConfig.InstalledUIType == "" { return constants.UITypeUOSC } return globalConfig.InstalledUIType } // SetUIConfigMigrationResolution records a durable user decision for a // versioned UI configuration migration. Callers use stable migration IDs so a // dismissed or applied recommendation is not shown again after restart. func SetUIConfigMigrationResolution(migrationID, resolution string) error { migrationID = strings.TrimSpace(migrationID) resolution = strings.TrimSpace(resolution) if migrationID == "" { return fmt.Errorf("UI config migration ID is empty") } if resolution == "" { return fmt.Errorf("UI config migration resolution is empty") } return Update(func(cfg *Config) error { if cfg.UIConfigMigrations == nil { cfg.UIConfigMigrations = make(map[string]string) } if existing := cfg.UIConfigMigrations[migrationID]; existing != "" && existing != resolution { return fmt.Errorf("UI config migration %q was already resolved as %q", migrationID, existing) } cfg.UIConfigMigrations[migrationID] = resolution return nil }) } // GetUIConfigMigrationResolution returns a previously persisted migration // decision, or an empty string when the migration still needs consideration. func GetUIConfigMigrationResolution(migrationID string) string { ensureLoaded() globalMu.RLock() defer globalMu.RUnlock() return globalConfig.UIConfigMigrations[migrationID] } // resolveConfigPath is the single config path resolution used by both Load // and Save, so a custom install path cannot silently reset the config each // run. Callers must hold globalMu. func resolveConfigPath() string { return resolveConfigPathFor(globalConfig) } // resolveConfigPathForLoad resolves the authoritative manager-config file // without converting permission or transient I/O failures into "not found". // On Windows the default locator also recovers an interrupted publication // before selecting a custom portable destination; malformed JSON is returned as the default file itself so the // normal corruption quarantine path can retain its bytes. func resolveConfigPathForLoad() (string, error) { if runtime.GOOS != "windows" { path, err := constants.GetInstallerConfigFilePathWithHome() if err != nil { return "", fmt.Errorf("resolve manager config path: %w", err) } return path, nil } defaultPath := getDefaultConfigPath() data, err := os.ReadFile(defaultPath) if os.IsNotExist(err) { return defaultPath, nil } if err != nil { return "", fmt.Errorf("read Windows config locator %s: %w", defaultPath, err) } var partial windowsConfigLocator if err := json.Unmarshal(data, &partial); err != nil { return defaultPath, nil } if partial.Pending != nil { if err := finishWindowsConfigPublication(defaultPath, &partial); err != nil { return "", fmt.Errorf("recover Windows config publication: %w", err) } } if strings.TrimSpace(partial.InstallPath) == "" { return defaultPath, nil } return filepath.Join(partial.InstallPath, constants.PortableConfigDir, constants.ConfigFileName), nil } func resolveConfigPathFor(config *Config) string { // On Windows, the config lives in /portable_config if runtime.GOOS == "windows" { installPath := "" if config != nil { installPath = config.InstallPath } if installPath == "" && globalConfig == nil { installPath = readInstallPathFromFile(getDefaultConfigPath()) } if installPath == "" { installPath = getDefaultInstallPath() } return filepath.Join(installPath, constants.PortableConfigDir, constants.ConfigFileName) } // On Linux/macOS, use constants helper (respects XDG on Linux) path, err := constants.GetInstallerConfigFilePathWithHome() if err != nil { return filepath.Join(".", fmt.Sprintf(".%s", constants.ConfigFileName)) } return path } func getDefaultConfigPath() string { // On Windows, default to portable config location in detected MPV directory if runtime.GOOS == "windows" { return filepath.Join(constants.GetWindowsMPVBaseDir(), "portable_config", constants.ConfigFileName) } // On Linux/macOS, use constants helper (respects XDG on Linux) path, err := constants.GetInstallerConfigFilePathWithHome() if err != nil { return filepath.Join(".", constants.ConfigFileName) } return path } func getDefaultInstallPath() string { // On Windows, use detected MPV directory (AppData for fresh installs, legacy for existing) if runtime.GOOS == "windows" { return constants.GetWindowsMPVBaseDir() } // On Linux/macOS, use home directory homeDir, err := os.UserHomeDir() if err != nil { return filepath.Join(".", "mpv") } return filepath.Join(homeDir, "mpv") } func GetMPVConfigPath() string { ensureLoaded() globalMu.RLock() configuredConfigPath := globalConfig.MPVConfigPath configuredInstallPath := globalConfig.InstallPath globalMu.RUnlock() // Windows MPV uses portable_config beside the selected executable. Derive // this path from the effective install destination every time so changing a // custom install path cannot leave a stale cached config directory behind. if runtime.GOOS == "windows" { installPath := configuredInstallPath if installPath == "" { installPath = getDefaultInstallPath() } return resolveActiveMPVConfigDir(runtime.GOOS, installPath, "") } if configuredConfigPath != "" { return configuredConfigPath } // Do not cache the environment-derived default in globalConfig. Apart from // keeping tests isolated when HOME/XDG change, this prevents a transient // environment from being persisted later as if it were an explicit user // choice. path, err := constants.GetMPVConfigPathWithHome() if err != nil { homeDir, _ := os.UserHomeDir() return filepath.Join(homeDir, ".config", "mpv") } return resolveActiveMPVConfigDir(runtime.GOOS, "", path) } // GetMPVConfigFilePath returns the active mpv.conf path used by all editors, // Web/TUI settings, hotkeys, UI scripts, installers, and backup operations. func GetMPVConfigFilePath() (string, error) { dir := GetMPVConfigPath() if strings.TrimSpace(dir) == "" { return "", fmt.Errorf("MPV config directory is empty") } return filepath.Join(dir, constants.MPVConfigFileName), nil } // GetMPVConfigBackupDir returns the backup directory beside the active // mpv.conf, including Windows custom portable installs. func GetMPVConfigBackupDir() (string, error) { dir := GetMPVConfigPath() if strings.TrimSpace(dir) == "" { return "", fmt.Errorf("MPV config directory is empty") } return filepath.Join(dir, constants.ConfigBackupsDir), nil } func resolveActiveMPVConfigDir(goos, installPath, defaultConfigFile string) string { if goos == "windows" { return filepath.Join(installPath, constants.PortableConfigDir) } if strings.TrimSpace(defaultConfigFile) == "" { return "" } return filepath.Dir(defaultConfigFile) } func GetPortableConfigPath(mpvDir string) string { return filepath.Join(mpvDir, "portable_config") } func GetConfigDir() string { // Use constants helper which respects XDG on Linux homeDir, err := os.UserHomeDir() if err != nil { return "." } return constants.GetInstallerConfigPath(homeDir) } func SetManagerInPATH(inPath bool) error { if err := Update(func(config *Config) error { config.ManagerInPATH = inPath return nil }); err != nil { log.Warn(fmt.Sprintf("Failed to save config after setting manager_in_path: %s", err.Error())) return err } log.Info(fmt.Sprintf("Setting manager_in_path: %v", inPath)) return nil } // SetManagerPATHState persists the PATH flag and binary path as one candidate // update, so callers never publish only half of the authoritative state. func SetManagerPATHState(inPath bool, binPath string) error { if err := Update(func(config *Config) error { config.ManagerInPATH = inPath config.ManagerBinPath = binPath return nil }); err != nil { log.Warn(fmt.Sprintf("Failed to save manager PATH state: %s", err.Error())) return err } log.Info(fmt.Sprintf("Setting manager PATH state: enabled=%v, bin=%q", inPath, binPath)) return nil } func GetManagerInPATH() bool { ensureLoaded() globalMu.RLock() defer globalMu.RUnlock() return globalConfig.ManagerInPATH } func SetManagerBinPath(path string) error { if err := Update(func(config *Config) error { config.ManagerBinPath = path return nil }); err != nil { log.Warn(fmt.Sprintf("Failed to save config after setting manager_bin_path: %s", err.Error())) return err } if path == "" { log.Info("Setting manager_bin_path: (empty)") } else { log.Info(fmt.Sprintf("Setting manager_bin_path: %s", path)) } return nil } func GetManagerBinPath() string { ensureLoaded() globalMu.RLock() defer globalMu.RUnlock() return globalConfig.ManagerBinPath } func AddInstalledApp(app InstalledApp) error { app.ID = installedAppID(app) log.Info(fmt.Sprintf("Adding app to installed_apps: %s", app.AppName)) log.Info(fmt.Sprintf("App type: %s, Method: %s, ID: %s", app.AppType, app.InstallMethod, app.ID)) if err := Update(func(config *Config) error { replaced := false for i := range config.InstalledApps { if config.InstalledApps[i].ID == app.ID || installedAppID(config.InstalledApps[i]) == app.ID { config.InstalledApps[i] = app replaced = true break } } if !replaced { config.InstalledApps = append(config.InstalledApps, app) } return nil }); err != nil { log.Error(fmt.Sprintf("Failed to save installed app %s: %s", app.AppName, err.Error())) return fmt.Errorf("failed to save installed app %s: %w", app.AppName, err) } return nil } func RemoveInstalledApp(appName string) error { if err := Update(func(config *Config) error { matchingIndex := -1 for i := range config.InstalledApps { if config.InstalledApps[i].AppName != appName { continue } if matchingIndex >= 0 { return fmt.Errorf("%w: app name %q matches multiple records; use the stable app ID", ErrInstalledAppAmbiguous, appName) } matchingIndex = i } if matchingIndex < 0 { return nil } config.InstalledApps = append(config.InstalledApps[:matchingIndex], config.InstalledApps[matchingIndex+1:]...) return nil }); err != nil { log.Error(fmt.Sprintf("Failed to remove installed app %s: %s", appName, err.Error())) return fmt.Errorf("failed to remove installed app %s: %w", appName, err) } log.Info(fmt.Sprintf("Removing app from installed_apps by legacy name: %s", appName)) return nil } // RemoveInstalledAppByID removes exactly one installed-app record by its // persisted stable ID. func RemoveInstalledAppByID(appID string) error { appName := "" if err := Update(func(config *Config) error { for i := range config.InstalledApps { if config.InstalledApps[i].ID != appID { continue } appName = config.InstalledApps[i].AppName config.InstalledApps = append(config.InstalledApps[:i], config.InstalledApps[i+1:]...) return nil } return fmt.Errorf("%w: ID %s", ErrInstalledAppNotFound, appID) }); err != nil { return fmt.Errorf("failed to remove installed app %s (%s): %w", appName, appID, err) } log.Info(fmt.Sprintf("Removed installed app %s (%s)", appName, appID)) return nil } // RemoveInstalledAppIfUnchanged applies an absence observation only to the // exact record that was probed. A concurrent adoption/update/replacement wins // over the stale observation, even when it uses the same install method. func RemoveInstalledAppIfUnchanged(observed InstalledApp) (bool, error) { if observed.ID == "" { return false, nil } removed := false err := Update(func(cfg *Config) error { for index, app := range cfg.InstalledApps { if app.ID == observed.ID && app == observed { cfg.InstalledApps = append(cfg.InstalledApps[:index], cfg.InstalledApps[index+1:]...) removed = true break } } return nil }) return removed && err == nil, err } func RemoveInstalledAppByType(appType string) error { return Update(func(config *Config) error { var remaining []InstalledApp for _, app := range config.InstalledApps { if app.AppType != appType { remaining = append(remaining, app) } } config.InstalledApps = remaining return nil }) } // RemoveInstalledAppByMethod removes an installed app by its install method ID // This is useful for cleaning up stale apps that were uninstalled externally func RemoveInstalledAppByMethod(installMethod string) error { return Update(func(config *Config) error { var remaining []InstalledApp for _, app := range config.InstalledApps { if app.InstallMethod != installMethod { remaining = append(remaining, app) } } config.InstalledApps = remaining return nil }) } // GetInstalledApps returns a copy of the installed apps slice so callers // cannot mutate shared config state. func GetInstalledApps() []InstalledApp { ensureLoaded() globalMu.RLock() defer globalMu.RUnlock() apps := make([]InstalledApp, len(globalConfig.InstalledApps)) copy(apps, globalConfig.InstalledApps) return apps } // ResolveInstalledApp returns one installed app using its stable ID. For // backward-compatible API clients that do not yet send an ID, it can fall back // to method/path/name fields, but refuses an ambiguous match. func ResolveInstalledApp(appID, appName, installMethod, installPath string) (InstalledApp, error) { ensureLoaded() globalMu.RLock() defer globalMu.RUnlock() if appID != "" { for _, app := range globalConfig.InstalledApps { if app.ID == appID { return app, nil } } return InstalledApp{}, fmt.Errorf("%w: ID %s", ErrInstalledAppNotFound, appID) } method := strings.TrimSpace(installMethod) name := strings.TrimSpace(appName) pathWasProvided := strings.TrimSpace(installPath) != "" normalizedPath := normalizeInstallPathForIdentity(installPath) matches := make([]InstalledApp, 0, 1) for _, app := range globalConfig.InstalledApps { if method != "" && app.InstallMethod != method { continue } if name != "" && app.AppName != name { continue } if pathWasProvided && normalizeInstallPathForIdentity(app.InstallPath) != normalizedPath { continue } matches = append(matches, app) } if len(matches) == 1 { return matches[0], nil } if len(matches) > 1 { return InstalledApp{}, fmt.Errorf("%w: name=%q method=%q path=%q", ErrInstalledAppAmbiguous, appName, installMethod, installPath) } return InstalledApp{}, fmt.Errorf("%w: name=%q method=%q path=%q", ErrInstalledAppNotFound, appName, installMethod, installPath) } func IsAppInstalled(appName string) bool { apps := GetInstalledApps() for _, app := range apps { if app.AppName == appName { return true } } return false } func findUniqueInstalledAppIndex(apps []InstalledApp, match func(InstalledApp) bool) (int, error) { matchingIndex := -1 for i, app := range apps { if !match(app) { continue } if matchingIndex >= 0 { return -1, ErrInstalledAppAmbiguous } matchingIndex = i } if matchingIndex < 0 { return -1, ErrInstalledAppNotFound } return matchingIndex, nil } // GetCreateManagerShortcut returns whether to create desktop shortcuts func GetCreateManagerShortcut() bool { ensureLoaded() globalMu.RLock() defer globalMu.RUnlock() if globalConfig == nil { return false } return globalConfig.CreateManagerShortcut } // SetCreateManagerShortcut sets whether to create desktop shortcuts func SetCreateManagerShortcut(create bool) error { return Update(func(config *Config) error { config.CreateManagerShortcut = create return nil }) } // SetAudioLanguagePrefs sets the audio language preferences in mpv.conf func SetAudioLanguagePrefs(langs []string) error { return SetConfigValue(constants.ConfigKeyAudioLanguage, langs, true) } // GetAudioLanguagePrefs gets the audio language preferences from mpv.conf func GetAudioLanguagePrefs() []string { return GetConfigValue(constants.ConfigKeyAudioLanguage) } // SetSubtitleLanguagePrefs sets the subtitle language preferences in mpv.conf func SetSubtitleLanguagePrefs(langs []string) error { return SetConfigValue(constants.ConfigKeySubtitleLanguage, langs, true) } // GetSubtitleLanguagePrefs gets the subtitle language preferences from mpv.conf func GetSubtitleLanguagePrefs() []string { return GetConfigValue(constants.ConfigKeySubtitleLanguage) } // UpdateInstalledAppUITypeByID updates the UI type for exactly one installed // app selected by stable ID. func UpdateInstalledAppUITypeByID(appID, uiType string) error { return updateInstalledAppUIType(func(app InstalledApp) bool { return app.ID == appID }, fmt.Sprintf("app ID %q", appID), uiType) } // UpdateInstalledAppUISelectionByID persists the app-specific UI type and the // manager-wide installed UI preference in one candidate-before-publish write. func UpdateInstalledAppUISelectionByID(appID, appUIType, installedUIType string) error { var updated InstalledApp if err := Update(func(config *Config) error { index, err := findUniqueInstalledAppIndex(config.InstalledApps, func(app InstalledApp) bool { return app.ID == appID }) if err != nil { return fmt.Errorf("failed to resolve app ID %q for UI selection update: %w", appID, err) } config.InstalledApps[index].UIType = appUIType config.InstalledUIType = installedUIType updated = config.InstalledApps[index] return nil }); err != nil { log.Error(fmt.Sprintf("Failed to save UI selection for app ID %q: %s", appID, err.Error())) return err } log.Info(fmt.Sprintf("Updated UI selection for %s (%s) to %s", updated.AppName, updated.ID, installedUIType)) return nil } func updateInstalledAppUIType(match func(InstalledApp) bool, description, uiType string) error { var updated InstalledApp if err := Update(func(config *Config) error { index, err := findUniqueInstalledAppIndex(config.InstalledApps, match) if err != nil { return fmt.Errorf("failed to resolve %s for UI update: %w", description, err) } config.InstalledApps[index].UIType = uiType updated = config.InstalledApps[index] return nil }); err != nil { log.Error(fmt.Sprintf("Failed to save app UI type for %s: %s", description, err.Error())) return err } log.Info(fmt.Sprintf("Updated UI type for %s (%s) to %s", updated.AppName, updated.ID, uiType)) return nil } // UpdateInstalledAppManagedByID updates managed/UI state for exactly one // installed app selected by stable ID. func UpdateInstalledAppManagedByID(appID string, managed bool, uiType string) error { return updateInstalledAppManaged(func(app InstalledApp) bool { return app.ID == appID }, fmt.Sprintf("app ID %q", appID), managed, uiType) } // UpdateInstalledAppVersionByMethod updates a legacy method-selected TUI // operation only when that method identifies exactly one installed app. A // stable app ID is required when multiple custom-path instances coexist. func UpdateInstalledAppVersionByMethod(installMethod, appVersion string) (InstalledApp, error) { var updated InstalledApp err := Update(func(config *Config) error { index, err := findUniqueInstalledAppIndex(config.InstalledApps, func(app InstalledApp) bool { return app.InstallMethod == installMethod }) if err != nil { return err } config.InstalledApps[index].AppVersion = appVersion updated = config.InstalledApps[index] return nil }) return updated, err } // UpdateInstalledAppVersionByID updates exactly the installation selected by // its stable identity. This is required when several installations share the // same method. func UpdateInstalledAppVersionByID(appID, appVersion string) (InstalledApp, error) { var updated InstalledApp err := Update(func(config *Config) error { index, err := findUniqueInstalledAppIndex(config.InstalledApps, func(app InstalledApp) bool { return app.ID == appID }) if err != nil { return fmt.Errorf("failed to resolve app ID %q: %w", appID, err) } config.InstalledApps[index].AppVersion = appVersion updated = config.InstalledApps[index] return nil }) return updated, err } func updateInstalledAppManaged(match func(InstalledApp) bool, description string, managed bool, uiType string) error { var updated InstalledApp if err := Update(func(config *Config) error { index, err := findUniqueInstalledAppIndex(config.InstalledApps, match) if err != nil { return fmt.Errorf("failed to resolve %s: %w", description, err) } config.InstalledApps[index].Managed = managed config.InstalledApps[index].UIType = uiType updated = config.InstalledApps[index] return nil }); err != nil { log.Error(fmt.Sprintf("Failed to update %s: %s", description, err.Error())) return err } log.Info(fmt.Sprintf("Updated app %s (%s): managed=%v, uiType=%s", updated.AppName, updated.ID, managed, uiType)) return nil } // GetHWADecoderPreference returns the stored hardware decoder preference // Returns empty string if no preference is stored func GetHWADecoderPreference() string { ensureLoaded() globalMu.RLock() defer globalMu.RUnlock() return globalConfig.HWADecoderPreference } // SetHWADecoderPreference saves the hardware decoder preference to the manager config // This is stored separately from mpv.conf for backup/restore purposes func SetHWADecoderPreference(hwdec string) error { if err := Update(func(config *Config) error { config.HWADecoderPreference = hwdec return nil }); err != nil { log.Error(fmt.Sprintf("Failed to save HWA decoder preference: %s", err.Error())) return fmt.Errorf("failed to save HWA decoder preference: %w", err) } log.Info(fmt.Sprintf("Saved HWA decoder preference: %s", hwdec)) return nil } // ResetToDefaults resets the config file to default values // It creates a backup of the existing config before resetting func ResetToDefaults() error { ensureLoaded() globalMu.Lock() defer globalMu.Unlock() if globalLoadErr != nil { return fmt.Errorf("config reset blocked after a failed load: %w", globalLoadErr) } release, err := fileops.AcquireAdvisory(managerConfigCoordinationPath()) if err != nil { return managerCoordinationError(err) } defer release() configPath, err := resolveConfigPathForLoad() if err != nil { return err } latest, err := readConfigSnapshot(configPath) if err != nil { globalLoadErr = err return err } globalConfig = latest configPath = resolveConfigPath() // A reset is allowed only after a unique durable backup of the current // bytes. The live path remains present until the default snapshot is ready // for one atomic replacement. if _, err := os.Stat(configPath); err == nil { data, err := os.ReadFile(configPath) if err != nil { return fmt.Errorf("read manager config before reset: %w", err) } pattern := filepath.Base(configPath) + ".backup-" + time.Now().Format("20060102-150405.000000000") + "-*" backupPath, err := fileops.WriteUnique(filepath.Dir(configPath), pattern, data, constants.FilePermission) if err != nil { return fmt.Errorf("create manager config reset backup: %w", err) } log.Info("Created backup of config at: " + backupPath) } else if !os.IsNotExist(err) { return fmt.Errorf("inspect manager config before reset: %w", err) } newConfig := newDefaultConfig() if err := saveConfigLocked(newConfig); err != nil { log.Error(fmt.Sprintf("Failed to write default config: %s", err)) return fmt.Errorf("failed to write default config: %w", err) } // Swap in the new default only after it was written successfully, // so a failed reset never leaves globalConfig nil for other goroutines globalConfig = newConfig log.Info("Config reset to defaults successfully") return nil } // readInstallPathFromFile reads just the install_path field from a config // file. Returns "" if the file is missing, unreadable, or has no install_path. func readInstallPathFromFile(path string) string { data, err := os.ReadFile(path) if err != nil { return "" } var partial struct { InstallPath string `json:"install_path"` } if err := json.Unmarshal(data, &partial); err != nil { return "" } return partial.InstallPath } // ReconcileInstalledAppPathIfUnchanged replaces a proven legacy location only // while its ownership record is unchanged. An unmanaged duplicate observation // may be absorbed; a second managed record is ambiguous and is preserved. func ReconcileInstalledAppPathIfUnchanged(observed InstalledApp, installPath string) (bool, error) { changed := false err := Update(func(cfg *Config) error { index := -1 for i, app := range cfg.InstalledApps { if app == observed { index = i break } } if index < 0 || !observed.Managed || installPath == "" { return nil } replacement := observed replacement.InstallPath = installPath replacement.ID = installedAppID(replacement) for i, app := range cfg.InstalledApps { if i != index && app.ID == replacement.ID && app.Managed { return nil } } apps := make([]InstalledApp, 0, len(cfg.InstalledApps)) for i, app := range cfg.InstalledApps { if i == index { apps = append(apps, replacement) } else if app.ID != replacement.ID { apps = append(apps, app) } } cfg.InstalledApps = apps changed = true return nil }) return changed && err == nil, err }