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 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)
} }
func (h *OAuth2Handler) newCodeReceiver() (codeCh chan string, err error) {
// 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,11 +1,16 @@
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
@@ -15,6 +20,7 @@ type PlatformSpecificConfig struct {
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{
AuthURL: authURL,
RedirectURL: options.RedirectURI,
ExchangeFn: func(code string) error {
cfg.Config.ClientCode = code cfg.Config.ClientCode = code
err = safeCfgFn(cfg) err = safeCfgFn(cfg)
errmon.ObserveErrorCtx(ctx, err) errmon.ObserveErrorCtx(ctx, err)
return nil return nil
}, "") },
err := oauthHandler.Handle(ctx) }
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{
AuthURL: googleAuthCfg.AuthCodeURL("state-token", oauth2.AccessTypeOffline),
RedirectURL: googleAuthCfg.RedirectURL,
ExchangeFn: func(code string) error {
_tok, err := googleAuthCfg.Exchange(ctx, code) _tok, err := googleAuthCfg.Exchange(ctx, code)
if err != nil { if err != nil {
return fmt.Errorf("unable to get a token: %w", err) return fmt.Errorf("unable to get a token: %w", err)
} }
tok = _tok tok = _tok
return nil return nil
}, "") },
err := oauthHandler.Handle(ctx) }
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{