package tui import ( "context" "fmt" "path/filepath" "runtime/debug" tea "charm.land/bubbletea/v2" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/hotkeys" "gitgud.io/mike/mpv-manager/pkg/installer" "gitgud.io/mike/mpv-manager/pkg/log" "gitgud.io/mike/mpv-manager/pkg/platform" "gitgud.io/mike/mpv-manager/pkg/version" ) func (m *Model) executeInstallCmd(methodID, installDir string, installUOSC bool, p *platform.Platform, inst *installer.Installer) tea.Cmd { log.Separator("Installation Operation") log.Info(fmt.Sprintf("Starting installation: %s (%s)", methodID, installer.GetMethodDisplayName(methodID))) log.Info(fmt.Sprintf("Install directory: %s", installDir)) log.Info(fmt.Sprintf("uOSC enabled: %v", installUOSC)) log.Info(fmt.Sprintf("Platform: %s/%s", p.OSType, p.Arch)) // Convert bool to uiType string uiType := constants.UITypeNone if installUOSC { uiType = constants.UITypeUOSC } operationInstaller := installerForTUIOperation(inst, installDir) return m.executeWithChannels(fmt.Sprintf("Installing %s", methodID), func(ctx context.Context, cr *installer.CommandRunner) error { handler := installer.NewInstallationHandler(p, operationInstaller) return handler.ExecuteInstall(ctx, cr, methodID, uiType, false) }) } func (m *Model) executeUninstallCmd(app *config.InstalledApp, p *platform.Platform, inst *installer.Installer) tea.Cmd { log.Separator("Uninstallation Operation") log.Info(fmt.Sprintf("Starting uninstallation: %s", app.AppName)) log.Info(fmt.Sprintf("App type: %s", app.AppType)) log.Info(fmt.Sprintf("Install method: %s", app.InstallMethod)) if app.InstallPath != "" { log.Info(fmt.Sprintf("Install path: %s", app.InstallPath)) } operationInstaller := installerForTUIOperation(inst, app.InstallPath) return m.executeWithChannels(fmt.Sprintf("Uninstalling %s", app.AppName), func(ctx context.Context, cr *installer.CommandRunner) error { handler := installer.NewInstallationHandler(p, operationInstaller) return handler.ExecuteUninstall(ctx, cr, app) }) } func (m *Model) executeUpdateCmd(item updateItem, p *platform.Platform, inst *installer.Installer) tea.Cmd { log.Separator("Update Operation") log.Info(fmt.Sprintf("Updating: %s (%s, ID %s)", item.AppName, item.MethodID, item.AppID)) if item.InstallPath != "" { log.Info(fmt.Sprintf("Install path: %s", item.InstallPath)) } operationInstaller := installerForTUIOperation(inst, item.InstallPath) return m.executeWithChannels(fmt.Sprintf("Updating %s", item.AppName), func(ctx context.Context, cr *installer.CommandRunner) error { handler := installer.NewInstallationHandler(p, operationInstaller) return handler.ExecuteInstall(ctx, cr, item.MethodID, constants.DefaultUIType, true) }) } func installerForTUIOperation(base *installer.Installer, installDir string) *installer.Installer { if base == nil { return installer.NewInstaller(installer.ReleaseInfo{}, installDir) } if installDir == "" { installDir = base.InstallDir } return base.CloneForOperation(base.ReleaseInfo, installDir) } func getClientName(methodID string, methodName string) string { return installer.GetMethodDisplayName(methodID) } func (m *Model) executePathCmd(isRemoving bool, inst *installer.Installer) tea.Cmd { if m.operationActive { return nil } outputChan := make(chan string, 100) installDone := make(chan error, 1) ctx, cancel := context.WithCancel(context.Background()) commitGuard := launchOperationWorker(ctx, outputChan, installDone, func(ctx context.Context, _ *installer.CommandRunner) error { if err := ctx.Err(); err != nil { return err } _ = inst // The PATH copy always uses the currently running executable. return applyManagerPATHOperation(isRemoving, func(line string) { outputChan <- line }) }) return tea.Sequence( func() tea.Msg { return cmdStartMsg{command: fmt.Sprintf("%s mpv-manager from PATH", map[bool]string{true: "Removing", false: "Adding"}[isRemoving])} }, m.ownOperation(saveChannelsMsg{ outputChan: outputChan, installDone: installDone, cancel: cancel, commitGuard: commitGuard, }), ) } func startStreaming( outputChan <-chan string, installDone <-chan error, progressChan <-chan downloadProgressMsg, preparedChan <-chan *version.PreparedSelfUpdate, ) tea.Cmd { return func() tea.Msg { select { case line, ok := <-outputChan: if ok { return cmdOutputMsg{line: line, isError: false} } return operationStreamClosedMsg{kind: operationStreamOutput} case err, ok := <-installDone: return operationResultMsg{err: err, ok: ok} case progress, ok := <-progressChan: if ok { return progress } return operationStreamClosedMsg{kind: operationStreamProgress} case prepared, ok := <-preparedChan: if ok { return managerUpdatePreparedMsg{prepared: prepared} } return operationStreamClosedMsg{kind: operationStreamPrepared} } } } func (m *Model) executeWithChannels(cmdStartMsgText string, fn func(context.Context, *installer.CommandRunner) error) tea.Cmd { if m.operationActive { return nil } outputChan := make(chan string, constants.OutputChanBufferSize) installDone := make(chan error, 1) ctx, cancel := context.WithCancel(context.Background()) commitGuard := launchOperationWorker(ctx, outputChan, installDone, fn) return tea.Sequence( func() tea.Msg { return cmdStartMsg{command: cmdStartMsgText} }, m.ownOperation(saveChannelsMsg{ outputChan: outputChan, installDone: installDone, cancel: cancel, commitGuard: commitGuard, }), ) } // launchOperationWorker is the sole lifecycle boundary for detached TUI // operations. It converts panics into ordinary terminal results, disables // interactive sudo while Bubble Tea owns the terminal, and closes both streams // exactly once so cancellation can join and drain the worker safely. func launchOperationWorker( ctx context.Context, outputChan chan string, installDone chan error, fn func(context.Context, *installer.CommandRunner) error, ) *operationCommitGuard { guard := &operationCommitGuard{ctx: ctx} go func() { var result error defer func() { if recovered := recover(); recovered != nil { log.Error(formatRecoveredPanic("TUI operation", recovered)) result = fmt.Errorf("operation panicked: %v", recovered) } installDone <- result close(installDone) close(outputChan) }() cr := installer.NewCommandRunnerWithContext(ctx, outputChan, nil) cr.SetInteractiveSudoAllowed(false) cr.SetCommitGuard(guard.begin) result = fn(ctx, cr) }() return guard } func launchManagerUpdateWorker( outputChan chan string, installDone chan error, progressChan chan downloadProgressMsg, preparedChan chan *version.PreparedSelfUpdate, fn func() error, ) { go func() { var result error defer func() { if recovered := recover(); recovered != nil { log.Error(formatRecoveredPanic("TUI manager-update", recovered)) result = fmt.Errorf("manager update panicked: %v", recovered) } installDone <- result close(installDone) close(outputChan) close(progressChan) close(preparedChan) }() result = fn() }() } func formatRecoveredPanic(scope string, recovered interface{}) string { message := fmt.Sprintf("%s panic: %v", scope, recovered) if log.IsDebugMode() { message += "\n" + string(debug.Stack()) } return message } func isInstallMPVInPATH() bool { return managerPATHInstalled() } func (m *Model) checkForUpdateCmd() tea.Cmd { return m.observationCmd(func(ctx context.Context) tea.Msg { return versionCheckMsg{result: version.CheckForUpdateContext(ctx)} }) } func loadReleaseInfoCmd(loader func() (*installer.ReleaseInfo, error)) tea.Cmd { return loadReleaseInfoContextCmd(context.Background(), func(context.Context) (*installer.ReleaseInfo, error) { return loader() }) } func loadReleaseInfoContextCmd(ctx context.Context, loader func(context.Context) (*installer.ReleaseInfo, error)) tea.Cmd { return func() tea.Msg { info, err := loader(ctx) if err != nil { return releaseInfoLoadedMsg{ err: err, versionResult: &version.VersionCheckResult{ CurrentVersion: version.CurrentVersion, Error: fmt.Errorf("release information is unavailable: %w", err), }, } } return releaseInfoLoadedMsg{ info: info, versionResult: version.CheckForUpdateFromReleaseInfo(info), } } } func (m *Model) checkForUpdatesCmd(inst *installer.Installer) tea.Cmd { var release *installer.ReleaseInfo if inst != nil { snapshot := inst.ReleaseInfo release = &snapshot } return m.observationCmd(func(ctx context.Context) tea.Msg { installedApps := config.GetInstalledApps() result := version.CheckForAppUpdatesContext(ctx, release, installedApps) result.ConfigBackupItems, _ = version.ListConfigBackups() return updateCheckResultMsg{result: result} }) } func refreshUninstallList() tea.Cmd { return func() tea.Msg { apps := config.GetInstalledApps() return uninstallAppsLoadedMsg{apps: apps} } } // executeInstallCmdWithUI executes installation with a specific UI type func (m *Model) executeInstallCmdWithUI(methodID, installDir, uiType string, p *platform.Platform, inst *installer.Installer) tea.Cmd { log.Separator("Installation Operation") log.Info(fmt.Sprintf("Starting installation: %s (%s)", methodID, installer.GetMethodDisplayName(methodID))) log.Info(fmt.Sprintf("Install directory: %s", installDir)) log.Info(fmt.Sprintf("UI type: %s", uiType)) log.Info(fmt.Sprintf("Platform: %s/%s", p.OSType, p.Arch)) operationInstaller := installerForTUIOperation(inst, installDir) return m.executeWithChannels(fmt.Sprintf("Installing %s", methodID), func(ctx context.Context, cr *installer.CommandRunner) error { handler := installer.NewInstallationHandler(p, operationInstaller) return handler.ExecuteInstall(ctx, cr, methodID, uiType, false) }) } func tuiConfigDirForApp(app config.InstalledApp) (string, error) { if (app.InstallMethod == constants.MethodMPVBinary || app.InstallMethod == constants.MethodMPVBinaryV3) && app.InstallPath != "" { return filepath.Join(app.InstallPath, constants.PortableConfigDir), nil } return installer.GetMPVConfigDir() } // executeUIChangeCmd changes only the selected app's UI/config surface and // then atomically persists that exact app ID plus the manager's installed-UI // selection. It deliberately never invokes the full installation flow. func (m *Model) executeUIChangeCmd(app config.InstalledApp, uiType string, inst *installer.Installer) tea.Cmd { return m.executeWithChannels(fmt.Sprintf("Changing UI for %s", app.AppName), func(ctx context.Context, cr *installer.CommandRunner) error { return changeUIForApp(ctx, app, uiType, inst, cr) }) } func changeUIForApp(ctx context.Context, app config.InstalledApp, uiType string, inst *installer.Installer, cr *installer.CommandRunner) error { if app.ID == "" { return fmt.Errorf("selected app has no stable identity") } configDir, err := tuiConfigDirForApp(app) if err != nil { return err } operationInstaller := installerForTUIOperation(inst, app.InstallPath) if err := ctx.Err(); err != nil { return err } if err := installer.InstallUISafely(operationInstaller, cr, configDir, uiType); err != nil { return err } uiTypeToSave := uiType if uiType == constants.UITypeNone { uiTypeToSave = "" } if err := config.UpdateInstalledAppUISelectionByID(app.ID, uiTypeToSave, uiType); err != nil { return &partialOperationError{ operation: "UI change", cause: err, guidance: "The selected app's UI changed on disk, but MPV Manager could not persist its metadata. Fix config permissions and retry the same selection to reconcile state.", } } return nil } // runApplyPresetCmd applies a hotkey preset and returns a presetApplyMsg func (m *Model) runApplyPresetCmd(presetID string) tea.Cmd { if m.operationActive { return nil } // Find the preset name for display var presetName string for _, p := range hotkeys.GetPresetList() { if p.ID == presetID { presetName = p.Name break } } if presetName == "" { presetName = presetID } output, result := make(chan string, constants.OutputChanBufferSize), make(chan error, 1) ctx, cancel := context.WithCancel(context.Background()) guard := launchOperationWorker(ctx, output, result, func(ctx context.Context, cr *installer.CommandRunner) error { configPath := hotkeys.GetInputConfPath() if configPath == "" { return fmt.Errorf("could not determine input.conf path") } if err := ctx.Err(); err != nil { return err } if err := cr.BeginCommit(); err != nil { return err } err := hotkeys.ApplyPreset(presetID, configPath) if err != nil { log.Error(fmt.Sprintf("Failed to apply preset %s: %v", presetID, err)) return err } log.Info(fmt.Sprintf("Applied hotkey preset: %s", presetName)) return nil }) return m.ownOperation(saveChannelsMsg{ outputChan: output, installDone: result, cancel: cancel, commitGuard: guard, terminal: func(err error) tea.Msg { return presetApplyMsg{presetID: presetID, preset: presetName, err: err} }, }) }