package web import ( "context" "encoding/json" "errors" "fmt" "net/http" "regexp" "strings" "time" "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" "gitgud.io/mike/mpv-manager/pkg/platform" ) // extractVersionFromURL extracts version from a download URL // e.g., .../mpv-v0.41.0-macos-26-arm.zip -> 0.41.0 func extractVersionFromURL(url, prefix string) string { // Match prefix + optional 'v' or 'V' + version re := regexp.MustCompile(prefix + `[vV]?(\d+\.\d+\.\d+)`) matches := re.FindStringSubmatch(url) if len(matches) > 1 { return matches[1] } return "" } func getAppVersionForMethod(ctx context.Context, methodID string, inst *installer.Installer, p *platform.Platform) string { // Get version from release info for some methods switch methodID { case constants.MethodMPVBinary, constants.MethodMPVBinaryV3: return inst.ReleaseInfo.MpvVersion case constants.MethodMPVApp: // For macOS MPV.app, extract version from download URL since MpvVersion may not be set if inst.ReleaseInfo.MpvVersion != "" { return inst.ReleaseInfo.MpvVersion } // Extract from URL based on architecture var url string if p.Arch == "arm64" { url = inst.ReleaseInfo.MacOS.ARMLatest.URL } else { url = inst.ReleaseInfo.MacOS.Intel15.URL } return extractVersionFromURL(url, "mpv-") case constants.MethodMPCQT: return inst.ReleaseInfo.MPCQT.AppVersion case constants.MethodIINA: return inst.ReleaseInfo.IINA.AppVersion } if constants.IsPackageManaged(methodID) { value, _ := getPackageVersionProbe(ctx, methodID) return value } return "" } // ============================================================================ // Job-Based Installation (New System) // ============================================================================ // handleInstallJobAPI creates a new job for installation (new job-based API) // This is the new endpoint that uses JobManager instead of sessions func (s *Server) handleInstallJobAPI(w http.ResponseWriter, r *http.Request) { var req InstallRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Error(constants.LogPrefixAPI + "Failed to decode install request: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Validate method ID if err := ValidateMethodID(req.MethodID); err != nil { log.Error(constants.LogPrefixAPI + "Invalid method ID: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Validate method ID is compatible with current platform if err := ValidateInstallMethod(req.MethodID, s.platform.IsWindows(), s.platform.IsDarwin(), s.platform.IsLinux()); err != nil { log.Error(constants.LogPrefixAPI + "Method not available on this platform: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Determine UI type, with backwards compatibility for install_uosc uiType := req.UIType if uiType == "" { if req.InstallUOSC { uiType = constants.UITypeUOSC } else { uiType = constants.DefaultUIType } } if err := ValidateUIType(uiType); err != nil { log.Error(constants.LogPrefixAPI + "Invalid UI type: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Get app name for the job appName := installer.GetMethodDisplayName(req.MethodID) // Atomically check for an existing active job and create a new one. // (closes the TOCTOU race between the old HasActiveJob check and CreateJob) installPath := installer.RecordedInstallPath(req.MethodID, config.GetInstallPath()) job, created := s.jobManager.CreateJobWithResources( "install", req.MethodID, appName, installerOperationResources(req.MethodID, "", installPath, "")..., ) if !created { log.Warn(constants.LogPrefixAPI + "Active job already exists for method: " + req.MethodID) sendJSONError(w, http.StatusConflict, "An installation is already in progress for this app") return } log.Info(constants.LogPrefixAPI + "Install job request received: method_id=" + req.MethodID + ", ui_type=" + req.UIType) // Run the installation in a goroutine using the job system s.jobManager.StartWorker(job.ID, func() { s.runInstallationJob(job.ID, req.MethodID, "", uiType, false) }) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "id": job.ID, "status": "started", }) } // handleAppUpdateJobAPI creates a new job for app update (new job-based API) func (s *Server) handleAppUpdateJobAPI(w http.ResponseWriter, r *http.Request) { var req InstallRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Validate method ID if err := ValidateMethodID(req.MethodID); err != nil { log.Error(constants.LogPrefixAPI + "Invalid method ID: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Validate method ID is compatible with current platform if err := ValidateInstallMethod(req.MethodID, s.platform.IsWindows(), s.platform.IsDarwin(), s.platform.IsLinux()); err != nil { log.Error(constants.LogPrefixAPI + "Method not available on this platform: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } appName := installer.GetMethodDisplayName(req.MethodID) installPath := config.GetInstallPath() if constants.MethodAppType[req.MethodID] != "component" { target, err := config.ResolveInstalledApp(req.AppID, "", req.MethodID, "") if err != nil { status := http.StatusNotFound if errors.Is(err, config.ErrInstalledAppAmbiguous) { status = http.StatusConflict } sendJSONError(w, status, err.Error()) return } if target.InstallMethod != req.MethodID { sendJSONError(w, http.StatusBadRequest, "app_id does not match method_id") return } req.AppID = target.ID appName = target.AppName installPath = target.InstallPath } // For updates, use the provided UI type or default uiType := req.UIType if uiType == "" { uiType = constants.DefaultUIType } if err := ValidateUIType(uiType); err != nil { log.Error(constants.LogPrefixAPI + "Invalid UI type: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Atomically check for an existing active job and create a new one job, created := s.jobManager.CreateJobWithResources( "update", req.MethodID, appName, installerOperationResources(req.MethodID, req.AppID, installPath, "")..., ) if !created { log.Warn(constants.LogPrefixAPI + "Active job already exists for method: " + req.MethodID) sendJSONError(w, http.StatusConflict, "An update is already in progress for this app") return } // Run the update in a goroutine s.jobManager.StartWorker(job.ID, func() { s.runInstallationJob(job.ID, req.MethodID, req.AppID, uiType, true) }) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "id": job.ID, "status": "started", }) } // handleUninstallJobAPI creates a new job for app uninstallation (new job-based API) func (s *Server) handleUninstallJobAPI(w http.ResponseWriter, r *http.Request) { var req UninstallRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Error(constants.LogPrefixAPI + "Failed to decode uninstall request: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Legacy clients identify records with display fields. New clients send a // stable app_id, in which case the stored record supplies these values. if req.AppID == "" { if err := ValidateAppName(req.AppName); err != nil { log.Error(constants.LogPrefixAPI + "Invalid app name: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } if err := ValidateAppType(req.AppType); err != nil { log.Error(constants.LogPrefixAPI + "Invalid app type: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } req.AppType = NormalizeAppType(req.AppType) if err := ValidateMethodID(req.InstallMethod); err != nil { log.Error(constants.LogPrefixAPI + "Invalid install method: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } } installedApp, 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 } // Never trust mutable/display fields from the request after resolving the // stable identity. The stored record is the source of truth for uninstall. req.AppID = installedApp.ID req.AppName = installedApp.AppName req.AppType = installedApp.AppType req.InstallMethod = installedApp.InstallMethod req.InstallPath = installedApp.InstallPath log.Info(constants.LogPrefixAPI + "Uninstall job request received: app_name=" + req.AppName + ", install_method=" + req.InstallMethod) // Atomically check for an existing active job and create a new one job, created := s.jobManager.CreateJobWithResources( "uninstall", req.InstallMethod, req.AppName, installerOperationResources(req.InstallMethod, req.AppID, req.InstallPath, "")..., ) if !created { log.Warn(constants.LogPrefixAPI + "Active job already exists for method: " + req.InstallMethod) sendJSONError(w, http.StatusConflict, "An operation is already in progress for this app") return } // Run the uninstallation in a goroutine s.jobManager.StartWorker(job.ID, func() { s.runUninstallationJob(job.ID, req) }) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "id": job.ID, "status": "started", }) } // runUninstallationJob executes an uninstallation using the job manager system func (s *Server) runUninstallationJob(jobID string, req UninstallRequest) { // 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 + "Uninstallation 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 } p := s.platform ctx := job.Context() // Update progress to indicate starting s.jobManager.UpdateProgress(jobID, 5, "Starting uninstallation...") cr, relay := newJobCommandRunner(ctx, s.jobManager, jobID, 1000) 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("uninstallation cancelled"), "Job was cancelled before it could start") return } // Update progress s.jobManager.UpdateProgress(jobID, 10, "Executing uninstallation commands...") if err := s.jobManager.BeginCommit(jobID); err != nil { relay.Close() s.jobManager.SetError(jobID, err, "Cancellation was acknowledged before uninstallation entered its commit boundary") return } app := config.InstalledApp{ ID: req.AppID, AppName: req.AppName, AppType: req.AppType, InstallMethod: req.InstallMethod, InstallPath: req.InstallPath, } inst := s.installerForOperation(req.InstallPath) handler := installer.NewInstallationHandler(p, inst) err := handler.ExecuteUninstall(ctx, cr, &app) relay.Close() // Check if cancelled during execution if ctx.Err() != nil { s.jobManager.SetError(jobID, fmt.Errorf("uninstallation cancelled"), "Job was cancelled during execution") log.Error(constants.LogPrefixAPI + "Uninstallation job cancelled: " + jobID) return } if err != nil { errorDetails := "" if errors.Is(err, installer.ErrWindowsUninstallBlocked) { errorDetails = installer.ErrWindowsUninstallBlocked.Error() + "." } else if strings.Contains(err.Error(), "exit status") { errorDetails = "Command execution failed. Check the output for details." } else if strings.Contains(err.Error(), "permission denied") { errorDetails = "Permission denied. Try running with elevated privileges." } s.jobManager.SetError(jobID, err, errorDetails) log.Error(constants.LogPrefixAPI + "Uninstallation job failed: " + err.Error()) return } // Remove from installed apps config if err := config.RemoveInstalledAppByID(req.AppID); err != nil { log.Error(constants.LogPrefixAPI + "Failed to remove app from config: " + err.Error()) s.jobManager.AddOutput(jobID, "The application was removed, but its tracking record could not be persisted") s.jobManager.SetPartialSuccess(jobID, err, "The application was physically uninstalled, but MPV Manager could not remove its record. Free disk space or fix config permissions, then reconcile the installed-app list.") return } else { log.Info(constants.LogPrefixAPI + "Successfully uninstalled: " + req.AppName) s.jobManager.AddOutput(jobID, "App removed from configuration successfully") } // Mark job as complete s.jobManager.UpdateProgress(jobID, 100, "Uninstallation completed successfully") s.jobManager.CompleteJob(jobID) log.Info(constants.LogPrefixAPI + "Uninstallation job completed: " + jobID) } // runInstallationJob executes an installation using the job manager system func (s *Server) runInstallationJob(jobID, methodID, appID string, uiType string, isUpdate bool) { // 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 + "Installation 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 } p := s.platform ctx := job.Context() // Update progress to indicate starting s.jobManager.UpdateProgress(jobID, 5, "Starting installation...") cr, relay := newJobCommandRunner(ctx, s.jobManager, jobID, 1000) 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("installation cancelled"), "Job was cancelled before it could start") return } // Update progress s.jobManager.UpdateProgress(jobID, 10, "Executing installation commands...") installDir := config.GetInstallPath() if isUpdate && appID != "" { app, err := config.ResolveInstalledApp(appID, "", "", "") if err != nil { relay.Close() s.jobManager.SetError(jobID, err, "The installed app record no longer exists") return } if app.InstallMethod != methodID { err := fmt.Errorf("installed app method changed from %s to %s", methodID, app.InstallMethod) relay.Close() s.jobManager.SetError(jobID, err, "The installed app record changed before the update started") return } if app.InstallPath != "" { installDir = app.InstallPath } } inst := s.installerForOperation(installDir) handler := installer.NewInstallationHandler(p, inst) err := handler.ExecuteInstall(ctx, cr, methodID, uiType, isUpdate) relay.Close() var partial *installer.PartialInstallError if errors.As(err, &partial) { s.jobManager.SetPartialSuccess(jobID, err, "The application is present, but setup did not finish. Resolve the reported error, then adopt or reconcile the installation.") return } // Check if cancelled during execution if ctx.Err() != nil { s.jobManager.SetError(jobID, fmt.Errorf("installation cancelled"), "Job was cancelled during execution") log.Error(constants.LogPrefixAPI + "Installation job cancelled: " + jobID) return } if err != nil { // Extract error details for more context errorDetails := "" if strings.Contains(err.Error(), "exit status") { errorDetails = "Command execution failed. Check the output for details." } else if strings.Contains(err.Error(), "permission denied") { errorDetails = "Permission denied. Try running with elevated privileges." } else if strings.Contains(err.Error(), "network") || strings.Contains(err.Error(), "connection") { errorDetails = "Network error. Check your internet connection." } s.jobManager.SetError(jobID, err, errorDetails) log.Error(constants.LogPrefixAPI + "Installation job failed: " + err.Error()) return } // Success path if !isUpdate { // Only save to config for new installations, not updates appName := installer.GetMethodDisplayName(methodID) appType := constants.MethodAppType[methodID] installPath := installer.RecordedInstallPath(methodID, installDir) // Only save version for portable/binary apps we manage // Package manager apps (flatpak, brew, apt) are checked at startup appVersion := "" if appType == "app" { appVersion = getAppVersionForMethod(ctx, methodID, inst, p) } app := config.InstalledApp{ AppName: appName, AppType: appType, InstallMethod: methodID, InstallPath: installPath, InstalledDate: time.Now().Format("2006-01-02 15:04:05"), AppVersion: appVersion, UIType: uiType, // Save UI type for MPV apps Managed: true, // Installed and managed by MPV Manager } if err := config.AddInstalledApp(app); err != nil { log.Error(constants.LogPrefixAPI + "Failed to save installed app: " + err.Error()) s.jobManager.AddOutput(jobID, "The application was installed, but its tracking record could not be persisted") s.jobManager.SetPartialSuccess(jobID, err, "The application is present on disk, but MPV Manager could not save its installed-app record. Free disk space or fix config permissions, then adopt/reconcile the installation.") return } else { log.Info(constants.LogPrefixAPI + "Successfully saved installed app: " + appName) s.jobManager.AddOutput(jobID, "App saved to configuration successfully") // Update version cache for package manager apps if constants.IsPackageManaged(methodID) { versionInfo, verr := getPackageVersionProbe(ctx, methodID) if verr != nil { log.Debug(constants.LogPrefixAPI + "Could not determine package version for " + methodID + ": " + verr.Error()) versionInfo = "" } typeTag := getTypeTag(methodID) s.versionCacheMux.Lock() if s.versionCache.PackageVersionMap == nil { s.versionCache.PackageVersionMap = make(map[string]AppVersionInfo) } s.versionCache.PackageVersionMap[methodID] = AppVersionInfo{ CurrentVersion: versionInfo, AvailableVersion: "Check via " + typeTag, } s.versionCacheMux.Unlock() log.Info(constants.LogPrefixAPI + "Updated version cache for " + methodID + ": " + versionInfo) } } } // Mark job as complete s.jobManager.UpdateProgress(jobID, 100, "Installation completed successfully") s.jobManager.CompleteJob(jobID) log.Info(constants.LogPrefixAPI + "Installation job completed: " + jobID) }