strimertul/app.go

390 lines
9.8 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"mime/multipart"
"net/http"
"os"
"runtime/debug"
"strconv"
"git.sr.ht/~ashkeel/strimertul/log"
"git.sr.ht/~ashkeel/containers/sync"
kv "git.sr.ht/~ashkeel/kilovolt/v12"
"github.com/nicklaw5/helix/v2"
"github.com/urfave/cli/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/runtime"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/docs"
"git.sr.ht/~ashkeel/strimertul/loyalty"
"git.sr.ht/~ashkeel/strimertul/migrations"
"git.sr.ht/~ashkeel/strimertul/twitch"
"git.sr.ht/~ashkeel/strimertul/twitch/client"
"git.sr.ht/~ashkeel/strimertul/webserver"
)
// App struct
type App struct {
ctx context.Context
cliParams *cli.Context
driver database.Driver
ready *sync.RWSync[bool]
isFatalError *sync.RWSync[bool]
backupOptions database.BackupOptions
cancelLogs database.CancelFunc
logger *slog.Logger
db *database.LocalDBClient
twitchManager *client.Manager
httpServer *webserver.WebServer
loyaltyManager *loyalty.Manager
}
// NewApp creates a new App application struct
func NewApp(cliParams *cli.Context) *App {
return &App{
cliParams: cliParams,
ready: sync.NewRWSync(false),
isFatalError: sync.NewRWSync(false),
logger: slog.Default(),
}
}
// startup is called when the app starts
func (a *App) startup(ctx context.Context) {
defer func() {
if r := recover(); r != nil {
a.stop(ctx)
switch v := r.(type) {
case error:
a.showFatalError(v, v.Error())
default:
a.showFatalError(errors.New(fmt.Sprint(v)), "Runtime error encountered")
}
}
}()
slog.Info("Started", slog.String("version", appVersion))
a.ctx = ctx
a.backupOptions = database.BackupOptions{
BackupDir: a.cliParams.String("backup-dir"),
BackupInterval: a.cliParams.Int("backup-interval"),
MaxBackups: a.cliParams.Int("max-backups"),
}
// Initialize database
if err := a.initializeDatabase(); err != nil {
a.showFatalError(err, "Failed to initialize database")
return
}
// Check for migrations
if err := migrations.Run(a.driver, a.db, slog.With(slog.String("operation", "migration"))); err != nil {
a.showFatalError(err, "Failed to migrate database to latest version")
return
}
// Initialize components
if err := a.initializeComponents(); err != nil {
a.showFatalError(err, "Failed to initialize required component")
return
}
// Set meta keys
_ = a.db.PutKey(docs.VersionKey, appVersion)
a.ready.Set(true)
runtime.EventsEmit(ctx, "ready", true)
slog.Info("Strimertul is ready")
// Add logs I/O to UI
a.cancelLogs, _ = a.listenForLogs()
go a.forwardLogs()
// Run HTTP server
if err := a.httpServer.Listen(); err != nil {
a.showFatalError(err, "HTTP server stopped")
return
}
}
func (a *App) initializeDatabase() error {
var err error
// Make KV hub
a.driver, err = database.GetDatabaseDriver(a.cliParams)
if err != nil {
return fmt.Errorf("could not get database driver: %w", err)
}
// Start database backup task
if a.backupOptions.BackupInterval > 0 {
go BackupTask(a.driver, a.backupOptions)
}
hub := a.driver.Hub()
go hub.Run()
hub.UseInteractiveAuth(a.interactiveAuth)
a.db, err = database.NewLocalClient(hub)
if err != nil {
return fmt.Errorf("could not initialize database client: %w", err)
}
return nil
}
func (a *App) initializeComponents() error {
var err error
// Create logger and endpoints
a.httpServer, err = webserver.NewServer(
log.WithLogger(a.ctx, a.logger.With(slog.String("strimertul.module", "webserver"))),
a.db,
webserver.DefaultServerFactory,
)
if err != nil {
return fmt.Errorf("could not initialize http server: %w", err)
}
// Create twitch client
a.twitchManager, err = client.NewManager(
log.WithLogger(a.ctx, a.logger.With(slog.String("strimertul.module", "twitch"))),
a.db,
a.httpServer,
)
if err != nil {
return fmt.Errorf("could not initialize twitch client: %w", err)
}
// Initialize loyalty system
a.loyaltyManager, err = loyalty.NewManager(
log.WithLogger(a.ctx, a.logger.With(slog.String("strimertul.module", "logger"))),
a.db,
a.twitchManager,
)
if err != nil {
return fmt.Errorf("could not initialize loyalty manager: %w", err)
}
return nil
}
func (a *App) listenForLogs() (database.CancelFunc, error) {
return a.db.SubscribeKey(docs.LogRPCKey, func(newValue string) {
var entry docs.ExternalLog
if err := json.Unmarshal([]byte(newValue), &entry); err != nil {
return
}
var level slog.Level
if err := level.UnmarshalText([]byte(entry.Level)); err != nil {
level = slog.LevelInfo
}
slog.Log(a.ctx, level, entry.Message, log.ParseLogFields(entry.Data)...)
})
}
func (a *App) forwardLogs() {
for entry := range log.IncomingLogs {
runtime.EventsEmit(a.ctx, "log-event", entry)
}
}
func (a *App) stop(_ context.Context) {
if a.cancelLogs != nil {
a.cancelLogs()
}
if a.loyaltyManager != nil {
warnOnError(a.loyaltyManager.Close(), "Could not cleanly close loyalty manager")
}
if a.httpServer != nil {
warnOnError(a.httpServer.Close(), "Could not cleanly close HTTP server")
}
warnOnError(a.db.Close(), "Could not cleanly close database")
warnOnError(a.driver.Close(), "Could not close driver")
}
func (a *App) AuthenticateKVClient(id string) {
idInt, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return
}
warnOnError(a.driver.Hub().SetAuthenticated(idInt, true), "Could not mark session as authenticated", slog.String("session-id", id))
}
func (a *App) IsServerReady() bool {
return a.ready.Get()
}
func (a *App) IsFatalError() bool {
return a.isFatalError.Get()
}
func (a *App) GetKilovoltBind() string {
if a.httpServer == nil {
return ""
}
return a.httpServer.Config.Get().Bind
}
func (a *App) GetTwitchAuthURL(state string) string {
return twitch.GetAuthorizationURL(a.twitchManager.Client().API, state)
}
func (a *App) GetTwitchLoggedUser(key string) (helix.User, error) {
userClient, err := twitch.GetUserClient(a.db, key, false)
if err != nil {
return helix.User{}, err
}
users, err := userClient.GetUsers(&helix.UsersParams{})
if err != nil {
return helix.User{}, err
}
if len(users.Data.Users) < 1 {
return helix.User{}, errors.New("no users found")
}
return users.Data.Users[0], nil
}
func (a *App) GetLastLogs() []log.Entry {
return log.LastLogs.Get()
}
func (a *App) GetDocumentation() map[string]docs.KeyObject {
return docs.Keys
}
func (a *App) SendCrashReport(errorData string, info string) (string, error) {
var b bytes.Buffer
w := multipart.NewWriter(&b)
// Add text fields
if err := w.WriteField("error", errorData); err != nil {
slog.Error("Could not encode field error for crash report", log.Error(err))
}
if len(info) > 0 {
if err := w.WriteField("info", info); err != nil {
slog.Error("Could not encode field info for crash report", log.Error(err))
}
}
// Add log files
addFile(w, "log", log.Filename)
addFile(w, "paniclog", log.PanicFilename)
if err := w.Close(); err != nil {
slog.Error("Could not prepare request for crash report", log.Error(err))
return "", err
}
resp, err := http.Post(crashReportURL, w.FormDataContentType(), &b)
if err != nil {
slog.Error("Could not send crash report", log.Error(err))
return "", err
}
defer resp.Body.Close()
// Check the response
if resp.StatusCode != http.StatusOK {
byt, _ := io.ReadAll(resp.Body)
slog.Error("Crash report server returned error", slog.String("status", resp.Status), slog.String("response", string(byt)))
return "", fmt.Errorf("crash report server returned error: %s - %s", resp.Status, string(byt))
}
byt, err := io.ReadAll(resp.Body)
return string(byt), err
}
type VersionInfo struct {
Release string `json:"release"`
BuildInfo *debug.BuildInfo `json:"build"`
}
func (a *App) GetAppVersion() VersionInfo {
info, _ := debug.ReadBuildInfo()
return VersionInfo{
Release: appVersion,
BuildInfo: info,
}
}
func (a *App) TestTemplate(message string, data any) error {
tpl, err := a.twitchManager.Client().GetTemplateEngine().MakeTemplate(message)
if err != nil {
return err
}
return tpl.Execute(io.Discard, data)
}
func (a *App) TestCommandTemplate(message string) error {
return a.TestTemplate(message, twitch.TestMessageData)
}
func (a *App) interactiveAuth(client kv.Client, message map[string]any) bool {
callbackID := fmt.Sprintf("auth-callback-%d", client.UID())
authResult := make(chan bool)
runtime.EventsOnce(a.ctx, callbackID, func(optional ...any) {
if len(optional) == 0 {
authResult <- false
return
}
val, _ := optional[0].(bool)
authResult <- val
})
runtime.EventsEmit(a.ctx, "interactiveAuth", client.UID(), message, callbackID)
return <-authResult
}
func (a *App) showFatalError(err error, text string, fields ...any) {
if err != nil {
fields = append(fields, log.ErrorSkip(err, 2), slog.String("Z", string(debug.Stack())))
slog.Error(text, fields...)
runtime.EventsEmit(a.ctx, "fatalError")
a.isFatalError.Set(true)
}
}
func (a *App) onSecondInstanceLaunch(_ options.SecondInstanceData) {
_, _ = runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{
Type: runtime.ErrorDialog,
Title: "strimertul is already running",
Message: "Only one copy of strimertul can run at the same time, make sure to close other instances first",
})
}
func addFile(m *multipart.Writer, field string, filename string) {
logfile, err := m.CreateFormFile(field, filename)
if err != nil {
slog.Error("Could not encode field log for crash report", log.Error(err))
return
}
file, err := os.Open(filename)
if err != nil {
slog.Error("Could not open file for including in crash report", slog.String("file", filename), log.Error(err))
return
}
if _, err = io.Copy(logfile, file); err != nil {
slog.Error("Could not read from file for including in crash report", slog.String("file", filename), log.Error(err))
}
}