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)) } }