mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-19 21:00:52 +08:00
474 lines
11 KiB
Go
474 lines
11 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
// oauthProvider represents an OAuth provider option.
|
|
type oauthProvider struct {
|
|
name string
|
|
apiPath string // management API path
|
|
emoji string
|
|
}
|
|
|
|
var oauthProviders = []oauthProvider{
|
|
{"Gemini CLI", "gemini-cli-auth-url", "🟦"},
|
|
{"Claude (Anthropic)", "anthropic-auth-url", "🟧"},
|
|
{"Codex (OpenAI)", "codex-auth-url", "🟩"},
|
|
{"Antigravity", "antigravity-auth-url", "🟪"},
|
|
{"Qwen", "qwen-auth-url", "🟨"},
|
|
{"Kimi", "kimi-auth-url", "🟫"},
|
|
{"IFlow", "iflow-auth-url", "⬜"},
|
|
}
|
|
|
|
// oauthTabModel handles OAuth login flows.
|
|
type oauthTabModel struct {
|
|
client *Client
|
|
viewport viewport.Model
|
|
cursor int
|
|
state oauthState
|
|
message string
|
|
err error
|
|
width int
|
|
height int
|
|
ready bool
|
|
|
|
// Remote browser mode
|
|
authURL string // auth URL to display
|
|
authState string // OAuth state parameter
|
|
providerName string // current provider name
|
|
callbackInput textinput.Model
|
|
inputActive bool // true when user is typing callback URL
|
|
}
|
|
|
|
type oauthState int
|
|
|
|
const (
|
|
oauthIdle oauthState = iota
|
|
oauthPending
|
|
oauthRemote // remote browser mode: waiting for manual callback
|
|
oauthSuccess
|
|
oauthError
|
|
)
|
|
|
|
// Messages
|
|
type oauthStartMsg struct {
|
|
url string
|
|
state string
|
|
providerName string
|
|
err error
|
|
}
|
|
|
|
type oauthPollMsg struct {
|
|
done bool
|
|
message string
|
|
err error
|
|
}
|
|
|
|
type oauthCallbackSubmitMsg struct {
|
|
err error
|
|
}
|
|
|
|
func newOAuthTabModel(client *Client) oauthTabModel {
|
|
ti := textinput.New()
|
|
ti.Placeholder = "http://localhost:.../auth/callback?code=...&state=..."
|
|
ti.CharLimit = 2048
|
|
ti.Prompt = " 回调 URL: "
|
|
return oauthTabModel{
|
|
client: client,
|
|
callbackInput: ti,
|
|
}
|
|
}
|
|
|
|
func (m oauthTabModel) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case localeChangedMsg:
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
case oauthStartMsg:
|
|
if msg.err != nil {
|
|
m.state = oauthError
|
|
m.err = msg.err
|
|
m.message = errorStyle.Render("✗ " + msg.err.Error())
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
}
|
|
m.authURL = msg.url
|
|
m.authState = msg.state
|
|
m.providerName = msg.providerName
|
|
m.state = oauthRemote
|
|
m.callbackInput.SetValue("")
|
|
m.callbackInput.Focus()
|
|
m.inputActive = true
|
|
m.message = ""
|
|
m.viewport.SetContent(m.renderContent())
|
|
// Also start polling in the background
|
|
return m, tea.Batch(textinput.Blink, m.pollOAuthStatus(msg.state))
|
|
|
|
case oauthPollMsg:
|
|
if msg.err != nil {
|
|
m.state = oauthError
|
|
m.err = msg.err
|
|
m.message = errorStyle.Render("✗ " + msg.err.Error())
|
|
m.inputActive = false
|
|
m.callbackInput.Blur()
|
|
} else if msg.done {
|
|
m.state = oauthSuccess
|
|
m.message = successStyle.Render("✓ " + msg.message)
|
|
m.inputActive = false
|
|
m.callbackInput.Blur()
|
|
} else {
|
|
m.message = warningStyle.Render("⏳ " + msg.message)
|
|
}
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
|
|
case oauthCallbackSubmitMsg:
|
|
if msg.err != nil {
|
|
m.message = errorStyle.Render(T("oauth_submit_fail") + ": " + msg.err.Error())
|
|
} else {
|
|
m.message = successStyle.Render(T("oauth_submit_ok"))
|
|
}
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
// ---- Input active: typing callback URL ----
|
|
if m.inputActive {
|
|
switch msg.String() {
|
|
case "enter":
|
|
callbackURL := m.callbackInput.Value()
|
|
if callbackURL == "" {
|
|
return m, nil
|
|
}
|
|
m.inputActive = false
|
|
m.callbackInput.Blur()
|
|
m.message = warningStyle.Render(T("oauth_submitting"))
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, m.submitCallback(callbackURL)
|
|
case "esc":
|
|
m.inputActive = false
|
|
m.callbackInput.Blur()
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
default:
|
|
var cmd tea.Cmd
|
|
m.callbackInput, cmd = m.callbackInput.Update(msg)
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, cmd
|
|
}
|
|
}
|
|
|
|
// ---- Remote mode but not typing ----
|
|
if m.state == oauthRemote {
|
|
switch msg.String() {
|
|
case "c", "C":
|
|
// Re-activate input
|
|
m.inputActive = true
|
|
m.callbackInput.Focus()
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, textinput.Blink
|
|
case "esc":
|
|
m.state = oauthIdle
|
|
m.message = ""
|
|
m.authURL = ""
|
|
m.authState = ""
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
}
|
|
var cmd tea.Cmd
|
|
m.viewport, cmd = m.viewport.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
// ---- Pending (auto polling) ----
|
|
if m.state == oauthPending {
|
|
if msg.String() == "esc" {
|
|
m.state = oauthIdle
|
|
m.message = ""
|
|
m.viewport.SetContent(m.renderContent())
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// ---- Idle ----
|
|
switch msg.String() {
|
|
case "up", "k":
|
|
if m.cursor > 0 {
|
|
m.cursor--
|
|
m.viewport.SetContent(m.renderContent())
|
|
}
|
|
return m, nil
|
|
case "down", "j":
|
|
if m.cursor < len(oauthProviders)-1 {
|
|
m.cursor++
|
|
m.viewport.SetContent(m.renderContent())
|
|
}
|
|
return m, nil
|
|
case "enter":
|
|
if m.cursor >= 0 && m.cursor < len(oauthProviders) {
|
|
provider := oauthProviders[m.cursor]
|
|
m.state = oauthPending
|
|
m.message = warningStyle.Render(fmt.Sprintf(T("oauth_initiating"), provider.name))
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, m.startOAuth(provider)
|
|
}
|
|
return m, nil
|
|
case "esc":
|
|
m.state = oauthIdle
|
|
m.message = ""
|
|
m.err = nil
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
m.viewport, cmd = m.viewport.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
m.viewport, cmd = m.viewport.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
func (m oauthTabModel) startOAuth(provider oauthProvider) tea.Cmd {
|
|
return func() tea.Msg {
|
|
// Call the auth URL endpoint with is_webui=true
|
|
data, err := m.client.getJSON("/v0/management/" + provider.apiPath + "?is_webui=true")
|
|
if err != nil {
|
|
return oauthStartMsg{err: fmt.Errorf("failed to start %s login: %w", provider.name, err)}
|
|
}
|
|
|
|
authURL := getString(data, "url")
|
|
state := getString(data, "state")
|
|
if authURL == "" {
|
|
return oauthStartMsg{err: fmt.Errorf("no auth URL returned for %s", provider.name)}
|
|
}
|
|
|
|
// Try to open browser (best effort)
|
|
_ = openBrowser(authURL)
|
|
|
|
return oauthStartMsg{url: authURL, state: state, providerName: provider.name}
|
|
}
|
|
}
|
|
|
|
func (m oauthTabModel) submitCallback(callbackURL string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
// Determine provider from current context
|
|
providerKey := ""
|
|
for _, p := range oauthProviders {
|
|
if p.name == m.providerName {
|
|
// Map provider name to the canonical key the API expects
|
|
switch p.apiPath {
|
|
case "gemini-cli-auth-url":
|
|
providerKey = "gemini"
|
|
case "anthropic-auth-url":
|
|
providerKey = "anthropic"
|
|
case "codex-auth-url":
|
|
providerKey = "codex"
|
|
case "antigravity-auth-url":
|
|
providerKey = "antigravity"
|
|
case "qwen-auth-url":
|
|
providerKey = "qwen"
|
|
case "kimi-auth-url":
|
|
providerKey = "kimi"
|
|
case "iflow-auth-url":
|
|
providerKey = "iflow"
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
body := map[string]string{
|
|
"provider": providerKey,
|
|
"redirect_url": callbackURL,
|
|
"state": m.authState,
|
|
}
|
|
err := m.client.postJSON("/v0/management/oauth-callback", body)
|
|
if err != nil {
|
|
return oauthCallbackSubmitMsg{err: err}
|
|
}
|
|
return oauthCallbackSubmitMsg{}
|
|
}
|
|
}
|
|
|
|
func (m oauthTabModel) pollOAuthStatus(state string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
// Poll session status for up to 5 minutes
|
|
deadline := time.Now().Add(5 * time.Minute)
|
|
for {
|
|
if time.Now().After(deadline) {
|
|
return oauthPollMsg{done: false, err: fmt.Errorf("%s", T("oauth_timeout"))}
|
|
}
|
|
|
|
time.Sleep(2 * time.Second)
|
|
|
|
status, errMsg, err := m.client.GetAuthStatus(state)
|
|
if err != nil {
|
|
continue // Ignore transient errors
|
|
}
|
|
|
|
switch status {
|
|
case "ok":
|
|
return oauthPollMsg{
|
|
done: true,
|
|
message: T("oauth_success"),
|
|
}
|
|
case "error":
|
|
return oauthPollMsg{
|
|
done: false,
|
|
err: fmt.Errorf("%s: %s", T("oauth_failed"), errMsg),
|
|
}
|
|
case "wait":
|
|
continue
|
|
default:
|
|
return oauthPollMsg{
|
|
done: true,
|
|
message: T("oauth_completed"),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *oauthTabModel) SetSize(w, h int) {
|
|
m.width = w
|
|
m.height = h
|
|
m.callbackInput.Width = w - 16
|
|
if !m.ready {
|
|
m.viewport = viewport.New(w, h)
|
|
m.viewport.SetContent(m.renderContent())
|
|
m.ready = true
|
|
} else {
|
|
m.viewport.Width = w
|
|
m.viewport.Height = h
|
|
}
|
|
}
|
|
|
|
func (m oauthTabModel) View() string {
|
|
if !m.ready {
|
|
return T("loading")
|
|
}
|
|
return m.viewport.View()
|
|
}
|
|
|
|
func (m oauthTabModel) renderContent() string {
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString(titleStyle.Render(T("oauth_title")))
|
|
sb.WriteString("\n\n")
|
|
|
|
if m.message != "" {
|
|
sb.WriteString(" " + m.message)
|
|
sb.WriteString("\n\n")
|
|
}
|
|
|
|
// ---- Remote browser mode ----
|
|
if m.state == oauthRemote {
|
|
sb.WriteString(m.renderRemoteMode())
|
|
return sb.String()
|
|
}
|
|
|
|
if m.state == oauthPending {
|
|
sb.WriteString(helpStyle.Render(T("oauth_press_esc")))
|
|
return sb.String()
|
|
}
|
|
|
|
sb.WriteString(helpStyle.Render(T("oauth_select")))
|
|
sb.WriteString("\n\n")
|
|
|
|
for i, p := range oauthProviders {
|
|
isSelected := i == m.cursor
|
|
prefix := " "
|
|
if isSelected {
|
|
prefix = "▸ "
|
|
}
|
|
|
|
label := fmt.Sprintf("%s %s", p.emoji, p.name)
|
|
if isSelected {
|
|
label = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Background(colorPrimary).Padding(0, 1).Render(label)
|
|
} else {
|
|
label = lipgloss.NewStyle().Foreground(colorText).Padding(0, 1).Render(label)
|
|
}
|
|
|
|
sb.WriteString(prefix + label + "\n")
|
|
}
|
|
|
|
sb.WriteString("\n")
|
|
sb.WriteString(helpStyle.Render(T("oauth_help")))
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func (m oauthTabModel) renderRemoteMode() string {
|
|
var sb strings.Builder
|
|
|
|
providerStyle := lipgloss.NewStyle().Bold(true).Foreground(colorHighlight)
|
|
sb.WriteString(providerStyle.Render(fmt.Sprintf(" ✦ %s OAuth", m.providerName)))
|
|
sb.WriteString("\n\n")
|
|
|
|
// Auth URL section
|
|
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(T("oauth_auth_url")))
|
|
sb.WriteString("\n")
|
|
|
|
// Wrap URL to fit terminal width
|
|
urlStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
|
|
maxURLWidth := m.width - 6
|
|
if maxURLWidth < 40 {
|
|
maxURLWidth = 40
|
|
}
|
|
wrappedURL := wrapText(m.authURL, maxURLWidth)
|
|
for _, line := range wrappedURL {
|
|
sb.WriteString(" " + urlStyle.Render(line) + "\n")
|
|
}
|
|
sb.WriteString("\n")
|
|
|
|
sb.WriteString(helpStyle.Render(T("oauth_remote_hint")))
|
|
sb.WriteString("\n\n")
|
|
|
|
// Callback URL input
|
|
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(T("oauth_callback_url")))
|
|
sb.WriteString("\n")
|
|
|
|
if m.inputActive {
|
|
sb.WriteString(m.callbackInput.View())
|
|
sb.WriteString("\n")
|
|
sb.WriteString(helpStyle.Render(" " + T("enter_submit") + " • " + T("esc_cancel")))
|
|
} else {
|
|
sb.WriteString(helpStyle.Render(T("oauth_press_c")))
|
|
}
|
|
|
|
sb.WriteString("\n\n")
|
|
sb.WriteString(warningStyle.Render(T("oauth_waiting")))
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// wrapText splits a long string into lines of at most maxWidth characters.
|
|
func wrapText(s string, maxWidth int) []string {
|
|
if maxWidth <= 0 {
|
|
return []string{s}
|
|
}
|
|
var lines []string
|
|
for len(s) > maxWidth {
|
|
lines = append(lines, s[:maxWidth])
|
|
s = s[maxWidth:]
|
|
}
|
|
if len(s) > 0 {
|
|
lines = append(lines, s)
|
|
}
|
|
return lines
|
|
}
|