1244 lines
35 KiB
Go
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,
|
|
})
|
|
}
|