1// Package hyper provides the dialog for Hyper device flow authentication.
2package hyper
3
4import (
5 "context"
6 "fmt"
7 "time"
8
9 "charm.land/bubbles/v2/spinner"
10 tea "charm.land/bubbletea/v2"
11 "charm.land/lipgloss/v2"
12 hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
13 "github.com/charmbracelet/crush/internal/oauth"
14 "github.com/charmbracelet/crush/internal/oauth/hyper"
15 "github.com/charmbracelet/crush/internal/tui/styles"
16 "github.com/charmbracelet/crush/internal/tui/util"
17 "github.com/pkg/browser"
18)
19
20// DeviceFlowState represents the current state of the device flow.
21type DeviceFlowState int
22
23const (
24 DeviceFlowStateDisplay DeviceFlowState = iota
25 DeviceFlowStateSuccess
26 DeviceFlowStateError
27)
28
29// DeviceAuthInitiatedMsg is sent when the device auth is initiated
30// successfully.
31type DeviceAuthInitiatedMsg struct {
32 deviceCode string
33 expiresIn int
34}
35
36// DeviceFlowCompletedMsg is sent when the device flow completes successfully.
37type DeviceFlowCompletedMsg struct {
38 Token *oauth.Token
39}
40
41// DeviceFlowErrorMsg is sent when the device flow encounters an error.
42type DeviceFlowErrorMsg struct {
43 Error error
44}
45
46// DeviceFlow handles the Hyper device flow authentication.
47type DeviceFlow struct {
48 State DeviceFlowState
49 width int
50 baseURL string
51 deviceCode string
52 userCode string
53 verificationURL string
54 expiresIn int
55 token *oauth.Token
56 cancelFunc context.CancelFunc
57 spinner spinner.Model
58}
59
60// NewDeviceFlow creates a new device flow component.
61func NewDeviceFlow() *DeviceFlow {
62 s := spinner.New()
63 s.Spinner = spinner.Dot
64 s.Style = lipgloss.NewStyle().Foreground(styles.CurrentTheme().GreenLight)
65 return &DeviceFlow{
66 State: DeviceFlowStateDisplay,
67 baseURL: hyperp.BaseURL(),
68 spinner: s,
69 }
70}
71
72// Init initializes the device flow by calling the device auth API and starting polling.
73func (d *DeviceFlow) Init() tea.Cmd {
74 return tea.Batch(d.spinner.Tick, d.initiateDeviceAuth)
75}
76
77// Update handles messages and state transitions.
78func (d *DeviceFlow) Update(msg tea.Msg) (util.Model, tea.Cmd) {
79 var cmd tea.Cmd
80 d.spinner, cmd = d.spinner.Update(msg)
81
82 switch msg := msg.(type) {
83 case DeviceAuthInitiatedMsg:
84 // Start polling now that we have the device code.
85 d.expiresIn = msg.expiresIn
86 return d, tea.Batch(cmd, d.startPolling(msg.deviceCode))
87 case DeviceFlowCompletedMsg:
88 d.State = DeviceFlowStateSuccess
89 d.token = msg.Token
90 return d, nil
91 case DeviceFlowErrorMsg:
92 d.State = DeviceFlowStateError
93 return d, util.ReportError(msg.Error)
94 }
95
96 return d, cmd
97}
98
99// View renders the device flow dialog.
100func (d *DeviceFlow) View() string {
101 t := styles.CurrentTheme()
102
103 whiteStyle := lipgloss.NewStyle().Foreground(t.White)
104 primaryStyle := lipgloss.NewStyle().Foreground(t.Primary)
105 greenStyle := lipgloss.NewStyle().Foreground(t.GreenLight)
106 errorStyle := lipgloss.NewStyle().Foreground(t.Error)
107 mutedStyle := lipgloss.NewStyle().Foreground(t.FgMuted)
108
109 switch d.State {
110 case DeviceFlowStateDisplay:
111 if d.userCode == "" {
112 return lipgloss.NewStyle().
113 Margin(0, 1).
114 Render(
115 greenStyle.Render(d.spinner.View()) +
116 mutedStyle.Render("Initializing..."),
117 )
118 }
119
120 instructions := lipgloss.NewStyle().
121 Margin(1, 1, 0, 1).
122 Width(d.width - 2).
123 Render(
124
125 whiteStyle.Render("Press ") +
126 primaryStyle.Render("enter") +
127 whiteStyle.Render(" to copy the code below and open the browser."),
128 )
129
130 codeBox := lipgloss.NewStyle().
131 Width(d.width-2).
132 Height(7).
133 Align(lipgloss.Center, lipgloss.Center).
134 Background(t.BgBaseLighter).
135 Margin(1).
136 Render(
137 lipgloss.NewStyle().
138 Bold(true).
139 Foreground(t.White).
140 Render(d.userCode),
141 )
142
143 link := lipgloss.NewStyle().Hyperlink(d.verificationURL, "id=hyper-verify").Render(d.verificationURL)
144 url := mutedStyle.
145 Margin(0, 1).
146 Width(d.width - 2).
147 Render("Browser not opening? Refer to\n" + link)
148
149 waiting := greenStyle.
150 Width(d.width-2).
151 Margin(1, 1, 0, 1).
152 Render(d.spinner.View() + "Verifying...")
153
154 return lipgloss.JoinVertical(
155 lipgloss.Left,
156 instructions,
157 codeBox,
158 url,
159 waiting,
160 )
161
162 case DeviceFlowStateSuccess:
163 return greenStyle.Margin(0, 1).Render("Authentication successful!")
164
165 case DeviceFlowStateError:
166 return lipgloss.NewStyle().
167 Margin(0, 1).
168 Width(d.width).
169 Render(errorStyle.Render("Authentication failed."))
170
171 default:
172 return ""
173 }
174}
175
176// SetWidth sets the width of the dialog.
177func (d *DeviceFlow) SetWidth(w int) {
178 d.width = w
179}
180
181// Cursor hides the cursor.
182func (d *DeviceFlow) Cursor() *tea.Cursor { return nil }
183
184// CopyCodeAndOpenURL copies the user code to the clipboard and opens the URL.
185func (d *DeviceFlow) CopyCodeAndOpenURL() tea.Cmd {
186 return tea.Sequence(
187 tea.SetClipboard(d.userCode),
188 func() tea.Msg {
189 if err := browser.OpenURL(d.verificationURL); err != nil {
190 return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)}
191 }
192 return nil
193 },
194 util.ReportInfo("Code copied and URL opened"),
195 )
196}
197
198// CopyCode copies just the user code to the clipboard.
199func (d *DeviceFlow) CopyCode() tea.Cmd {
200 return tea.Sequence(
201 tea.SetClipboard(d.userCode),
202 util.ReportInfo("Code copied to clipboard"),
203 )
204}
205
206// Cancel cancels the device flow polling.
207func (d *DeviceFlow) Cancel() {
208 if d.cancelFunc != nil {
209 d.cancelFunc()
210 }
211}
212
213func (d *DeviceFlow) initiateDeviceAuth() tea.Msg {
214 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
215 defer cancel()
216 authResp, err := hyper.InitiateDeviceAuth(ctx)
217 if err != nil {
218 return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to initiate device auth: %w", err)}
219 }
220
221 d.deviceCode = authResp.DeviceCode
222 d.userCode = authResp.UserCode
223 d.verificationURL = authResp.VerificationURL
224
225 return DeviceAuthInitiatedMsg{
226 deviceCode: authResp.DeviceCode,
227 expiresIn: authResp.ExpiresIn,
228 }
229}
230
231// startPolling starts polling for the device token.
232func (d *DeviceFlow) startPolling(deviceCode string) tea.Cmd {
233 return func() tea.Msg {
234 ctx, cancel := context.WithCancel(context.Background())
235 d.cancelFunc = cancel
236
237 // Poll for refresh token.
238 refreshToken, err := hyper.PollForToken(ctx, deviceCode, d.expiresIn)
239 if err != nil {
240 if ctx.Err() != nil {
241 // Cancelled, don't report error.
242 return nil
243 }
244 return DeviceFlowErrorMsg{Error: err}
245 }
246
247 // Exchange refresh token for access token.
248 token, err := hyper.ExchangeToken(ctx, refreshToken)
249 if err != nil {
250 return DeviceFlowErrorMsg{Error: fmt.Errorf("token exchange failed: %w", err)}
251 }
252
253 // Verify the access token works.
254 introspect, err := hyper.IntrospectToken(ctx, token.AccessToken)
255 if err != nil {
256 return DeviceFlowErrorMsg{Error: fmt.Errorf("token introspection failed: %w", err)}
257 }
258 if !introspect.Active {
259 return DeviceFlowErrorMsg{Error: fmt.Errorf("access token is not active")}
260 }
261
262 return DeviceFlowCompletedMsg{Token: token}
263 }
264}