device_flow.go 7.2 KB

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