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

1244 lines
35 KiB
Go

package plugin_ui
import (
"context"
"fmt"
"reflect"
"seanime/internal/events"
"seanime/internal/extension"
"seanime/internal/plugin"
goja_util "seanime/internal/util/goja"
"seanime/internal/util/result"
"sync"
"sync/atomic"
"time"
"github.com/dop251/goja"
"github.com/google/uuid"
"github.com/rs/zerolog"
"github.com/samber/mo"
)
// Constants for event batching
const (
maxEventBatchSize = 20 // Maximum number of events in a batch
eventBatchFlushInterval = 10 // Flush interval in milliseconds
)
// BatchedPluginEvents represents a collection of plugin events to be sent together
type BatchedPluginEvents struct {
Events []*ServerPluginEvent `json:"events"`
}
// BatchedEvents represents a collection of events to be sent together
type BatchedEvents struct {
Events []events.WebsocketClientEvent `json:"events"`
}
// Context manages the entire plugin UI during its lifecycle
type Context struct {
ui *UI
ext *extension.Extension
logger *zerolog.Logger
wsEventManager events.WSEventManagerInterface
mu sync.RWMutex
fetchSem chan struct{} // Semaphore for concurrent fetch requests
vm *goja.Runtime
states *result.Map[string, *State]
stateSubscribers []chan *State
scheduler *goja_util.Scheduler // Schedule VM executions concurrently and execute them in order.
wsSubscriber *events.ClientEventSubscriber
eventBus *result.Map[ClientEventType, *result.Map[string, *EventListener]] // map[string]map[string]*EventListener (event -> listenerID -> listener)
contextObj *goja.Object
fieldRefCount int // Number of field refs registered
exceptionCount int // Number of exceptions that have occurred
effectStack map[string]bool // Track currently executing effects to prevent infinite loops
effectCalls map[string][]time.Time // Track effect calls within time window
// State update batching
updateBatchMu sync.Mutex
pendingStateUpdates map[string]struct{} // Set of state IDs with pending updates
updateBatchTimer *time.Timer // Timer for flushing batched updates
// Event batching system
eventBatchMu sync.Mutex
pendingClientEvents []*ServerPluginEvent // Queue of pending events to send to client
eventBatchTimer *time.Timer // Timer for flushing batched events
eventBatchSize int // Current size of the event batch
// UI update rate limiting
lastUIUpdateAt time.Time
uiUpdateMu sync.Mutex
webviewManager *WebviewManager // UNUSED
screenManager *ScreenManager // Listen for screen events, send screen actions
trayManager *TrayManager // Register and manage tray
actionManager *ActionManager // Register and manage actions
formManager *FormManager // Register and manage forms
toastManager *ToastManager // Register and manage toasts
commandPaletteManager *CommandPaletteManager // Register and manage command palette
domManager *DOMManager // DOM manipulation manager
notificationManager *NotificationManager // Register and manage notifications
atomicCleanupCounter atomic.Int64
onCleanupFns *result.Map[int64, func()]
cron mo.Option[*plugin.Cron]
registeredInlineEventHandlers *result.Map[string, *EventListener]
}
type State struct {
ID string
Value goja.Value
}
// EventListener is used by Goja methods to listen for events from the client
type EventListener struct {
ID string
ListenTo []ClientEventType // Optional event type to listen for
queue []*ClientPluginEvent // Queue for event payloads
callback func(*ClientPluginEvent) // Callback function to process events
closed bool
mu sync.Mutex
}
func NewContext(ui *UI) *Context {
ret := &Context{
ui: ui,
ext: ui.ext,
logger: ui.logger,
vm: ui.vm,
states: result.NewResultMap[string, *State](),
fetchSem: make(chan struct{}, MaxConcurrentFetchRequests),
stateSubscribers: make([]chan *State, 0),
eventBus: result.NewResultMap[ClientEventType, *result.Map[string, *EventListener]](),
wsEventManager: ui.wsEventManager,
effectStack: make(map[string]bool),
effectCalls: make(map[string][]time.Time),
pendingStateUpdates: make(map[string]struct{}),
lastUIUpdateAt: time.Now().Add(-time.Hour), // Initialize to a time in the past
atomicCleanupCounter: atomic.Int64{},
onCleanupFns: result.NewResultMap[int64, func()](),
cron: mo.None[*plugin.Cron](),
registeredInlineEventHandlers: result.NewResultMap[string, *EventListener](),
pendingClientEvents: make([]*ServerPluginEvent, 0, maxEventBatchSize),
eventBatchSize: 0,
}
ret.scheduler = ui.scheduler
ret.updateBatchTimer = time.AfterFunc(time.Duration(StateUpdateBatchInterval)*time.Millisecond, ret.flushStateUpdates)
ret.updateBatchTimer.Stop() // Start in stopped state
ret.trayManager = NewTrayManager(ret)
ret.actionManager = NewActionManager(ret)
ret.webviewManager = NewWebviewManager(ret)
ret.screenManager = NewScreenManager(ret)
ret.formManager = NewFormManager(ret)
ret.toastManager = NewToastManager(ret)
ret.commandPaletteManager = NewCommandPaletteManager(ret)
ret.domManager = NewDOMManager(ret)
ret.notificationManager = NewNotificationManager(ret)
// Initialize the event batch timer
ret.eventBatchTimer = time.AfterFunc(eventBatchFlushInterval*time.Millisecond, func() {
ret.flushEventBatch()
})
ret.eventBatchTimer.Stop()
return ret
}
func (c *Context) createAndBindContextObject(vm *goja.Runtime) {
obj := vm.NewObject()
_ = obj.Set("newTray", c.trayManager.jsNewTray)
_ = obj.Set("newForm", c.formManager.jsNewForm)
_ = obj.Set("newCommandPalette", c.commandPaletteManager.jsNewCommandPalette)
_ = obj.Set("state", c.jsState)
_ = obj.Set("setTimeout", c.jsSetTimeout)
_ = obj.Set("setInterval", c.jsSetInterval)
_ = obj.Set("effect", c.jsEffect)
_ = obj.Set("registerEventHandler", c.jsRegisterEventHandler)
_ = obj.Set("eventHandler", c.jsEventHandler)
_ = obj.Set("fieldRef", c.jsfieldRef)
c.bindFetch(obj)
// Bind screen manager
c.screenManager.bind(obj)
// Bind action manager
c.actionManager.bind(obj)
// Bind toast manager
c.toastManager.bind(obj)
// Bind DOM manager
c.domManager.BindToObj(vm, obj)
// Bind manga
plugin.GlobalAppContext.BindMangaToContextObj(vm, obj, c.logger, c.ext, c.scheduler)
// Bind anime
plugin.GlobalAppContext.BindAnimeToContextObj(vm, obj, c.logger, c.ext, c.scheduler)
// Bind continuity
plugin.GlobalAppContext.BindContinuityToContextObj(vm, obj, c.logger, c.ext, c.scheduler)
// Bind filler manager
plugin.GlobalAppContext.BindFillerManagerToContextObj(vm, obj, c.logger, c.ext, c.scheduler)
// Bind auto downloader
plugin.GlobalAppContext.BindAutoDownloaderToContextObj(vm, obj, c.logger, c.ext, c.scheduler)
// Bind auto scanner
plugin.GlobalAppContext.BindAutoScannerToContextObj(vm, obj, c.logger, c.ext, c.scheduler)
// Bind external player link
plugin.GlobalAppContext.BindExternalPlayerLinkToContextObj(vm, obj, c.logger, c.ext, c.scheduler)
// Bind onlinestream
plugin.GlobalAppContext.BindOnlinestreamToContextObj(vm, obj, c.logger, c.ext, c.scheduler)
// Bind mediastream
plugin.GlobalAppContext.BindMediastreamToContextObj(vm, obj, c.logger, c.ext, c.scheduler)
if c.ext.Plugin != nil {
for _, permission := range c.ext.Plugin.Permissions.Scopes {
switch permission {
case extension.PluginPermissionPlayback:
// Bind playback to the context object
plugin.GlobalAppContext.BindPlaybackToContextObj(vm, obj, c.logger, c.ext, c.scheduler)
case extension.PluginPermissionSystem:
plugin.GlobalAppContext.BindDownloaderToContextObj(vm, obj, c.logger, c.ext, c.scheduler)
case extension.PluginPermissionCron:
// Bind cron to the context object
cron := plugin.GlobalAppContext.BindCronToContextObj(vm, obj, c.logger, c.ext, c.scheduler)
c.cron = mo.Some(cron)
case extension.PluginPermissionNotification:
// Bind notification to the context object
c.notificationManager.bind(obj)
case extension.PluginPermissionDiscord:
// Bind discord to the context object
plugin.GlobalAppContext.BindDiscordToContextObj(vm, obj, c.logger, c.ext, c.scheduler)
case extension.PluginPermissionTorrentClient:
// Bind torrent client to the context object
plugin.GlobalAppContext.BindTorrentClientToContextObj(vm, obj, c.logger, c.ext, c.scheduler)
}
}
}
_ = vm.Set("__ctx", obj)
c.contextObj = obj
}
// RegisterEventListener is used to register a new event listener in a Goja function
func (c *Context) RegisterEventListener(events ...ClientEventType) *EventListener {
id := uuid.New().String()
listener := &EventListener{
ID: id,
ListenTo: events,
queue: make([]*ClientPluginEvent, 0),
closed: false,
}
// Register the listener for each event type
for _, event := range events {
if !c.eventBus.Has(event) {
c.eventBus.Set(event, result.NewResultMap[string, *EventListener]())
}
listeners, _ := c.eventBus.Get(event)
listeners.Set(id, listener)
}
return listener
}
func (c *Context) UnregisterEventListener(id string) {
c.eventBus.Range(func(key ClientEventType, listenerMap *result.Map[string, *EventListener]) bool {
listener, ok := listenerMap.Get(id)
if !ok {
return true
}
// Close the listener first before removing it
listener.Close()
listenerMap.Delete(id)
return true
})
}
func (c *Context) UnregisterEventListenerE(e *EventListener) {
if e == nil {
return
}
for _, event := range e.ListenTo {
listeners, ok := c.eventBus.Get(event)
if !ok {
continue
}
listener, ok := listeners.Get(e.ID)
if !ok {
continue
}
// Close the listener first before removing it
listener.Close()
listeners.Delete(e.ID)
}
}
func (e *EventListener) Close() {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return
}
e.closed = true
e.queue = nil // Clear the queue
}
func (e *EventListener) Send(event *ClientPluginEvent) {
defer func() {
if r := recover(); r != nil {
fmt.Printf("plugin: Error sending event %s\n", event.Type)
}
}()
e.mu.Lock()
if e.closed {
e.mu.Unlock()
return
}
// Add event to queue
e.queue = append(e.queue, event)
hasCallback := e.callback != nil
e.mu.Unlock()
// Process immediately if callback is set - call after releasing the lock
if hasCallback {
go e.processEvents()
}
}
// SetCallback sets a function to call when events are received
func (e *EventListener) SetCallback(callback func(*ClientPluginEvent)) {
e.mu.Lock()
e.callback = callback
hasEvents := len(e.queue) > 0 && !e.closed
e.mu.Unlock()
// Process any existing events in the queue - call after releasing the lock
if hasEvents {
go e.processEvents()
}
}
// processEvents processes all events in the queue
func (e *EventListener) processEvents() {
var _events []*ClientPluginEvent
var callback func(*ClientPluginEvent)
e.mu.Lock()
if e.closed || e.callback == nil {
e.mu.Unlock()
return
}
// Get all _events from the queue and the callback
_events = make([]*ClientPluginEvent, len(e.queue))
copy(_events, e.queue)
e.queue = e.queue[:0] // Clear the queue
callback = e.callback // Make a copy of the callback
e.mu.Unlock()
// Process _events outside the lock with the copied callback
for _, event := range _events {
// Wrap each callback in a recover to prevent one bad event from stopping all processing
func(evt *ClientPluginEvent) {
defer func() {
if r := recover(); r != nil {
fmt.Printf("plugin: Error processing event: %v\n", r)
}
}()
callback(evt)
}(event)
}
}
// SendEventToClient sends an event to the client
// It always passes the extension ID
func (c *Context) SendEventToClient(eventType ServerEventType, payload interface{}) {
c.queueEventToClient("", eventType, payload)
}
// SendEventToClientWithClientID sends an event to the client with a specific client ID
func (c *Context) SendEventToClientWithClientID(clientID string, eventType ServerEventType, payload interface{}) {
c.wsEventManager.SendEventTo(clientID, string(events.PluginEvent), &ServerPluginEvent{
ExtensionID: c.ext.ID,
Type: eventType,
Payload: payload,
})
}
// PrintState prints all states to the logger
func (c *Context) PrintState() {
c.states.Range(func(key string, state *State) bool {
c.logger.Info().Msgf("State %s = %+v", key, state.Value)
return true
})
}
func (c *Context) GetContextObj() (*goja.Object, bool) {
return c.contextObj, c.contextObj != nil
}
// handleTypeError interrupts the UI the first time we encounter a type error.
// Interrupting early is better to catch wrong usage of the API.
func (c *Context) handleTypeError(msg string) {
c.logger.Error().Err(fmt.Errorf(msg)).Msg("plugin: Type error")
// c.fatalError(fmt.Errorf(msg))
panic(c.vm.NewTypeError(msg))
}
// handleException interrupts the UI after a certain number of exceptions have occurred.
// As opposed to HandleTypeError, this is more-so for unexpected errors and not wrong usage of the API.
func (c *Context) handleException(err error) {
// c.mu.Lock()
// defer c.mu.Unlock()
c.wsEventManager.SendEvent(events.ConsoleWarn, fmt.Sprintf("plugin(%s): Exception: %s", c.ext.ID, err.Error()))
c.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("plugin(%s): Exception: %s", c.ext.ID, err.Error()))
c.exceptionCount++
if c.exceptionCount >= MaxExceptions {
newErr := fmt.Errorf("plugin(%s): Encountered too many exceptions, last error: %w", c.ext.ID, err)
c.logger.Error().Err(newErr).Msg("plugin: Encountered too many exceptions, interrupting plugin")
c.fatalError(newErr)
}
}
func (c *Context) fatalError(err error) {
c.logger.Error().Err(err).Msg("plugin: Encountered fatal error, interrupting plugin")
c.wsEventManager.SendEvent(events.ConsoleWarn, fmt.Sprintf("plugin(%s): Encountered fatal error, interrupting plugin", c.ext.ID))
c.ui.lastException = err.Error()
c.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("plugin(%s): Fatal error: %s", c.ext.ID, err.Error()))
c.wsEventManager.SendEvent(events.ConsoleWarn, fmt.Sprintf("plugin(%s): Fatal error: %s", c.ext.ID, err.Error()))
// Unload the UI and signal the Plugin that it's been terminated
c.ui.Unload(true)
}
func (c *Context) registerOnCleanup(fn func()) {
c.atomicCleanupCounter.Add(1)
c.onCleanupFns.Set(c.atomicCleanupCounter.Load(), fn)
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// jsState is used to create a new state object
//
// Example:
// const text = ctx.state("Hello, world!");
// text.set("Button clicked");
// text.get(); // "Button clicked"
// text.length; // 15
// text.set(p => p + "!!!!");
// text.get(); // "Button clicked!!!!"
// text.length; // 19
func (c *Context) jsState(call goja.FunctionCall) goja.Value {
id := uuid.New().String()
initial := goja.Undefined()
if len(call.Arguments) > 0 {
initial = call.Argument(0)
}
state := &State{
ID: id,
Value: initial,
}
// Store the initial state
c.states.Set(id, state)
// Create a new JS object to represent the state
stateObj := c.vm.NewObject()
// Define getter and setter functions that interact with the Go-managed state
jsGetState := func(call goja.FunctionCall) goja.Value {
res, _ := c.states.Get(id)
return res.Value
}
jsSetState := func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
arg := call.Argument(0)
// e.g. state.set(prev => prev + "!!!")
if callback, ok := goja.AssertFunction(arg); ok {
prevState, ok := c.states.Get(id)
if ok {
newVal, _ := callback(goja.Undefined(), prevState.Value)
c.states.Set(id, &State{
ID: id,
Value: newVal,
})
c.queueStateUpdate(id)
}
} else {
c.states.Set(id, &State{
ID: id,
Value: arg,
})
c.queueStateUpdate(id)
}
}
return goja.Undefined()
}
jsGetStateVal := c.vm.ToValue(jsGetState)
jsSetStateVal := c.vm.ToValue(jsSetState)
// Define a dynamic state object that includes a 'value' property, get(), set(), and length
jsDynamicDefFuncValue, err := c.vm.RunString(`(function(obj, getter, setter) {
Object.defineProperty(obj, 'value', {
get: getter,
set: setter,
enumerable: true,
configurable: true
});
obj.get = function() { return this.value; };
obj.set = function(val) { this.value = val; return val; };
Object.defineProperty(obj, 'length', {
get: function() {
var val = this.value;
return (typeof val === 'string' ? val.length : undefined);
},
enumerable: true,
configurable: true
});
return obj;
})`)
if err != nil {
c.handleTypeError(err.Error())
}
jsDynamicDefFunc, ok := goja.AssertFunction(jsDynamicDefFuncValue)
if !ok {
c.handleTypeError("dynamic definition is not a function")
}
jsDynamicState, err := jsDynamicDefFunc(goja.Undefined(), stateObj, jsGetStateVal, jsSetStateVal)
if err != nil {
c.handleTypeError(err.Error())
}
// Attach hidden state ID for subscription
if obj, ok := jsDynamicState.(*goja.Object); ok {
_ = obj.Set("__stateId", id)
}
return jsDynamicState
}
// jsSetTimeout
//
// Example:
// const cancel = ctx.setTimeout(() => {
// console.log("Printing after 1 second");
// }, 1000);
// cancel(); // cancels the timeout
func (c *Context) jsSetTimeout(call goja.FunctionCall) goja.Value {
if len(call.Arguments) != 2 {
c.handleTypeError("setTimeout requires a function and a delay")
}
fnValue := call.Argument(0)
delayValue := call.Argument(1)
fn, ok := goja.AssertFunction(fnValue)
if !ok {
c.handleTypeError("setTimeout requires a function")
}
delay, ok := delayValue.Export().(int64)
if !ok {
c.handleTypeError("delay must be a number")
}
ctx, cancel := context.WithCancel(context.Background())
globalObj := c.vm.GlobalObject()
go func(fn goja.Callable, globalObj goja.Value) {
select {
case <-ctx.Done():
return
case <-time.After(time.Duration(delay) * time.Millisecond):
c.scheduler.ScheduleAsync(func() error {
_, err := fn(globalObj)
return err
})
}
}(fn, globalObj)
cancelFunc := func(call goja.FunctionCall) goja.Value {
cancel()
return goja.Undefined()
}
return c.vm.ToValue(cancelFunc)
}
// jsSetInterval
//
// Example:
// const cancel = ctx.setInterval(() => {
// console.log("Printing every second");
// }, 1000);
// cancel(); // cancels the interval
func (c *Context) jsSetInterval(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
c.handleTypeError("setInterval requires a function and a delay")
}
fnValue := call.Argument(0)
delayValue := call.Argument(1)
fn, ok := goja.AssertFunction(fnValue)
if !ok {
c.handleTypeError("setInterval requires a function")
}
delay, ok := delayValue.Export().(int64)
if !ok {
c.handleTypeError("delay must be a number")
}
globalObj := c.vm.GlobalObject()
ctx, cancel := context.WithCancel(context.Background())
go func(fn goja.Callable, globalObj goja.Value) {
for {
select {
case <-ctx.Done():
return
case <-time.After(time.Duration(delay) * time.Millisecond):
c.scheduler.ScheduleAsync(func() error {
_, err := fn(globalObj)
return err
})
}
}
}(fn, globalObj)
cancelFunc := func(call goja.FunctionCall) goja.Value {
cancel()
return goja.Undefined()
}
c.registerOnCleanup(func() {
cancel()
})
return c.vm.ToValue(cancelFunc)
}
// jsEffect
//
// Example:
// const text = ctx.state("Hello, world!");
// ctx.effect(() => {
// console.log("Text changed");
// }, [text]);
// text.set("Hello, world!"); // This will not trigger the effect
// text.set("Hello, world! 2"); // This will trigger the effect
func (c *Context) jsEffect(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
c.handleTypeError("effect requires a function and an array of dependencies")
}
effectFn, ok := goja.AssertFunction(call.Argument(0))
if !ok {
c.handleTypeError("first argument to effect must be a function")
}
depsObj, ok := call.Argument(1).(*goja.Object)
// If no dependencies, execute effect once and return
if !ok {
c.scheduler.ScheduleAsync(func() error {
_, err := effectFn(goja.Undefined())
return err
})
return c.vm.ToValue(func(call goja.FunctionCall) goja.Value {
return goja.Undefined()
})
}
// Generate unique ID for this effect
effectID := uuid.New().String()
// Prepare dependencies and their old values
lengthVal := depsObj.Get("length")
depsLen := int(lengthVal.ToInteger())
// If dependency array is empty, execute effect once and return
if depsLen == 0 {
c.scheduler.ScheduleAsync(func() error {
_, err := effectFn(goja.Undefined())
return err
})
return c.vm.ToValue(func(call goja.FunctionCall) goja.Value {
return goja.Undefined()
})
}
deps := make([]*goja.Object, depsLen)
oldValues := make([]goja.Value, depsLen)
dropIDs := make([]string, depsLen) // to store state IDs of dependencies
for i := 0; i < depsLen; i++ {
depVal := depsObj.Get(fmt.Sprintf("%d", i))
depObj, ok := depVal.(*goja.Object)
if !ok {
c.handleTypeError("dependency is not an object")
}
deps[i] = depObj
oldValues[i] = depObj.Get("value")
idVal := depObj.Get("__stateId")
exported := idVal.Export()
idStr, ok := exported.(string)
if !ok {
idStr = fmt.Sprintf("%v", exported)
}
dropIDs[i] = idStr
}
globalObj := c.vm.GlobalObject()
// Subscribe to state updates
subChan := c.subscribeStateUpdates()
ctxEffect, cancel := context.WithCancel(context.Background())
go func(effectFn *goja.Callable, globalObj goja.Value) {
for {
select {
case <-ctxEffect.Done():
return
case updatedState := <-subChan:
if effectFn != nil && updatedState != nil {
// Check if the updated state is one of our dependencies by matching __stateId
for i, depID := range dropIDs {
if depID == updatedState.ID {
newVal := deps[i].Get("value")
if !reflect.DeepEqual(oldValues[i].Export(), newVal.Export()) {
oldValues[i] = newVal
// Check for infinite loops
c.mu.Lock()
if c.effectStack[effectID] {
c.logger.Warn().Msgf("Detected potential infinite loop in effect %s, skipping execution", effectID)
c.mu.Unlock()
continue
}
// Clean up old calls and check rate
c.cleanupOldEffectCalls(effectID)
callsInWindow := len(c.effectCalls[effectID])
if callsInWindow >= MaxEffectCallsPerWindow {
c.mu.Unlock()
c.fatalError(fmt.Errorf("effect %s exceeded rate limit with %d calls in %dms window", effectID, callsInWindow, EffectTimeWindow))
return
}
// Track this call
c.effectStack[effectID] = true
c.effectCalls[effectID] = append(c.effectCalls[effectID], time.Now())
c.mu.Unlock()
c.scheduler.ScheduleAsync(func() error {
_, err := (*effectFn)(globalObj)
c.mu.Lock()
c.effectStack[effectID] = false
c.mu.Unlock()
return err
})
}
}
}
}
}
}
}(&effectFn, globalObj)
cancelFunc := func(call goja.FunctionCall) goja.Value {
cancel()
c.mu.Lock()
delete(c.effectCalls, effectID)
delete(c.effectStack, effectID)
c.mu.Unlock()
return goja.Undefined()
}
c.registerOnCleanup(func() {
cancel()
})
return c.vm.ToValue(cancelFunc)
}
// jsRegisterEventHandler
//
// Example:
// ctx.registerEventHandler("button-clicked", (e) => {
// console.log("Button clicked", e);
// });
func (c *Context) jsRegisterEventHandler(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
c.handleTypeError("registerEventHandler requires a handler name and a function")
}
handlerName := call.Argument(0).String()
handlerCallback, ok := goja.AssertFunction(call.Argument(1))
if !ok {
c.handleTypeError("second argument to registerEventHandler must be a function")
}
eventListener := c.RegisterEventListener(ClientEventHandlerTriggeredEvent)
payload := ClientEventHandlerTriggeredEventPayload{}
globalObj := c.vm.GlobalObject()
eventListener.SetCallback(func(event *ClientPluginEvent) {
if event.ParsePayloadAs(ClientEventHandlerTriggeredEvent, &payload) {
// Check if the handler name matches
if payload.HandlerName == handlerName {
c.scheduler.ScheduleAsync(func() error {
// Trigger the callback with the event payload
_, err := handlerCallback(globalObj, c.vm.ToValue(payload.Event))
return err
})
}
}
})
return goja.Undefined()
}
// jsEventHandler - inline event handler
//
// Example:
// tray.render(() => tray.button("Click me", {
// onClick: ctx.eventHandler("unique-key", (e) => {
// console.log("Button clicked", e);
// })
// }));
func (c *Context) jsEventHandler(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
c.handleTypeError("eventHandler requires a function")
}
uniqueKey := call.Argument(0).String()
if existingListener, ok := c.registeredInlineEventHandlers.Get(uniqueKey); ok {
c.UnregisterEventListenerE(existingListener)
}
handlerCallback, ok := goja.AssertFunction(call.Argument(1))
if !ok {
c.handleTypeError("second argument to eventHandler must be a function")
}
id := "__eventHandler__" + uuid.New().String()
eventListener := c.RegisterEventListener(ClientEventHandlerTriggeredEvent)
payload := ClientEventHandlerTriggeredEventPayload{}
eventListener.SetCallback(func(event *ClientPluginEvent) {
if event.ParsePayloadAs(ClientEventHandlerTriggeredEvent, &payload) {
// Check if the handler name matches
if payload.HandlerName == id {
c.scheduler.ScheduleAsync(func() error {
// Trigger the callback with the event payload
_, err := handlerCallback(goja.Undefined(), c.vm.ToValue(payload.Event))
return err
})
}
}
})
c.registeredInlineEventHandlers.Set(uniqueKey, eventListener)
return c.vm.ToValue(id)
}
// jsfieldRef allows to dynamically handle the value of a field outside the rendering context
//
// Example:
// const fieldRef = ctx.fieldRef("defaultValue")
// fieldRef.current // "defaultValue"
// fieldRef.setValue("Hello World!") // Triggers an immediate update on the client
// fieldRef.current // "Hello World!"
//
// tray.render(() => tray.input({ fieldRef: "my-field" }))
func (c *Context) jsfieldRef(call goja.FunctionCall) goja.Value {
fieldRefObj := c.vm.NewObject()
if c.fieldRefCount >= MAX_FIELD_REFS {
c.handleTypeError("Too many field refs registered")
return goja.Undefined()
}
id := uuid.New().String()
fieldRefObj.Set("__ID", id)
c.fieldRefCount++
var valueRef interface{}
var onChangeCallback func(value interface{})
// Handle default value if provided
if len(call.Arguments) > 0 {
valueRef = call.Argument(0).Export()
fieldRefObj.Set("current", valueRef)
} else {
fieldRefObj.Set("current", goja.Undefined())
}
fieldRefObj.Set("setValue", func(call goja.FunctionCall) goja.Value {
value := call.Argument(0).Export()
if value == nil {
c.handleTypeError("setValue requires a value")
}
c.SendEventToClient(ServerFieldRefSetValueEvent, ServerFieldRefSetValueEventPayload{
FieldRef: id,
Value: value,
})
valueRef = value
fieldRefObj.Set("current", value)
return goja.Undefined()
})
fieldRefObj.Set("onValueChange", func(call goja.FunctionCall) goja.Value {
callback, ok := goja.AssertFunction(call.Argument(0))
if !ok {
c.handleTypeError("onValueChange requires a function")
}
onChangeCallback = func(value interface{}) {
_, err := callback(goja.Undefined(), c.vm.ToValue(value))
if err != nil {
c.handleTypeError(err.Error())
}
}
return goja.Undefined()
})
// Listen for changes from the client
eventListener := c.RegisterEventListener(ClientFieldRefSendValueEvent, ClientRenderTrayEvent)
eventListener.SetCallback(func(event *ClientPluginEvent) {
payload := ClientFieldRefSendValueEventPayload{}
renderPayload := ClientRenderTrayEventPayload{}
if event.ParsePayloadAs(ClientFieldRefSendValueEvent, &payload) && payload.FieldRef == id {
valueRef = payload.Value
// Schedule the update of the object
if payload.Value != nil {
c.scheduler.ScheduleAsync(func() error {
fieldRefObj.Set("current", payload.Value)
return nil
})
if onChangeCallback != nil {
c.scheduler.ScheduleAsync(func() error {
onChangeCallback(payload.Value)
return nil
})
}
}
}
// Check if the client is requesting a render
// If it is, we send the current value to the client
if event.ParsePayloadAs(ClientRenderTrayEvent, &renderPayload) {
c.SendEventToClient(ServerFieldRefSetValueEvent, ServerFieldRefSetValueEventPayload{
FieldRef: id,
Value: valueRef,
})
}
})
return fieldRefObj
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (c *Context) subscribeStateUpdates() chan *State {
ch := make(chan *State, 10)
c.mu.Lock()
c.stateSubscribers = append(c.stateSubscribers, ch)
c.mu.Unlock()
return ch
}
func (c *Context) publishStateUpdate(id string) {
state, ok := c.states.Get(id)
if !ok {
return
}
c.mu.RLock()
defer c.mu.RUnlock()
for _, sub := range c.stateSubscribers {
select {
case sub <- state:
default:
}
}
}
func (c *Context) cleanupOldEffectCalls(effectID string) {
now := time.Now()
window := time.Duration(EffectTimeWindow) * time.Millisecond
var validCalls []time.Time
for _, t := range c.effectCalls[effectID] {
if now.Sub(t) <= window {
validCalls = append(validCalls, t)
}
}
c.effectCalls[effectID] = validCalls
}
// queueStateUpdate adds a state update to the batch queue
func (c *Context) queueStateUpdate(id string) {
c.updateBatchMu.Lock()
defer c.updateBatchMu.Unlock()
// Add to pending updates
c.pendingStateUpdates[id] = struct{}{}
// Start the timer if it's not running
if !c.updateBatchTimer.Stop() {
select {
case <-c.updateBatchTimer.C:
// Timer already fired, drain the channel
default:
// Timer was already stopped
}
}
c.updateBatchTimer.Reset(time.Duration(StateUpdateBatchInterval) * time.Millisecond)
}
// flushStateUpdates processes all pending state updates
func (c *Context) flushStateUpdates() {
c.updateBatchMu.Lock()
// Get all pending updates
pendingUpdates := make([]string, 0, len(c.pendingStateUpdates))
for id := range c.pendingStateUpdates {
pendingUpdates = append(pendingUpdates, id)
}
// Clear the pending updates
c.pendingStateUpdates = make(map[string]struct{})
c.updateBatchMu.Unlock()
// Process all updates
for _, id := range pendingUpdates {
c.publishStateUpdate(id)
}
// Trigger UI update after state changes
c.triggerUIUpdate()
}
// triggerUIUpdate schedules a UI update after state changes
func (c *Context) triggerUIUpdate() {
c.uiUpdateMu.Lock()
defer c.uiUpdateMu.Unlock()
// Rate limit UI updates
if time.Since(c.lastUIUpdateAt) < time.Millisecond*time.Duration(UIUpdateRateLimit) {
return
}
c.lastUIUpdateAt = time.Now()
// Trigger tray update if available
if c.trayManager != nil {
c.trayManager.renderTrayScheduled()
}
}
// Cleanup is called when the UI is being unloaded
func (c *Context) Cleanup() {
// Flush any pending state updates
c.flushStateUpdates()
// Flush any pending events
c.flushEventBatch()
}
// Stop is called when the UI is being unloaded
func (c *Context) Stop() {
c.logger.Debug().Msg("plugin: Stopping context")
if c.updateBatchTimer != nil {
c.logger.Trace().Msg("plugin: Stopping update batch timer")
c.updateBatchTimer.Stop()
}
if c.eventBatchTimer != nil {
c.logger.Trace().Msg("plugin: Stopping event batch timer")
c.eventBatchTimer.Stop()
}
// Stop the scheduler
c.logger.Trace().Msg("plugin: Stopping scheduler")
c.scheduler.Stop()
// Stop the cron
if cron, hasCron := c.cron.Get(); hasCron {
c.logger.Trace().Msg("plugin: Stopping cron")
cron.Stop()
}
// Stop all event listeners
c.logger.Trace().Msg("plugin: Stopping event listeners")
eventListenersToClose := make([]*EventListener, 0)
// First collect all listeners to avoid modification during iteration
c.eventBus.Range(func(_ ClientEventType, listenerMap *result.Map[string, *EventListener]) bool {
listenerMap.Range(func(_ string, listener *EventListener) bool {
eventListenersToClose = append(eventListenersToClose, listener)
return true
})
return true
})
// Then close them all outside the locks
for _, listener := range eventListenersToClose {
func(l *EventListener) {
defer func() {
if r := recover(); r != nil {
c.logger.Error().Err(fmt.Errorf("%v", r)).Msg("plugin: Error stopping event listener")
}
}()
l.Close()
}(listener)
}
// Finally clear the maps
c.eventBus.Range(func(_ ClientEventType, listenerMap *result.Map[string, *EventListener]) bool {
listenerMap.Clear()
return true
})
c.eventBus.Clear()
// Stop all state subscribers
c.logger.Trace().Msg("plugin: Stopping state subscribers")
for _, sub := range c.stateSubscribers {
go func(sub chan *State) {
defer func() {
if r := recover(); r != nil {
c.logger.Error().Err(fmt.Errorf("%v", r)).Msg("plugin: Error stopping state subscriber")
}
}()
close(sub)
}(sub)
}
// Run all cleanup functions
c.onCleanupFns.Range(func(key int64, fn func()) bool {
fn()
return true
})
c.onCleanupFns.Clear()
c.actionManager.UnmountAll()
c.actionManager.renderAnimePageButtons()
c.logger.Debug().Msg("plugin: Stopped context")
}
// queueEventToClient adds an event to the batch queue for sending to the client
func (c *Context) queueEventToClient(clientID string, eventType ServerEventType, payload interface{}) {
c.eventBatchMu.Lock()
defer c.eventBatchMu.Unlock()
// Create the plugin event
event := &ServerPluginEvent{
ExtensionID: c.ext.ID,
Type: eventType,
Payload: payload,
}
// Add to pending events
c.pendingClientEvents = append(c.pendingClientEvents, event)
c.eventBatchSize++
// If this is the first event, start the timer
if c.eventBatchSize == 1 {
c.eventBatchTimer.Reset(eventBatchFlushInterval * time.Millisecond)
}
// If we've reached max batch size, flush immediately
if c.eventBatchSize >= maxEventBatchSize {
// Use goroutine to avoid deadlock since we're already holding the lock
go c.flushEventBatch()
}
}
// flushEventBatch sends all pending events as a batch to the client
func (c *Context) flushEventBatch() {
c.eventBatchMu.Lock()
// If there are no events, just unlock and return
if c.eventBatchSize == 0 {
c.eventBatchMu.Unlock()
return
}
// Stop the timer
c.eventBatchTimer.Stop()
// Create a copy of the pending events
allEvents := make([]*ServerPluginEvent, len(c.pendingClientEvents))
copy(allEvents, c.pendingClientEvents)
// Clear the pending events
c.pendingClientEvents = c.pendingClientEvents[:0]
c.eventBatchSize = 0
c.eventBatchMu.Unlock()
// If only one event, send it directly to maintain compatibility with current system
if len(allEvents) == 1 {
// c.wsEventManager.SendEvent("plugin", allEvents[0])
c.wsEventManager.SendEvent(string(events.PluginEvent), &ServerPluginEvent{
ExtensionID: c.ext.ID,
Type: allEvents[0].Type,
Payload: allEvents[0].Payload,
})
return
}
// Send events as a batch
batchPayload := &BatchedPluginEvents{
Events: allEvents,
}
// Send the batch
c.wsEventManager.SendEvent(string(events.PluginEvent), &ServerPluginEvent{
ExtensionID: c.ext.ID,
Type: "plugin:batch-events",
Payload: batchPayload,
})
}