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