package tui import ( "context" "fmt" "os" "path/filepath" "runtime" "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" tea "github.com/charmbracelet/bubbletea" ) func 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 } ctx := context.Background() return executeWithChannels(fmt.Sprintf("Installing %s", methodID), func(cr *installer.CommandRunner) error { handler := installer.NewInstallationHandler(p, inst) return handler.ExecuteInstall(ctx, cr, methodID, uiType, false) }) } func 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)) } ctx := context.Background() return executeWithChannels(fmt.Sprintf("Uninstalling %s", app.AppName), func(cr *installer.CommandRunner) error { handler := installer.NewInstallationHandler(p, inst) return handler.ExecuteUninstall(ctx, cr, app) }) } func executeUpdateCmd(methodID, appName string, p *platform.Platform, inst *installer.Installer) tea.Cmd { log.Separator("Update Operation") log.Info(fmt.Sprintf("Updating: %s (%s)", appName, methodID)) ctx := context.Background() return executeWithChannels(fmt.Sprintf("Updating %s", appName), func(cr *installer.CommandRunner) error { handler := installer.NewInstallationHandler(p, inst) return handler.ExecuteInstall(ctx, cr, methodID, constants.DefaultUIType, true) }) } func getClientName(methodID string, methodName string) string { return installer.GetMethodDisplayName(methodID) } func (m Model) platformInfoScreen() tea.Cmd { return func() tea.Msg { return struct{ content string }{content: m.platformInfoView()} } } func getUserBinDir() string { homeDir, err := os.UserHomeDir() if err != nil { return "" } switch runtime.GOOS { case "windows": return filepath.Join(homeDir, "bin") case "darwin", "linux": return filepath.Join(homeDir, ".local", "bin") default: return filepath.Join(homeDir, "bin") } } func getShellConfigFile(binDir string) string { homeDir, err := os.UserHomeDir() if err != nil { return "" } shell := os.Getenv("SHELL") if shell == "" { return "" } switch { case filepath.Base(shell) == "bash": bashrc := filepath.Join(homeDir, ".bashrc") if _, err := os.Stat(bashrc); err == nil { return bashrc } return filepath.Join(homeDir, ".bash_profile") case filepath.Base(shell) == "zsh": return filepath.Join(homeDir, ".zshrc") case filepath.Base(shell) == "fish": return filepath.Join(homeDir, ".config", "fish", "config.fish") case filepath.Base(shell) == "nu": return filepath.Join(homeDir, ".config", "nu", "config.nu") default: return "" } } func getShellName() string { shell := os.Getenv("SHELL") if shell == "" { return "unknown" } return filepath.Base(shell) } // writeAliasToFile safely writes an alias command to a shell config file using direct file I/O. // This prevents shell command injection by avoiding shell interpretation. func writeAliasToFile(aliasCommand, shellConfigFile string) error { // Open file in append mode, create if doesn't exist f, err := os.OpenFile(shellConfigFile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) if err != nil { return fmt.Errorf("failed to open shell config file: %w", err) } defer f.Close() // Write alias command + newline _, err = f.WriteString(aliasCommand + "\n") if err != nil { return fmt.Errorf("failed to write alias to shell config: %w", err) } return nil } func copyBinaryToDir(inst *installer.Installer, srcPath, destDir string, outputChan chan string) error { if _, err := os.Stat(srcPath); err != nil { log.Warn(fmt.Sprintf("Source binary not found: %s", srcPath)) outputChan <- fmt.Sprintf("Warning: Source binary not found: %s", srcPath) return fmt.Errorf("source binary not found: %s", srcPath) } destPath := filepath.Join(destDir, constants.AppName) input, err := os.ReadFile(srcPath) if err != nil { log.Warn(fmt.Sprintf("Failed to read binary: %v", err)) outputChan <- fmt.Sprintf("Warning: Failed to read binary: %v", err) return fmt.Errorf("failed to read binary: %w", err) } if err := os.WriteFile(destPath, input, 0755); err != nil { log.Warn(fmt.Sprintf("Failed to write binary: %v", err)) outputChan <- fmt.Sprintf("Warning: Failed to write binary: %v", err) return fmt.Errorf("failed to write binary: %w", err) } log.Info(fmt.Sprintf("Binary copied to: %s", destPath)) outputChan <- "Binary copied successfully" return nil } func executePathCmd(isRemoving bool, inst *installer.Installer) tea.Cmd { outputChan := make(chan string, 100) installDone := make(chan error, 1) go func() { var err error if isRemoving { log.Separator("PATH Removal Operation") log.Info("Removing mpv-manager from system PATH") binDir := getUserBinDir() destPath := filepath.Join(binDir, constants.AppName) altDestPath := filepath.Join(binDir, "mpv-install") log.Info(fmt.Sprintf("Bin directory: %s", binDir)) log.Info(fmt.Sprintf("Binary path: %s", destPath)) log.Info(fmt.Sprintf("Alias path: %s", altDestPath)) outputChan <- fmt.Sprintf("Bin directory: %s", binDir) outputChan <- fmt.Sprintf("Removing binary: %s", destPath) outputChan <- fmt.Sprintf("Removing symlink: %s", altDestPath) if err1 := os.Remove(destPath); err1 != nil && !os.IsNotExist(err1) { outputChan <- fmt.Sprintf("Warning: %v", err1) } else { outputChan <- "Binary removed successfully" } if err2 := os.Remove(altDestPath); err2 != nil && !os.IsNotExist(err2) { outputChan <- fmt.Sprintf("Warning: %v", err2) } else { outputChan <- "Alias removed successfully" } err = nil } else { log.Separator("PATH Addition Operation") log.Info("Adding mpv-manager to system PATH") binDir := getUserBinDir() shellConfigFile := getShellConfigFile(binDir) shellConfigDir := filepath.Dir(shellConfigFile) if _, err := os.Stat(shellConfigDir); os.IsNotExist(err) { os.MkdirAll(shellConfigDir, 0755) log.Info(fmt.Sprintf("Created directory: %s", shellConfigDir)) } destPath := filepath.Join(binDir, constants.AppName) srcBinary, err := os.Executable() if err != nil { log.Warn(fmt.Sprintf("Failed to get executable path: %v", err)) outputChan <- fmt.Sprintf("Warning: Failed to get executable path: %v", err) installDone <- err close(outputChan) return } if _, err := os.Stat(binDir); os.IsNotExist(err) { outputChan <- fmt.Sprintf("Creating directory: %s", binDir) if err := os.MkdirAll(binDir, 0755); err != nil { log.Warn(fmt.Sprintf("Failed to create directory: %v", err)) outputChan <- fmt.Sprintf("Warning: Failed to create directory: %v", err) installDone <- err close(outputChan) return } } outputChan <- fmt.Sprintf("Bin directory: %s", binDir) outputChan <- fmt.Sprintf("Source binary: %s", srcBinary) outputChan <- fmt.Sprintf("Binary path: %s", destPath) outputChan <- fmt.Sprintf("Shell config: %s", shellConfigFile) outputChan <- "Copying binary to bin directory..." err = copyBinaryToDir(inst, srcBinary, binDir, outputChan) if err != nil { installDone <- err close(outputChan) return } aliasPath := filepath.Join(binDir, "mpv-install") aliasCommand := fmt.Sprintf("alias mpv-install='%s'", destPath) outputChan <- "Creating symlink from mpv-install to mpv-manager..." if _, err := os.Stat(aliasPath); err == nil { log.Info("Removing existing alias file/symlink") os.Remove(aliasPath) } if err := os.Symlink(destPath, aliasPath); err != nil { log.Warn(fmt.Sprintf("Failed to create symlink: %v", err)) outputChan <- fmt.Sprintf("Warning: Failed to create symlink: %v", err) } else { log.Info(fmt.Sprintf("Symlink created: %s -> %s", aliasPath, destPath)) outputChan <- "Symlink created successfully" } if shellConfigFile != "" { log.Info(fmt.Sprintf("Adding shell alias to: %s", shellConfigFile)) outputChan <- fmt.Sprintf("Adding shell alias to: %s", shellConfigFile) shell := getShellName() log.Info(fmt.Sprintf("Shell type: %s", shell)) outputChan <- fmt.Sprintf("Shell type: %s", shell) err = writeAliasToFile(aliasCommand, shellConfigFile) if err != nil { log.Warn(fmt.Sprintf("Failed to write alias: %v", err)) outputChan <- fmt.Sprintf("Warning: Failed to write alias to %s: %v", shellConfigFile, err) } else { log.Info(fmt.Sprintf("Alias command: %s", aliasCommand)) log.Info("Shell alias written successfully") outputChan <- "Shell alias written successfully" } } else { log.Warn("Shell config file not found, skipping shell alias") outputChan <- "Warning: Shell config file not found, skipping shell alias" } outputChan <- fmt.Sprintf("You may need to restart your terminal or run: source %s", shellConfigFile) err = nil } installDone <- err close(outputChan) }() return tea.Sequence( func() tea.Msg { return cmdStartMsg{command: fmt.Sprintf("%s mpv-manager from PATH", map[bool]string{true: "Removing", false: "Adding"}[isRemoving])} }, func() tea.Msg { return saveChannelsMsg{ outputChan: outputChan, installDone: installDone, } }, ) } func startStreaming(outputChan <-chan string, installDone <-chan error, progressChan <-chan downloadProgressMsg) tea.Cmd { return func() tea.Msg { select { case line, ok := <-outputChan: if ok { return cmdOutputMsg{line: line, isError: false} } case err, ok := <-installDone: if ok { return cmdDoneMsg{err: err} } case progress, ok := <-progressChan: if ok { return downloadProgressMsg{written: progress.written, total: progress.total} } } return tickMsg{} } } func executeWithChannels(cmdStartMsgText string, fn func(*installer.CommandRunner) error) tea.Cmd { outputChan := make(chan string, constants.OutputChanBufferSize) installDone := make(chan error, 1) go func() { cr := installer.NewCommandRunner(outputChan, nil) err := fn(cr) installDone <- err close(outputChan) }() return tea.Sequence( func() tea.Msg { return cmdStartMsg{command: cmdStartMsgText} }, func() tea.Msg { return saveChannelsMsg{ outputChan: outputChan, installDone: installDone, } }, ) } func isInstallMPVInPATH() bool { binDir := getUserBinDir() if binDir == "" { return false } destPath := filepath.Join(binDir, constants.AppName) if _, err := os.Stat(destPath); err == nil { return true } return false } func checkForUpdateCmd() tea.Cmd { return func() tea.Msg { result := version.CheckForUpdate() return versionCheckMsg{result: result} } } func checkForUpdatesCmd(inst *installer.Installer) tea.Cmd { return func() tea.Msg { installedApps := config.GetInstalledApps() result := version.CheckForAppUpdates(&inst.ReleaseInfo, installedApps) return updateCheckResultMsg{result: result} } } func runUpdatesCheckCmd(inst *installer.Installer) tea.Cmd { return tea.Sequence( func() tea.Msg { result := version.CheckForUpdate() return versionCheckMsg{result: result} }, func() tea.Msg { installedApps := config.GetInstalledApps() result := version.CheckForAppUpdates(&inst.ReleaseInfo, installedApps) return updateCheckResultMsg{result: result} }, ) } func refreshUninstallList() tea.Cmd { return func() tea.Msg { apps := config.GetInstalledApps() return uninstallAppsLoadedMsg{apps: apps} } } // uiSelectedMsg is sent when a UI overlay is selected type uiSelectedMsg struct { methodID string uiType string } // executeInstallCmdWithUI executes installation with a specific UI type func 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)) ctx := context.Background() return executeWithChannels(fmt.Sprintf("Installing %s", methodID), func(cr *installer.CommandRunner) error { handler := installer.NewInstallationHandler(p, inst) return handler.ExecuteInstall(ctx, cr, methodID, uiType, false) }) } // runApplyPresetCmd applies a hotkey preset and returns a presetApplyMsg func (m *Model) runApplyPresetCmd(presetID string) tea.Cmd { // 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 } return func() tea.Msg { configPath := hotkeys.GetInputConfPath() if configPath == "" { return presetApplyMsg{presetID: presetID, preset: presetName, err: fmt.Errorf("could not determine input.conf path")} } err := hotkeys.ApplyPreset(presetID, configPath) if err != nil { log.Error(fmt.Sprintf("Failed to apply preset %s: %v", presetID, err)) return presetApplyMsg{presetID: presetID, preset: presetName, err: err} } log.Info(fmt.Sprintf("Applied hotkey preset: %s", presetName)) return presetApplyMsg{presetID: presetID, preset: presetName} } }