package web import ( "encoding/json" "errors" "fmt" "net/http" "os" "os/exec" "path/filepath" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/installer" "gitgud.io/mike/mpv-manager/pkg/log" ) // ============================================================================ // UI Selection API Endpoints // ============================================================================ // UIChangeRequest for the change UI API type UIChangeRequest struct { AppID string `json:"app_id,omitempty"` AppName string `json:"app_name"` InstallMethod string `json:"install_method,omitempty"` InstallPath string `json:"install_path,omitempty"` UIType string `json:"ui_type"` } // handleUIOptionsAPI returns UI options for the selection modal // GET /api/ui/options?method_id=mpv-binary&app_name=MPV func (s *Server) handleUIOptionsAPI(w http.ResponseWriter, r *http.Request) { methodID := r.URL.Query().Get(constants.QueryParamMethodID) appName := r.URL.Query().Get(constants.QueryParamAppName) // Check if this method needs UI selection (MPV-based apps only) needsUI := constants.SupportsMPVOverlay(methodID) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "needs_ui_selection": needsUI, "method_id": methodID, "app_name": appName, "options": []map[string]interface{}{ { "id": constants.UITypeUOSC, "name": "UOSC", "description": "Feature-rich modern UI with touch support", "recommended": true, }, { "id": constants.UITypeModernZ, "name": "ModernZ", "description": "Classic OSC look with modern features", "recommended": false, }, { "id": constants.UITypeNone, "name": "No UI Overlay", "description": "Use MPV's default interface", "recommended": false, }, }, }) } // handleUIChangeAPI handles requests to change the UI for an installed app // POST /api/ui/change func (s *Server) handleUIChangeAPI(w http.ResponseWriter, r *http.Request) { var req UIChangeRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, http.StatusBadRequest, "Invalid request body") return } // Validate app name if req.AppID == "" && req.AppName == "" { sendJSONError(w, http.StatusBadRequest, "Missing app_name") return } // Validate UI type if req.UIType != constants.UITypeUOSC && req.UIType != constants.UITypeModernZ && req.UIType != constants.UITypeNone { sendJSONError(w, http.StatusBadRequest, "Invalid UI type. Must be 'uosc', 'modernz', or 'none'") return } // Resolve a stable identity. Legacy clients may omit app_id when their // name/method/path tuple is still unambiguous. foundApp, err := config.ResolveInstalledApp(req.AppID, req.AppName, req.InstallMethod, req.InstallPath) if err != nil { status := http.StatusNotFound if errors.Is(err, config.ErrInstalledAppAmbiguous) { status = http.StatusConflict } sendJSONError(w, status, err.Error()) return } // Check if this method supports UI selection if !constants.SupportsMPVOverlay(foundApp.InstallMethod) { sendJSONError(w, http.StatusBadRequest, "This app does not support UI customization") return } log.Info(constants.LogPrefixAPI + "UI change request received: app_name=" + foundApp.AppName + ", ui_type=" + req.UIType) // Atomically check for an existing active job and create a new one configDir := getConfigDirForApp(&foundApp) job, created := s.jobManager.CreateJobWithResources( "ui-change", foundApp.InstallMethod, foundApp.AppName, installerOperationResources(foundApp.InstallMethod, foundApp.ID, foundApp.InstallPath, configDir)..., ) if !created { log.Warn(constants.LogPrefixAPI + "Active job already exists for method: " + foundApp.InstallMethod) sendJSONError(w, http.StatusConflict, "An operation is already in progress for this app") return } // Run the UI change in a goroutine s.jobManager.StartWorker(job.ID, func() { s.runUIChangeJob(job.ID, &foundApp, req.UIType) }) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "id": job.ID, "status": "started", }) } // runUIChangeJob executes a UI change operation using the job manager system func (s *Server) runUIChangeJob(jobID string, app *config.InstalledApp, newUIType string) { // A panic must never leave the job stuck in "running" — that would // permanently block future operations for this method (HasActiveJob) defer func() { if rec := recover(); rec != nil { err := fmt.Errorf("internal panic: %v", rec) log.Error(constants.LogPrefixAPI + "UI change job panicked (" + jobID + "): " + err.Error()) s.jobManager.SetError(jobID, err, "An unexpected internal error occurred. Please try again.") } }() // Get the job to access its context job, exists := s.jobManager.GetJob(jobID) if !exists { log.Error(constants.LogPrefixAPI + "Job not found: " + jobID) return } ctx := job.Context() // Update progress to indicate starting s.jobManager.UpdateProgress(jobID, 5, "Starting UI change...") cr, relay := newJobCommandRunner(ctx, s.jobManager, jobID, 100) defer relay.Close() // Panic/early-return safety; Close is idempotent. // Check for cancellation before proceeding if job.IsCancelled() { relay.Close() s.jobManager.SetError(jobID, fmt.Errorf("UI change cancelled"), "Job was cancelled before it could start") return } // Determine old UI type (default to UOSC if not set) oldUIType := app.UIType if oldUIType == "" { oldUIType = constants.UITypeUOSC } // Get config directory for this app configDir := getConfigDirForApp(app) operationInstaller := s.installerForOperation(app.InstallPath) s.jobManager.UpdateProgress(jobID, 10, fmt.Sprintf("Changing UI from %s to %s...", oldUIType, newUIType)) s.jobManager.AddOutput(jobID, fmt.Sprintf("Current UI: %s", oldUIType)) s.jobManager.AddOutput(jobID, fmt.Sprintf("Target UI: %s", newUIType)) // Check for cancellation if job.IsCancelled() { relay.Close() s.jobManager.SetError(jobID, fmt.Errorf("UI change cancelled"), "Job was cancelled during execution") return } // Perform UI change if different from current type if oldUIType != newUIType { s.jobManager.UpdateProgress(jobID, 20, "Removing old UI files...") // Check for cancellation if job.IsCancelled() { relay.Close() s.jobManager.SetError(jobID, fmt.Errorf("UI change cancelled"), "Job was cancelled during execution") return } // Install new UI (InstallUISafely handles removing old files first) s.jobManager.UpdateProgress(jobID, 40, "Installing new UI...") if err := installer.InstallUISafely(operationInstaller, cr, configDir, newUIType); err != nil { relay.Close() s.jobManager.SetError(jobID, err, "Failed to install the requested UI; check the job output for details") log.Error(constants.LogPrefixAPI + "UI change job failed: " + err.Error()) return } s.jobManager.AddOutput(jobID, "New UI installed") } else { s.jobManager.AddOutput(jobID, "UI type unchanged, nothing to do") } relay.Close() // Update app's UI type in config uiTypeToSave := newUIType if newUIType == constants.UITypeNone { uiTypeToSave = "" } if err := config.UpdateInstalledAppUISelectionByID(app.ID, uiTypeToSave, newUIType); err != nil { s.jobManager.AddOutput(jobID, "The UI files changed, but app metadata could not be persisted: "+err.Error()) log.Error(constants.LogPrefixAPI + "Failed to update app UI type: " + err.Error()) s.jobManager.SetPartialSuccess(jobID, err, "The selected UI was changed on disk, but MPV Manager could not atomically persist the app and manager UI metadata. Fix config permissions and retry the same UI selection to reconcile state.") return } else { s.jobManager.AddOutput(jobID, "Configuration updated successfully") } // Mark job as complete s.jobManager.UpdateProgress(jobID, 100, "UI change completed successfully") s.jobManager.CompleteJob(jobID) log.Info(constants.LogPrefixAPI + "UI change job completed: " + jobID) } // getConfigDirForApp returns the config directory path for an installed app func getConfigDirForApp(app *config.InstalledApp) string { homeDir, _ := os.UserHomeDir() switch app.InstallMethod { case constants.MethodMPVBinary, constants.MethodMPVBinaryV3: // Windows portable installs use portable_config inside the install directory return filepath.Join(app.InstallPath, constants.PortableConfigDir) default: // macOS MPV.app, Flatpak, Package Manager, Brew all use ~/.config/mpv return filepath.Join(homeDir, ".config", "mpv") } } // ============================================================================ // Adopt App API Endpoints // ============================================================================ // AdoptAppRequest represents a request to adopt an externally installed app type AdoptAppRequest struct { AppID string `json:"app_id,omitempty"` AppName string `json:"app_name"` InstallMethod string `json:"install_method"` InstallPath string `json:"install_path,omitempty"` UIType string `json:"ui_type"` } // handleAdoptAppAPI handles requests to adopt an externally installed app // POST /api/apps/adopt // This configures mpv.conf, optionally installs UI overlays, and marks the app as managed func (s *Server) handleAdoptAppAPI(w http.ResponseWriter, r *http.Request) { var req AdoptAppRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, http.StatusBadRequest, "Invalid request body") return } // Validate app name if req.AppID == "" && req.AppName == "" { sendJSONError(w, http.StatusBadRequest, "Missing app_name") return } // Validate install method if req.AppID == "" && req.InstallMethod == "" { sendJSONError(w, http.StatusBadRequest, "Missing install_method") return } // Validate UI type (default to none if not provided) if req.UIType == "" { req.UIType = constants.UITypeNone } if req.UIType != constants.UITypeUOSC && req.UIType != constants.UITypeModernZ && req.UIType != constants.UITypeNone { sendJSONError(w, http.StatusBadRequest, "Invalid UI type. Must be 'uosc', 'modernz', or 'none'") return } // Resolve the exact detected record. Legacy clients remain supported when // their name/method/path fields identify one app unambiguously. foundApp, err := config.ResolveInstalledApp(req.AppID, req.AppName, req.InstallMethod, req.InstallPath) if err != nil { status := http.StatusNotFound if errors.Is(err, config.ErrInstalledAppAmbiguous) { status = http.StatusConflict } sendJSONError(w, status, err.Error()) return } // Check if already managed if foundApp.Managed { sendJSONError(w, http.StatusBadRequest, "App is already managed by MPV Manager") return } log.Info(constants.LogPrefixAPI + "Adopt app request received: app_name=" + foundApp.AppName + ", method=" + foundApp.InstallMethod + ", ui_type=" + req.UIType) // Atomically check for an existing active job and create a new one configDir := getConfigDirForApp(&foundApp) job, created := s.jobManager.CreateJobWithResources( "adopt", foundApp.InstallMethod, foundApp.AppName, installerOperationResources(foundApp.InstallMethod, foundApp.ID, foundApp.InstallPath, configDir)..., ) if !created { log.Warn(constants.LogPrefixAPI + "Active job already exists for method: " + foundApp.InstallMethod) sendJSONError(w, http.StatusConflict, "An operation is already in progress for this app") return } // Run the adoption in a goroutine s.jobManager.StartWorker(job.ID, func() { s.runAdoptAppJob(job.ID, &foundApp, req.UIType) }) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "id": job.ID, "status": "started", }) } // runAdoptAppJob executes an app adoption operation using the job manager system func (s *Server) runAdoptAppJob(jobID string, app *config.InstalledApp, uiType string) { // A panic must never leave the job stuck in "running" — that would // permanently block future operations for this method (HasActiveJob) defer func() { if rec := recover(); rec != nil { err := fmt.Errorf("internal panic: %v", rec) log.Error(constants.LogPrefixAPI + "Adoption job panicked (" + jobID + "): " + err.Error()) s.jobManager.SetError(jobID, err, "An unexpected internal error occurred. Please try again.") } }() // Get the job to access its context job, exists := s.jobManager.GetJob(jobID) if !exists { log.Error(constants.LogPrefixAPI + "Job not found: " + jobID) return } ctx := job.Context() // Update progress to indicate starting s.jobManager.UpdateProgress(jobID, 5, "Starting app adoption...") s.jobManager.AddOutput(jobID, fmt.Sprintf("Adopting %s (%s)", app.AppName, app.InstallMethod)) cr, relay := newJobCommandRunner(ctx, s.jobManager, jobID, 100) defer relay.Close() // Panic/early-return safety; Close is idempotent. // Check for cancellation before proceeding if job.IsCancelled() { relay.Close() s.jobManager.SetError(jobID, fmt.Errorf("adoption cancelled"), "Job was cancelled before it could start") return } // Get home directory homeDir, err := os.UserHomeDir() if err != nil { relay.Close() s.jobManager.SetError(jobID, err, "Failed to get home directory") return } // Resolve the app's config dir (custom-path Windows binaries use // /portable_config; others use ~/.config/mpv). configDir := getConfigDirForApp(app) operationInstaller := s.installerForOperation(app.InstallPath) var preparedUI *installer.PreparedUI if constants.SupportsMPVOverlay(app.InstallMethod) && uiType != constants.UITypeNone { s.jobManager.UpdateProgress(jobID, 10, "Downloading and verifying UI overlay...") preparedUI, err = operationInstaller.PrepareUI(cr, configDir, uiType) if err != nil { relay.Close() s.jobManager.SetError(jobID, err, "Failed to prepare the UI overlay; adoption has not changed the app") return } defer preparedUI.Close() } if err := cr.BeginCommit(); err != nil { relay.Close() s.jobManager.SetError(jobID, err, "Cancellation was acknowledged before adoption entered its commit boundary") return } // Step 1: Configure mpv.conf s.jobManager.UpdateProgress(jobID, 20, "Configuring mpv.conf...") if err := installer.InstallMPVConfigSafely(operationInstaller, cr); err != nil { relay.Close() s.jobManager.SetError(jobID, err, "Failed to install the MPV configuration") log.Error(constants.LogPrefixAPI + "Adopt job failed: " + err.Error()) return } s.jobManager.AddOutput(jobID, "mpv.conf configured") // Check for cancellation if job.IsCancelled() { relay.Close() s.jobManager.SetError(jobID, fmt.Errorf("adoption cancelled"), "Job was cancelled during execution") return } // Step 2: Configure Flatpak-specific settings if applicable if app.InstallMethod == constants.MethodMPVFlatpak { s.jobManager.UpdateProgress(jobID, 40, "Configuring Flatpak permissions...") s.jobManager.AddOutput(jobID, "Granting filesystem access to mpv config...") if err := cr.RunCommand("flatpak", "override", "--user", "--filesystem=xdg-config/mpv:ro", constants.FlatpakMPV); err != nil { s.jobManager.AddOutput(jobID, "Warning: Failed to configure Flatpak: "+err.Error()) } else { s.jobManager.AddOutput(jobID, "Flatpak permissions configured") } } else if app.InstallMethod == constants.MethodCelluloidFlatpak { s.jobManager.UpdateProgress(jobID, 40, "Configuring Celluloid Flatpak...") if err := s.configureCelluloidFlatpakJob(jobID, cr, homeDir); err != nil { s.jobManager.AddOutput(jobID, "Warning: Failed to configure Celluloid: "+err.Error()) } else { s.jobManager.AddOutput(jobID, "Celluloid configured") } } else if app.InstallMethod == constants.MethodCelluloidPackage { s.jobManager.UpdateProgress(jobID, 40, "Configuring Celluloid...") if err := s.configureCelluloidPackageJob(jobID, cr, homeDir); err != nil { s.jobManager.AddOutput(jobID, "Warning: Failed to configure Celluloid: "+err.Error()) } else { s.jobManager.AddOutput(jobID, "Celluloid configured") } } // Check for cancellation if job.IsCancelled() { relay.Close() s.jobManager.SetError(jobID, fmt.Errorf("adoption cancelled"), "Job was cancelled during execution") return } // Step 3: Install UI overlay if selected (only for MPV-based apps) if constants.SupportsMPVOverlay(app.InstallMethod) && uiType != constants.UITypeNone { s.jobManager.UpdateProgress(jobID, 60, "Installing UI overlay...") if err := preparedUI.Commit(cr); err != nil { relay.Close() s.jobManager.SetError(jobID, err, "Failed to install the requested UI overlay") log.Error(constants.LogPrefixAPI + "Adopt job failed: " + err.Error()) return } s.jobManager.AddOutput(jobID, fmt.Sprintf("UI overlay '%s' installed", uiType)) } relay.Close() if s.platform.OSType == "windows" && (app.InstallMethod == constants.MethodMPVBinary || app.InstallMethod == constants.MethodMPVBinaryV3) { if err := installer.MigrateWindowsInstallOwnership(app.InstallPath); err != nil { s.jobManager.SetPartialSuccess(jobID, err, "Configuration was applied, but MPV ownership could not be recorded. The app remains unmanaged; correct the selected installation and retry adoption.") return } } // Step 4: Update app in config to mark as managed s.jobManager.UpdateProgress(jobID, 90, "Updating configuration...") uiTypeToSave := uiType if uiType == constants.UITypeNone { uiTypeToSave = "" } if err := config.UpdateInstalledAppManagedByID(app.ID, true, uiTypeToSave); err != nil { s.jobManager.AddOutput(jobID, "The app was configured, but adoption metadata could not be persisted: "+err.Error()) log.Error(constants.LogPrefixAPI + "Failed to update app managed status: " + err.Error()) s.jobManager.SetPartialSuccess(jobID, err, "MPV configuration/UI changes were applied, but MPV Manager could not persist the managed-app record. Fix config permissions and retry adoption to reconcile state.") return } else { s.jobManager.AddOutput(jobID, "App marked as managed") } s.jobManager.AddOutput(jobID, "App adopted successfully!") // Mark job as complete only after the final output has been recorded. s.jobManager.CompleteJob(jobID) log.Info(constants.LogPrefixAPI + "Adopt job completed successfully: " + app.AppName) } // configureCelluloidFlatpakJob configures Celluloid Flatpak with mpv config path (for job system) func (s *Server) configureCelluloidFlatpakJob(jobID string, cr *installer.CommandRunner, homeDir string) error { s.jobManager.AddOutput(jobID, "Configuring Celluloid Flatpak...") // Grant filesystem access s.jobManager.AddOutput(jobID, "Granting filesystem access to mpv config...") if err := cr.RunCommand("flatpak", "override", "--user", "--filesystem=xdg-config/mpv:ro", constants.FlatpakCelluloid); err != nil { return err } // Set mpv-config-enable s.jobManager.AddOutput(jobID, "Setting mpv-config-enable to true...") if err := cr.RunCommand("flatpak", "run", "--command=gsettings", constants.FlatpakCelluloid, "set", constants.GSettingsCelluloidSchema, "mpv-config-enable", "true"); err != nil { s.jobManager.AddOutput(jobID, "Warning: Failed to set gsettings (Celluloid may not have been run yet)") } // Set mpv-config-file configPath := installer.GetMPVConfigDirFromHome(homeDir) + "/mpv.conf" s.jobManager.AddOutput(jobID, fmt.Sprintf("Setting mpv-config-file to: %s", configPath)) if err := cr.RunCommand("flatpak", "run", "--command=gsettings", constants.FlatpakCelluloid, "set", constants.GSettingsCelluloidSchema, "mpv-config-file", configPath); err != nil { s.jobManager.AddOutput(jobID, "Warning: Failed to set gsettings mpv-config-file") } return nil } // configureCelluloidPackageJob configures Celluloid (package manager version) with mpv config path (for job system) func (s *Server) configureCelluloidPackageJob(jobID string, cr *installer.CommandRunner, homeDir string) error { s.jobManager.AddOutput(jobID, "Configuring Celluloid settings...") // Check if gsettings is available if _, err := exec.LookPath("gsettings"); err != nil { s.jobManager.AddOutput(jobID, "Warning: gsettings not found, skipping Celluloid configuration") return nil } // Set mpv-config-enable s.jobManager.AddOutput(jobID, "Setting mpv-config-enable to true...") if err := cr.RunCommand("gsettings", "set", "io.github.celluloid-player.Celluloid", "mpv-config-enable", "true"); err != nil { s.jobManager.AddOutput(jobID, "Warning: Failed to set gsettings (Celluloid may not have been run yet)") } // Set mpv-config-file configPath := installer.GetMPVConfigDirFromHome(homeDir) + "/mpv.conf" s.jobManager.AddOutput(jobID, fmt.Sprintf("Setting mpv-config-file to: %s", configPath)) if err := cr.RunCommand("gsettings", "set", "io.github.celluloid-player.Celluloid", "mpv-config-file", configPath); err != nil { s.jobManager.AddOutput(jobID, "Warning: Failed to set gsettings mpv-config-file") } return nil }