Files
seanime-docker/seanime-2.9.10/internal/plugin/ui/ui.go
2025-09-20 14:08:38 +01:00

326 lines
11 KiB
Go

package plugin_ui
import (
"errors"
"fmt"
"seanime/internal/database/db"
"seanime/internal/events"
"seanime/internal/extension"
"seanime/internal/plugin"
"seanime/internal/util"
goja_util "seanime/internal/util/goja"
"sync"
"github.com/dop251/goja"
"github.com/rs/zerolog"
)
var (
ErrTooManyExceptions = errors.New("plugin: Too many exceptions")
ErrFatalError = errors.New("plugin: Fatal error")
)
const (
MaxExceptions = 5 // Maximum number of exceptions that can be thrown before the UI is interrupted
MaxConcurrentFetchRequests = 10 // Maximum number of concurrent fetch requests
MaxEffectCallsPerWindow = 100 // Maximum number of effect calls allowed in time window
EffectTimeWindow = 1000 // Time window in milliseconds to track effect calls
StateUpdateBatchInterval = 10 // Time in milliseconds to batch state updates
UIUpdateRateLimit = 120 // Time in milliseconds to rate limit UI updates
)
// UI registry, unique to a plugin and VM
type UI struct {
ext *extension.Extension
context *Context
mu sync.RWMutex
vm *goja.Runtime // VM executing the UI
logger *zerolog.Logger
wsEventManager events.WSEventManagerInterface
appContext plugin.AppContext
scheduler *goja_util.Scheduler
lastException string
// Channel to signal the UI has been unloaded
// This is used to interrupt the Plugin when the UI is stopped
destroyedCh chan struct{}
destroyed bool
}
type NewUIOptions struct {
Logger *zerolog.Logger
VM *goja.Runtime
WSManager events.WSEventManagerInterface
Database *db.Database
Scheduler *goja_util.Scheduler
Extension *extension.Extension
}
func NewUI(options NewUIOptions) *UI {
ui := &UI{
ext: options.Extension,
vm: options.VM,
logger: options.Logger,
wsEventManager: options.WSManager,
appContext: plugin.GlobalAppContext, // Get the app context from the global hook manager
scheduler: options.Scheduler,
destroyedCh: make(chan struct{}),
}
ui.context = NewContext(ui)
ui.context.scheduler.SetOnException(func(err error) {
ui.logger.Error().Err(err).Msg("plugin: Encountered exception in asynchronous task")
ui.context.handleException(err)
})
return ui
}
// Called by the Plugin when it's being unloaded
func (u *UI) Unload(signalDestroyed bool) {
u.logger.Debug().Msg("plugin: Stopping UI")
u.UnloadFromInside(signalDestroyed)
u.logger.Debug().Msg("plugin: Stopped UI")
}
// UnloadFromInside is called by the UI module itself when it's being unloaded
func (u *UI) UnloadFromInside(signalDestroyed bool) {
u.mu.Lock()
defer u.mu.Unlock()
if u.destroyed {
return
}
// Stop the VM
u.vm.ClearInterrupt()
// Unsubscribe from client all events
if u.context.wsSubscriber != nil {
u.wsEventManager.UnsubscribeFromClientEvents("plugin-" + u.ext.ID)
}
// Clean up the context (all modules)
if u.context != nil {
u.context.Stop()
}
// Send the plugin unloaded event to the client
u.wsEventManager.SendEvent(events.PluginUnloaded, u.ext.ID)
if signalDestroyed {
u.signalDestroyed()
}
}
// Destroyed returns a channel that is closed when the UI is destroyed
func (u *UI) Destroyed() <-chan struct{} {
return u.destroyedCh
}
// signalDestroyed tells the plugin that the UI has been destroyed.
// This is used to interrupt the Plugin when the UI is stopped
// TODO: FIX
func (u *UI) signalDestroyed() {
defer func() {
if r := recover(); r != nil {
u.logger.Error().Msgf("plugin: Panic in signalDestroyed: %v", r)
}
}()
if u.destroyed {
return
}
u.destroyed = true
close(u.destroyedCh)
}
// Register a UI
// This is the main entry point for the UI
// - It is called once when the plugin is loaded and registers all necessary modules
func (u *UI) Register(callback string) error {
defer util.HandlePanicInModuleThen("plugin_ui/Register", func() {
u.logger.Error().Msg("plugin: Panic in Register")
})
u.mu.Lock()
// Create a wrapper JavaScript function that calls the provided callback
callback = `function(ctx) { return (` + callback + `).call(undefined, ctx); }`
// Compile the callback into a Goja program
// pr := goja.MustCompile("", "{("+callback+").apply(undefined, __ctx)}", true)
// Subscribe the plugin to client events
u.context.wsSubscriber = u.wsEventManager.SubscribeToClientEvents("plugin-" + u.ext.ID)
u.logger.Debug().Msg("plugin: Registering UI")
// Listen for client events and send them to the event listeners
go func() {
for event := range u.context.wsSubscriber.Channel {
//u.logger.Trace().Msgf("Received event %s", event.Type)
u.HandleWSEvent(event)
}
u.logger.Debug().Msg("plugin: Event goroutine stopped")
}()
u.context.createAndBindContextObject(u.vm)
// Execute the callback
_, err := u.vm.RunString(`(` + callback + `).call(undefined, __ctx)`)
if err != nil {
u.mu.Unlock()
u.logger.Error().Err(err).Msg("plugin: Encountered exception in UI handler, unloading plugin")
u.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("plugin(%s): Encountered exception in UI handler: %s", u.ext.ID, err.Error()))
u.wsEventManager.SendEvent(events.ConsoleLog, fmt.Sprintf("plugin(%s): Encountered exception in UI handler: %s", u.ext.ID, err.Error()))
// Unload the UI and signal the Plugin that it's been terminated
u.UnloadFromInside(true)
return fmt.Errorf("plugin: Encountered exception in UI handler: %w", err)
}
// Send events to the client
u.context.trayManager.renderTrayScheduled()
u.context.trayManager.sendIconToClient()
u.context.actionManager.renderAnimePageButtons()
u.context.actionManager.renderAnimePageDropdownItems()
u.context.actionManager.renderAnimeLibraryDropdownItems()
u.context.actionManager.renderMangaPageButtons()
u.context.actionManager.renderMediaCardContextMenuItems()
u.context.actionManager.renderEpisodeCardContextMenuItems()
u.context.actionManager.renderEpisodeGridItemMenuItems()
u.context.commandPaletteManager.renderCommandPaletteScheduled()
u.context.commandPaletteManager.sendInfoToClient()
u.wsEventManager.SendEvent(events.PluginLoaded, u.ext.ID)
u.mu.Unlock()
return nil
}
// Add this new type to handle batched events from the client
type BatchedClientEvents struct {
Events []map[string]interface{} `json:"events"`
}
// HandleWSEvent handles a websocket event from the client
func (u *UI) HandleWSEvent(event *events.WebsocketClientEvent) {
defer util.HandlePanicInModuleThen("plugin/HandleWSEvent", func() {})
u.mu.RLock()
defer u.mu.RUnlock()
// Ignore if UI is destroyed
if u.destroyed {
return
}
if event.Type == events.PluginEvent {
// Extract the event payload
payload, ok := event.Payload.(map[string]interface{})
if !ok {
u.logger.Error().Str("payload", fmt.Sprintf("%+v", event.Payload)).Msg("plugin/ui: Failed to parse plugin event payload")
return
}
// Check if this is a batch event
eventType, _ := payload["type"].(string)
if eventType == "client:batch-events" {
u.handleBatchedClientEvents(event.ClientID, payload)
return
}
// Process normal event
clientEvent := NewClientPluginEvent(payload)
if clientEvent == nil {
u.logger.Error().Interface("payload", payload).Msg("plugin/ui: Failed to create client plugin event")
return
}
// If the event is for this plugin
if clientEvent.ExtensionID == u.ext.ID || clientEvent.ExtensionID == "" {
// Process the event based on type
u.dispatchClientEvent(clientEvent)
}
}
}
// dispatchClientEvent handles a client event based on its type
func (u *UI) dispatchClientEvent(clientEvent *ClientPluginEvent) {
switch clientEvent.Type {
case ClientRenderTrayEvent: // Client wants to render the tray
u.context.trayManager.renderTrayScheduled()
case ClientListTrayIconsEvent: // Client wants to list all tray icons from all plugins
u.context.trayManager.sendIconToClient()
case ClientActionRenderAnimePageButtonsEvent: // Client wants to update the anime page buttons
u.context.actionManager.renderAnimePageButtons()
case ClientActionRenderAnimePageDropdownItemsEvent: // Client wants to update the anime page dropdown items
u.context.actionManager.renderAnimePageDropdownItems()
case ClientActionRenderAnimeLibraryDropdownItemsEvent: // Client wants to update the anime library dropdown items
u.context.actionManager.renderAnimeLibraryDropdownItems()
case ClientActionRenderMangaPageButtonsEvent: // Client wants to update the manga page buttons
u.context.actionManager.renderMangaPageButtons()
case ClientActionRenderMediaCardContextMenuItemsEvent: // Client wants to update the media card context menu items
u.context.actionManager.renderMediaCardContextMenuItems()
case ClientActionRenderEpisodeCardContextMenuItemsEvent: // Client wants to update the episode card context menu items
u.context.actionManager.renderEpisodeCardContextMenuItems()
case ClientActionRenderEpisodeGridItemMenuItemsEvent: // Client wants to update the episode grid item menu items
u.context.actionManager.renderEpisodeGridItemMenuItems()
case ClientRenderCommandPaletteEvent: // Client wants to render the command palette
u.context.commandPaletteManager.renderCommandPaletteScheduled()
case ClientListCommandPalettesEvent: // Client wants to list all command palettes
u.context.commandPaletteManager.sendInfoToClient()
default:
// Send to registered event listeners
eventListeners, ok := u.context.eventBus.Get(clientEvent.Type)
if !ok {
return
}
eventListeners.Range(func(key string, listener *EventListener) bool {
listener.Send(clientEvent)
return true
})
}
}
// handleBatchedClientEvents processes a batch of client events
func (u *UI) handleBatchedClientEvents(clientID string, payload map[string]interface{}) {
if eventPayload, ok := payload["payload"].(map[string]interface{}); ok {
if eventsRaw, ok := eventPayload["events"].([]interface{}); ok {
// Process each event in the batch
for _, eventRaw := range eventsRaw {
if eventMap, ok := eventRaw.(map[string]interface{}); ok {
// Create a synthetic event object
syntheticPayload := map[string]interface{}{
"type": eventMap["type"],
"extensionId": eventMap["extensionId"],
"payload": eventMap["payload"],
}
// Create and dispatch the event
clientEvent := NewClientPluginEvent(syntheticPayload)
if clientEvent == nil {
u.logger.Error().Interface("payload", syntheticPayload).Msg("plugin/ui: Failed to create client plugin event from batch")
continue
}
// If the event is for this plugin
if clientEvent.ExtensionID == u.ext.ID || clientEvent.ExtensionID == "" {
// Process the event
u.dispatchClientEvent(clientEvent)
}
}
}
}
}
}