diff --git a/pkg/oauthhandler/oauth2_handler.go b/pkg/oauthhandler/oauth2_handler.go index e96b906..1dfd081 100644 --- a/pkg/oauthhandler/oauth2_handler.go +++ b/pkg/oauthhandler/oauth2_handler.go @@ -6,47 +6,36 @@ import ( "log" "net" "net/http" + "net/url" "os/exec" "runtime" "github.com/facebookincubator/go-belt/tool/logger" ) -type OAuth2Handler struct { - authURL string - exchangeFn func(code string) error - receiverAddr string -} - -func NewOAuth2Handler( - authURL string, - exchangeFn func(code string) error, - receiverAddr string, -) *OAuth2Handler { - return &OAuth2Handler{ - authURL: authURL, - exchangeFn: exchangeFn, - receiverAddr: receiverAddr, - } +type OAuthHandlerArgument struct { + AuthURL string + RedirectURL string + ExchangeFn func(code string) error } // it is guaranteed exchangeFn was called if error is nil. -func (h *OAuth2Handler) Handle(ctx context.Context) error { - if h.receiverAddr != "" { - err := h.handleViaBrowser() +func OAuth2Handler(ctx context.Context, arg OAuthHandlerArgument) error { + if arg.RedirectURL != "" { + err := OAuth2HandlerViaBrowser(ctx, arg) if err == nil { return nil } logger.Errorf(ctx, "unable to authenticate automatically: %v", err) } - return h.handleViaCLI() + return OAuth2HandlerViaCLI(ctx, arg) } -func (h *OAuth2Handler) handleViaCLI() error { +func OAuth2HandlerViaCLI(ctx context.Context, arg OAuthHandlerArgument) error { fmt.Printf( "It is required to get an oauth2 token. "+ "Please open the link below in the browser:\n\n\t%s\n\n", - h.authURL, + arg.AuthURL, ) fmt.Printf("Enter the code: ") @@ -54,30 +43,35 @@ func (h *OAuth2Handler) handleViaCLI() error { if _, err := fmt.Scan(&code); err != nil { log.Fatalf("Unable to read authorization code %v", err) } - return h.exchangeFn(code) + return arg.ExchangeFn(code) } -func (h *OAuth2Handler) handleViaBrowser() error { - codeCh, err := h.newCodeReceiver() +func OAuth2HandlerViaBrowser(ctx context.Context, arg OAuthHandlerArgument) error { + codeCh, err := NewCodeReceiver(arg.RedirectURL) if err != nil { return err } - err = launchBrowser(h.authURL) + err = LaunchBrowser(arg.AuthURL) if err != nil { return err } - fmt.Printf("Your browser has been launched (URL: %s).\nPlease approve the permissions.\n", h.authURL) + fmt.Printf("Your browser has been launched (URL: %s).\nPlease approve the permissions.\n", arg.AuthURL) // Wait for the web server to get the code. code := <-codeCh - return h.exchangeFn(code) + return arg.ExchangeFn(code) } -func (h *OAuth2Handler) newCodeReceiver() (codeCh chan string, err error) { +func NewCodeReceiver(redirectURL string) (codeCh chan string, err error) { + urlParsed, err := url.Parse(redirectURL) + if err != nil { + return nil, fmt.Errorf("unable to parse URL '%s': %w", redirectURL, err) + } + // this function was mostly borrowed from https://developers.google.com/youtube/v3/code_samples/go#authorize_a_request - listener, err := net.Listen("tcp", h.receiverAddr) + listener, err := net.Listen("tcp", urlParsed.Host) if err != nil { return nil, err } @@ -94,7 +88,7 @@ func (h *OAuth2Handler) newCodeReceiver() (codeCh chan string, err error) { return codeCh, nil } -func launchBrowser(url string) error { +func LaunchBrowser(url string) error { switch runtime.GOOS { case "darwin": return exec.Command("open", url).Start() diff --git a/pkg/streamcontrol/config.go b/pkg/streamcontrol/config.go index 02a8f24..485d414 100644 --- a/pkg/streamcontrol/config.go +++ b/pkg/streamcontrol/config.go @@ -200,6 +200,8 @@ func GetPlatformSpecificConfig[T any]( switch platCfgCfg := platCfgCfg.(type) { case T: return platCfgCfg + case *T: + return *platCfgCfg case RawMessage: var v T err := yaml.Unmarshal(platCfgCfg, &v) diff --git a/pkg/streamcontrol/twitch/config.go b/pkg/streamcontrol/twitch/config.go index 42ab248..c2dcb35 100644 --- a/pkg/streamcontrol/twitch/config.go +++ b/pkg/streamcontrol/twitch/config.go @@ -1,20 +1,26 @@ package twitch import ( + "context" + + "github.com/xaionaro-go/streamctl/pkg/oauthhandler" streamctl "github.com/xaionaro-go/streamctl/pkg/streamcontrol" ) const ID = streamctl.PlatformName("twitch") +type OAuthHandler func(context.Context, oauthhandler.OAuthHandlerArgument) error + type PlatformSpecificConfig struct { - Channel string - ClientID string - ClientSecret string - ClientCode string - AuthType string - AppAccessToken string - UserAccessToken string - RefreshToken string + Channel string + ClientID string + ClientSecret string + ClientCode string + AuthType string + AppAccessToken string + UserAccessToken string + RefreshToken string + CustomOAuthHandler OAuthHandler `yaml:"-"` } type Config = streamctl.PlatformConfig[PlatformSpecificConfig, StreamProfile] diff --git a/pkg/streamcontrol/twitch/twitch.go b/pkg/streamcontrol/twitch/twitch.go index 6fc5a83..dc52e2c 100644 --- a/pkg/streamcontrol/twitch/twitch.go +++ b/pkg/streamcontrol/twitch/twitch.go @@ -208,11 +208,12 @@ func getClient( cfg Config, safeCfgFn func(Config) error, ) (*helix.Client, error) { - client, err := helix.NewClient(&helix.Options{ + options := &helix.Options{ ClientID: cfg.Config.ClientID, ClientSecret: cfg.Config.ClientSecret, - RedirectURI: "http://localhost/", - }) + RedirectURI: "http://localhost:8091/", + } + client, err := helix.NewClient(options) if err != nil { return nil, fmt.Errorf("unable to create a helix client object: %w", err) } @@ -236,13 +237,23 @@ func getClient( Scopes: []string{"channel:manage:broadcast"}, }) - oauthHandler := oauthhandler.NewOAuth2Handler(authURL, func(code string) error { - cfg.Config.ClientCode = code - err = safeCfgFn(cfg) - errmon.ObserveErrorCtx(ctx, err) - return nil - }, "") - err := oauthHandler.Handle(ctx) + arg := oauthhandler.OAuthHandlerArgument{ + AuthURL: authURL, + RedirectURL: options.RedirectURI, + ExchangeFn: func(code string) error { + cfg.Config.ClientCode = code + err = safeCfgFn(cfg) + errmon.ObserveErrorCtx(ctx, err) + return nil + }, + } + + oauthHandler := cfg.Config.CustomOAuthHandler + if oauthHandler == nil { + oauthHandler = oauthhandler.OAuth2HandlerViaCLI + } + + err := oauthHandler(ctx, arg) if err != nil { return nil, fmt.Errorf("unable to get or exchange the oauth code to a token: %w", err) } diff --git a/pkg/streamcontrol/youtube/config.go b/pkg/streamcontrol/youtube/config.go index 2805168..8e38e2a 100644 --- a/pkg/streamcontrol/youtube/config.go +++ b/pkg/streamcontrol/youtube/config.go @@ -1,16 +1,22 @@ package youtube import ( + "context" + + "github.com/xaionaro-go/streamctl/pkg/oauthhandler" streamctl "github.com/xaionaro-go/streamctl/pkg/streamcontrol" "golang.org/x/oauth2" ) const ID = streamctl.PlatformName("youtube") +type OAuthHandler func(context.Context, oauthhandler.OAuthHandlerArgument) error + type PlatformSpecificConfig struct { - ClientID string - ClientSecret string - Token *oauth2.Token + ClientID string + ClientSecret string + Token *oauth2.Token + CustomOAuthHandler OAuthHandler `yaml:"-"` } type Config = streamctl.PlatformConfig[PlatformSpecificConfig, StreamProfile] diff --git a/pkg/streamcontrol/youtube/youtube.go b/pkg/streamcontrol/youtube/youtube.go index e516a64..31487bf 100644 --- a/pkg/streamcontrol/youtube/youtube.go +++ b/pkg/streamcontrol/youtube/youtube.go @@ -122,18 +122,26 @@ func getAuthCfg(cfg Config) *oauth2.Config { func getToken(ctx context.Context, cfg Config) (*oauth2.Token, error) { googleAuthCfg := getAuthCfg(cfg) - authURL := googleAuthCfg.AuthCodeURL("state-token", oauth2.AccessTypeOffline) var tok *oauth2.Token - oauthHandler := oauthhandler.NewOAuth2Handler(authURL, func(code string) error { - _tok, err := googleAuthCfg.Exchange(ctx, code) - if err != nil { - return fmt.Errorf("unable to get a token: %w", err) - } - tok = _tok - return nil - }, "") - err := oauthHandler.Handle(ctx) + oauthHandlerArg := oauthhandler.OAuthHandlerArgument{ + AuthURL: googleAuthCfg.AuthCodeURL("state-token", oauth2.AccessTypeOffline), + RedirectURL: googleAuthCfg.RedirectURL, + ExchangeFn: func(code string) error { + _tok, err := googleAuthCfg.Exchange(ctx, code) + if err != nil { + return fmt.Errorf("unable to get a token: %w", err) + } + tok = _tok + return nil + }, + } + + oauthHandler := cfg.Config.CustomOAuthHandler + if oauthHandler == nil { + oauthHandler = oauthhandler.OAuth2HandlerViaCLI + } + err := oauthHandler(ctx, oauthHandlerArg) if err != nil { return nil, err } diff --git a/pkg/streampanel/panel.go b/pkg/streampanel/panel.go index 484c75b..db19e51 100644 --- a/pkg/streampanel/panel.go +++ b/pkg/streampanel/panel.go @@ -5,7 +5,9 @@ import ( _ "embed" "fmt" "os" + "os/exec" "path" + "runtime" "runtime/debug" "sort" "strings" @@ -20,6 +22,7 @@ import ( "github.com/facebookincubator/go-belt/tool/experimental/errmon" "github.com/facebookincubator/go-belt/tool/logger" "github.com/go-ng/xmath" + "github.com/xaionaro-go/streamctl/pkg/oauthhandler" "github.com/xaionaro-go/streamctl/pkg/streamcontrol" "github.com/xaionaro-go/streamctl/pkg/streamcontrol/twitch" "github.com/xaionaro-go/streamctl/pkg/streamcontrol/youtube" @@ -35,6 +38,7 @@ type Panel struct { dataLock sync.Mutex data panelData + app fyne.App config config startStopMutex sync.Mutex updateTimerHandler *updateTimerHandler @@ -77,23 +81,24 @@ func (p *Panel) Loop(ctx context.Context) error { return fmt.Errorf("unable to load the data '%s': %w", p.dataPath, err) } + p.app = fyneapp.New() + if err := p.initStreamControllers(); err != nil { return fmt.Errorf("unable to initialize stream controllers: %w", err) } - a := fyneapp.New() - p.initTwitchData(a) + p.initTwitchData() p.normalizeTwitchData() - p.initYoutubeData(a) + p.initYoutubeData() p.normalizeYoutubeData() - p.initMainWindow(ctx, a) + p.initMainWindow(ctx) p.mainWindow.ShowAndRun() return nil } -func (p *Panel) initTwitchData(a fyne.App) { +func (p *Panel) initTwitchData() { logger.FromCtx(p.defaultContext).Debugf("initializing Twitch data") defer logger.FromCtx(p.defaultContext).Debugf("endof initializing Twitch data") @@ -110,7 +115,7 @@ func (p *Panel) initTwitchData(a fyne.App) { allCategories, err := twitch.GetAllCategories(p.defaultContext) if err != nil { - p.displayError(a, err) + p.displayError(err) return } @@ -133,7 +138,7 @@ func (p *Panel) normalizeTwitchData() { }) } -func (p *Panel) initYoutubeData(a fyne.App) { +func (p *Panel) initYoutubeData() { logger.FromCtx(p.defaultContext).Debugf("initializing Youtube data") defer logger.FromCtx(p.defaultContext).Debugf("endof initializing Youtube data") @@ -150,7 +155,7 @@ func (p *Panel) initYoutubeData(a fyne.App) { broadcasts, err := youtube.ListBroadcasts(p.defaultContext) if err != nil { - p.displayError(a, err) + p.displayError(err) return } @@ -223,6 +228,77 @@ func (p *Panel) savePlatformConfig( return p.saveData() } +func (p *Panel) oauthHandlerTwitch(ctx context.Context, arg oauthhandler.OAuthHandlerArgument) error { + logger.Infof(ctx, "oauthHandlerTwitch: %#+v", arg) + defer logger.Infof(ctx, "/oauthHandlerTwitch") + return p.oauthHandler(ctx, arg) +} + +func (p *Panel) oauthHandlerYouTube(ctx context.Context, arg oauthhandler.OAuthHandlerArgument) error { + logger.Infof(ctx, "oauthHandlerYouTube: %#+v", arg) + defer logger.Infof(ctx, "/oauthHandlerYouTube") + return p.oauthHandler(ctx, arg) +} + +func (p *Panel) oauthHandler(ctx context.Context, arg oauthhandler.OAuthHandlerArgument) error { + codeCh, err := oauthhandler.NewCodeReceiver(arg.RedirectURL) + if err != nil { + return err + } + + if err := p.openBrowser(arg.AuthURL); err != nil { + return fmt.Errorf("unable to open browser with URL '%s': %w", arg.AuthURL, err) + } + + logger.Infof(ctx, "Your browser has been launched (URL: %s).\nPlease approve the permissions.\n", arg.AuthURL) + + // Wait for the web server to get the code. + code := <-codeCh + logger.Debugf(ctx, "received the auth code") + return arg.ExchangeFn(code) +} + +func (p *Panel) openBrowser(authURL string) error { + var browserCmd string + switch runtime.GOOS { + case "darwin": + browserCmd = "open" + case "linux": + browserCmd = "xdg-open" + default: + return oauthhandler.LaunchBrowser(authURL) + } + + waitCh := make(chan struct{}) + + w := p.app.NewWindow("Browser selection window") + browserField := widget.NewEntry() + browserField.PlaceHolder = "command to execute the browser" + browserField.OnSubmitted = func(s string) { + close(waitCh) + } + okButton := widget.NewButton("OK", func() { + close(waitCh) + }) + w.SetContent(container.NewBorder( + browserField, + okButton, + nil, + nil, + nil, + )) + + go w.ShowAndRun() + <-waitCh + w.Close() + + if browserField.Text != "" { + browserCmd = browserField.Text + } + + return exec.Command(browserCmd, authURL).Start() +} + func (p *Panel) initStreamControllers() error { for platName, cfg := range p.data.Backends { var err error @@ -230,11 +306,11 @@ func (p *Panel) initStreamControllers() error { case strings.ToLower(string(twitch.ID)): p.streamControllers.Twitch, err = newTwitch(p.defaultContext, cfg, func(cfg *streamcontrol.AbstractPlatformConfig) error { return p.savePlatformConfig(twitch.ID, cfg) - }) + }, p.oauthHandlerTwitch) case strings.ToLower(string(youtube.ID)): p.streamControllers.YouTube, err = newYouTube(p.defaultContext, cfg, func(cfg *streamcontrol.AbstractPlatformConfig) error { return p.savePlatformConfig(youtube.ID, cfg) - }) + }, p.oauthHandlerYouTube) } if err != nil { return fmt.Errorf("unable to initialize '%s': %w", platName, err) @@ -432,8 +508,8 @@ func (p *Panel) setFilter(filter string) { p.refilterProfiles() } -func (p *Panel) initMainWindow(ctx context.Context, a fyne.App) { - w := a.NewWindow("TimeTracker") +func (p *Panel) initMainWindow(ctx context.Context) { + w := p.app.NewWindow("StreamPanel") p.startStopButton = widget.NewButtonWithIcon("", theme.MediaPlayIcon(), p.onStartStopButton) p.startStopButton.Importance = widget.SuccessImportance @@ -449,7 +525,7 @@ func (p *Panel) initMainWindow(ctx context.Context, a fyne.App) { topPanel := container.NewVBox( container.NewHBox( widget.NewButtonWithIcon("Profile", theme.ContentAddIcon(), func() { - p.newProfileWindow(ctx, a) + p.newProfileWindow(ctx) }), ), profileFilter, @@ -512,13 +588,13 @@ func cleanYoutubeRecordingName(in string) string { return strings.ToLower(strings.Trim(in, " ")) } -func (p *Panel) newProfileWindow(_ context.Context, a fyne.App) fyne.Window { +func (p *Panel) newProfileWindow(_ context.Context) fyne.Window { var ( twitchProfile *twitch.StreamProfile youtubeProfile *youtube.StreamProfile ) - w := a.NewWindow("Create a profile") + w := p.app.NewWindow("Create a profile") w.Resize(fyne.NewSize(400, 300)) activityTitle := widget.NewEntry() activityTitle.SetPlaceHolder("title") @@ -682,7 +758,7 @@ func (p *Panel) newProfileWindow(_ context.Context, a fyne.App) fyne.Window { } err := fmt.Errorf("creating a profile is not implemented") if err != nil { - p.displayError(a, err) + p.displayError(err) return } w.Close() @@ -704,8 +780,8 @@ func (p *Panel) newProfileWindow(_ context.Context, a fyne.App) fyne.Window { return w } -func (p *Panel) displayError(a fyne.App, err error) { - w := a.NewWindow("Got an error: " + err.Error()) +func (p *Panel) displayError(err error) { + w := p.app.NewWindow("Got an error: " + err.Error()) errorMessage := fmt.Sprintf("Error: %v\n\nstack trace:\n%s", err, debug.Stack()) w.Resize(fyne.NewSize(400, 300)) w.SetContent(widget.NewLabelWithStyle(errorMessage, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})) diff --git a/pkg/streampanel/stream_controller.go b/pkg/streampanel/stream_controller.go index 89df741..f455ee3 100644 --- a/pkg/streampanel/stream_controller.go +++ b/pkg/streampanel/stream_controller.go @@ -14,6 +14,7 @@ func newTwitch( ctx context.Context, cfg *streamcontrol.AbstractPlatformConfig, saveCfgFunc func(*streamcontrol.AbstractPlatformConfig) error, + customOAuthHandler twitch.OAuthHandler, ) ( *twitch.Twitch, error, @@ -26,6 +27,7 @@ func newTwitch( } logger.Debugf(ctx, "twitch config: %#+v", platCfg) + platCfg.Config.CustomOAuthHandler = customOAuthHandler return twitch.New(ctx, *platCfg, func(c twitch.Config) error { return saveCfgFunc(&streamcontrol.AbstractPlatformConfig{ @@ -40,6 +42,7 @@ func newYouTube( ctx context.Context, cfg *streamcontrol.AbstractPlatformConfig, saveCfgFunc func(*streamcontrol.AbstractPlatformConfig) error, + customOAuthHandler youtube.OAuthHandler, ) ( *youtube.YouTube, error, @@ -52,6 +55,7 @@ func newYouTube( } logger.Debugf(ctx, "youtube config: %#+v", platCfg) + platCfg.Config.CustomOAuthHandler = customOAuthHandler return youtube.New(ctx, *platCfg, func(c youtube.Config) error { return saveCfgFunc(&streamcontrol.AbstractPlatformConfig{