@@ -4,7 +4,6 @@ import (
44 "context"
55 "fmt"
66 "strings"
7− "time"
87
98 "charm.land/bubbles/v2/help"
109 "charm.land/bubbles/v2/key"
@@ -14,13 +13,19 @@ import (
1413 "github.com/charmbracelet/catwalk/pkg/catwalk"
1514 "github.com/charmbracelet/crush/internal/config"
1615 "github.com/charmbracelet/crush/internal/oauth"
17− "github.com/charmbracelet/crush/internal/oauth/hyper"
1816 "github.com/charmbracelet/crush/internal/ui/common"
1917 "github.com/charmbracelet/crush/internal/uiutil"
2018 uv "github.com/charmbracelet/ultraviolet"
2119 "github.com/pkg/browser"
2220)
2321
22+type OAuthProvider interface {
23+ name() string
24+ initiateAuth() tea.Msg
25+ startPolling(deviceCode string, expiresIn int) tea.Cmd
26+ stopPolling() tea.Msg
27+}
28+
2429// OAuthState represents the current state of the device flow.
2530type OAuthState int
2631
@@ -38,9 +43,10 @@ const OAuthID = "oauth"
3843type OAuth struct {
3944 com *common.Common
4045
41− provider catwalk.Provider
42− model config.SelectedModel
43− modelType config.SelectedModelType
46+ provider catwalk.Provider
47+ model config.SelectedModel
48+ modelType config.SelectedModelType
49+ oAuthProvider OAuthProvider
4450
4551 State OAuthState
4652
@@ -63,8 +69,8 @@ type OAuth struct {
6369
6470var _ Dialog = (*OAuth)(nil)
6571
66−// NewOAuth creates a new device flow component.
67−func NewOAuth(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, error) {
72+// newOAuth creates a new device flow component.
73+func newOAuth(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType, oAuthProvider OAuthProvider) (*OAuth, error) {
6874 t := com.Styles
6975
7076 m := OAuth{}
@@ -72,6 +78,7 @@ func NewOAuth(com *common.Common, provider catwalk.Provider, model config.Select
7278 m.provider = provider
7379 m.model = model
7480 m.modelType = modelType
81+ m.oAuthProvider = oAuthProvider
7582 m.width = 60
7683 m.State = OAuthStateInitializing
7784
@@ -103,7 +110,7 @@ func (m *OAuth) ID() string {
103110
104111// Init implements Dialog.
105112func (m *OAuth) Init() tea.Cmd {
106− return tea.Batch(m.spinner.Tick, m.initiateDeviceAuth)
113+ return tea.Batch(m.spinner.Tick, m.oAuthProvider.initiateAuth)
107114}
108115
109116// HandleMsg handles messages and state transitions.
@@ -151,16 +158,16 @@ func (m *OAuth) HandleMsg(msg tea.Msg) Action {
151158 m.expiresIn = msg.ExpiresIn
152159 m.verificationURL = msg.VerificationURL
153160 m.State = OAuthStateDisplay
154− return ActionCmd{m.startPolling(msg.DeviceCode)}
161+ return ActionCmd{m.oAuthProvider.startPolling(msg.DeviceCode, msg.ExpiresIn)}
155162
156163 case ActionCompleteOAuth:
157164 m.State = OAuthStateSuccess
158165 m.token = msg.Token
159− return ActionCmd{m.stopPolling}
166+ return ActionCmd{m.oAuthProvider.stopPolling}
160167
161168 case ActionOAuthErrored:
162169 m.State = OAuthStateError
163− cmd := tea.Batch(m.stopPolling, uiutil.ReportError(msg.Error))
170+ cmd := tea.Batch(m.oAuthProvider.stopPolling, uiutil.ReportError(msg.Error))
164171 return ActionCmd{cmd}
165172 }
166173 return nil
@@ -204,7 +211,7 @@ func (m *OAuth) headerContent() string {
204211 dialogStyle = t.Dialog.View.Width(m.width)
205212 headerOffset = titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
206213 )
207− return common.DialogTitle(t, titleStyle.Render("Authenticate with Hyper"), m.width-headerOffset)
214+ return common.DialogTitle(t, titleStyle.Render("Authenticate with "+m.oAuthProvider.name()), m.width-headerOffset)
208215}
209216
210217func (m *OAuth) innerDialogContent() string {
@@ -357,67 +364,3 @@ func (m *OAuth) saveKeyAndContinue() Action {
357364 ModelType: m.modelType,
358365 }
359366}
360−
361−func (m *OAuth) initiateDeviceAuth() tea.Msg {
362− minimumWait := 750 * time.Millisecond
363− startTime := time.Now()
364−
365− ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
366− defer cancel()
367−
368− authResp, err := hyper.InitiateDeviceAuth(ctx)
369−
370− ellapsed := time.Since(startTime)
371− if ellapsed < minimumWait {
372− time.Sleep(minimumWait - ellapsed)
373− }
374−
375− if err != nil {
376− return ActionOAuthErrored{fmt.Errorf("failed to initiate device auth: %w", err)}
377− }
378−
379− return ActionInitiateOAuth{
380− DeviceCode: authResp.DeviceCode,
381− UserCode: authResp.UserCode,
382− ExpiresIn: authResp.ExpiresIn,
383− VerificationURL: authResp.VerificationURL,
384− }
385−}
386−
387−// startPolling starts polling for the device token.
388−func (m *OAuth) startPolling(deviceCode string) tea.Cmd {
389− return func() tea.Msg {
390− ctx, cancel := context.WithCancel(context.Background())
391− m.cancelFunc = cancel
392−
393− refreshToken, err := hyper.PollForToken(ctx, deviceCode, m.expiresIn)
394− if err != nil {
395− if ctx.Err() != nil {
396− return nil
397− }
398− return ActionOAuthErrored{err}
399− }
400−
401− token, err := hyper.ExchangeToken(ctx, refreshToken)
402− if err != nil {
403− return ActionOAuthErrored{fmt.Errorf("token exchange failed: %w", err)}
404− }
405−
406− introspect, err := hyper.IntrospectToken(ctx, token.AccessToken)
407− if err != nil {
408− return ActionOAuthErrored{fmt.Errorf("token introspection failed: %w", err)}
409− }
410− if !introspect.Active {
411− return ActionOAuthErrored{fmt.Errorf("access token is not active")}
412− }
413−
414− return ActionCompleteOAuth{token}
415− }
416−}
417−
418−func (m *OAuth) stopPolling() tea.Msg {
419− if m.cancelFunc != nil {
420− m.cancelFunc()
421− }
422− return nil
423−}