feat: add twitch tv login flow and auth state ui
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
// main.go is the executable entrypoint for the rewritten CLI.
|
||||
// It owns process-level concerns such as signal handling, exit codes,
|
||||
// and delegating the actual application startup to internal/app.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -11,6 +14,7 @@ import (
|
||||
"parasocial/internal/app"
|
||||
)
|
||||
|
||||
// main sets up process cancellation and reports fatal startup errors to stderr.
|
||||
func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
+43
-1
@@ -1,24 +1,66 @@
|
||||
// app.go wires together config loading, auth bootstrap, and the Bubble Tea program.
|
||||
// It decides whether cached Twitch auth can be reused and, when needed,
|
||||
// connects the interactive login flow to the TUI through auth update messages.
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"parasocial/internal/auth"
|
||||
"parasocial/internal/config"
|
||||
"parasocial/internal/tui"
|
||||
)
|
||||
|
||||
// Run loads the application configuration and starts the terminal UI.
|
||||
func Run(ctx context.Context) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
cfg, err := config.LoadDefault()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
httpClient := &http.Client{Timeout: 30 * time.Second}
|
||||
authClient := auth.NewClient(httpClient)
|
||||
authPath := auth.DefaultPath()
|
||||
|
||||
state, err := authClient.ReuseAuth(ctx, authPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
program := tea.NewProgram(
|
||||
tui.New(cfg.Streamers),
|
||||
tui.New(tui.Options{
|
||||
Streamers: cfg.Streamers,
|
||||
AuthState: state,
|
||||
StartAuth: func(ch chan<- tui.AuthUpdate) {
|
||||
go func() {
|
||||
defer close(ch)
|
||||
|
||||
state, err := authClient.EnsureAuth(ctx, authPath, func(line string) {
|
||||
ch <- tui.AuthUpdate{Line: strings.TrimRight(line, "\n")}
|
||||
})
|
||||
if err != nil {
|
||||
ch <- tui.AuthUpdate{
|
||||
Line: fmt.Sprintf("Authentication failed: %v", err),
|
||||
Err: err,
|
||||
Done: true,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ch <- tui.AuthUpdate{State: state, Done: true}
|
||||
}()
|
||||
},
|
||||
}),
|
||||
tea.WithContext(ctx),
|
||||
tea.WithInput(os.Stdin),
|
||||
tea.WithOutput(os.Stdout),
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
// auth.go implements the Twitch TV device login flow and cached token validation.
|
||||
// It owns the HTTP interaction with Twitch OAuth endpoints, the polling loop,
|
||||
// and the status messages consumed by the TUI while authentication is in progress.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ClientID = "ue6666qo983tsx6so1t0vnawi233wa"
|
||||
|
||||
deviceIDLength = 32
|
||||
contentTypeForm = "application/x-www-form-urlencoded"
|
||||
activateURL = "https://www.twitch.tv/activate"
|
||||
pollGrantType = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
|
||||
tvOrigin = "https://android.tv.twitch.tv"
|
||||
tvReferer = "https://android.tv.twitch.tv/"
|
||||
tvUserAgent = "Mozilla/5.0 (Linux; Android 7.1; Smart Box C1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
var RequiredScopes = []string{
|
||||
"channel_read",
|
||||
"chat:read",
|
||||
"user_blocks_edit",
|
||||
"user_blocks_read",
|
||||
"user_follows_edit",
|
||||
"user_read",
|
||||
}
|
||||
|
||||
var DefaultEndpoints = Endpoints{
|
||||
Device: "https://id.twitch.tv/oauth2/device",
|
||||
Token: "https://id.twitch.tv/oauth2/token",
|
||||
Validate: "https://id.twitch.tv/oauth2/validate",
|
||||
}
|
||||
|
||||
// Endpoints groups the Twitch OAuth URLs used by the TV device flow client.
|
||||
type Endpoints struct {
|
||||
Device string
|
||||
Token string
|
||||
Validate string
|
||||
}
|
||||
|
||||
// Client performs token validation and TV device-flow authentication against Twitch.
|
||||
type Client struct {
|
||||
HTTPClient *http.Client
|
||||
Endpoints Endpoints
|
||||
Now func() time.Time
|
||||
Sleep func(context.Context, time.Duration) error
|
||||
}
|
||||
|
||||
// deviceCodeResponse models the device-code payload returned by Twitch.
|
||||
type deviceCodeResponse struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Interval int `json:"interval"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
}
|
||||
|
||||
// tokenResponse models the token payload returned after device authorization succeeds.
|
||||
type tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Scope []string `json:"scope"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// validationResponse models the token introspection data returned by Twitch validate.
|
||||
type validationResponse struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Login string `json:"login"`
|
||||
Scopes []string `json:"scopes"`
|
||||
UserID string `json:"user_id"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// oauthErrorResponse captures structured OAuth errors returned by Twitch endpoints.
|
||||
type oauthErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
Message string `json:"message"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type StatusFunc func(string)
|
||||
|
||||
// StatusError reports a non-200 HTTP response together with its response body.
|
||||
type StatusError struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
// Error formats the HTTP status failure in a way that preserves the response body.
|
||||
func (e *StatusError) Error() string {
|
||||
body := strings.TrimSpace(e.Body)
|
||||
if body == "" {
|
||||
return fmt.Sprintf("unexpected status %d", e.StatusCode)
|
||||
}
|
||||
return fmt.Sprintf("unexpected status %d: %s", e.StatusCode, body)
|
||||
}
|
||||
|
||||
// NewClient constructs an auth client with default endpoints and timing hooks.
|
||||
func NewClient(httpClient *http.Client) *Client {
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
return &Client{
|
||||
HTTPClient: httpClient,
|
||||
Endpoints: DefaultEndpoints,
|
||||
Now: time.Now,
|
||||
Sleep: sleepContext,
|
||||
}
|
||||
}
|
||||
|
||||
// ReuseAuth validates a saved auth bundle and returns it only when it is still usable.
|
||||
func (c *Client) ReuseAuth(ctx context.Context, path string) (*State, error) {
|
||||
state, err := LoadState(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load auth state: %w", err)
|
||||
}
|
||||
if state == nil || state.AccessToken == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if state.DeviceID == "" {
|
||||
deviceID, err := randomAlphaNumeric(deviceIDLength)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create device ID: %w", err)
|
||||
}
|
||||
state.DeviceID = deviceID
|
||||
}
|
||||
|
||||
validation, err := c.ValidateToken(ctx, state.AccessToken)
|
||||
if err != nil || !hasAllScopes(validation.Scopes, RequiredScopes) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
state.applyValidation(validation, c.now())
|
||||
if err := SaveState(path, state); err != nil {
|
||||
return nil, fmt.Errorf("save validated auth state: %w", err)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// EnsureAuth reuses cached auth when possible and otherwise runs the TV device flow.
|
||||
func (c *Client) EnsureAuth(ctx context.Context, path string, status StatusFunc) (*State, error) {
|
||||
state, err := LoadState(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load auth state: %w", err)
|
||||
}
|
||||
if state == nil {
|
||||
state = &State{}
|
||||
}
|
||||
if state.DeviceID == "" {
|
||||
deviceID, err := randomAlphaNumeric(deviceIDLength)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create device ID: %w", err)
|
||||
}
|
||||
state.DeviceID = deviceID
|
||||
}
|
||||
|
||||
if state.AccessToken != "" {
|
||||
c.status(status, "Validating cached token from %s", path)
|
||||
validation, err := c.ValidateToken(ctx, state.AccessToken)
|
||||
if err == nil && hasAllScopes(validation.Scopes, RequiredScopes) {
|
||||
state.applyValidation(validation, c.now())
|
||||
if err := SaveState(path, state); err != nil {
|
||||
return nil, fmt.Errorf("save validated auth state: %w", err)
|
||||
}
|
||||
c.status(status, "Cached token is valid; authenticated as %s", state.Login)
|
||||
return state, nil
|
||||
}
|
||||
if err != nil {
|
||||
c.status(status, "Cached token could not be reused: %v", err)
|
||||
} else {
|
||||
c.status(status, "Cached token is missing required scopes; activation is required")
|
||||
}
|
||||
}
|
||||
|
||||
tokenResp, err := c.activate(ctx, state.DeviceID, status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
validation, err := c.ValidateToken(ctx, tokenResp.AccessToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("validate new token: %w", err)
|
||||
}
|
||||
if !hasAllScopes(validation.Scopes, RequiredScopes) {
|
||||
return nil, fmt.Errorf("new token is missing required scopes")
|
||||
}
|
||||
|
||||
state.AccessToken = tokenResp.AccessToken
|
||||
state.RefreshToken = tokenResp.RefreshToken
|
||||
state.TokenType = tokenResp.TokenType
|
||||
if state.TokenType == "" {
|
||||
state.TokenType = "bearer"
|
||||
}
|
||||
state.Scopes = tokenResp.Scope
|
||||
state.ExpiresIn = tokenResp.ExpiresIn
|
||||
state.applyValidation(validation, c.now())
|
||||
|
||||
if err := SaveState(path, state); err != nil {
|
||||
return nil, fmt.Errorf("save auth state: %w", err)
|
||||
}
|
||||
c.status(status, "Saved auth state to %s", path)
|
||||
c.status(status, "Authenticated as %s", state.Login)
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// ValidateToken asks Twitch whether an OAuth access token is still valid.
|
||||
func (c *Client) ValidateToken(ctx context.Context, accessToken string) (*validationResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoints().Validate, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "OAuth "+accessToken)
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, &StatusError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
|
||||
var validation validationResponse
|
||||
if err := json.Unmarshal(body, &validation); err != nil {
|
||||
return nil, fmt.Errorf("parse validation response: %w", err)
|
||||
}
|
||||
if validation.Login == "" {
|
||||
return nil, errors.New("validation response missing login")
|
||||
}
|
||||
if validation.UserID == "" {
|
||||
return nil, errors.New("validation response missing user_id")
|
||||
}
|
||||
if validation.ClientID == "" {
|
||||
return nil, errors.New("validation response missing client_id")
|
||||
}
|
||||
return &validation, nil
|
||||
}
|
||||
|
||||
// activate requests a device code and waits for the user to authorize it.
|
||||
func (c *Client) activate(ctx context.Context, deviceID string, status StatusFunc) (*tokenResponse, error) {
|
||||
deviceResp, err := c.requestDeviceCode(ctx, deviceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request device code: %w", err)
|
||||
}
|
||||
|
||||
interval := time.Duration(deviceResp.Interval) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 5 * time.Second
|
||||
}
|
||||
deadline := c.now().Add(time.Duration(deviceResp.ExpiresIn) * time.Second)
|
||||
|
||||
c.status(status, "== Twitch activation ==")
|
||||
c.status(status, "Open page: %s", deviceResp.VerificationURI)
|
||||
c.status(status, "User code: %s", deviceResp.UserCode)
|
||||
c.status(status, "Polling interval: %s", interval)
|
||||
c.status(status, "Expires at: %s", deadline.Format(time.RFC3339))
|
||||
|
||||
return c.pollForToken(ctx, deviceID, deviceResp.DeviceCode, interval, deadline, status)
|
||||
}
|
||||
|
||||
// requestDeviceCode starts the Twitch TV device flow and returns the activation instructions.
|
||||
func (c *Client) requestDeviceCode(ctx context.Context, deviceID string) (*deviceCodeResponse, error) {
|
||||
form := url.Values{}
|
||||
form.Set("client_id", ClientID)
|
||||
form.Set("scopes", strings.Join(RequiredScopes, " "))
|
||||
|
||||
body, statusCode, err := c.postForm(ctx, c.endpoints().Device, deviceID, form)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if statusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status %d from device endpoint: %s", statusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var resp deviceCodeResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, fmt.Errorf("parse device-code response: %w", err)
|
||||
}
|
||||
if resp.VerificationURI == "" {
|
||||
resp.VerificationURI = activateURL
|
||||
}
|
||||
if err := validateDeviceCodeResponse(&resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// pollForToken repeatedly checks the token endpoint until the device flow resolves.
|
||||
func (c *Client) pollForToken(ctx context.Context, deviceID, deviceCode string, interval time.Duration, deadline time.Time, status StatusFunc) (*tokenResponse, error) {
|
||||
attempt := 1
|
||||
for {
|
||||
if !c.now().Before(deadline) {
|
||||
return nil, fmt.Errorf("device code expired at %s before authorization completed", deadline.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
remaining := deadline.Sub(c.now()).Round(time.Second)
|
||||
c.status(status, "[poll %d] waiting %s before token request (%s remaining)", attempt, interval, remaining)
|
||||
if err := c.sleep(ctx, interval); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("client_id", ClientID)
|
||||
form.Set("device_code", deviceCode)
|
||||
form.Set("grant_type", pollGrantType)
|
||||
|
||||
body, statusCode, err := c.postForm(ctx, c.endpoints().Token, deviceID, form)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("poll request failed on attempt %d: %w", attempt, err)
|
||||
}
|
||||
|
||||
if statusCode == http.StatusOK {
|
||||
var resp tokenResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, fmt.Errorf("parse token response: %w", err)
|
||||
}
|
||||
if resp.AccessToken == "" {
|
||||
return nil, errors.New("token response missing access_token")
|
||||
}
|
||||
c.status(status, "[poll %d] authorization succeeded", attempt)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
oauthErr := parseOAuthError(body)
|
||||
switch oauthErr.Error {
|
||||
case "authorization_pending", "":
|
||||
c.status(status, "[poll %d] authorization pending (status %d)", attempt, statusCode)
|
||||
case "slow_down":
|
||||
interval += 5 * time.Second
|
||||
c.status(status, "[poll %d] Twitch requested slow_down; new interval %s", attempt, interval)
|
||||
case "access_denied":
|
||||
return nil, fmt.Errorf("authorization denied by user: %s", oauthErr.summary())
|
||||
case "expired_token", "invalid_device_code":
|
||||
return nil, fmt.Errorf("device flow expired or invalid: %s", oauthErr.summary())
|
||||
default:
|
||||
return nil, fmt.Errorf("token endpoint returned status %d: %s", statusCode, oauthErr.summary())
|
||||
}
|
||||
|
||||
attempt++
|
||||
}
|
||||
}
|
||||
|
||||
// postForm sends a form-encoded Twitch OAuth request with the TV client headers attached.
|
||||
func (c *Client) postForm(ctx context.Context, endpoint, deviceID string, form url.Values) ([]byte, int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for key, value := range defaultOAuthHeaders(deviceID) {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
req.Header.Set("Content-Type", contentTypeForm)
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
return body, resp.StatusCode, err
|
||||
}
|
||||
|
||||
// validateDeviceCodeResponse rejects incomplete or unusable device-code payloads.
|
||||
func validateDeviceCodeResponse(resp *deviceCodeResponse) error {
|
||||
switch {
|
||||
case resp.DeviceCode == "":
|
||||
return errors.New("device_code is missing")
|
||||
case resp.UserCode == "":
|
||||
return errors.New("user_code is missing")
|
||||
case resp.VerificationURI == "":
|
||||
return errors.New("verification_uri is missing")
|
||||
case resp.ExpiresIn <= 0:
|
||||
return errors.New("expires_in must be greater than zero")
|
||||
case resp.Interval <= 0:
|
||||
return errors.New("interval must be greater than zero")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// parseOAuthError decodes a structured OAuth error or falls back to raw response text.
|
||||
func parseOAuthError(body []byte) oauthErrorResponse {
|
||||
var resp oauthErrorResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
resp.Message = strings.TrimSpace(string(body))
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// summary compresses an OAuth error payload into one log-friendly string.
|
||||
func (r oauthErrorResponse) summary() string {
|
||||
parts := []string{}
|
||||
if r.Error != "" {
|
||||
parts = append(parts, fmt.Sprintf("error=%s", r.Error))
|
||||
}
|
||||
if r.ErrorDescription != "" {
|
||||
parts = append(parts, fmt.Sprintf("description=%s", r.ErrorDescription))
|
||||
}
|
||||
if r.Message != "" {
|
||||
parts = append(parts, fmt.Sprintf("message=%s", r.Message))
|
||||
}
|
||||
if r.Status != 0 {
|
||||
parts = append(parts, fmt.Sprintf("status=%d", r.Status))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "no JSON error payload"
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// defaultOAuthHeaders builds the Twitch TV request headers used for auth requests.
|
||||
func defaultOAuthHeaders(deviceID string) map[string]string {
|
||||
return map[string]string{
|
||||
"Accept": "application/json",
|
||||
"Accept-Language": "en-US",
|
||||
"Cache-Control": "no-cache",
|
||||
"Client-Id": ClientID,
|
||||
"Origin": tvOrigin,
|
||||
"Pragma": "no-cache",
|
||||
"Referer": tvReferer,
|
||||
"User-Agent": TVUserAgent(),
|
||||
"X-Device-Id": deviceID,
|
||||
}
|
||||
}
|
||||
|
||||
// TVUserAgent returns the Twitch TV user agent used to impersonate the device client.
|
||||
func TVUserAgent() string {
|
||||
return tvUserAgent
|
||||
}
|
||||
|
||||
// applyValidation copies validated account metadata back onto the persisted auth state.
|
||||
func (s *State) applyValidation(validation *validationResponse, validatedAt time.Time) {
|
||||
s.Login = validation.Login
|
||||
s.UserID = validation.UserID
|
||||
s.ClientID = validation.ClientID
|
||||
s.Scopes = validation.Scopes
|
||||
s.ExpiresIn = validation.ExpiresIn
|
||||
s.ValidatedAt = validatedAt
|
||||
s.Cookies = s.persistedCookies()
|
||||
}
|
||||
|
||||
// hasScope reports whether the token scopes include one exact scope string.
|
||||
func hasScope(scopes []string, scope string) bool {
|
||||
for _, candidate := range scopes {
|
||||
if candidate == scope {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasAllScopes reports whether the token satisfies the full required scope set.
|
||||
func hasAllScopes(scopes []string, required []string) bool {
|
||||
for _, scope := range required {
|
||||
if !hasScope(scopes, scope) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// randomAlphaNumeric creates a device identifier using Twitch-safe alphanumeric characters.
|
||||
func randomAlphaNumeric(length int) (string, error) {
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
var builder strings.Builder
|
||||
builder.Grow(length)
|
||||
|
||||
max := big.NewInt(int64(len(alphabet)))
|
||||
for i := 0; i < length; i++ {
|
||||
n, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
builder.WriteByte(alphabet[n.Int64()])
|
||||
}
|
||||
return builder.String(), nil
|
||||
}
|
||||
|
||||
// sleepContext waits for a duration unless the surrounding context is canceled first.
|
||||
func sleepContext(ctx context.Context, d time.Duration) error {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// httpClient returns the configured HTTP client or falls back to the default client.
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
return c.HTTPClient
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
// endpoints returns the configured endpoint set or the package defaults.
|
||||
func (c *Client) endpoints() Endpoints {
|
||||
if c.Endpoints == (Endpoints{}) {
|
||||
return DefaultEndpoints
|
||||
}
|
||||
return c.Endpoints
|
||||
}
|
||||
|
||||
// now returns the current time using the injected clock when one is configured.
|
||||
func (c *Client) now() time.Time {
|
||||
if c.Now != nil {
|
||||
return c.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// sleep delegates waiting to the injected hook when tests need deterministic timing.
|
||||
func (c *Client) sleep(ctx context.Context, d time.Duration) error {
|
||||
if c.Sleep != nil {
|
||||
return c.Sleep(ctx, d)
|
||||
}
|
||||
return sleepContext(ctx, d)
|
||||
}
|
||||
|
||||
// status formats and emits one auth progress line when a status sink is attached.
|
||||
func (c *Client) status(status StatusFunc, format string, args ...any) {
|
||||
if status == nil {
|
||||
return
|
||||
}
|
||||
status(fmt.Sprintf(format, args...))
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSaveStatePersistsCookieEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "cookies.json")
|
||||
state := &State{
|
||||
AccessToken: "token",
|
||||
TokenType: "bearer",
|
||||
Scopes: RequiredScopes,
|
||||
Login: "viewer",
|
||||
UserID: "123",
|
||||
ClientID: ClientID,
|
||||
ExpiresIn: 3600,
|
||||
DeviceID: "device",
|
||||
}
|
||||
if err := SaveState(path, state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := LoadState(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.AccessToken != "token" {
|
||||
t.Fatalf("AccessToken = %q", loaded.AccessToken)
|
||||
}
|
||||
|
||||
gotNames := []string{}
|
||||
for _, cookie := range loaded.Cookies {
|
||||
gotNames = append(gotNames, cookie.Name)
|
||||
}
|
||||
wantNames := []string{"auth-token", "login", "persistent"}
|
||||
if !slices.Equal(gotNames, wantNames) {
|
||||
t.Fatalf("cookie names = %#v, want %#v", gotNames, wantNames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStateMissingFileReturnsNil(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
state, err := LoadState(filepath.Join(t.TempDir(), "missing.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state != nil {
|
||||
t.Fatalf("LoadState() = %#v, want nil", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStateRejectsPartialAuthBundle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "cookies.json")
|
||||
if err := os.WriteFile(path, []byte(`{"access_token":"token"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := LoadState(path)
|
||||
if err == nil {
|
||||
t.Fatal("LoadState() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing token_type") {
|
||||
t.Fatalf("error = %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReuseAuthReusesValidCachedToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var validateCalls int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/validate" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
validateCalls++
|
||||
if got := r.Header.Get("Authorization"); got != "OAuth cached-token" {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
writeJSON(t, w, validationResponse{
|
||||
ClientID: ClientID,
|
||||
Login: "viewer",
|
||||
UserID: "123",
|
||||
Scopes: RequiredScopes,
|
||||
ExpiresIn: 3600,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tokenFile := filepath.Join(t.TempDir(), "cookies.json")
|
||||
if err := SaveState(tokenFile, &State{
|
||||
AccessToken: "cached-token",
|
||||
TokenType: "bearer",
|
||||
Scopes: RequiredScopes,
|
||||
Login: "viewer",
|
||||
UserID: "123",
|
||||
ClientID: ClientID,
|
||||
ExpiresIn: 3600,
|
||||
DeviceID: "device",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := NewClient(server.Client())
|
||||
client.Endpoints = Endpoints{Validate: server.URL + "/validate"}
|
||||
client.Now = func() time.Time { return time.Unix(100, 0).UTC() }
|
||||
|
||||
state, err := client.ReuseAuth(context.Background(), tokenFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if validateCalls != 1 {
|
||||
t.Fatalf("validateCalls = %d", validateCalls)
|
||||
}
|
||||
if state == nil || state.Login != "viewer" {
|
||||
t.Fatalf("state = %#v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAuthActivatesWhenCachedTokenMissingChatRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validateCalls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/validate":
|
||||
validateCalls++
|
||||
if validateCalls == 1 {
|
||||
writeJSON(t, w, validationResponse{
|
||||
ClientID: ClientID,
|
||||
Login: "viewer",
|
||||
UserID: "123",
|
||||
Scopes: []string{"user:read:email"},
|
||||
ExpiresIn: 100,
|
||||
})
|
||||
return
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "OAuth new-token" {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
writeJSON(t, w, validationResponse{
|
||||
ClientID: ClientID,
|
||||
Login: "viewer",
|
||||
UserID: "123",
|
||||
Scopes: RequiredScopes,
|
||||
ExpiresIn: 3600,
|
||||
})
|
||||
case "/device":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := r.Form.Get("scopes"); got != "channel_read chat:read user_blocks_edit user_blocks_read user_follows_edit user_read" {
|
||||
t.Fatalf("scopes = %q", got)
|
||||
}
|
||||
writeJSON(t, w, deviceCodeResponse{
|
||||
DeviceCode: "device-code",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
UserCode: "ABCD-EFGH",
|
||||
VerificationURI: "https://www.twitch.tv/activate",
|
||||
})
|
||||
case "/token":
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := values.Get("device_code"); got != "device-code" {
|
||||
t.Fatalf("device_code = %q", got)
|
||||
}
|
||||
writeJSON(t, w, tokenResponse{
|
||||
AccessToken: "new-token",
|
||||
RefreshToken: "refresh-token",
|
||||
Scope: RequiredScopes,
|
||||
TokenType: "bearer",
|
||||
ExpiresIn: 3600,
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tokenFile := filepath.Join(t.TempDir(), "cookies.json")
|
||||
if err := SaveState(tokenFile, &State{
|
||||
AccessToken: "wrong-scope-token",
|
||||
TokenType: "bearer",
|
||||
Scopes: []string{"user:read:email"},
|
||||
Login: "viewer",
|
||||
UserID: "123",
|
||||
ClientID: ClientID,
|
||||
ExpiresIn: 3600,
|
||||
DeviceID: "device",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := NewClient(server.Client())
|
||||
client.Endpoints = Endpoints{
|
||||
Device: server.URL + "/device",
|
||||
Token: server.URL + "/token",
|
||||
Validate: server.URL + "/validate",
|
||||
}
|
||||
client.Now = func() time.Time { return time.Unix(100, 0).UTC() }
|
||||
client.Sleep = func(context.Context, time.Duration) error { return nil }
|
||||
|
||||
var logs []string
|
||||
state, err := client.EnsureAuth(context.Background(), tokenFile, func(line string) {
|
||||
logs = append(logs, line)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if validateCalls != 2 {
|
||||
t.Fatalf("validateCalls = %d", validateCalls)
|
||||
}
|
||||
if state.AccessToken != "new-token" {
|
||||
t.Fatalf("AccessToken = %q", state.AccessToken)
|
||||
}
|
||||
if !containsLine(logs, "User code: ABCD-EFGH") {
|
||||
t.Fatalf("logs = %#v", logs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAuthReportsAccessDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/device":
|
||||
writeJSON(t, w, deviceCodeResponse{
|
||||
DeviceCode: "device-code",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
UserCode: "ABCD-EFGH",
|
||||
VerificationURI: "https://www.twitch.tv/activate",
|
||||
})
|
||||
case "/token":
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
writeJSON(t, w, oauthErrorResponse{Error: "access_denied", ErrorDescription: "user said no"})
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.Client())
|
||||
client.Endpoints = Endpoints{
|
||||
Device: server.URL + "/device",
|
||||
Token: server.URL + "/token",
|
||||
Validate: server.URL + "/validate",
|
||||
}
|
||||
client.Now = func() time.Time { return time.Unix(100, 0).UTC() }
|
||||
client.Sleep = func(context.Context, time.Duration) error { return nil }
|
||||
|
||||
_, err := client.EnsureAuth(context.Background(), filepath.Join(t.TempDir(), "cookies.json"), nil)
|
||||
if err == nil {
|
||||
t.Fatal("EnsureAuth() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "authorization denied by user") {
|
||||
t.Fatalf("error = %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, value any) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func containsLine(lines []string, want string) bool {
|
||||
for _, line := range lines {
|
||||
if line == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// store.go defines the persisted auth bundle stored as cookies.json in the cwd.
|
||||
// It handles loading, saving, and validating the auth state so cached login data
|
||||
// can be reused across runs without redoing the Twitch device flow every time.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const authFileName = "cookies.json"
|
||||
|
||||
// Cookie stores one persisted cookie-like name/value entry in the auth bundle.
|
||||
type Cookie struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// State is the persisted cwd auth bundle for the app.
|
||||
type State struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
TokenType string `json:"token_type"`
|
||||
Scopes []string `json:"scopes"`
|
||||
Login string `json:"login"`
|
||||
UserID string `json:"user_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
ValidatedAt time.Time `json:"validated_at"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Cookies []Cookie `json:"cookies,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultPath returns the cwd auth bundle path.
|
||||
func DefaultPath() string {
|
||||
return authFileName
|
||||
}
|
||||
|
||||
// LoadState reads a persisted auth bundle from disk when one exists.
|
||||
func LoadState(path string) (*State, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var state State
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := state.validate(); err != nil {
|
||||
return nil, fmt.Errorf("validate auth state %s: %w", path, err)
|
||||
}
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
// SaveState writes the validated auth bundle to the configured cwd path.
|
||||
func SaveState(path string, state *State) error {
|
||||
if state == nil {
|
||||
return fmt.Errorf("auth state is nil")
|
||||
}
|
||||
if err := state.validate(); err != nil {
|
||||
return fmt.Errorf("validate auth state: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
copy := *state
|
||||
copy.Cookies = copy.persistedCookies()
|
||||
|
||||
data, err := json.MarshalIndent(copy, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
|
||||
// persistedCookies rebuilds the cookie-like entries that must travel with the saved auth bundle.
|
||||
func (s *State) persistedCookies() []Cookie {
|
||||
return []Cookie{
|
||||
{Name: "auth-token", Value: s.AccessToken},
|
||||
{Name: "login", Value: s.Login},
|
||||
{Name: "persistent", Value: s.UserID},
|
||||
}
|
||||
}
|
||||
|
||||
// validate rejects partial auth bundles so loading and saving use one canonical shape.
|
||||
func (s *State) validate() error {
|
||||
switch {
|
||||
case s.AccessToken == "":
|
||||
return fmt.Errorf("missing access_token")
|
||||
case s.TokenType == "":
|
||||
return fmt.Errorf("missing token_type")
|
||||
case s.Login == "":
|
||||
return fmt.Errorf("missing login")
|
||||
case s.UserID == "":
|
||||
return fmt.Errorf("missing user_id")
|
||||
case s.ClientID == "":
|
||||
return fmt.Errorf("missing client_id")
|
||||
case len(s.Scopes) == 0:
|
||||
return fmt.Errorf("missing scopes")
|
||||
case s.ExpiresIn <= 0:
|
||||
return fmt.Errorf("expires_in must be greater than zero")
|
||||
case s.DeviceID == "":
|
||||
return fmt.Errorf("missing device_id")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// config.go loads and validates the cwd config.toml file for the new CLI.
|
||||
// It currently owns streamer list parsing, normalization, and the default
|
||||
// config path behavior used by application startup.
|
||||
package config
|
||||
|
||||
import (
|
||||
@@ -44,6 +47,7 @@ func Load(path string) (Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// normalizeStreamers deduplicates streamer names after applying input cleanup rules.
|
||||
func normalizeStreamers(streamers []string) []string {
|
||||
seen := make(map[string]struct{}, len(streamers))
|
||||
normalized := make([]string, 0, len(streamers))
|
||||
@@ -63,6 +67,7 @@ func normalizeStreamers(streamers []string) []string {
|
||||
return normalized
|
||||
}
|
||||
|
||||
// normalizeStreamer trims formatting noise and canonicalizes a single streamer name.
|
||||
func normalizeStreamer(streamer string) string {
|
||||
streamer = strings.TrimSpace(streamer)
|
||||
streamer = strings.TrimPrefix(streamer, "#")
|
||||
|
||||
+129
-9
@@ -1,28 +1,106 @@
|
||||
// model.go contains the Bubble Tea state machine for the current terminal UI.
|
||||
// It switches between the Twitch login log view and the streamer list view,
|
||||
// and applies auth progress messages emitted by the app layer.
|
||||
package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"parasocial/internal/auth"
|
||||
)
|
||||
|
||||
// Model is the initial terminal UI for parasocial.
|
||||
type viewMode int
|
||||
|
||||
const (
|
||||
authView viewMode = iota
|
||||
streamerView
|
||||
)
|
||||
|
||||
type StartAuthFunc func(chan<- AuthUpdate)
|
||||
|
||||
// AuthUpdate carries one incremental auth log line or completion result into the TUI.
|
||||
type AuthUpdate struct {
|
||||
Line string
|
||||
State *auth.State
|
||||
Err error
|
||||
Done bool
|
||||
}
|
||||
|
||||
// Options configures the initial streamer list, auth state, and auth starter hook.
|
||||
type Options struct {
|
||||
Streamers []string
|
||||
AuthState *auth.State
|
||||
StartAuth StartAuthFunc
|
||||
}
|
||||
|
||||
// authStartedMsg hands the model the channel that will stream auth updates.
|
||||
type authStartedMsg struct {
|
||||
Updates <-chan AuthUpdate
|
||||
}
|
||||
|
||||
// Model is the terminal UI for auth and streamer display.
|
||||
type Model struct {
|
||||
streamers []string
|
||||
authState *auth.State
|
||||
startAuth StartAuthFunc
|
||||
authUpdates <-chan AuthUpdate
|
||||
authLogs []string
|
||||
authErr error
|
||||
mode viewMode
|
||||
}
|
||||
|
||||
// New returns a Bubble Tea model that displays configured streamers.
|
||||
func New(streamers []string) Model {
|
||||
return Model{streamers: append([]string(nil), streamers...)}
|
||||
// New returns a Bubble Tea model for auth and streamer display.
|
||||
func New(options Options) Model {
|
||||
model := Model{
|
||||
streamers: append([]string(nil), options.Streamers...),
|
||||
authState: options.AuthState,
|
||||
startAuth: options.StartAuth,
|
||||
authLogs: []string{},
|
||||
mode: authView,
|
||||
}
|
||||
if options.AuthState != nil {
|
||||
model.mode = streamerView
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
// Init kicks off authentication only when the UI starts in the login state.
|
||||
func (m Model) Init() tea.Cmd {
|
||||
if m.mode == authView {
|
||||
return startAuthSession(m.startAuth)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update applies auth progress events and handles the global quit keys.
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case authStartedMsg:
|
||||
m.authUpdates = msg.Updates
|
||||
return m, waitForAuthUpdate(msg.Updates)
|
||||
case AuthUpdate:
|
||||
if msg.Line != "" {
|
||||
m.authLogs = append(m.authLogs, msg.Line)
|
||||
}
|
||||
if msg.Done {
|
||||
if msg.Err != nil {
|
||||
m.authErr = msg.Err
|
||||
return m, nil
|
||||
}
|
||||
if msg.State != nil {
|
||||
m.authState = msg.State
|
||||
m.authErr = nil
|
||||
m.mode = streamerView
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if m.authUpdates != nil {
|
||||
return m, waitForAuthUpdate(m.authUpdates)
|
||||
}
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc", "q":
|
||||
@@ -33,14 +111,56 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// View renders either the login log screen or the authenticated streamer list.
|
||||
func (m Model) View() string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("Streamers\n")
|
||||
|
||||
for i, streamer := range m.streamers {
|
||||
fmt.Fprintf(&builder, "%d. %s\n", i+1, streamer)
|
||||
if m.mode == authView {
|
||||
builder.WriteString("Twitch Login\n")
|
||||
if len(m.authLogs) == 0 {
|
||||
builder.WriteString("Starting authentication...\n")
|
||||
} else {
|
||||
for _, line := range m.authLogs {
|
||||
builder.WriteString(line)
|
||||
builder.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
if m.authErr != nil {
|
||||
builder.WriteString("\nLogin did not complete. Press q to quit.\n")
|
||||
}
|
||||
|
||||
builder.WriteString("\n")
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
if m.authState != nil && m.authState.Login != "" {
|
||||
fmt.Fprintf(&builder, "Logged in as %s\n\n", m.authState.Login)
|
||||
}
|
||||
builder.WriteString("Streamers\n")
|
||||
for i, streamer := range m.streamers {
|
||||
fmt.Fprintf(&builder, "%d. %s\n", i+1, streamer)
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// startAuthSession starts the background auth worker and returns its update channel to Bubble Tea.
|
||||
func startAuthSession(start StartAuthFunc) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if start == nil {
|
||||
return AuthUpdate{Err: errors.New("auth start function is nil"), Done: true}
|
||||
}
|
||||
updates := make(chan AuthUpdate, 32)
|
||||
start(updates)
|
||||
return authStartedMsg{Updates: updates}
|
||||
}
|
||||
}
|
||||
|
||||
// waitForAuthUpdate blocks until the next auth update is available from the worker.
|
||||
func waitForAuthUpdate(updates <-chan AuthUpdate) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
update, ok := <-updates
|
||||
if !ok {
|
||||
return AuthUpdate{Err: errors.New("auth updates ended unexpectedly"), Done: true}
|
||||
}
|
||||
return update
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
package tui
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"parasocial/internal/auth"
|
||||
)
|
||||
|
||||
func TestViewDisplaysStreamers(t *testing.T) {
|
||||
model := New([]string{"alpha", "beta"})
|
||||
model := New(Options{
|
||||
Streamers: []string{"alpha", "beta"},
|
||||
})
|
||||
model.mode = streamerView
|
||||
|
||||
got := model.View()
|
||||
want := "Streamers\n1. alpha\n2. beta\n\n"
|
||||
@@ -11,3 +18,35 @@ func TestViewDisplaysStreamers(t *testing.T) {
|
||||
t.Fatalf("View() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthUpdateAppendsLogLine(t *testing.T) {
|
||||
model := New(Options{Streamers: []string{"alpha"}})
|
||||
|
||||
updated, cmd := model.Update(AuthUpdate{Line: "Open page: https://www.twitch.tv/activate"})
|
||||
if cmd != nil {
|
||||
t.Fatal("expected nil cmd after auth update without channel")
|
||||
}
|
||||
|
||||
next := updated.(Model)
|
||||
got := next.View()
|
||||
want := "Twitch Login\nOpen page: https://www.twitch.tv/activate\n\n"
|
||||
if got != want {
|
||||
t.Fatalf("View() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSuccessSwitchesToStreamerView(t *testing.T) {
|
||||
model := New(Options{Streamers: []string{"alpha"}})
|
||||
|
||||
updated, _ := model.Update(AuthUpdate{
|
||||
State: &auth.State{Login: "viewer"},
|
||||
Done: true,
|
||||
})
|
||||
|
||||
next := updated.(Model)
|
||||
got := next.View()
|
||||
want := "Logged in as viewer\n\nStreamers\n1. alpha\n\n"
|
||||
if got != want {
|
||||
t.Fatalf("View() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user