device_flow.go

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