package extension_repo import ( "context" "fmt" "reflect" "seanime/internal/events" "seanime/internal/extension" goja_bindings "seanime/internal/goja/goja_bindings" "seanime/internal/goja/goja_runtime" "seanime/internal/hook" "seanime/internal/plugin" plugin_ui "seanime/internal/plugin/ui" "seanime/internal/util" goja_util "seanime/internal/util/goja" "slices" "strings" "github.com/dop251/goja" "github.com/dop251/goja/parser" "github.com/rs/zerolog" "github.com/samber/lo" ) ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Load Plugin ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// func (r *Repository) loadPluginExtension(ext *extension.Extension) (err error) { defer util.HandlePanicInModuleWithError("extension_repo/loadPluginExtension", &err) _, gojaExt, err := NewGojaPlugin(ext, ext.Language, r.logger, r.gojaRuntimeManager, r.wsEventManager) if err != nil { return err } // Add the extension to the map retExt := extension.NewPluginExtension(ext) r.extensionBank.Set(ext.ID, retExt) r.gojaExtensions.Set(ext.ID, gojaExt) return } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Plugin ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// type GojaPlugin struct { ext *extension.Extension logger *zerolog.Logger pool *goja_runtime.Pool runtimeManager *goja_runtime.Manager store *plugin.Store[string, any] storage *plugin.Storage ui *plugin_ui.UI scheduler *goja_util.Scheduler loader *goja.Runtime unbindHookFuncs []func() interrupted bool wsEventManager events.WSEventManagerInterface } func (p *GojaPlugin) GetExtension() *extension.Extension { return p.ext } func (p *GojaPlugin) PutVM(vm *goja.Runtime) { p.pool.Put(vm) } // ClearInterrupt stops the UI VM and other modules. // It is called when the extension is unloaded. func (p *GojaPlugin) ClearInterrupt() { if p.interrupted { return } p.interrupted = true p.logger.Debug().Msg("plugin: Interrupting plugin") // Unload the UI if p.ui != nil { p.ui.Unload(false) } // Clear the interrupt if p.loader != nil { p.loader.ClearInterrupt() } // Stop the store if p.store != nil { p.store.Stop() } // Stop the storage if p.storage != nil { p.storage.Stop() } // Delete the plugin pool if p.runtimeManager != nil { p.runtimeManager.DeletePluginPool(p.ext.ID) } p.logger.Debug().Msgf("plugin: Unbinding hooks (%d)", len(p.unbindHookFuncs)) // Unbind all hooks for _, unbindHookFunc := range p.unbindHookFuncs { unbindHookFunc() } // Run garbage collection // runtime.GC() p.logger.Debug().Msg("plugin: Interrupted plugin") } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// func NewGojaPlugin( ext *extension.Extension, language extension.Language, mLogger *zerolog.Logger, runtimeManager *goja_runtime.Manager, wsEventManager events.WSEventManagerInterface, ) (*GojaPlugin, GojaExtension, error) { logger := lo.ToPtr(mLogger.With().Str("id", ext.ID).Logger()) defer util.HandlePanicInModuleThen("extension_repo/NewGojaPlugin", func() { logger.Error().Msg("extensions: Failed to create Goja plugin") }) logger.Trace().Msg("extensions: Loading plugin") // 1. Create a new plugin instance p := &GojaPlugin{ ext: ext, logger: logger, runtimeManager: runtimeManager, store: plugin.NewStore[string, any](nil), // Create a store (must be stopped when unloading) scheduler: goja_util.NewScheduler(), // Create a scheduler (must be stopped when unloading) ui: nil, // To be initialized loader: goja.New(), // To be initialized unbindHookFuncs: []func(){}, wsEventManager: wsEventManager, } // 2. Create a new loader for the plugin // Bind shared APIs to the loader ShareBinds(p.loader, logger) BindUserConfig(p.loader, ext, logger) // Bind hooks to the loader p.bindHooks() // 3. Convert the payload to JavaScript if necessary source := ext.Payload if language == extension.LanguageTypescript { var err error source, err = JSVMTypescriptToJS(ext.Payload) if err != nil { logger.Error().Err(err).Msg("extensions: Failed to convert typescript") return nil, nil, err } } // 4. Create a new pool for the plugin hooks (must be deleted when unloading) var err error p.pool, err = runtimeManager.GetOrCreatePrivatePool(ext.ID, func() *goja.Runtime { runtime := goja.New() ShareBinds(runtime, logger) BindUserConfig(runtime, ext, logger) p.BindPluginAPIs(runtime, logger) return runtime }) if err != nil { return nil, nil, err } //////// UI // 5. Create a new VM for the UI (The UI uses a single VM instead of a pool in order to share state) // (must be interrupted when unloading) uiVM := goja.New() uiVM.SetParserOptions(parser.WithDisableSourceMaps) // Bind shared APIs ShareBinds(uiVM, logger) BindUserConfig(uiVM, ext, logger) // Bind the store to the UI VM p.BindPluginAPIs(uiVM, logger) // Create a new UI instance p.ui = plugin_ui.NewUI(plugin_ui.NewUIOptions{ Extension: ext, Logger: logger, VM: uiVM, WSManager: wsEventManager, Scheduler: p.scheduler, }) go func() { <-p.ui.Destroyed() p.logger.Warn().Msg("plugin: UI interrupted, interrupting plugin") p.ClearInterrupt() }() // 6. Bind the UI API to the loader so the plugin can register a new UI // $ui.register(callback) uiObj := p.loader.NewObject() _ = uiObj.Set("register", p.ui.Register) _ = p.loader.Set("$ui", uiObj) // 7. Load the plugin source code in the VM (nothing will execute) _, err = p.loader.RunString(source) if err != nil { logger.Error().Err(err).Msg("extensions: Failed to load plugin") return nil, nil, err } // 8. Get and call the init function to actually run the plugin if initFunc := p.loader.Get("init"); initFunc != nil && initFunc != goja.Undefined() { _, err = p.loader.RunString("init();") if err != nil { logger.Error().Err(err).Msg("extensions: Failed to run plugin") return nil, nil, fmt.Errorf("failed to run plugin: %w", err) } logger.Debug().Msg("extensions: Plugin initialized") } return p, p, nil } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // BindPluginAPIs adds plugin-specific APIs func (p *GojaPlugin) BindPluginAPIs(vm *goja.Runtime, logger *zerolog.Logger) { // Bind the app context //_ = vm.Set("$ctx", hook.GlobalHookManager.AppContext()) fm := FieldMapper{} vm.SetFieldNameMapper(fm) // Bind the store p.store.Bind(vm, p.scheduler) // Bind mutable bindings goja_util.BindMutable(vm) // Bind await bindings goja_util.BindAwait(vm) // Bind console bindings _ = goja_bindings.BindConsoleWithWS(p.ext, vm, logger, p.wsEventManager) // Bind the app context plugin.GlobalAppContext.BindApp(vm, logger, p.ext) // Bind permission-specific APIs if p.ext.Plugin != nil { for _, permission := range p.ext.Plugin.Permissions.Scopes { switch permission { case extension.PluginPermissionStorage: // Storage p.storage = plugin.GlobalAppContext.BindStorage(vm, logger, p.ext, p.scheduler) case extension.PluginPermissionAnilist: // Anilist plugin.GlobalAppContext.BindAnilist(vm, logger, p.ext) case extension.PluginPermissionDatabase: // Database plugin.GlobalAppContext.BindDatabase(vm, logger, p.ext) case extension.PluginPermissionSystem: // System plugin.GlobalAppContext.BindSystem(vm, logger, p.ext, p.scheduler) } } } } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // bindHooks sets up hooks for the Goja runtime func (p *GojaPlugin) bindHooks() { // Create a FieldMapper instance for method name mapping fm := FieldMapper{} // Get the type of the global hook manager appType := reflect.TypeOf(hook.GlobalHookManager) // Get the value of the global hook manager appValue := reflect.ValueOf(hook.GlobalHookManager) // Get the total number of methods in the global hook manager // i.e. OnGetAnime, OnGetAnimeDetails, etc. totalMethods := appType.NumMethod() // Define methods to exclude from binding excludeHooks := []string{"OnServe", ""} // Create a new JavaScript object to hold the hooks ($app) appObj := p.loader.NewObject() // Iterate through all methods of the global hook manager // i.e. OnGetAnime, OnGetAnimeDetails, etc. for i := 0; i < totalMethods; i++ { // Get the method at the current index method := appType.Method(i) // Check that the method name starts with "On" and is not excluded if !strings.HasPrefix(method.Name, "On") || slices.Contains(excludeHooks, method.Name) { continue // Skip to the next method if not a hook or excluded } // Map the method name to a JavaScript-friendly name // e.g. OnGetAnime -> onGetAnime jsName := fm.MethodName(appType, method) // Set the method on the app object with a callback function // e.g. $app.onGetAnime(callback, "tag1", "tag2") appObj.Set(jsName, func(callback string, tags ...string) { // Create a wrapper JavaScript function that calls the provided callback // This is necessary because the callback will be called with the provided args callback = `function(e) { return (` + callback + `).call(undefined, e); }` // Compile the callback into a Goja program pr := goja.MustCompile("", "{("+callback+").apply(undefined, __args)}", true) // Prepare the tags as reflect.Values for method invocation tagsAsValues := make([]reflect.Value, len(tags)) for i, tag := range tags { tagsAsValues[i] = reflect.ValueOf(tag) } // Get the hook function from the global hook manager and invokes it with the provided tags // The invokation returns a hook instance // i.e. OnTaggedHook(tags...) -> TaggedHook / OnHook() -> Hook hookInstance := appValue.MethodByName(method.Name).Call(tagsAsValues)[0] // Get the BindFunc method from the hook instance hookBindFunc := hookInstance.MethodByName("BindFunc") unbindHookFunc := hookInstance.MethodByName("Unbind") // Get the expected handler type for the hook // i.e. func(e *hook_resolver.Resolver) error handlerType := hookBindFunc.Type().In(0) // Create a new handler function for the hook // - returns a new handler of the given handlerType that wraps the function handler := reflect.MakeFunc(handlerType, func(args []reflect.Value) (results []reflect.Value) { // Prepare arguments for the handler handlerArgs := make([]any, len(args)) // var err error // if p.interrupted { // return []reflect.Value{reflect.ValueOf(&err).Elem()} // } // Run the handler in an isolated "executor" runtime for concurrency err := p.runtimeManager.Run(context.Background(), p.ext.ID, func(executor *goja.Runtime) error { // Set the field name mapper for the executor executor.SetFieldNameMapper(fm) // Convert each argument (event property) to the appropriate type for i, arg := range args { handlerArgs[i] = arg.Interface() } // Set the global variable $ctx in the executor // executor.Set("$$app", plugin.GlobalAppContext) executor.Set("__args", handlerArgs) // Execute the handler program res, err := executor.RunProgram(pr) // Clear the __args variable for this executor executor.Set("__args", goja.Undefined()) // executor.Set("$ctx", goja.Undefined()) // Check for returned Go error value if res != nil { if resErr, ok := res.Export().(error); ok { return resErr } } return normalizeException(err) }) // Return the error as a reflect.Value return []reflect.Value{reflect.ValueOf(&err).Elem()} }) // Bind the hook if the plugin is not interrupted if p.interrupted { return } // Register the wrapped hook handler callRet := hookBindFunc.Call([]reflect.Value{handler}) // Get the ID from the return value id, ok := callRet[0].Interface().(string) if ok { p.unbindHookFuncs = append(p.unbindHookFuncs, func() { p.logger.Trace().Str("id", p.ext.ID).Msgf("plugin: Unbinding hook %s", id) unbindHookFunc.Call([]reflect.Value{reflect.ValueOf(id)}) }) } }) } // Set the $app object in the loader for JavaScript access p.loader.Set("$app", appObj) } // normalizeException checks if the provided error is a goja.Exception // and attempts to return its underlying Go error. // // note: using just goja.Exception.Unwrap() is insufficient and may falsely result in nil. func normalizeException(err error) error { if err == nil { return nil } jsException, ok := err.(*goja.Exception) if !ok { return err // no exception } switch v := jsException.Value().Export().(type) { case error: err = v case map[string]any: // goja.GoError if vErr, ok := v["value"].(error); ok { err = vErr } } return err } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////