Initial commit, pt. 10

This commit is contained in:
Dmitrii Okunev
2024-06-04 00:37:23 +01:00
parent 56f78af025
commit ada2d30192
8 changed files with 187 additions and 80 deletions

View File

@@ -6,47 +6,36 @@ import (
"log" "log"
"net" "net"
"net/http" "net/http"
"net/url"
"os/exec" "os/exec"
"runtime" "runtime"
"github.com/facebookincubator/go-belt/tool/logger" "github.com/facebookincubator/go-belt/tool/logger"
) )
type OAuth2Handler struct { type OAuthHandlerArgument struct {
authURL string AuthURL string
exchangeFn func(code string) error RedirectURL string
receiverAddr string ExchangeFn func(code string) error
}
func NewOAuth2Handler(
authURL string,
exchangeFn func(code string) error,
receiverAddr string,
) *OAuth2Handler {
return &OAuth2Handler{
authURL: authURL,
exchangeFn: exchangeFn,
receiverAddr: receiverAddr,
}
} }
// it is guaranteed exchangeFn was called if error is nil. // it is guaranteed exchangeFn was called if error is nil.
func (h *OAuth2Handler) Handle(ctx context.Context) error { func OAuth2Handler(ctx context.Context, arg OAuthHandlerArgument) error {
if h.receiverAddr != "" { if arg.RedirectURL != "" {
err := h.handleViaBrowser() err := OAuth2HandlerViaBrowser(ctx, arg)
if err == nil { if err == nil {
return nil return nil
} }
logger.Errorf(ctx, "unable to authenticate automatically: %v", err) 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( fmt.Printf(
"It is required to get an oauth2 token. "+ "It is required to get an oauth2 token. "+
"Please open the link below in the browser:\n\n\t%s\n\n", "Please open the link below in the browser:\n\n\t%s\n\n",
h.authURL, arg.AuthURL,
) )
fmt.Printf("Enter the code: ") fmt.Printf("Enter the code: ")
@@ -54,30 +43,35 @@ func (h *OAuth2Handler) handleViaCLI() error {
if _, err := fmt.Scan(&code); err != nil { if _, err := fmt.Scan(&code); err != nil {
log.Fatalf("Unable to read authorization code %v", err) log.Fatalf("Unable to read authorization code %v", err)
} }
return h.exchangeFn(code) return arg.ExchangeFn(code)
} }
func (h *OAuth2Handler) handleViaBrowser() error { func OAuth2HandlerViaBrowser(ctx context.Context, arg OAuthHandlerArgument) error {
codeCh, err := h.newCodeReceiver() codeCh, err := NewCodeReceiver(arg.RedirectURL)
if err != nil { if err != nil {
return err return err
} }
err = launchBrowser(h.authURL) err = LaunchBrowser(arg.AuthURL)
if err != nil { if err != nil {
return err 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. // Wait for the web server to get the code.
code := <-codeCh 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 // 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 { if err != nil {
return nil, err return nil, err
} }
@@ -94,7 +88,7 @@ func (h *OAuth2Handler) newCodeReceiver() (codeCh chan string, err error) {
return codeCh, nil return codeCh, nil
} }
func launchBrowser(url string) error { func LaunchBrowser(url string) error {
switch runtime.GOOS { switch runtime.GOOS {
case "darwin": case "darwin":
return exec.Command("open", url).Start() return exec.Command("open", url).Start()

View File

@@ -200,6 +200,8 @@ func GetPlatformSpecificConfig[T any](
switch platCfgCfg := platCfgCfg.(type) { switch platCfgCfg := platCfgCfg.(type) {
case T: case T:
return platCfgCfg return platCfgCfg
case *T:
return *platCfgCfg
case RawMessage: case RawMessage:
var v T var v T
err := yaml.Unmarshal(platCfgCfg, &v) err := yaml.Unmarshal(platCfgCfg, &v)

View File

@@ -1,20 +1,26 @@
package twitch package twitch
import ( import (
"context"
"github.com/xaionaro-go/streamctl/pkg/oauthhandler"
streamctl "github.com/xaionaro-go/streamctl/pkg/streamcontrol" streamctl "github.com/xaionaro-go/streamctl/pkg/streamcontrol"
) )
const ID = streamctl.PlatformName("twitch") const ID = streamctl.PlatformName("twitch")
type OAuthHandler func(context.Context, oauthhandler.OAuthHandlerArgument) error
type PlatformSpecificConfig struct { type PlatformSpecificConfig struct {
Channel string Channel string
ClientID string ClientID string
ClientSecret string ClientSecret string
ClientCode string ClientCode string
AuthType string AuthType string
AppAccessToken string AppAccessToken string
UserAccessToken string UserAccessToken string
RefreshToken string RefreshToken string
CustomOAuthHandler OAuthHandler `yaml:"-"`
} }
type Config = streamctl.PlatformConfig[PlatformSpecificConfig, StreamProfile] type Config = streamctl.PlatformConfig[PlatformSpecificConfig, StreamProfile]

View File

@@ -208,11 +208,12 @@ func getClient(
cfg Config, cfg Config,
safeCfgFn func(Config) error, safeCfgFn func(Config) error,
) (*helix.Client, error) { ) (*helix.Client, error) {
client, err := helix.NewClient(&helix.Options{ options := &helix.Options{
ClientID: cfg.Config.ClientID, ClientID: cfg.Config.ClientID,
ClientSecret: cfg.Config.ClientSecret, ClientSecret: cfg.Config.ClientSecret,
RedirectURI: "http://localhost/", RedirectURI: "http://localhost:8091/",
}) }
client, err := helix.NewClient(options)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to create a helix client object: %w", err) return nil, fmt.Errorf("unable to create a helix client object: %w", err)
} }
@@ -236,13 +237,23 @@ func getClient(
Scopes: []string{"channel:manage:broadcast"}, Scopes: []string{"channel:manage:broadcast"},
}) })
oauthHandler := oauthhandler.NewOAuth2Handler(authURL, func(code string) error { arg := oauthhandler.OAuthHandlerArgument{
cfg.Config.ClientCode = code AuthURL: authURL,
err = safeCfgFn(cfg) RedirectURL: options.RedirectURI,
errmon.ObserveErrorCtx(ctx, err) ExchangeFn: func(code string) error {
return nil cfg.Config.ClientCode = code
}, "") err = safeCfgFn(cfg)
err := oauthHandler.Handle(ctx) errmon.ObserveErrorCtx(ctx, err)
return nil
},
}
oauthHandler := cfg.Config.CustomOAuthHandler
if oauthHandler == nil {
oauthHandler = oauthhandler.OAuth2HandlerViaCLI
}
err := oauthHandler(ctx, arg)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to get or exchange the oauth code to a token: %w", err) return nil, fmt.Errorf("unable to get or exchange the oauth code to a token: %w", err)
} }

View File

@@ -1,16 +1,22 @@
package youtube package youtube
import ( import (
"context"
"github.com/xaionaro-go/streamctl/pkg/oauthhandler"
streamctl "github.com/xaionaro-go/streamctl/pkg/streamcontrol" streamctl "github.com/xaionaro-go/streamctl/pkg/streamcontrol"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
const ID = streamctl.PlatformName("youtube") const ID = streamctl.PlatformName("youtube")
type OAuthHandler func(context.Context, oauthhandler.OAuthHandlerArgument) error
type PlatformSpecificConfig struct { type PlatformSpecificConfig struct {
ClientID string ClientID string
ClientSecret string ClientSecret string
Token *oauth2.Token Token *oauth2.Token
CustomOAuthHandler OAuthHandler `yaml:"-"`
} }
type Config = streamctl.PlatformConfig[PlatformSpecificConfig, StreamProfile] type Config = streamctl.PlatformConfig[PlatformSpecificConfig, StreamProfile]

View File

@@ -122,18 +122,26 @@ func getAuthCfg(cfg Config) *oauth2.Config {
func getToken(ctx context.Context, cfg Config) (*oauth2.Token, error) { func getToken(ctx context.Context, cfg Config) (*oauth2.Token, error) {
googleAuthCfg := getAuthCfg(cfg) googleAuthCfg := getAuthCfg(cfg)
authURL := googleAuthCfg.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
var tok *oauth2.Token var tok *oauth2.Token
oauthHandler := oauthhandler.NewOAuth2Handler(authURL, func(code string) error { oauthHandlerArg := oauthhandler.OAuthHandlerArgument{
_tok, err := googleAuthCfg.Exchange(ctx, code) AuthURL: googleAuthCfg.AuthCodeURL("state-token", oauth2.AccessTypeOffline),
if err != nil { RedirectURL: googleAuthCfg.RedirectURL,
return fmt.Errorf("unable to get a token: %w", err) ExchangeFn: func(code string) error {
} _tok, err := googleAuthCfg.Exchange(ctx, code)
tok = _tok if err != nil {
return nil return fmt.Errorf("unable to get a token: %w", err)
}, "") }
err := oauthHandler.Handle(ctx) tok = _tok
return nil
},
}
oauthHandler := cfg.Config.CustomOAuthHandler
if oauthHandler == nil {
oauthHandler = oauthhandler.OAuth2HandlerViaCLI
}
err := oauthHandler(ctx, oauthHandlerArg)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -5,7 +5,9 @@ import (
_ "embed" _ "embed"
"fmt" "fmt"
"os" "os"
"os/exec"
"path" "path"
"runtime"
"runtime/debug" "runtime/debug"
"sort" "sort"
"strings" "strings"
@@ -20,6 +22,7 @@ import (
"github.com/facebookincubator/go-belt/tool/experimental/errmon" "github.com/facebookincubator/go-belt/tool/experimental/errmon"
"github.com/facebookincubator/go-belt/tool/logger" "github.com/facebookincubator/go-belt/tool/logger"
"github.com/go-ng/xmath" "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"
"github.com/xaionaro-go/streamctl/pkg/streamcontrol/twitch" "github.com/xaionaro-go/streamctl/pkg/streamcontrol/twitch"
"github.com/xaionaro-go/streamctl/pkg/streamcontrol/youtube" "github.com/xaionaro-go/streamctl/pkg/streamcontrol/youtube"
@@ -35,6 +38,7 @@ type Panel struct {
dataLock sync.Mutex dataLock sync.Mutex
data panelData data panelData
app fyne.App
config config config config
startStopMutex sync.Mutex startStopMutex sync.Mutex
updateTimerHandler *updateTimerHandler 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) return fmt.Errorf("unable to load the data '%s': %w", p.dataPath, err)
} }
p.app = fyneapp.New()
if err := p.initStreamControllers(); err != nil { if err := p.initStreamControllers(); err != nil {
return fmt.Errorf("unable to initialize stream controllers: %w", err) return fmt.Errorf("unable to initialize stream controllers: %w", err)
} }
a := fyneapp.New() p.initTwitchData()
p.initTwitchData(a)
p.normalizeTwitchData() p.normalizeTwitchData()
p.initYoutubeData(a) p.initYoutubeData()
p.normalizeYoutubeData() p.normalizeYoutubeData()
p.initMainWindow(ctx, a) p.initMainWindow(ctx)
p.mainWindow.ShowAndRun() p.mainWindow.ShowAndRun()
return nil return nil
} }
func (p *Panel) initTwitchData(a fyne.App) { func (p *Panel) initTwitchData() {
logger.FromCtx(p.defaultContext).Debugf("initializing Twitch data") logger.FromCtx(p.defaultContext).Debugf("initializing Twitch data")
defer logger.FromCtx(p.defaultContext).Debugf("endof 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) allCategories, err := twitch.GetAllCategories(p.defaultContext)
if err != nil { if err != nil {
p.displayError(a, err) p.displayError(err)
return 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") logger.FromCtx(p.defaultContext).Debugf("initializing Youtube data")
defer logger.FromCtx(p.defaultContext).Debugf("endof 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) broadcasts, err := youtube.ListBroadcasts(p.defaultContext)
if err != nil { if err != nil {
p.displayError(a, err) p.displayError(err)
return return
} }
@@ -223,6 +228,77 @@ func (p *Panel) savePlatformConfig(
return p.saveData() 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 { func (p *Panel) initStreamControllers() error {
for platName, cfg := range p.data.Backends { for platName, cfg := range p.data.Backends {
var err error var err error
@@ -230,11 +306,11 @@ func (p *Panel) initStreamControllers() error {
case strings.ToLower(string(twitch.ID)): case strings.ToLower(string(twitch.ID)):
p.streamControllers.Twitch, err = newTwitch(p.defaultContext, cfg, func(cfg *streamcontrol.AbstractPlatformConfig) error { p.streamControllers.Twitch, err = newTwitch(p.defaultContext, cfg, func(cfg *streamcontrol.AbstractPlatformConfig) error {
return p.savePlatformConfig(twitch.ID, cfg) return p.savePlatformConfig(twitch.ID, cfg)
}) }, p.oauthHandlerTwitch)
case strings.ToLower(string(youtube.ID)): case strings.ToLower(string(youtube.ID)):
p.streamControllers.YouTube, err = newYouTube(p.defaultContext, cfg, func(cfg *streamcontrol.AbstractPlatformConfig) error { p.streamControllers.YouTube, err = newYouTube(p.defaultContext, cfg, func(cfg *streamcontrol.AbstractPlatformConfig) error {
return p.savePlatformConfig(youtube.ID, cfg) return p.savePlatformConfig(youtube.ID, cfg)
}) }, p.oauthHandlerYouTube)
} }
if err != nil { if err != nil {
return fmt.Errorf("unable to initialize '%s': %w", platName, err) return fmt.Errorf("unable to initialize '%s': %w", platName, err)
@@ -432,8 +508,8 @@ func (p *Panel) setFilter(filter string) {
p.refilterProfiles() p.refilterProfiles()
} }
func (p *Panel) initMainWindow(ctx context.Context, a fyne.App) { func (p *Panel) initMainWindow(ctx context.Context) {
w := a.NewWindow("TimeTracker") w := p.app.NewWindow("StreamPanel")
p.startStopButton = widget.NewButtonWithIcon("", theme.MediaPlayIcon(), p.onStartStopButton) p.startStopButton = widget.NewButtonWithIcon("", theme.MediaPlayIcon(), p.onStartStopButton)
p.startStopButton.Importance = widget.SuccessImportance p.startStopButton.Importance = widget.SuccessImportance
@@ -449,7 +525,7 @@ func (p *Panel) initMainWindow(ctx context.Context, a fyne.App) {
topPanel := container.NewVBox( topPanel := container.NewVBox(
container.NewHBox( container.NewHBox(
widget.NewButtonWithIcon("Profile", theme.ContentAddIcon(), func() { widget.NewButtonWithIcon("Profile", theme.ContentAddIcon(), func() {
p.newProfileWindow(ctx, a) p.newProfileWindow(ctx)
}), }),
), ),
profileFilter, profileFilter,
@@ -512,13 +588,13 @@ func cleanYoutubeRecordingName(in string) string {
return strings.ToLower(strings.Trim(in, " ")) 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 ( var (
twitchProfile *twitch.StreamProfile twitchProfile *twitch.StreamProfile
youtubeProfile *youtube.StreamProfile youtubeProfile *youtube.StreamProfile
) )
w := a.NewWindow("Create a profile") w := p.app.NewWindow("Create a profile")
w.Resize(fyne.NewSize(400, 300)) w.Resize(fyne.NewSize(400, 300))
activityTitle := widget.NewEntry() activityTitle := widget.NewEntry()
activityTitle.SetPlaceHolder("title") 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") err := fmt.Errorf("creating a profile is not implemented")
if err != nil { if err != nil {
p.displayError(a, err) p.displayError(err)
return return
} }
w.Close() w.Close()
@@ -704,8 +780,8 @@ func (p *Panel) newProfileWindow(_ context.Context, a fyne.App) fyne.Window {
return w return w
} }
func (p *Panel) displayError(a fyne.App, err error) { func (p *Panel) displayError(err error) {
w := a.NewWindow("Got an error: " + err.Error()) w := p.app.NewWindow("Got an error: " + err.Error())
errorMessage := fmt.Sprintf("Error: %v\n\nstack trace:\n%s", err, debug.Stack()) errorMessage := fmt.Sprintf("Error: %v\n\nstack trace:\n%s", err, debug.Stack())
w.Resize(fyne.NewSize(400, 300)) w.Resize(fyne.NewSize(400, 300))
w.SetContent(widget.NewLabelWithStyle(errorMessage, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})) w.SetContent(widget.NewLabelWithStyle(errorMessage, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}))

View File

@@ -14,6 +14,7 @@ func newTwitch(
ctx context.Context, ctx context.Context,
cfg *streamcontrol.AbstractPlatformConfig, cfg *streamcontrol.AbstractPlatformConfig,
saveCfgFunc func(*streamcontrol.AbstractPlatformConfig) error, saveCfgFunc func(*streamcontrol.AbstractPlatformConfig) error,
customOAuthHandler twitch.OAuthHandler,
) ( ) (
*twitch.Twitch, *twitch.Twitch,
error, error,
@@ -26,6 +27,7 @@ func newTwitch(
} }
logger.Debugf(ctx, "twitch config: %#+v", platCfg) logger.Debugf(ctx, "twitch config: %#+v", platCfg)
platCfg.Config.CustomOAuthHandler = customOAuthHandler
return twitch.New(ctx, *platCfg, return twitch.New(ctx, *platCfg,
func(c twitch.Config) error { func(c twitch.Config) error {
return saveCfgFunc(&streamcontrol.AbstractPlatformConfig{ return saveCfgFunc(&streamcontrol.AbstractPlatformConfig{
@@ -40,6 +42,7 @@ func newYouTube(
ctx context.Context, ctx context.Context,
cfg *streamcontrol.AbstractPlatformConfig, cfg *streamcontrol.AbstractPlatformConfig,
saveCfgFunc func(*streamcontrol.AbstractPlatformConfig) error, saveCfgFunc func(*streamcontrol.AbstractPlatformConfig) error,
customOAuthHandler youtube.OAuthHandler,
) ( ) (
*youtube.YouTube, *youtube.YouTube,
error, error,
@@ -52,6 +55,7 @@ func newYouTube(
} }
logger.Debugf(ctx, "youtube config: %#+v", platCfg) logger.Debugf(ctx, "youtube config: %#+v", platCfg)
platCfg.Config.CustomOAuthHandler = customOAuthHandler
return youtube.New(ctx, *platCfg, return youtube.New(ctx, *platCfg,
func(c youtube.Config) error { func(c youtube.Config) error {
return saveCfgFunc(&streamcontrol.AbstractPlatformConfig{ return saveCfgFunc(&streamcontrol.AbstractPlatformConfig{