2022-11-16 11:23:54 +00:00
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
2023-04-14 18:04:21 +00:00
|
|
|
"bytes"
|
2022-11-16 11:23:54 +00:00
|
|
|
"context"
|
2023-04-14 18:04:21 +00:00
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"mime/multipart"
|
2023-11-05 11:47:04 +00:00
|
|
|
"net/http"
|
2023-04-14 18:04:21 +00:00
|
|
|
"os"
|
2023-02-15 09:44:44 +00:00
|
|
|
"runtime/debug"
|
2022-11-18 19:28:13 +00:00
|
|
|
"strconv"
|
2022-11-18 16:37:30 +00:00
|
|
|
|
2024-02-23 08:56:19 +00:00
|
|
|
"github.com/wailsapp/wails/v2/pkg/options"
|
|
|
|
|
2023-11-03 15:55:57 +00:00
|
|
|
kv "github.com/strimertul/kilovolt/v11"
|
2023-04-19 16:06:41 +00:00
|
|
|
|
2023-11-10 20:36:15 +00:00
|
|
|
"git.sr.ht/~ashkeel/containers/sync"
|
2022-12-04 13:45:34 +00:00
|
|
|
"github.com/nicklaw5/helix/v2"
|
|
|
|
"github.com/urfave/cli/v2"
|
|
|
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
2022-12-02 22:52:45 +00:00
|
|
|
"go.uber.org/zap"
|
2023-11-05 11:47:04 +00:00
|
|
|
"go.uber.org/zap/zapcore"
|
2022-12-02 22:52:45 +00:00
|
|
|
|
2023-11-10 20:36:15 +00:00
|
|
|
"git.sr.ht/~ashkeel/strimertul/database"
|
|
|
|
"git.sr.ht/~ashkeel/strimertul/docs"
|
|
|
|
"git.sr.ht/~ashkeel/strimertul/loyalty"
|
|
|
|
"git.sr.ht/~ashkeel/strimertul/twitch"
|
|
|
|
"git.sr.ht/~ashkeel/strimertul/webserver"
|
2022-11-16 11:23:54 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
// App struct
|
|
|
|
type App struct {
|
2023-04-14 18:04:21 +00:00
|
|
|
ctx context.Context
|
|
|
|
cliParams *cli.Context
|
2024-02-25 13:58:35 +00:00
|
|
|
driver database.Driver
|
2023-04-14 18:04:21 +00:00
|
|
|
ready *sync.RWSync[bool]
|
|
|
|
isFatalError *sync.RWSync[bool]
|
|
|
|
backupOptions database.BackupOptions
|
2023-11-05 11:47:04 +00:00
|
|
|
cancelLogs database.CancelFunc
|
2022-11-30 18:15:47 +00:00
|
|
|
|
|
|
|
db *database.LocalDBClient
|
2022-12-03 15:16:59 +00:00
|
|
|
twitchManager *twitch.Manager
|
2023-05-31 12:49:45 +00:00
|
|
|
httpServer *webserver.WebServer
|
2022-11-30 18:15:47 +00:00
|
|
|
loyaltyManager *loyalty.Manager
|
2022-11-16 11:23:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// NewApp creates a new App application struct
|
2022-11-18 16:37:30 +00:00
|
|
|
func NewApp(cliParams *cli.Context) *App {
|
|
|
|
return &App{
|
2023-04-14 18:04:21 +00:00
|
|
|
cliParams: cliParams,
|
|
|
|
ready: sync.NewRWSync(false),
|
|
|
|
isFatalError: sync.NewRWSync(false),
|
2022-11-18 16:37:30 +00:00
|
|
|
}
|
2022-11-16 11:23:54 +00:00
|
|
|
}
|
|
|
|
|
2022-11-18 16:37:30 +00:00
|
|
|
// startup is called when the app starts
|
2022-11-16 11:23:54 +00:00
|
|
|
func (a *App) startup(ctx context.Context) {
|
2023-04-14 18:04:21 +00:00
|
|
|
defer func() {
|
|
|
|
if r := recover(); r != nil {
|
|
|
|
a.stop(ctx)
|
|
|
|
_ = logger.Sync()
|
|
|
|
switch v := r.(type) {
|
|
|
|
case error:
|
|
|
|
a.showFatalError(v, v.Error())
|
|
|
|
default:
|
|
|
|
a.showFatalError(errors.New(fmt.Sprint(v)), "Runtime error encountered")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
2023-04-19 16:06:41 +00:00
|
|
|
logger.Info("Started", zap.String("version", appVersion))
|
|
|
|
|
2022-11-16 11:23:54 +00:00
|
|
|
a.ctx = ctx
|
2023-05-03 12:37:28 +00:00
|
|
|
|
2023-04-14 18:04:21 +00:00
|
|
|
a.backupOptions = database.BackupOptions{
|
|
|
|
BackupDir: a.cliParams.String("backup-dir"),
|
|
|
|
BackupInterval: a.cliParams.Int("backup-interval"),
|
|
|
|
MaxBackups: a.cliParams.Int("max-backups"),
|
|
|
|
}
|
2022-11-18 16:37:30 +00:00
|
|
|
|
2023-05-03 12:37:28 +00:00
|
|
|
// Initialize database
|
2024-02-23 08:56:19 +00:00
|
|
|
if err := a.initializeDatabase(); err != nil {
|
2023-05-03 12:37:28 +00:00
|
|
|
a.showFatalError(err, "Failed to initialize database")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Initialize components
|
2024-02-23 08:56:19 +00:00
|
|
|
if err := a.initializeComponents(); err != nil {
|
2023-05-03 12:37:28 +00:00
|
|
|
a.showFatalError(err, "Failed to initialize required component")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Set meta keys
|
2023-11-05 12:34:33 +00:00
|
|
|
_ = a.db.PutKey(docs.VersionKey, appVersion)
|
2023-05-03 12:37:28 +00:00
|
|
|
|
|
|
|
a.ready.Set(true)
|
|
|
|
runtime.EventsEmit(ctx, "ready", true)
|
|
|
|
logger.Info("Strimertul is ready")
|
|
|
|
|
2023-11-05 11:47:04 +00:00
|
|
|
// Add logs I/O to UI
|
2024-02-25 13:46:59 +00:00
|
|
|
a.cancelLogs, _ = a.listenForLogs()
|
2023-05-03 12:37:28 +00:00
|
|
|
go a.forwardLogs()
|
|
|
|
|
|
|
|
// Run HTTP server
|
2024-02-23 08:56:19 +00:00
|
|
|
if err := a.httpServer.Listen(); err != nil {
|
2023-05-03 12:37:28 +00:00
|
|
|
a.showFatalError(err, "HTTP server stopped")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (a *App) initializeDatabase() error {
|
|
|
|
var err error
|
|
|
|
|
2022-11-18 16:37:30 +00:00
|
|
|
// Make KV hub
|
2022-11-30 18:15:47 +00:00
|
|
|
a.driver, err = database.GetDatabaseDriver(a.cliParams)
|
2023-04-14 18:04:21 +00:00
|
|
|
if err != nil {
|
2023-05-03 12:37:28 +00:00
|
|
|
return fmt.Errorf("could not get database driver: %w", err)
|
2023-04-14 18:04:21 +00:00
|
|
|
}
|
2022-11-18 16:37:30 +00:00
|
|
|
|
|
|
|
// Start database backup task
|
2023-04-14 18:04:21 +00:00
|
|
|
if a.backupOptions.BackupInterval > 0 {
|
|
|
|
go BackupTask(a.driver, a.backupOptions)
|
2022-11-18 16:37:30 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
hub := a.driver.Hub()
|
|
|
|
go hub.Run()
|
2023-04-19 16:06:41 +00:00
|
|
|
hub.UseInteractiveAuth(a.interactiveAuth)
|
2022-11-18 16:37:30 +00:00
|
|
|
|
2022-11-30 18:15:47 +00:00
|
|
|
a.db, err = database.NewLocalClient(hub, logger)
|
2023-04-14 18:04:21 +00:00
|
|
|
if err != nil {
|
2023-05-03 12:37:28 +00:00
|
|
|
return fmt.Errorf("could not initialize database client: %w", err)
|
2023-04-14 18:04:21 +00:00
|
|
|
}
|
2022-11-18 16:37:30 +00:00
|
|
|
|
2023-05-03 12:37:28 +00:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (a *App) initializeComponents() error {
|
|
|
|
var err error
|
2022-11-18 16:37:30 +00:00
|
|
|
|
|
|
|
// Create logger and endpoints
|
2023-05-31 12:49:45 +00:00
|
|
|
a.httpServer, err = webserver.NewServer(a.db, logger, webserver.DefaultServerFactory)
|
2023-04-14 18:04:21 +00:00
|
|
|
if err != nil {
|
2023-05-03 12:37:28 +00:00
|
|
|
return fmt.Errorf("could not initialize http server: %w", err)
|
2023-04-14 18:04:21 +00:00
|
|
|
}
|
2022-11-30 18:15:47 +00:00
|
|
|
|
|
|
|
// Create twitch client
|
2022-12-03 15:16:59 +00:00
|
|
|
a.twitchManager, err = twitch.NewManager(a.db, a.httpServer, logger)
|
2023-04-14 18:04:21 +00:00
|
|
|
if err != nil {
|
2023-05-03 12:37:28 +00:00
|
|
|
return fmt.Errorf("could not initialize twitch client: %w", err)
|
2023-04-14 18:04:21 +00:00
|
|
|
}
|
2022-11-30 18:15:47 +00:00
|
|
|
|
|
|
|
// Initialize loyalty system
|
2022-12-03 15:16:59 +00:00
|
|
|
a.loyaltyManager, err = loyalty.NewManager(a.db, a.twitchManager, logger)
|
2023-04-14 18:04:21 +00:00
|
|
|
if err != nil {
|
2023-05-03 12:37:28 +00:00
|
|
|
return fmt.Errorf("could not initialize loyalty manager: %w", err)
|
2023-04-14 18:04:21 +00:00
|
|
|
}
|
2022-11-18 16:37:30 +00:00
|
|
|
|
2023-05-03 12:37:28 +00:00
|
|
|
return nil
|
|
|
|
}
|
2022-11-24 00:54:56 +00:00
|
|
|
|
2024-02-25 13:46:59 +00:00
|
|
|
func (a *App) listenForLogs() (database.CancelFunc, error) {
|
2023-11-05 12:34:33 +00:00
|
|
|
return a.db.SubscribeKey(docs.LogRPCKey, func(newValue string) {
|
|
|
|
var entry docs.ExternalLog
|
2023-11-05 11:47:04 +00:00
|
|
|
if err := json.Unmarshal([]byte(newValue), &entry); err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-11-05 12:34:33 +00:00
|
|
|
level, err := zapcore.ParseLevel(string(entry.Level))
|
2023-11-05 11:47:04 +00:00
|
|
|
if err != nil {
|
|
|
|
level = zapcore.InfoLevel
|
|
|
|
}
|
|
|
|
|
|
|
|
fields := parseAsFields(entry.Data)
|
|
|
|
logger.Log(level, entry.Message, fields...)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2023-05-03 12:37:28 +00:00
|
|
|
func (a *App) forwardLogs() {
|
|
|
|
for entry := range incomingLogs {
|
|
|
|
runtime.EventsEmit(a.ctx, "log-event", entry)
|
2023-04-14 18:04:21 +00:00
|
|
|
}
|
2022-11-18 16:37:30 +00:00
|
|
|
}
|
|
|
|
|
2024-02-23 08:56:19 +00:00
|
|
|
func (a *App) stop(_ context.Context) {
|
2023-11-05 11:47:04 +00:00
|
|
|
if a.cancelLogs != nil {
|
|
|
|
a.cancelLogs()
|
|
|
|
}
|
2022-11-30 18:15:47 +00:00
|
|
|
if a.loyaltyManager != nil {
|
2023-04-23 00:26:46 +00:00
|
|
|
warnOnError(a.loyaltyManager.Close(), "Could not cleanly close loyalty manager")
|
2022-11-30 18:15:47 +00:00
|
|
|
}
|
2022-12-03 15:16:59 +00:00
|
|
|
if a.twitchManager != nil {
|
2023-04-23 00:26:46 +00:00
|
|
|
warnOnError(a.twitchManager.Close(), "Could not cleanly close twitch client")
|
2022-11-29 23:52:54 +00:00
|
|
|
}
|
2022-11-30 18:15:47 +00:00
|
|
|
if a.httpServer != nil {
|
2023-04-23 00:26:46 +00:00
|
|
|
warnOnError(a.httpServer.Close(), "Could not cleanly close HTTP server")
|
2022-11-30 18:15:47 +00:00
|
|
|
}
|
2023-04-23 00:26:46 +00:00
|
|
|
warnOnError(a.db.Close(), "Could not cleanly close database")
|
2022-11-29 23:52:54 +00:00
|
|
|
|
2023-04-23 00:26:46 +00:00
|
|
|
warnOnError(a.driver.Close(), "Could not close driver")
|
2022-11-18 16:37:30 +00:00
|
|
|
}
|
|
|
|
|
2022-11-18 19:28:13 +00:00
|
|
|
func (a *App) AuthenticateKVClient(id string) {
|
|
|
|
idInt, err := strconv.ParseInt(id, 10, 64)
|
|
|
|
if err != nil {
|
|
|
|
return
|
|
|
|
}
|
2023-04-23 00:26:46 +00:00
|
|
|
warnOnError(a.driver.Hub().SetAuthenticated(idInt, true), "Could not mark session as authenticated", zap.String("session-id", id))
|
2022-11-18 19:28:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func (a *App) IsServerReady() bool {
|
2022-11-30 18:15:47 +00:00
|
|
|
return a.ready.Get()
|
2022-11-18 19:28:13 +00:00
|
|
|
}
|
|
|
|
|
2023-04-14 18:04:21 +00:00
|
|
|
func (a *App) IsFatalError() bool {
|
|
|
|
return a.isFatalError.Get()
|
|
|
|
}
|
|
|
|
|
2022-11-18 19:28:13 +00:00
|
|
|
func (a *App) GetKilovoltBind() string {
|
2022-11-30 18:15:47 +00:00
|
|
|
if a.httpServer == nil {
|
2022-11-23 21:22:49 +00:00
|
|
|
return ""
|
|
|
|
}
|
2022-12-04 13:45:34 +00:00
|
|
|
return a.httpServer.Config.Get().Bind
|
2022-11-23 21:22:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func (a *App) GetTwitchAuthURL() string {
|
2022-12-03 15:16:59 +00:00
|
|
|
return a.twitchManager.Client().GetAuthorizationURL()
|
2022-11-23 21:22:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func (a *App) GetTwitchLoggedUser() (helix.User, error) {
|
2022-12-03 15:16:59 +00:00
|
|
|
return a.twitchManager.Client().GetLoggedUser()
|
2022-11-16 11:23:54 +00:00
|
|
|
}
|
2022-11-24 00:54:56 +00:00
|
|
|
|
|
|
|
func (a *App) GetLastLogs() []LogEntry {
|
2022-12-04 17:35:15 +00:00
|
|
|
return lastLogs.Get()
|
2022-11-24 00:54:56 +00:00
|
|
|
}
|
2023-02-07 21:29:26 +00:00
|
|
|
|
|
|
|
func (a *App) GetDocumentation() map[string]docs.KeyObject {
|
|
|
|
return docs.Keys
|
|
|
|
}
|
2023-02-15 09:44:44 +00:00
|
|
|
|
2023-04-14 18:04:21 +00:00
|
|
|
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 {
|
2023-04-23 00:26:46 +00:00
|
|
|
logger.Error("Could not encode field error for crash report", zap.Error(err))
|
2023-04-14 18:04:21 +00:00
|
|
|
}
|
|
|
|
if len(info) > 0 {
|
|
|
|
if err := w.WriteField("info", info); err != nil {
|
2023-04-23 00:26:46 +00:00
|
|
|
logger.Error("Could not encode field info for crash report", zap.Error(err))
|
2023-04-14 18:04:21 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Add log files
|
|
|
|
_ = logger.Sync()
|
|
|
|
addFile(w, "log", logFilename)
|
|
|
|
addFile(w, "paniclog", panicFilename)
|
|
|
|
|
|
|
|
if err := w.Close(); err != nil {
|
2023-04-23 00:26:46 +00:00
|
|
|
logger.Error("Could not prepare request for crash report", zap.Error(err))
|
2023-04-14 18:04:21 +00:00
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
|
2023-11-05 11:47:04 +00:00
|
|
|
resp, err := http.Post(crashReportURL, w.FormDataContentType(), &b)
|
2023-04-14 18:04:21 +00:00
|
|
|
if err != nil {
|
2023-04-23 00:26:46 +00:00
|
|
|
logger.Error("Could not send crash report", zap.Error(err))
|
2023-04-14 18:04:21 +00:00
|
|
|
return "", err
|
|
|
|
}
|
2024-02-25 13:58:35 +00:00
|
|
|
defer resp.Body.Close()
|
2023-04-14 18:04:21 +00:00
|
|
|
|
|
|
|
// Check the response
|
2023-11-05 11:47:04 +00:00
|
|
|
if resp.StatusCode != http.StatusOK {
|
2023-04-14 18:04:21 +00:00
|
|
|
byt, _ := io.ReadAll(resp.Body)
|
2023-04-23 00:26:46 +00:00
|
|
|
logger.Error("Crash report server returned error", zap.String("status", resp.Status), zap.String("response", string(byt)))
|
2023-04-14 18:04:21 +00:00
|
|
|
return "", fmt.Errorf("crash report server returned error: %s - %s", resp.Status, string(byt))
|
|
|
|
}
|
|
|
|
|
|
|
|
byt, err := io.ReadAll(resp.Body)
|
|
|
|
return string(byt), err
|
|
|
|
}
|
|
|
|
|
2023-02-15 09:44:44 +00:00
|
|
|
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,
|
|
|
|
}
|
|
|
|
}
|
2023-04-14 18:04:21 +00:00
|
|
|
|
2023-05-19 13:07:32 +00:00
|
|
|
func (a *App) TestTemplate(message string, data any) error {
|
|
|
|
tpl, err := a.twitchManager.Client().Bot.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)
|
|
|
|
}
|
|
|
|
|
2023-04-23 00:59:30 +00:00
|
|
|
func (a *App) interactiveAuth(client kv.Client, message map[string]any) bool {
|
2023-04-19 16:06:41 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2023-04-14 18:04:21 +00:00
|
|
|
func (a *App) showFatalError(err error, text string, fields ...zap.Field) {
|
|
|
|
if err != nil {
|
|
|
|
fields = append(fields, zap.Error(err))
|
|
|
|
fields = append(fields, zap.String("Z", string(debug.Stack())))
|
|
|
|
logger.Error(text, fields...)
|
|
|
|
runtime.EventsEmit(a.ctx, "fatalError")
|
|
|
|
a.isFatalError.Set(true)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-02-23 08:56:19 +00:00
|
|
|
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",
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2023-04-14 18:04:21 +00:00
|
|
|
func addFile(m *multipart.Writer, field string, filename string) {
|
|
|
|
logfile, err := m.CreateFormFile(field, filename)
|
|
|
|
if err != nil {
|
2023-04-23 00:26:46 +00:00
|
|
|
logger.Error("Could not encode field log for crash report", zap.Error(err))
|
2023-04-14 18:04:21 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
file, err := os.Open(filename)
|
|
|
|
if err != nil {
|
2023-04-23 00:26:46 +00:00
|
|
|
logger.Error("Could not open file for including in crash report", zap.Error(err), zap.String("file", filename))
|
2023-04-14 18:04:21 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if _, err = io.Copy(logfile, file); err != nil {
|
2023-04-23 00:26:46 +00:00
|
|
|
logger.Error("Could not read from file for including in crash report", zap.Error(err), zap.String("file", filename))
|
2023-04-14 18:04:21 +00:00
|
|
|
}
|
|
|
|
}
|