node build fixed

This commit is contained in:
ra_ma
2025-09-20 14:08:38 +01:00
parent c6ebbe069d
commit 3d298fa434
1516 changed files with 535727 additions and 2 deletions

View File

@@ -0,0 +1,87 @@
# Code
## Dev notes for the plugin UI
Avoid
```go
func (d *DOMManager) getElementChildren(elementID string) []*goja.Object {
// Listen for changes from the client
eventListener := d.ctx.RegisterEventListener(ClientDOMElementUpdatedEvent)
defer d.ctx.UnregisterEventListener(eventListener.ID)
payload := ClientDOMElementUpdatedEventPayload{}
doneCh := make(chan []*goja.Object)
go func(eventListener *EventListener) {
for event := range eventListener.Channel {
if event.ParsePayloadAs(ClientDOMElementUpdatedEvent, &payload) {
if payload.Action == "getChildren" && payload.ElementId == elementID {
if v, ok := payload.Result.([]interface{}); ok {
arr := make([]*goja.Object, 0, len(v))
for _, elem := range v {
if elemData, ok := elem.(map[string]interface{}); ok {
arr = append(arr, d.createDOMElementObject(elemData))
}
}
doneCh <- arr
return
}
}
}
}
}(eventListener)
d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{
ElementId: elementID,
Action: "getChildren",
Params: map[string]interface{}{},
})
timeout := time.After(4 * time.Second)
select {
case <-timeout:
return []*goja.Object{}
case res := <-doneCh:
return res
}
}
```
In the above code
```go
arr = append(arr, d.createDOMElementObject(elemData))
```
Uses the VM so it should be scheduled.
```go
d.ctx.ScheduleAsync(func() error {
arr := make([]*goja.Object, 0, len(v))
for _, elem := range v {
if elemData, ok := elem.(map[string]interface{}); ok {
arr = append(arr, d.createDOMElementObject(elemData))
}
}
return nil
})
```
However, getElementChildren() might be launched in a scheduled task.
```ts
ctx.registerEventHandler("test", () => {
const el = ctx.dom.queryOne("#test")
el.getChildren()
})
```
And since getElementChildren() is coded "synchronously" (without promises), it will block the task
until the timeout and won't run its own task.
You'll end up with something like this:
```txt
> event received
> timeout
> processing scheduled task
> sending task
```
Conclusion: Prefer promises when possible. For synchronous functions, avoid scheduling tasks inside them.

View File

@@ -0,0 +1,214 @@
package plugin_ui
import (
"context"
"fmt"
"sync"
"time"
)
// Job represents a task to be executed in the VM
type Job struct {
fn func() error
resultCh chan error
async bool // Flag to indicate if the job is async (doesn't need to wait for result)
}
// Scheduler handles all VM operations added concurrently in a single goroutine
// Any goroutine that needs to execute a VM operation must schedule it because the UI VM isn't thread safe
type Scheduler struct {
jobQueue chan *Job
ctx context.Context
context *Context
cancel context.CancelFunc
wg sync.WaitGroup
// Track the currently executing job to detect nested scheduling
currentJob *Job
currentJobLock sync.Mutex
}
func NewScheduler(uiCtx *Context) *Scheduler {
ctx, cancel := context.WithCancel(context.Background())
s := &Scheduler{
jobQueue: make(chan *Job, 9999),
ctx: ctx,
context: uiCtx,
cancel: cancel,
}
s.start()
return s
}
func (s *Scheduler) start() {
s.wg.Add(1)
go func() {
defer s.wg.Done()
for {
select {
case <-s.ctx.Done():
return
case job := <-s.jobQueue:
// Set the current job before execution
s.currentJobLock.Lock()
s.currentJob = job
s.currentJobLock.Unlock()
err := job.fn()
// Clear the current job after execution
s.currentJobLock.Lock()
s.currentJob = nil
s.currentJobLock.Unlock()
// Only send result if the job is not async
if !job.async {
job.resultCh <- err
}
if err != nil {
s.context.HandleException(err)
}
}
}
}()
}
func (s *Scheduler) Stop() {
s.cancel()
s.wg.Wait()
}
// Schedule adds a job to the queue and waits for its completion
func (s *Scheduler) Schedule(fn func() error) error {
resultCh := make(chan error, 1)
job := &Job{
fn: func() error {
defer func() {
if r := recover(); r != nil {
resultCh <- fmt.Errorf("panic: %v", r)
}
}()
return fn()
},
resultCh: resultCh,
async: false,
}
// Check if we're already in a job execution context
s.currentJobLock.Lock()
isNestedCall := s.currentJob != nil && !s.currentJob.async
s.currentJobLock.Unlock()
// If this is a nested call from a synchronous job, we need to be careful
// We can't execute directly because the VM isn't thread-safe
// Instead, we'll queue it and use a separate goroutine to wait for the result
if isNestedCall {
// Queue the job
select {
case <-s.ctx.Done():
return fmt.Errorf("scheduler stopped")
case s.jobQueue <- job:
// Create a separate goroutine to wait for the result
// This prevents deadlock while still ensuring the job runs in the scheduler
resultCh2 := make(chan error, 1)
go func() {
resultCh2 <- <-resultCh
}()
return <-resultCh2
}
}
// Otherwise, queue the job normally
select {
case <-s.ctx.Done():
return fmt.Errorf("scheduler stopped")
case s.jobQueue <- job:
return <-resultCh
}
}
// ScheduleAsync adds a job to the queue without waiting for completion
// This is useful for fire-and-forget operations or when a job needs to schedule another job
func (s *Scheduler) ScheduleAsync(fn func() error) {
job := &Job{
fn: func() error {
defer func() {
if r := recover(); r != nil {
s.context.HandleException(fmt.Errorf("panic in async job: %v", r))
}
}()
return fn()
},
resultCh: nil, // No result channel needed
async: true,
}
// Queue the job without blocking
select {
case <-s.ctx.Done():
// Scheduler is stopped, just ignore
return
case s.jobQueue <- job:
// Job queued successfully
return
default:
// Queue is full, log an error
s.context.HandleException(fmt.Errorf("async job queue is full"))
}
}
// ScheduleWithTimeout schedules a job with a timeout
func (s *Scheduler) ScheduleWithTimeout(fn func() error, timeout time.Duration) error {
resultCh := make(chan error, 1)
job := &Job{
fn: func() error {
defer func() {
if r := recover(); r != nil {
resultCh <- fmt.Errorf("panic: %v", r)
}
}()
return fn()
},
resultCh: resultCh,
async: false,
}
// Check if we're already in a job execution context
s.currentJobLock.Lock()
isNestedCall := s.currentJob != nil && !s.currentJob.async
s.currentJobLock.Unlock()
// If this is a nested call from a synchronous job, handle it specially
if isNestedCall {
// Queue the job
select {
case <-s.ctx.Done():
return fmt.Errorf("scheduler stopped")
case s.jobQueue <- job:
// Create a separate goroutine to wait for the result with timeout
resultCh2 := make(chan error, 1)
go func() {
select {
case err := <-resultCh:
resultCh2 <- err
case <-time.After(timeout):
resultCh2 <- fmt.Errorf("operation timed out")
}
}()
return <-resultCh2
}
}
select {
case <-s.ctx.Done():
return fmt.Errorf("scheduler stopped")
case s.jobQueue <- job:
select {
case err := <-resultCh:
return err
case <-time.After(timeout):
return fmt.Errorf("operation timed out")
}
}
}

View File

@@ -0,0 +1,683 @@
package plugin_ui
import (
"fmt"
"seanime/internal/util/result"
"github.com/dop251/goja"
"github.com/goccy/go-json"
"github.com/google/uuid"
)
const (
MaxActionsPerType = 3 // A plugin can only at most X actions of a certain type
)
// ActionManager
//
// Actions are buttons, dropdown items, and context menu items that are displayed in certain places in the UI.
// They are defined in the plugin code and are used to trigger events.
//
// The ActionManager is responsible for registering, rendering, and handling events for actions.
type ActionManager struct {
ctx *Context
animePageButtons *result.Map[string, *AnimePageButton]
animePageDropdownItems *result.Map[string, *AnimePageDropdownMenuItem]
animeLibraryDropdownItems *result.Map[string, *AnimeLibraryDropdownMenuItem]
mangaPageButtons *result.Map[string, *MangaPageButton]
mediaCardContextMenuItems *result.Map[string, *MediaCardContextMenuItem]
episodeCardContextMenuItems *result.Map[string, *EpisodeCardContextMenuItem]
episodeGridItemMenuItems *result.Map[string, *EpisodeGridItemMenuItem]
}
type BaseActionProps struct {
ID string `json:"id"`
Label string `json:"label"`
Style map[string]string `json:"style,omitempty"`
}
// Base action struct that all action types embed
type BaseAction struct {
BaseActionProps
}
// GetProps returns the base action properties
func (a *BaseAction) GetProps() BaseActionProps {
return a.BaseActionProps
}
// SetProps sets the base action properties
func (a *BaseAction) SetProps(props BaseActionProps) {
a.BaseActionProps = props
}
// UnmountAll unmounts all actions
// It should be called when the plugin is unloaded
func (a *ActionManager) UnmountAll() {
if a.animePageButtons.ClearN() > 0 {
a.renderAnimePageButtons()
}
if a.animePageDropdownItems.ClearN() > 0 {
a.renderAnimePageDropdownItems()
}
if a.animeLibraryDropdownItems.ClearN() > 0 {
a.renderAnimeLibraryDropdownItems()
}
if a.mangaPageButtons.ClearN() > 0 {
a.renderMangaPageButtons()
}
if a.mediaCardContextMenuItems.ClearN() > 0 {
a.renderMediaCardContextMenuItems()
}
if a.episodeCardContextMenuItems.ClearN() > 0 {
a.renderEpisodeCardContextMenuItems()
}
if a.episodeGridItemMenuItems.ClearN() > 0 {
a.renderEpisodeGridItemMenuItems()
}
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
type AnimePageButton struct {
BaseAction
Intent string `json:"intent,omitempty"`
}
func (a *AnimePageButton) CreateObject(actionManager *ActionManager) *goja.Object {
obj := actionManager.ctx.vm.NewObject()
actionManager.bindSharedToObject(obj, a)
_ = obj.Set("setIntent", func(intent string) {
a.Intent = intent
})
return obj
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
type EpisodeCardContextMenuItem struct {
BaseAction
}
func (a *EpisodeCardContextMenuItem) CreateObject(actionManager *ActionManager) *goja.Object {
obj := actionManager.ctx.vm.NewObject()
actionManager.bindSharedToObject(obj, a)
return obj
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
type EpisodeGridItemMenuItem struct {
BaseAction
Type string `json:"type"`
}
func (a *EpisodeGridItemMenuItem) CreateObject(actionManager *ActionManager) *goja.Object {
obj := actionManager.ctx.vm.NewObject()
actionManager.bindSharedToObject(obj, a)
return obj
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
type MangaPageButton struct {
BaseAction
Intent string `json:"intent,omitempty"`
}
func (a *MangaPageButton) CreateObject(actionManager *ActionManager) *goja.Object {
obj := actionManager.ctx.vm.NewObject()
actionManager.bindSharedToObject(obj, a)
_ = obj.Set("setIntent", func(intent string) {
a.Intent = intent
})
return obj
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
type AnimePageDropdownMenuItem struct {
BaseAction
}
func (a *AnimePageDropdownMenuItem) CreateObject(actionManager *ActionManager) *goja.Object {
obj := actionManager.ctx.vm.NewObject()
actionManager.bindSharedToObject(obj, a)
return obj
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
type AnimeLibraryDropdownMenuItem struct {
BaseAction
}
func (a *AnimeLibraryDropdownMenuItem) CreateObject(actionManager *ActionManager) *goja.Object {
obj := actionManager.ctx.vm.NewObject()
actionManager.bindSharedToObject(obj, a)
return obj
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
type MediaCardContextMenuItemFor string
const (
MediaCardContextMenuItemForAnime MediaCardContextMenuItemFor = "anime"
MediaCardContextMenuItemForManga MediaCardContextMenuItemFor = "manga"
MediaCardContextMenuItemForBoth MediaCardContextMenuItemFor = "both"
)
type MediaCardContextMenuItem struct {
BaseAction
For MediaCardContextMenuItemFor `json:"for"` // anime, manga, both
}
func (a *MediaCardContextMenuItem) CreateObject(actionManager *ActionManager) *goja.Object {
obj := actionManager.ctx.vm.NewObject()
actionManager.bindSharedToObject(obj, a)
_ = obj.Set("setFor", func(_for MediaCardContextMenuItemFor) {
a.For = _for
})
return obj
}
// ///////////////////////////////////////////////////////////////////////////////////////////////////////////
func NewActionManager(ctx *Context) *ActionManager {
return &ActionManager{
ctx: ctx,
animePageButtons: result.NewResultMap[string, *AnimePageButton](),
animeLibraryDropdownItems: result.NewResultMap[string, *AnimeLibraryDropdownMenuItem](),
animePageDropdownItems: result.NewResultMap[string, *AnimePageDropdownMenuItem](),
mangaPageButtons: result.NewResultMap[string, *MangaPageButton](),
mediaCardContextMenuItems: result.NewResultMap[string, *MediaCardContextMenuItem](),
episodeCardContextMenuItems: result.NewResultMap[string, *EpisodeCardContextMenuItem](),
episodeGridItemMenuItems: result.NewResultMap[string, *EpisodeGridItemMenuItem](),
}
}
// renderAnimePageButtons is called when the client requests the buttons to display on the anime page.
func (a *ActionManager) renderAnimePageButtons() {
buttons := make([]*AnimePageButton, 0)
a.animePageButtons.Range(func(key string, value *AnimePageButton) bool {
buttons = append(buttons, value)
return true
})
a.ctx.SendEventToClient(ServerActionRenderAnimePageButtonsEvent, ServerActionRenderAnimePageButtonsEventPayload{
Buttons: buttons,
})
}
func (a *ActionManager) renderAnimePageDropdownItems() {
items := make([]*AnimePageDropdownMenuItem, 0)
a.animePageDropdownItems.Range(func(key string, value *AnimePageDropdownMenuItem) bool {
items = append(items, value)
return true
})
a.ctx.SendEventToClient(ServerActionRenderAnimePageDropdownItemsEvent, ServerActionRenderAnimePageDropdownItemsEventPayload{
Items: items,
})
}
func (a *ActionManager) renderAnimeLibraryDropdownItems() {
items := make([]*AnimeLibraryDropdownMenuItem, 0)
a.animeLibraryDropdownItems.Range(func(key string, value *AnimeLibraryDropdownMenuItem) bool {
items = append(items, value)
return true
})
a.ctx.SendEventToClient(ServerActionRenderAnimeLibraryDropdownItemsEvent, ServerActionRenderAnimeLibraryDropdownItemsEventPayload{
Items: items,
})
}
func (a *ActionManager) renderMangaPageButtons() {
buttons := make([]*MangaPageButton, 0)
a.mangaPageButtons.Range(func(key string, value *MangaPageButton) bool {
buttons = append(buttons, value)
return true
})
a.ctx.SendEventToClient(ServerActionRenderMangaPageButtonsEvent, ServerActionRenderMangaPageButtonsEventPayload{
Buttons: buttons,
})
}
func (a *ActionManager) renderMediaCardContextMenuItems() {
items := make([]*MediaCardContextMenuItem, 0)
a.mediaCardContextMenuItems.Range(func(key string, value *MediaCardContextMenuItem) bool {
items = append(items, value)
return true
})
a.ctx.SendEventToClient(ServerActionRenderMediaCardContextMenuItemsEvent, ServerActionRenderMediaCardContextMenuItemsEventPayload{
Items: items,
})
}
func (a *ActionManager) renderEpisodeCardContextMenuItems() {
items := make([]*EpisodeCardContextMenuItem, 0)
a.episodeCardContextMenuItems.Range(func(key string, value *EpisodeCardContextMenuItem) bool {
items = append(items, value)
return true
})
a.ctx.SendEventToClient(ServerActionRenderEpisodeCardContextMenuItemsEvent, ServerActionRenderEpisodeCardContextMenuItemsEventPayload{
Items: items,
})
}
func (a *ActionManager) renderEpisodeGridItemMenuItems() {
items := make([]*EpisodeGridItemMenuItem, 0)
a.episodeGridItemMenuItems.Range(func(key string, value *EpisodeGridItemMenuItem) bool {
items = append(items, value)
return true
})
a.ctx.SendEventToClient(ServerActionRenderEpisodeGridItemMenuItemsEvent, ServerActionRenderEpisodeGridItemMenuItemsEventPayload{
Items: items,
})
}
// bind binds 'action' to the ctx object
//
// Example:
// ctx.action.newAnimePageButton(...)
func (a *ActionManager) bind(ctxObj *goja.Object) {
actionObj := a.ctx.vm.NewObject()
_ = actionObj.Set("newAnimePageButton", a.jsNewAnimePageButton)
_ = actionObj.Set("newAnimePageDropdownItem", a.jsNewAnimePageDropdownItem)
_ = actionObj.Set("newAnimeLibraryDropdownItem", a.jsNewAnimeLibraryDropdownItem)
_ = actionObj.Set("newMediaCardContextMenuItem", a.jsNewMediaCardContextMenuItem)
_ = actionObj.Set("newMangaPageButton", a.jsNewMangaPageButton)
_ = actionObj.Set("newEpisodeCardContextMenuItem", a.jsNewEpisodeCardContextMenuItem)
_ = actionObj.Set("newEpisodeGridItemMenuItem", a.jsNewEpisodeGridItemMenuItem)
_ = ctxObj.Set("action", actionObj)
}
////////////////////////////////////////////////////////////////////////////////////////////////
// Actions
////////////////////////////////////////////////////////////////////////////////////////////////
// jsNewEpisodeCardContextMenuItem
//
// Example:
// const downloadButton = ctx.newEpisodeCardContextMenuItem({
// label: "Download",
// onClick: "download-button-clicked",
// })
func (a *ActionManager) jsNewEpisodeCardContextMenuItem(call goja.FunctionCall) goja.Value {
// Create a new action
action := &EpisodeCardContextMenuItem{}
// Get the props
a.unmarshalProps(call, action)
action.ID = uuid.New().String()
// Create the object
obj := action.CreateObject(a)
return obj
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// jsNewEpisodeGridItemMenuItem
//
// Example:
// const downloadButton = ctx.newEpisodeGridItemContextMenuItem({
// label: "Download",
// onClick: "download-button-clicked",
// type: "library",
// })
func (a *ActionManager) jsNewEpisodeGridItemMenuItem(call goja.FunctionCall) goja.Value {
// Create a new action
action := &EpisodeGridItemMenuItem{}
// Get the props
a.unmarshalProps(call, action)
action.ID = uuid.New().String()
// Create the object
obj := action.CreateObject(a)
return obj
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// jsNewAnimePageButton
//
// Example:
// const downloadButton = ctx.newAnimePageButton({
// label: "Download",
// intent: "primary",
// onClick: "download-button-clicked",
// })
func (a *ActionManager) jsNewAnimePageButton(call goja.FunctionCall) goja.Value {
// Create a new action
action := &AnimePageButton{}
// Get the props
a.unmarshalProps(call, action)
action.ID = uuid.New().String()
// Create the object
obj := action.CreateObject(a)
return obj
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// jsNewAnimePageDropdownItem
//
// Example:
// const downloadButton = ctx.newAnimePageDropdownItem({
// label: "Download",
// onClick: "download-button-clicked",
// })
func (a *ActionManager) jsNewAnimePageDropdownItem(call goja.FunctionCall) goja.Value {
// Create a new action
action := &AnimePageDropdownMenuItem{}
// Get the props
a.unmarshalProps(call, action)
action.ID = uuid.New().String()
// Create the object
obj := action.CreateObject(a)
return obj
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// jsNewAnimeLibraryDropdownItem
//
// Example:
// const downloadButton = ctx.newAnimeLibraryDropdownItem({
// label: "Download",
// onClick: "download-button-clicked",
// })
func (a *ActionManager) jsNewAnimeLibraryDropdownItem(call goja.FunctionCall) goja.Value {
// Create a new action
action := &AnimeLibraryDropdownMenuItem{}
// Get the props
a.unmarshalProps(call, action)
action.ID = uuid.New().String()
// Create the object
obj := action.CreateObject(a)
return obj
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// jsNewMediaCardContextMenuItem
//
// Example:
// const downloadButton = ctx.newMediaCardContextMenuItem({
// label: "Download",
// onClick: "download-button-clicked",
// })
func (a *ActionManager) jsNewMediaCardContextMenuItem(call goja.FunctionCall) goja.Value {
// Create a new action
action := &MediaCardContextMenuItem{}
// Get the props
a.unmarshalProps(call, action)
action.ID = uuid.New().String()
// Create the object
obj := action.CreateObject(a)
return obj
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// jsNewMangaPageButton
//
// Example:
// const downloadButton = ctx.newMangaPageButton({
// label: "Download",
// onClick: "download-button-clicked",
// })
func (a *ActionManager) jsNewMangaPageButton(call goja.FunctionCall) goja.Value {
// Create a new action
action := &MangaPageButton{}
// Get the props
a.unmarshalProps(call, action)
action.ID = uuid.New().String()
// Create the object
obj := action.CreateObject(a)
return obj
}
// ///////////////////////////////////////////////////////////////////////////////////
// Shared
// ///////////////////////////////////////////////////////////////////////////////////
// bindSharedToObject binds shared methods to action objects
//
// Example:
// const downloadButton = ctx.newAnimePageButton(...)
// downloadButton.mount()
// downloadButton.unmount()
// downloadButton.setLabel("Downloading...")
func (a *ActionManager) bindSharedToObject(obj *goja.Object, action interface{}) {
var id string
var props BaseActionProps
var mapToUse interface{}
switch act := action.(type) {
case *AnimePageButton:
id = act.ID
props = act.GetProps()
mapToUse = a.animePageButtons
case *MangaPageButton:
id = act.ID
props = act.GetProps()
mapToUse = a.mangaPageButtons
case *AnimePageDropdownMenuItem:
id = act.ID
props = act.GetProps()
mapToUse = a.animePageDropdownItems
case *AnimeLibraryDropdownMenuItem:
id = act.ID
props = act.GetProps()
mapToUse = a.animeLibraryDropdownItems
case *MediaCardContextMenuItem:
id = act.ID
props = act.GetProps()
mapToUse = a.mediaCardContextMenuItems
case *EpisodeCardContextMenuItem:
id = act.ID
props = act.GetProps()
mapToUse = a.episodeCardContextMenuItems
case *EpisodeGridItemMenuItem:
id = act.ID
props = act.GetProps()
mapToUse = a.episodeGridItemMenuItems
}
_ = obj.Set("mount", func() {
switch m := mapToUse.(type) {
case *result.Map[string, *AnimePageButton]:
if btn, ok := action.(*AnimePageButton); ok {
m.Set(id, btn)
a.renderAnimePageButtons()
}
case *result.Map[string, *MangaPageButton]:
if btn, ok := action.(*MangaPageButton); ok {
m.Set(id, btn)
a.renderMangaPageButtons()
}
case *result.Map[string, *AnimePageDropdownMenuItem]:
if item, ok := action.(*AnimePageDropdownMenuItem); ok {
m.Set(id, item)
a.renderAnimePageDropdownItems()
}
case *result.Map[string, *AnimeLibraryDropdownMenuItem]:
if item, ok := action.(*AnimeLibraryDropdownMenuItem); ok {
m.Set(id, item)
a.renderAnimeLibraryDropdownItems()
}
case *result.Map[string, *MediaCardContextMenuItem]:
if item, ok := action.(*MediaCardContextMenuItem); ok {
if item.For == "" {
item.For = MediaCardContextMenuItemForBoth
}
m.Set(id, item)
a.renderMediaCardContextMenuItems()
}
case *result.Map[string, *EpisodeCardContextMenuItem]:
if item, ok := action.(*EpisodeCardContextMenuItem); ok {
m.Set(id, item)
a.renderEpisodeCardContextMenuItems()
}
case *result.Map[string, *EpisodeGridItemMenuItem]:
if item, ok := action.(*EpisodeGridItemMenuItem); ok {
m.Set(id, item)
a.renderEpisodeGridItemMenuItems()
}
}
})
_ = obj.Set("unmount", func() {
switch m := mapToUse.(type) {
case *result.Map[string, *AnimePageButton]:
m.Delete(id)
a.renderAnimePageButtons()
case *result.Map[string, *MangaPageButton]:
m.Delete(id)
a.renderMangaPageButtons()
case *result.Map[string, *AnimePageDropdownMenuItem]:
m.Delete(id)
a.renderAnimePageDropdownItems()
case *result.Map[string, *AnimeLibraryDropdownMenuItem]:
m.Delete(id)
a.renderAnimeLibraryDropdownItems()
case *result.Map[string, *MediaCardContextMenuItem]:
m.Delete(id)
a.renderMediaCardContextMenuItems()
case *result.Map[string, *EpisodeCardContextMenuItem]:
m.Delete(id)
a.renderEpisodeCardContextMenuItems()
case *result.Map[string, *EpisodeGridItemMenuItem]:
m.Delete(id)
a.renderEpisodeGridItemMenuItems()
}
})
_ = obj.Set("setLabel", func(label string) {
newProps := props
newProps.Label = label
switch act := action.(type) {
case *AnimePageButton:
act.SetProps(newProps)
case *MangaPageButton:
act.SetProps(newProps)
case *AnimePageDropdownMenuItem:
act.SetProps(newProps)
case *AnimeLibraryDropdownMenuItem:
act.SetProps(newProps)
case *MediaCardContextMenuItem:
act.SetProps(newProps)
case *EpisodeCardContextMenuItem:
act.SetProps(newProps)
case *EpisodeGridItemMenuItem:
act.SetProps(newProps)
}
})
_ = obj.Set("setStyle", func(style map[string]string) {
newProps := props
newProps.Style = style
switch act := action.(type) {
case *AnimePageButton:
act.SetProps(newProps)
a.renderAnimePageButtons()
case *MangaPageButton:
act.SetProps(newProps)
a.renderMangaPageButtons()
case *AnimePageDropdownMenuItem:
act.SetProps(newProps)
a.renderAnimePageDropdownItems()
case *AnimeLibraryDropdownMenuItem:
act.SetProps(newProps)
a.renderAnimeLibraryDropdownItems()
case *MediaCardContextMenuItem:
act.SetProps(newProps)
a.renderMediaCardContextMenuItems()
case *EpisodeCardContextMenuItem:
act.SetProps(newProps)
a.renderEpisodeCardContextMenuItems()
case *EpisodeGridItemMenuItem:
act.SetProps(newProps)
}
})
_ = obj.Set("onClick", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
a.ctx.handleTypeError("onClick requires a callback function")
}
callback, ok := goja.AssertFunction(call.Argument(0))
if !ok {
a.ctx.handleTypeError("onClick requires a callback function")
}
eventListener := a.ctx.RegisterEventListener(ClientActionClickedEvent)
eventListener.SetCallback(func(event *ClientPluginEvent) {
payload := ClientActionClickedEventPayload{}
if event.ParsePayloadAs(ClientActionClickedEvent, &payload) && payload.ActionID == id {
a.ctx.scheduler.ScheduleAsync(func() error {
_, err := callback(goja.Undefined(), a.ctx.vm.ToValue(payload.Event))
return err
})
}
})
return goja.Undefined()
})
}
/////////////////////////////////////////////////////////////////////////////////////
// Utils
/////////////////////////////////////////////////////////////////////////////////////
func (a *ActionManager) unmarshalProps(call goja.FunctionCall, ret interface{}) {
if len(call.Arguments) < 1 {
a.ctx.handleException(fmt.Errorf("expected 1 argument"))
}
props := call.Arguments[0].Export()
if props == nil {
a.ctx.handleException(fmt.Errorf("expected props object"))
}
marshaled, err := json.Marshal(props)
if err != nil {
a.ctx.handleException(err)
}
err = json.Unmarshal(marshaled, ret)
if err != nil {
a.ctx.handleException(err)
}
}

View File

@@ -0,0 +1,380 @@
package plugin_ui
import (
goja_util "seanime/internal/util/goja"
"seanime/internal/util/result"
"slices"
"sync"
"time"
"github.com/dop251/goja"
"github.com/google/uuid"
)
// CommandPaletteManager is a manager for the command palette.
// Unlike the Tray, command palette items are not reactive to state changes.
// They are only rendered when the setItems function is called or the refresh function is called.
type CommandPaletteManager struct {
ctx *Context
updateMutex sync.Mutex
lastUpdated time.Time
componentManager *ComponentManager
placeholder string
keyboardShortcut string
// registered is true if the command palette has been registered
registered bool
items *result.Map[string, *commandItem]
renderedItems []*CommandItemJSON // Store rendered items when setItems is called
}
type (
commandItem struct {
index int
id string
label string
value string
filterType string // "includes" or "startsWith" or ""
heading string
renderFunc func(goja.FunctionCall) goja.Value
onSelectFunc func(goja.FunctionCall) goja.Value
}
// CommandItemJSON is the JSON representation of a command item.
// It is used to send the command item to the client.
CommandItemJSON struct {
Index int `json:"index"`
ID string `json:"id"`
Label string `json:"label"`
Value string `json:"value"`
FilterType string `json:"filterType"`
Heading string `json:"heading"`
Components interface{} `json:"components"`
}
)
func NewCommandPaletteManager(ctx *Context) *CommandPaletteManager {
return &CommandPaletteManager{
ctx: ctx,
componentManager: &ComponentManager{ctx: ctx},
items: result.NewResultMap[string, *commandItem](),
renderedItems: make([]*CommandItemJSON, 0),
}
}
type NewCommandPaletteOptions struct {
Placeholder string `json:"placeholder,omitempty"`
KeyboardShortcut string `json:"keyboardShortcut,omitempty"`
}
// sendInfoToClient sends the command palette info to the client after it's been requested.
func (c *CommandPaletteManager) sendInfoToClient() {
if c.registered {
c.ctx.SendEventToClient(ServerCommandPaletteInfoEvent, ServerCommandPaletteInfoEventPayload{
Placeholder: c.placeholder,
KeyboardShortcut: c.keyboardShortcut,
})
}
}
func (c *CommandPaletteManager) jsNewCommandPalette(options NewCommandPaletteOptions) goja.Value {
c.registered = true
c.keyboardShortcut = options.KeyboardShortcut
c.placeholder = options.Placeholder
cmdObj := c.ctx.vm.NewObject()
_ = cmdObj.Set("setItems", func(items []interface{}) {
c.items.Clear()
for idx, item := range items {
itemMap := item.(map[string]interface{})
id := uuid.New().String()
label, _ := itemMap["label"].(string)
value, ok := itemMap["value"].(string)
if !ok {
c.ctx.handleTypeError("value must be a string")
return
}
filterType, _ := itemMap["filterType"].(string)
if filterType != "includes" && filterType != "startsWith" && filterType != "" {
c.ctx.handleTypeError("filterType must be 'includes', 'startsWith'")
return
}
heading, _ := itemMap["heading"].(string)
renderFunc, ok := itemMap["render"].(func(goja.FunctionCall) goja.Value)
if len(label) == 0 && !ok {
c.ctx.handleTypeError("label or render function must be provided")
return
}
onSelectFunc, ok := itemMap["onSelect"].(func(goja.FunctionCall) goja.Value)
if !ok {
c.ctx.handleTypeError("onSelect must be a function")
return
}
c.items.Set(id, &commandItem{
index: idx,
id: id,
label: label,
value: value,
filterType: filterType,
heading: heading,
renderFunc: renderFunc,
onSelectFunc: onSelectFunc,
})
}
// Convert the items to JSON
itemsJSON := make([]*CommandItemJSON, 0)
c.items.Range(func(key string, value *commandItem) bool {
itemsJSON = append(itemsJSON, value.ToJSON(c.ctx, c.componentManager, c.ctx.scheduler))
return true
})
// Store the converted items
c.renderedItems = itemsJSON
c.renderCommandPaletteScheduled()
})
_ = cmdObj.Set("refresh", func() {
// Convert the items to JSON
itemsJSON := make([]*CommandItemJSON, 0)
c.items.Range(func(key string, value *commandItem) bool {
itemsJSON = append(itemsJSON, value.ToJSON(c.ctx, c.componentManager, c.ctx.scheduler))
return true
})
c.renderedItems = itemsJSON
c.renderCommandPaletteScheduled()
})
_ = cmdObj.Set("setPlaceholder", func(placeholder string) {
c.placeholder = placeholder
c.renderCommandPaletteScheduled()
})
_ = cmdObj.Set("open", func() {
c.ctx.SendEventToClient(ServerCommandPaletteOpenEvent, ServerCommandPaletteOpenEventPayload{})
})
_ = cmdObj.Set("close", func() {
c.ctx.SendEventToClient(ServerCommandPaletteCloseEvent, ServerCommandPaletteCloseEventPayload{})
})
_ = cmdObj.Set("setInput", func(input string) {
c.ctx.SendEventToClient(ServerCommandPaletteSetInputEvent, ServerCommandPaletteSetInputEventPayload{
Value: input,
})
})
_ = cmdObj.Set("getInput", func() string {
c.ctx.SendEventToClient(ServerCommandPaletteGetInputEvent, ServerCommandPaletteGetInputEventPayload{})
eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteInputEvent)
defer c.ctx.UnregisterEventListener(eventListener.ID)
timeout := time.After(1500 * time.Millisecond)
input := make(chan string)
eventListener.SetCallback(func(event *ClientPluginEvent) {
payload := ClientCommandPaletteInputEventPayload{}
if event.ParsePayloadAs(ClientCommandPaletteInputEvent, &payload) {
input <- payload.Value
}
})
// go func() {
// for event := range eventListener.Channel {
// if event.ParsePayloadAs(ClientCommandPaletteInputEvent, &payload) {
// input <- payload.Value
// }
// }
// }()
select {
case <-timeout:
return ""
case input := <-input:
return input
}
})
// jsOnOpen
//
// Example:
// commandPalette.onOpen(() => {
// console.log("command palette opened by the user")
// })
_ = cmdObj.Set("onOpen", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
c.ctx.handleTypeError("onOpen requires a callback function")
}
callback, ok := goja.AssertFunction(call.Argument(0))
if !ok {
c.ctx.handleTypeError("onOpen requires a callback function")
}
eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteOpenedEvent)
eventListener.SetCallback(func(event *ClientPluginEvent) {
payload := ClientCommandPaletteOpenedEventPayload{}
if event.ParsePayloadAs(ClientCommandPaletteOpenedEvent, &payload) {
c.ctx.scheduler.ScheduleAsync(func() error {
_, err := callback(goja.Undefined(), c.ctx.vm.ToValue(map[string]interface{}{}))
return err
})
}
})
// go func() {
// for event := range eventListener.Channel {
// if event.ParsePayloadAs(ClientCommandPaletteOpenedEvent, &payload) {
// c.ctx.scheduler.ScheduleAsync(func() error {
// _, err := callback(goja.Undefined(), c.ctx.vm.ToValue(map[string]interface{}{}))
// if err != nil {
// c.ctx.logger.Error().Err(err).Msg("plugin: Error running command palette open callback")
// }
// return err
// })
// }
// }
// }()
return goja.Undefined()
})
// jsOnClose
//
// Example:
// commandPalette.onClose(() => {
// console.log("command palette closed by the user")
// })
_ = cmdObj.Set("onClose", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
c.ctx.handleTypeError("onClose requires a callback function")
}
callback, ok := goja.AssertFunction(call.Argument(0))
if !ok {
c.ctx.handleTypeError("onClose requires a callback function")
}
eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteClosedEvent)
eventListener.SetCallback(func(event *ClientPluginEvent) {
payload := ClientCommandPaletteClosedEventPayload{}
if event.ParsePayloadAs(ClientCommandPaletteClosedEvent, &payload) {
c.ctx.scheduler.ScheduleAsync(func() error {
_, err := callback(goja.Undefined(), c.ctx.vm.ToValue(map[string]interface{}{}))
return err
})
}
})
// go func() {
// for event := range eventListener.Channel {
// if event.ParsePayloadAs(ClientCommandPaletteClosedEvent, &payload) {
// c.ctx.scheduler.ScheduleAsync(func() error {
// _, err := callback(goja.Undefined(), c.ctx.vm.ToValue(map[string]interface{}{}))
// if err != nil {
// c.ctx.logger.Error().Err(err).Msg("plugin: Error running command palette close callback")
// }
// return err
// })
// }
// }
// }()
return goja.Undefined()
})
eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteItemSelectedEvent)
eventListener.SetCallback(func(event *ClientPluginEvent) {
payload := ClientCommandPaletteItemSelectedEventPayload{}
if event.ParsePayloadAs(ClientCommandPaletteItemSelectedEvent, &payload) {
c.ctx.scheduler.ScheduleAsync(func() error {
item, found := c.items.Get(payload.ItemID)
if found {
_ = item.onSelectFunc(goja.FunctionCall{})
}
return nil
})
}
})
// go func() {
// eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteItemSelectedEvent)
// payload := ClientCommandPaletteItemSelectedEventPayload{}
// for event := range eventListener.Channel {
// if event.ParsePayloadAs(ClientCommandPaletteItemSelectedEvent, &payload) {
// item, found := c.items.Get(payload.ItemID)
// if found {
// c.ctx.scheduler.ScheduleAsync(func() error {
// _ = item.onSelectFunc(goja.FunctionCall{})
// return nil
// })
// }
// }
// }
// }()
// Register components
_ = cmdObj.Set("div", c.componentManager.jsDiv)
_ = cmdObj.Set("flex", c.componentManager.jsFlex)
_ = cmdObj.Set("stack", c.componentManager.jsStack)
_ = cmdObj.Set("text", c.componentManager.jsText)
_ = cmdObj.Set("button", c.componentManager.jsButton)
_ = cmdObj.Set("anchor", c.componentManager.jsAnchor)
return cmdObj
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (c *commandItem) ToJSON(ctx *Context, componentManager *ComponentManager, scheduler *goja_util.Scheduler) *CommandItemJSON {
var components interface{}
if c.renderFunc != nil {
var err error
components, err = componentManager.renderComponents(c.renderFunc)
if err != nil {
ctx.logger.Error().Err(err).Msg("plugin: Failed to render command palette item")
ctx.handleException(err)
return nil
}
}
// Reset the last rendered components, we don't care about diffing
componentManager.lastRenderedComponents = nil
return &CommandItemJSON{
Index: c.index,
ID: c.id,
Label: c.label,
Value: c.value,
FilterType: c.filterType,
Heading: c.heading,
Components: components,
}
}
func (c *CommandPaletteManager) renderCommandPaletteScheduled() {
c.updateMutex.Lock()
defer c.updateMutex.Unlock()
if !c.registered {
return
}
slices.SortFunc(c.renderedItems, func(a, b *CommandItemJSON) int {
return a.Index - b.Index
})
c.ctx.SendEventToClient(ServerCommandPaletteUpdatedEvent, ServerCommandPaletteUpdatedEventPayload{
Placeholder: c.placeholder,
Items: c.renderedItems,
})
}

View File

@@ -0,0 +1,374 @@
package plugin_ui
import (
"errors"
"fmt"
"github.com/dop251/goja"
"github.com/goccy/go-json"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
)
func (c *ComponentManager) renderComponents(renderFunc func(goja.FunctionCall) goja.Value) (interface{}, error) {
if renderFunc == nil {
return nil, errors.New("render function is not set")
}
// Get new components
newComponents := c.getComponentsData(renderFunc)
// If we have previous components, perform diffing
if c.lastRenderedComponents != nil {
newComponents = c.componentDiff(c.lastRenderedComponents, newComponents)
}
// Store the new components for next render
c.lastRenderedComponents = newComponents
return newComponents, nil
}
// getComponentsData calls the render function and returns the current state of the component tree
func (c *ComponentManager) getComponentsData(renderFunc func(goja.FunctionCall) goja.Value) interface{} {
// Call the render function
value := renderFunc(goja.FunctionCall{})
// Convert the value to a JSON string
v, err := json.Marshal(value)
if err != nil {
return nil
}
var ret interface{}
err = json.Unmarshal(v, &ret)
if err != nil {
return nil
}
return ret
}
////
type ComponentProp struct {
Name string // e.g. "label"
Type string // e.g. "string"
Default interface{} // Is set if the prop is not provided, if not set and required is false, the prop will not be included in the component
Required bool // If true an no default value is provided, the component will throw a type error
Validate func(value interface{}) error // Optional validation function
OptionalFirstArg bool // If true, it can be the first argument to declaring the component as a shorthand (e.g. tray.button("Click me") instead of tray.button({label: "Click me"}))
}
func defineComponent(vm *goja.Runtime, call goja.FunctionCall, t string, propDefs []ComponentProp) goja.Value {
component := Component{
ID: uuid.New().String(),
Type: t,
Props: make(map[string]interface{}),
}
propsList := make(map[string]interface{})
propDefsMap := make(map[string]*ComponentProp)
var shorthandProp *ComponentProp
for _, propDef := range propDefs {
propDefsMap[propDef.Name] = &propDef
if propDef.OptionalFirstArg {
shorthandProp = &propDef
}
}
if len(call.Arguments) > 0 {
// Check if the first argument is the type of the shorthand
hasShorthand := false
if shorthandProp != nil {
switch shorthandProp.Type {
case "string":
if _, ok := call.Argument(0).Export().(string); ok {
propsList[shorthandProp.Name] = call.Argument(0).Export().(string)
hasShorthand = true
}
case "boolean":
if _, ok := call.Argument(0).Export().(bool); ok {
propsList[shorthandProp.Name] = call.Argument(0).Export().(bool)
hasShorthand = true
}
case "array":
if _, ok := call.Argument(0).Export().([]interface{}); ok {
propsList[shorthandProp.Name] = call.Argument(0).Export().([]interface{})
hasShorthand = true
}
}
if hasShorthand {
// Get the rest of the props from the second argument
if len(call.Arguments) > 1 {
rest, ok := call.Argument(1).Export().(map[string]interface{})
if ok {
// Only add props that are defined in the propDefs
for k, v := range rest {
if _, ok := propDefsMap[k]; ok {
propsList[k] = v
}
}
}
}
}
}
if !hasShorthand {
propsArg, ok := call.Argument(0).Export().(map[string]interface{})
if ok {
for k, v := range propsArg {
if _, ok := propDefsMap[k]; ok {
propsList[k] = v
} else {
// util.SpewMany(k, fmt.Sprintf("%T", v))
}
}
}
}
}
// Validate props
for _, propDef := range propDefs {
// If a prop is required and no value is provided, panic
if propDef.Required && len(propsList) == 0 {
panic(vm.NewTypeError(fmt.Sprintf("%s is required", propDef.Name)))
}
// Validate the prop if the prop is defined
if propDef.Validate != nil {
if val, ok := propsList[propDef.Name]; ok {
err := propDef.Validate(val)
if err != nil {
log.Error().Msgf("Invalid prop value: %s", err.Error())
panic(vm.NewTypeError(err.Error()))
}
}
}
// Set a default value if the prop is not provided
if _, ok := propsList[propDef.Name]; !ok && propDef.Default != nil {
propsList[propDef.Name] = propDef.Default
}
}
// Set the props
for k, v := range propsList {
component.Props[k] = v
}
return vm.ToValue(component)
}
// Helper function to create a validation function for a specific type
func validateType(expectedType string) func(interface{}) error {
return func(value interface{}) error {
if value == nil {
return fmt.Errorf("expected %s, got nil", expectedType)
}
switch expectedType {
case "string":
_, ok := value.(string)
if !ok {
if value == nil {
return nil
}
return fmt.Errorf("expected string, got %T", value)
}
return nil
case "number":
_, ok := value.(float64)
if !ok {
_, ok := value.(int64)
if !ok {
if value == nil {
return nil
}
return fmt.Errorf("expected number, got %T", value)
}
return nil
}
return nil
case "boolean":
_, ok := value.(bool)
if !ok {
if value == nil {
return nil
}
return fmt.Errorf("expected boolean, got %T", value)
}
return nil
case "array":
_, ok := value.([]interface{})
if !ok {
if value == nil {
return nil
}
return fmt.Errorf("expected array, got %T", value)
}
return nil
case "object":
_, ok := value.(map[string]interface{})
if !ok {
if value == nil {
return nil
}
return fmt.Errorf("expected object, got %T", value)
}
return nil
case "function":
_, ok := value.(func(goja.FunctionCall) goja.Value)
if !ok {
return fmt.Errorf("expected function, got %T", value)
}
return nil
default:
return fmt.Errorf("invalid type: %s", expectedType)
}
}
}
// componentDiff compares two component trees and returns a new component tree that preserves the ID of old components that did not change.
// It also recursively handles props and items arrays.
//
// This is important to preserve state between renders in React.
func (c *ComponentManager) componentDiff(old, new interface{}) (ret interface{}) {
defer func() {
if r := recover(); r != nil {
// If a panic occurs, return the new component tree
ret = new
}
}()
if old == nil || new == nil {
return new
}
// Handle maps (components)
if oldMap, ok := old.(map[string]interface{}); ok {
if newMap, ok := new.(map[string]interface{}); ok {
// If types match and it's a component (has "type" field), preserve ID
if oldType, hasOldType := oldMap["type"]; hasOldType {
if newType, hasNewType := newMap["type"]; hasNewType && oldType == newType {
// Preserve the ID from the old component
if oldID, hasOldID := oldMap["id"]; hasOldID {
newMap["id"] = oldID
}
// Recursively handle props
if oldProps, hasOldProps := oldMap["props"].(map[string]interface{}); hasOldProps {
if newProps, hasNewProps := newMap["props"].(map[string]interface{}); hasNewProps {
// Special handling for items array in props
if oldItems, ok := oldProps["items"].([]interface{}); ok {
if newItems, ok := newProps["items"].([]interface{}); ok {
newProps["items"] = c.componentDiff(oldItems, newItems)
}
}
// Handle other props
for k, v := range newProps {
if k != "items" { // Skip items as we already handled it
if oldV, exists := oldProps[k]; exists {
newProps[k] = c.componentDiff(oldV, v)
}
}
}
newMap["props"] = newProps
}
}
}
}
return newMap
}
}
// Handle arrays
if oldArr, ok := old.([]interface{}); ok {
if newArr, ok := new.([]interface{}); ok {
// Create a new array to store the diffed components
result := make([]interface{}, len(newArr))
// First, try to match components by key if available
oldKeyMap := make(map[string]interface{})
for _, oldComp := range oldArr {
if oldMap, ok := oldComp.(map[string]interface{}); ok {
if key, ok := oldMap["key"].(string); ok && key != "" {
oldKeyMap[key] = oldComp
}
}
}
// Process each new component
for i, newComp := range newArr {
matched := false
// Try to match by key first
if newMap, ok := newComp.(map[string]interface{}); ok {
if key, ok := newMap["key"].(string); ok && key != "" {
if oldComp, exists := oldKeyMap[key]; exists {
// Found a match by key
result[i] = c.componentDiff(oldComp, newComp)
matched = true
// t.ctx.logger.Debug().
// Str("key", key).
// Str("type", fmt.Sprintf("%v", newMap["type"])).
// Msg("Component matched by key")
}
}
}
// If no key match, try to match by position and type
if !matched && i < len(oldArr) {
oldComp := oldArr[i]
oldType, newType := "", ""
if oldMap, ok := oldComp.(map[string]interface{}); ok {
if t, ok := oldMap["type"].(string); ok {
oldType = t
}
}
if newMap, ok := newComp.(map[string]interface{}); ok {
if t, ok := newMap["type"].(string); ok {
newType = t
}
}
if oldType != "" && oldType == newType {
result[i] = c.componentDiff(oldComp, newComp)
matched = true
// t.ctx.logger.Debug().
// Str("type", oldType).
// Msg("Component matched by type and position")
}
}
// If no match found, use the new component as is
if !matched {
result[i] = newComp
// if newMap, ok := newComp.(map[string]interface{}); ok {
// t.ctx.logger.Debug().
// Str("type", fmt.Sprintf("%v", newMap["type"])).
// Msg("New component added")
// }
}
}
// Log removed components
// if len(oldArr) > len(newArr) {
// for i := len(newArr); i < len(oldArr); i++ {
// if oldMap, ok := oldArr[i].(map[string]interface{}); ok {
// t.ctx.logger.Debug().
// Str("type", fmt.Sprintf("%v", oldMap["type"])).
// Msg("Component removed")
// }
// }
// }
return result
}
}
return new
}

View File

@@ -0,0 +1,280 @@
package plugin_ui
import (
"errors"
"github.com/dop251/goja"
"github.com/goccy/go-json"
)
const (
MAX_FIELD_REFS = 100
)
// ComponentManager is used to register components.
// Any higher-order UI system must use this to register components. (Tray)
type ComponentManager struct {
ctx *Context
// Last rendered components
lastRenderedComponents interface{}
}
// jsDiv
//
// Example:
// const div = tray.div({
// items: [
// tray.text("Some text"),
// ]
// })
func (c *ComponentManager) jsDiv(call goja.FunctionCall) goja.Value {
return defineComponent(c.ctx.vm, call, "div", []ComponentProp{
{Name: "items", Type: "array", Required: false, OptionalFirstArg: true},
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
})
}
// jsFlex
//
// Example:
// const flex = tray.flex({
// items: [
// tray.button({ label: "A button", onClick: "my-action" }),
// true ? tray.text("Some text") : null,
// ]
// })
// tray.render(() => flex)
func (c *ComponentManager) jsFlex(call goja.FunctionCall) goja.Value {
return defineComponent(c.ctx.vm, call, "flex", []ComponentProp{
{Name: "items", Type: "array", Required: false, OptionalFirstArg: true},
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
{Name: "gap", Type: "number", Required: false, Default: 2, Validate: validateType("number")},
{Name: "direction", Type: "string", Required: false, Default: "row", Validate: validateType("string")},
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
})
}
// jsStack
//
// Example:
// const stack = tray.stack({
// items: [
// tray.text("Some text"),
// ]
// })
func (c *ComponentManager) jsStack(call goja.FunctionCall) goja.Value {
return defineComponent(c.ctx.vm, call, "stack", []ComponentProp{
{Name: "items", Type: "array", Required: false, OptionalFirstArg: true},
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
{Name: "gap", Type: "number", Required: false, Default: 2, Validate: validateType("number")},
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
})
}
// jsText
//
// Example:
// const text = tray.text("Some text")
// // or
// const text = tray.text({ text: "Some text" })
func (c *ComponentManager) jsText(call goja.FunctionCall) goja.Value {
return defineComponent(c.ctx.vm, call, "text", []ComponentProp{
{Name: "text", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")},
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
})
}
// jsButton
//
// Example:
// const button = tray.button("Click me")
// // or
// const button = tray.button({ label: "Click me", onClick: "my-action" })
func (c *ComponentManager) jsButton(call goja.FunctionCall) goja.Value {
return defineComponent(c.ctx.vm, call, "button", []ComponentProp{
{Name: "label", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")},
{Name: "onClick", Type: "string", Required: false, Validate: validateType("string")},
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
{Name: "intent", Type: "string", Required: false, Validate: validateType("string")},
{Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
{Name: "loading", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
{Name: "size", Type: "string", Required: false, Validate: validateType("string")},
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
})
}
// jsAnchor
//
// Example:
// const anchor = tray.anchor("Click here", { href: "https://example.com" })
// // or
// const anchor = tray.anchor({ text: "Click here", href: "https://example.com" })
func (c *ComponentManager) jsAnchor(call goja.FunctionCall) goja.Value {
return defineComponent(c.ctx.vm, call, "anchor", []ComponentProp{
{Name: "text", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")},
{Name: "href", Type: "string", Required: true, Validate: validateType("string")},
{Name: "target", Type: "string", Required: false, Default: "_blank", Validate: validateType("string")},
{Name: "onClick", Type: "string", Required: false, Validate: validateType("string")},
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
})
}
////////////////////////////////////////////
// Fields
////////////////////////////////////////////
// jsInput
//
// Example:
// const input = tray.input("Enter your name") // placeholder as shorthand
// // or
// const input = tray.input({
// placeholder: "Enter your name",
// value: "John",
// onChange: "input-changed"
// })
func (c *ComponentManager) jsInput(call goja.FunctionCall) goja.Value {
return defineComponent(c.ctx.vm, call, "input", []ComponentProp{
{Name: "label", Type: "string", Required: false, OptionalFirstArg: true, Validate: validateType("string")},
{Name: "placeholder", Type: "string", Required: false, Validate: validateType("string")},
{Name: "value", Type: "string", Required: false, Default: "", Validate: validateType("string")},
{Name: "onChange", Type: "string", Required: false, Validate: validateType("string")},
{Name: "onSelect", Type: "string", Required: false, Validate: validateType("string")},
{Name: "fieldRef", Type: "object", Required: false, Validate: validateType("object")},
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
{Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
{Name: "textarea", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
{Name: "size", Type: "string", Required: false, Validate: validateType("string")},
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
})
}
func validateOptions(v interface{}) error {
if v == nil {
return errors.New("options must be an array of objects")
}
marshaled, err := json.Marshal(v)
if err != nil {
return err
}
var arr []map[string]interface{}
if err := json.Unmarshal(marshaled, &arr); err != nil {
return err
}
if len(arr) == 0 {
return nil
}
for _, option := range arr {
if _, ok := option["label"]; !ok {
return errors.New("options must be an array of objects with a label property")
}
if _, ok := option["value"]; !ok {
return errors.New("options must be an array of objects with a value property")
}
}
return nil
}
// jsSelect
//
// Example:
// const select = tray.select("Select an item", {
// options: [{ label: "Item 1", value: "item1" }, { label: "Item 2", value: "item2" }],
// onChange: "select-changed"
// })
// // or
// const select = tray.select({
// placeholder: "Select an item",
// options: [{ label: "Item 1", value: "item1" }, { label: "Item 2", value: "item2" }],
// value: "Item 1",
// onChange: "select-changed"
// })
func (c *ComponentManager) jsSelect(call goja.FunctionCall) goja.Value {
return defineComponent(c.ctx.vm, call, "select", []ComponentProp{
{Name: "label", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")},
{Name: "placeholder", Type: "string", Required: false, Validate: validateType("string")},
{
Name: "options",
Type: "array",
Required: true,
Validate: validateOptions,
},
{Name: "value", Type: "string", Required: false, Default: "", Validate: validateType("string")},
{Name: "onChange", Type: "string", Required: false, Validate: validateType("string")},
{Name: "fieldRef", Type: "object", Required: false, Validate: validateType("object")},
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
{Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
{Name: "size", Type: "string", Required: false, Validate: validateType("string")},
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
})
}
// jsCheckbox
//
// Example:
// const checkbox = tray.checkbox("I agree to the terms and conditions")
// // or
// const checkbox = tray.checkbox({ label: "I agree to the terms and conditions", value: true })
func (c *ComponentManager) jsCheckbox(call goja.FunctionCall) goja.Value {
return defineComponent(c.ctx.vm, call, "checkbox", []ComponentProp{
{Name: "label", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")},
{Name: "value", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
{Name: "onChange", Type: "string", Required: false, Validate: validateType("string")},
{Name: "fieldRef", Type: "object", Required: false, Validate: validateType("object")},
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
{Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
{Name: "size", Type: "string", Required: false, Validate: validateType("string")},
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
})
}
// jsRadioGroup
//
// Example:
// const radioGroup = tray.radioGroup({
// options: [{ label: "Item 1", value: "item1" }, { label: "Item 2", value: "item2" }],
// onChange: "radio-group-changed"
// })
func (c *ComponentManager) jsRadioGroup(call goja.FunctionCall) goja.Value {
return defineComponent(c.ctx.vm, call, "radio-group", []ComponentProp{
{Name: "label", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")},
{Name: "value", Type: "string", Required: false, Default: "", Validate: validateType("string")},
{
Name: "options",
Type: "array",
Required: true,
Validate: validateOptions,
},
{Name: "onChange", Type: "string", Required: false, Validate: validateType("string")},
{Name: "fieldRef", Type: "object", Required: false, Validate: validateType("object")},
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
{Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
{Name: "size", Type: "string", Required: false, Validate: validateType("string")},
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
})
}
// jsSwitch
//
// Example:
// const switch = tray.switch({
// label: "Toggle me",
// value: true
// })
func (c *ComponentManager) jsSwitch(call goja.FunctionCall) goja.Value {
return defineComponent(c.ctx.vm, call, "switch", []ComponentProp{
{Name: "label", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")},
{Name: "value", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
{Name: "onChange", Type: "string", Required: false, Validate: validateType("string")},
{Name: "fieldRef", Type: "object", Required: false, Validate: validateType("object")},
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
{Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
{Name: "size", Type: "string", Required: false, Validate: validateType("string")},
{Name: "side", Type: "string", Required: false, Validate: validateType("string")},
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
})
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,397 @@
package plugin_ui
import "github.com/goccy/go-json"
/////////////////////////////////////////////////////////////////////////////////////
// Client to server
/////////////////////////////////////////////////////////////////////////////////////
type ClientEventType string
// ClientPluginEvent is an event received from the client
type ClientPluginEvent struct {
// ExtensionID is the "sent to"
// If not set, the event is being sent to all plugins
ExtensionID string `json:"extensionId,omitempty"`
Type ClientEventType `json:"type"`
Payload interface{} `json:"payload"`
}
const (
ClientRenderTrayEvent ClientEventType = "tray:render" // Client wants to render the tray
ClientListTrayIconsEvent ClientEventType = "tray:list-icons" // Client wants to list all icons from all plugins
ClientTrayOpenedEvent ClientEventType = "tray:opened" // When the tray is opened
ClientTrayClosedEvent ClientEventType = "tray:closed" // When the tray is closed
ClientTrayClickedEvent ClientEventType = "tray:clicked" // When the tray is clicked
ClientListCommandPalettesEvent ClientEventType = "command-palette:list" // When the client wants to list all command palettes
ClientCommandPaletteOpenedEvent ClientEventType = "command-palette:opened" // When the client opens the command palette
ClientCommandPaletteClosedEvent ClientEventType = "command-palette:closed" // When the client closes the command palette
ClientRenderCommandPaletteEvent ClientEventType = "command-palette:render" // When the client requests the command palette to render
ClientCommandPaletteInputEvent ClientEventType = "command-palette:input" // The client sends the current input of the command palette
ClientCommandPaletteItemSelectedEvent ClientEventType = "command-palette:item-selected" // When the client selects an item from the command palette
ClientActionRenderAnimePageButtonsEvent ClientEventType = "action:anime-page-buttons:render" // When the client requests the buttons to display on the anime page
ClientActionRenderAnimePageDropdownItemsEvent ClientEventType = "action:anime-page-dropdown-items:render" // When the client requests the dropdown items to display on the anime page
ClientActionRenderMangaPageButtonsEvent ClientEventType = "action:manga-page-buttons:render" // When the client requests the buttons to display on the manga page
ClientActionRenderMediaCardContextMenuItemsEvent ClientEventType = "action:media-card-context-menu-items:render" // When the client requests the context menu items to display on the media card
ClientActionRenderAnimeLibraryDropdownItemsEvent ClientEventType = "action:anime-library-dropdown-items:render" // When the client requests the dropdown items to display on the anime library
ClientActionRenderEpisodeCardContextMenuItemsEvent ClientEventType = "action:episode-card-context-menu-items:render" // When the client requests the context menu items to display on the episode card
ClientActionRenderEpisodeGridItemMenuItemsEvent ClientEventType = "action:episode-grid-item-menu-items:render" // When the client requests the context menu items to display on the episode grid item
ClientActionClickedEvent ClientEventType = "action:clicked" // When the user clicks on an action
ClientFormSubmittedEvent ClientEventType = "form:submitted" // When the form registered by the tray is submitted
ClientScreenChangedEvent ClientEventType = "screen:changed" // When the current screen changes
ClientEventHandlerTriggeredEvent ClientEventType = "handler:triggered" // When a custom event registered by the plugin is triggered
ClientFieldRefSendValueEvent ClientEventType = "field-ref:send-value" // When the client sends the value of a field that has a ref
ClientDOMQueryResultEvent ClientEventType = "dom:query-result" // Result of a DOM query
ClientDOMQueryOneResultEvent ClientEventType = "dom:query-one-result" // Result of a DOM query for one element
ClientDOMObserveResultEvent ClientEventType = "dom:observe-result" // Result of a DOM observation
ClientDOMStopObserveEvent ClientEventType = "dom:stop-observe" // Stop observing DOM elements
ClientDOMCreateResultEvent ClientEventType = "dom:create-result" // Result of creating a DOM element
ClientDOMElementUpdatedEvent ClientEventType = "dom:element-updated" // When a DOM element is updated
ClientDOMEventTriggeredEvent ClientEventType = "dom:event-triggered" // When a DOM event is triggered
ClientDOMReadyEvent ClientEventType = "dom:ready" // When a DOM element is ready
)
type ClientRenderTrayEventPayload struct{}
type ClientListTrayIconsEventPayload struct{}
type ClientTrayOpenedEventPayload struct{}
type ClientTrayClosedEventPayload struct{}
type ClientTrayClickedEventPayload struct{}
type ClientActionRenderAnimePageButtonsEventPayload struct{}
type ClientActionRenderAnimePageDropdownItemsEventPayload struct{}
type ClientActionRenderMangaPageButtonsEventPayload struct{}
type ClientActionRenderMediaCardContextMenuItemsEventPayload struct{}
type ClientActionRenderAnimeLibraryDropdownItemsEventPayload struct{}
type ClientActionRenderEpisodeCardContextMenuItemsEventPayload struct{}
type ClientActionRenderEpisodeGridItemMenuItemsEventPayload struct{}
type ClientListCommandPalettesEventPayload struct{}
type ClientCommandPaletteOpenedEventPayload struct{}
type ClientCommandPaletteClosedEventPayload struct{}
type ClientActionClickedEventPayload struct {
ActionID string `json:"actionId"`
Event map[string]interface{} `json:"event"`
}
type ClientEventHandlerTriggeredEventPayload struct {
HandlerName string `json:"handlerName"`
Event map[string]interface{} `json:"event"`
}
type ClientFormSubmittedEventPayload struct {
FormName string `json:"formName"`
Data map[string]interface{} `json:"data"`
}
type ClientScreenChangedEventPayload struct {
Pathname string `json:"pathname"`
Query string `json:"query"`
}
type ClientFieldRefSendValueEventPayload struct {
FieldRef string `json:"fieldRef"`
Value interface{} `json:"value"`
}
type ClientRenderCommandPaletteEventPayload struct{}
type ClientCommandPaletteItemSelectedEventPayload struct {
ItemID string `json:"itemId"`
}
type ClientCommandPaletteInputEventPayload struct {
Value string `json:"value"`
}
type ClientDOMEventTriggeredEventPayload struct {
ElementId string `json:"elementId"`
EventType string `json:"eventType"`
Event map[string]interface{} `json:"event"`
}
type ClientDOMQueryResultEventPayload struct {
RequestID string `json:"requestId"`
Elements []interface{} `json:"elements"`
}
type ClientDOMQueryOneResultEventPayload struct {
RequestID string `json:"requestId"`
Element interface{} `json:"element"`
}
type ClientDOMObserveResultEventPayload struct {
ObserverId string `json:"observerId"`
Elements []interface{} `json:"elements"`
}
type ClientDOMCreateResultEventPayload struct {
RequestID string `json:"requestId"`
Element interface{} `json:"element"`
}
type ClientDOMElementUpdatedEventPayload struct {
ElementId string `json:"elementId"`
Action string `json:"action"`
Result interface{} `json:"result"`
RequestID string `json:"requestId"`
}
type ClientDOMStopObserveEventPayload struct {
ObserverId string `json:"observerId"`
}
type ClientDOMReadyEventPayload struct {
}
/////////////////////////////////////////////////////////////////////////////////////
// Server to client
/////////////////////////////////////////////////////////////////////////////////////
type ServerEventType string
// ServerPluginEvent is an event sent to the client
type ServerPluginEvent struct {
ExtensionID string `json:"extensionId"` // Extension ID must be set
Type ServerEventType `json:"type"`
Payload interface{} `json:"payload"`
}
const (
ServerTrayUpdatedEvent ServerEventType = "tray:updated" // When the trays are updated
ServerTrayIconEvent ServerEventType = "tray:icon" // When the tray sends its icon to the client
ServerTrayBadgeUpdatedEvent ServerEventType = "tray:badge-updated" // When the tray badge is updated
ServerTrayOpenEvent ServerEventType = "tray:open" // When the tray is opened
ServerTrayCloseEvent ServerEventType = "tray:close" // When the tray is closed
ServerCommandPaletteInfoEvent ServerEventType = "command-palette:info" // When the command palette sends its state to the client
ServerCommandPaletteUpdatedEvent ServerEventType = "command-palette:updated" // When the command palette is updated
ServerCommandPaletteOpenEvent ServerEventType = "command-palette:open" // When the command palette is opened
ServerCommandPaletteCloseEvent ServerEventType = "command-palette:close" // When the command palette is closed
ServerCommandPaletteGetInputEvent ServerEventType = "command-palette:get-input" // When the command palette requests the input from the client
ServerCommandPaletteSetInputEvent ServerEventType = "command-palette:set-input" // When the command palette sets the input
ServerActionRenderAnimePageButtonsEvent ServerEventType = "action:anime-page-buttons:updated" // When the server renders the anime page buttons
ServerActionRenderAnimePageDropdownItemsEvent ServerEventType = "action:anime-page-dropdown-items:updated" // When the server renders the anime page dropdown items
ServerActionRenderMangaPageButtonsEvent ServerEventType = "action:manga-page-buttons:updated" // When the server renders the manga page buttons
ServerActionRenderMediaCardContextMenuItemsEvent ServerEventType = "action:media-card-context-menu-items:updated" // When the server renders the media card context menu items
ServerActionRenderEpisodeCardContextMenuItemsEvent ServerEventType = "action:episode-card-context-menu-items:updated" // When the server renders the episode card context menu items
ServerActionRenderEpisodeGridItemMenuItemsEvent ServerEventType = "action:episode-grid-item-menu-items:updated" // When the server renders the episode grid item menu items
ServerActionRenderAnimeLibraryDropdownItemsEvent ServerEventType = "action:anime-library-dropdown-items:updated" // When the server renders the anime library dropdown items
ServerFormResetEvent ServerEventType = "form:reset"
ServerFormSetValuesEvent ServerEventType = "form:set-values"
ServerFieldRefSetValueEvent ServerEventType = "field-ref:set-value" // Set the value of a field (not in a form)
ServerFatalErrorEvent ServerEventType = "fatal-error" // When the UI encounters a fatal error
ServerScreenNavigateToEvent ServerEventType = "screen:navigate-to" // Navigate to a new screen
ServerScreenReloadEvent ServerEventType = "screen:reload" // Reload the current screen
ServerScreenGetCurrentEvent ServerEventType = "screen:get-current" // Get the current screen
ServerDOMQueryEvent ServerEventType = "dom:query" // When the server queries for DOM elements
ServerDOMQueryOneEvent ServerEventType = "dom:query-one" // When the server queries for a single DOM element
ServerDOMObserveEvent ServerEventType = "dom:observe" // When the server starts observing DOM elements
ServerDOMStopObserveEvent ServerEventType = "dom:stop-observe" // When the server stops observing DOM elements
ServerDOMCreateEvent ServerEventType = "dom:create" // When the server creates a DOM element
ServerDOMManipulateEvent ServerEventType = "dom:manipulate" // When the server manipulates a DOM element
ServerDOMObserveInViewEvent ServerEventType = "dom:observe-in-view"
)
type ServerTrayUpdatedEventPayload struct {
Components interface{} `json:"components"`
}
type ServerCommandPaletteUpdatedEventPayload struct {
Placeholder string `json:"placeholder"`
Items interface{} `json:"items"`
}
type ServerTrayOpenEventPayload struct {
ExtensionID string `json:"extensionId"`
}
type ServerTrayCloseEventPayload struct {
ExtensionID string `json:"extensionId"`
}
type ServerTrayIconEventPayload struct {
ExtensionID string `json:"extensionId"`
ExtensionName string `json:"extensionName"`
IconURL string `json:"iconUrl"`
WithContent bool `json:"withContent"`
TooltipText string `json:"tooltipText"`
BadgeNumber int `json:"badgeNumber"`
BadgeIntent string `json:"badgeIntent"`
Width string `json:"width,omitempty"`
MinHeight string `json:"minHeight,omitempty"`
}
type ServerTrayBadgeUpdatedEventPayload struct {
BadgeNumber int `json:"badgeNumber"`
BadgeIntent string `json:"badgeIntent"`
}
type ServerFormResetEventPayload struct {
FormName string `json:"formName"`
FieldToReset string `json:"fieldToReset"` // If not set, the form will be reset
}
type ServerFormSetValuesEventPayload struct {
FormName string `json:"formName"`
Data map[string]interface{} `json:"data"`
}
type ServerFieldRefSetValueEventPayload struct {
FieldRef string `json:"fieldRef"`
Value interface{} `json:"value"`
}
type ServerFieldRefGetValueEventPayload struct {
FieldRef string `json:"fieldRef"`
}
type ServerFatalErrorEventPayload struct {
Error string `json:"error"`
}
type ServerScreenNavigateToEventPayload struct {
Path string `json:"path"`
}
type ServerActionRenderAnimePageButtonsEventPayload struct {
Buttons interface{} `json:"buttons"`
}
type ServerActionRenderAnimePageDropdownItemsEventPayload struct {
Items interface{} `json:"items"`
}
type ServerActionRenderMangaPageButtonsEventPayload struct {
Buttons interface{} `json:"buttons"`
}
type ServerActionRenderMediaCardContextMenuItemsEventPayload struct {
Items interface{} `json:"items"`
}
type ServerActionRenderAnimeLibraryDropdownItemsEventPayload struct {
Items interface{} `json:"items"`
}
type ServerActionRenderEpisodeCardContextMenuItemsEventPayload struct {
Items interface{} `json:"items"`
}
type ServerActionRenderEpisodeGridItemMenuItemsEventPayload struct {
Items interface{} `json:"items"`
}
type ServerScreenReloadEventPayload struct{}
type ServerCommandPaletteInfoEventPayload struct {
Placeholder string `json:"placeholder"`
KeyboardShortcut string `json:"keyboardShortcut"`
}
type ServerCommandPaletteOpenEventPayload struct{}
type ServerCommandPaletteCloseEventPayload struct{}
type ServerCommandPaletteGetInputEventPayload struct{}
type ServerCommandPaletteSetInputEventPayload struct {
Value string `json:"value"`
}
type ServerScreenGetCurrentEventPayload struct{}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func NewClientPluginEvent(data map[string]interface{}) *ClientPluginEvent {
extensionID, ok := data["extensionId"].(string)
if !ok {
extensionID = ""
}
eventType, ok := data["type"].(string)
if !ok {
return nil
}
payload, ok := data["payload"]
if !ok {
return nil
}
return &ClientPluginEvent{
ExtensionID: extensionID,
Type: ClientEventType(eventType),
Payload: payload,
}
}
func (e *ClientPluginEvent) ParsePayload(ret interface{}) bool {
data, err := json.Marshal(e.Payload)
if err != nil {
return false
}
if err := json.Unmarshal(data, &ret); err != nil {
return false
}
return true
}
func (e *ClientPluginEvent) ParsePayloadAs(t ClientEventType, ret interface{}) bool {
if e.Type != t {
return false
}
return e.ParsePayload(ret)
}
// Add DOM event payloads
type ServerDOMQueryEventPayload struct {
Selector string `json:"selector"`
RequestID string `json:"requestId"`
WithInnerHTML bool `json:"withInnerHTML"`
WithOuterHTML bool `json:"withOuterHTML"`
IdentifyChildren bool `json:"identifyChildren"`
}
type ServerDOMQueryOneEventPayload struct {
Selector string `json:"selector"`
RequestID string `json:"requestId"`
WithInnerHTML bool `json:"withInnerHTML"`
WithOuterHTML bool `json:"withOuterHTML"`
IdentifyChildren bool `json:"identifyChildren"`
}
type ServerDOMObserveEventPayload struct {
Selector string `json:"selector"`
ObserverId string `json:"observerId"`
WithInnerHTML bool `json:"withInnerHTML"`
WithOuterHTML bool `json:"withOuterHTML"`
IdentifyChildren bool `json:"identifyChildren"`
}
type ServerDOMStopObserveEventPayload struct {
ObserverId string `json:"observerId"`
}
type ServerDOMCreateEventPayload struct {
TagName string `json:"tagName"`
RequestID string `json:"requestId"`
}
type ServerDOMManipulateEventPayload struct {
ElementId string `json:"elementId"`
Action string `json:"action"`
Params map[string]interface{} `json:"params"`
RequestID string `json:"requestId"`
}
type ServerDOMObserveInViewEventPayload struct {
Selector string `json:"selector"`
ObserverId string `json:"observerId"`
WithInnerHTML bool `json:"withInnerHTML"`
WithOuterHTML bool `json:"withOuterHTML"`
IdentifyChildren bool `json:"identifyChildren"`
Margin string `json:"margin"`
}

View File

@@ -0,0 +1,27 @@
package plugin_ui
import (
"seanime/internal/goja/goja_bindings"
"github.com/dop251/goja"
)
func (c *Context) bindFetch(obj *goja.Object) {
f := goja_bindings.NewFetch(c.vm)
_ = obj.Set("fetch", f.Fetch)
go func() {
for fn := range f.ResponseChannel() {
c.scheduler.ScheduleAsync(func() error {
fn()
return nil
})
}
}()
c.registerOnCleanup(func() {
c.logger.Debug().Msg("plugin: Terminating fetch")
f.Close()
})
}

View File

@@ -0,0 +1,337 @@
package plugin_ui
import (
"github.com/dop251/goja"
"github.com/google/uuid"
)
type FormManager struct {
ctx *Context
}
func NewFormManager(ctx *Context) *FormManager {
return &FormManager{
ctx: ctx,
}
}
type FormField struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Label string `json:"label"`
Placeholder string `json:"placeholder,omitempty"`
Value interface{} `json:"value,omitempty"`
Options []FormFieldOption `json:"options,omitempty"`
Props map[string]interface{} `json:"props,omitempty"`
}
type FormFieldOption struct {
Label string `json:"label"`
Value interface{} `json:"value"`
}
type Form struct {
Name string `json:"name"`
ID string `json:"id"`
Type string `json:"type"`
Props FormProps `json:"props"`
manager *FormManager
}
type FormProps struct {
Name string `json:"name"`
Fields []FormField `json:"fields"`
}
// jsNewForm
//
// Example:
// const form = tray.newForm("form-1")
func (f *FormManager) jsNewForm(call goja.FunctionCall) goja.Value {
name, ok := call.Argument(0).Export().(string)
if !ok {
f.ctx.handleTypeError("newForm requires a name")
}
form := &Form{
Name: name,
ID: uuid.New().String(),
Type: "form",
Props: FormProps{Fields: make([]FormField, 0), Name: name},
manager: f,
}
formObj := f.ctx.vm.NewObject()
// Form methods
formObj.Set("render", form.jsRender)
formObj.Set("onSubmit", form.jsOnSubmit)
// Field creation methods
formObj.Set("inputField", form.jsInputField)
formObj.Set("numberField", form.jsNumberField)
formObj.Set("selectField", form.jsSelectField)
formObj.Set("checkboxField", form.jsCheckboxField)
formObj.Set("radioField", form.jsRadioField)
formObj.Set("dateField", form.jsDateField)
formObj.Set("switchField", form.jsSwitchField)
formObj.Set("submitButton", form.jsSubmitButton)
formObj.Set("reset", form.jsReset)
formObj.Set("setValues", form.jsSetValues)
return formObj
}
func (f *Form) jsRender(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
f.manager.ctx.handleTypeError("render requires a config object")
}
config, ok := call.Argument(0).Export().(map[string]interface{})
if !ok {
f.manager.ctx.handleTypeError("render requires a config object")
}
if fields, ok := config["fields"].([]interface{}); ok {
f.Props.Fields = make([]FormField, 0)
for _, field := range fields {
if fieldMap, ok := field.(FormField); ok {
f.Props.Fields = append(f.Props.Fields, fieldMap)
}
}
}
return f.manager.ctx.vm.ToValue(f)
}
func (f *Form) jsOnSubmit(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
f.manager.ctx.handleTypeError("onSubmit requires a callback function")
}
callback, ok := goja.AssertFunction(call.Argument(0))
if !ok {
f.manager.ctx.handleTypeError("onSubmit requires a callback function")
}
eventListener := f.manager.ctx.RegisterEventListener(ClientFormSubmittedEvent)
eventListener.SetCallback(func(event *ClientPluginEvent) {
var payload ClientFormSubmittedEventPayload
if event.ParsePayloadAs(ClientFormSubmittedEvent, &payload) && payload.FormName == f.Name {
f.manager.ctx.scheduler.ScheduleAsync(func() error {
_, err := callback(goja.Undefined(), f.manager.ctx.vm.ToValue(payload.Data))
return err
})
}
})
// go func() {
// for event := range eventListener.Channel {
// if event.ParsePayloadAs(ClientFormSubmittedEvent, &payload) {
// if payload.FormName == f.Name {
// f.manager.ctx.scheduler.ScheduleAsync(func() error {
// _, err := callback(goja.Undefined(), f.manager.ctx.vm.ToValue(payload.Data))
// if err != nil {
// f.manager.ctx.logger.Error().Err(err).Msg("error running form submit callback")
// }
// return err
// })
// }
// }
// }
// }()
return goja.Undefined()
}
func (f *Form) jsReset(call goja.FunctionCall) goja.Value {
fieldToReset := ""
if len(call.Arguments) > 0 {
var ok bool
fieldToReset, ok = call.Argument(0).Export().(string)
if !ok {
f.manager.ctx.handleTypeError("reset requires a field name")
}
}
f.manager.ctx.SendEventToClient(ServerFormResetEvent, ServerFormResetEventPayload{
FormName: f.Name,
FieldToReset: fieldToReset,
})
return goja.Undefined()
}
func (f *Form) jsSetValues(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
f.manager.ctx.handleTypeError("setValues requires a config object")
}
props, ok := call.Argument(0).Export().(map[string]interface{})
if !ok {
f.manager.ctx.handleTypeError("setValues requires a config object")
}
f.manager.ctx.SendEventToClient(ServerFormSetValuesEvent, ServerFormSetValuesEventPayload{
FormName: f.Name,
Data: props,
})
return goja.Undefined()
}
func (f *Form) createField(fieldType string, props map[string]interface{}) goja.Value {
nameRaw, ok := props["name"]
name := ""
if ok {
name, ok = nameRaw.(string)
if !ok {
f.manager.ctx.handleTypeError("name must be a string")
}
}
label := ""
labelRaw, ok := props["label"]
if ok {
label, ok = labelRaw.(string)
if !ok {
f.manager.ctx.handleTypeError("label must be a string")
}
}
placeholder, ok := props["placeholder"]
if ok {
placeholder, ok = placeholder.(string)
if !ok {
f.manager.ctx.handleTypeError("placeholder must be a string")
}
}
field := FormField{
ID: uuid.New().String(),
Type: fieldType,
Name: name,
Label: label,
Value: props["value"],
Options: nil,
}
// Handle options if present
if options, ok := props["options"].([]interface{}); ok {
fieldOptions := make([]FormFieldOption, len(options))
for i, opt := range options {
if optMap, ok := opt.(map[string]interface{}); ok {
fieldOptions[i] = FormFieldOption{
Label: optMap["label"].(string),
Value: optMap["value"],
}
}
}
field.Options = fieldOptions
}
return f.manager.ctx.vm.ToValue(field)
}
func (f *Form) jsInputField(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
f.manager.ctx.handleTypeError("inputField requires a config object")
}
props, ok := call.Argument(0).Export().(map[string]interface{})
if !ok {
f.manager.ctx.handleTypeError("inputField requires a config object")
}
return f.createField("input", props)
}
func (f *Form) jsNumberField(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
f.manager.ctx.handleTypeError("numberField requires a config object")
}
props, ok := call.Argument(0).Export().(map[string]interface{})
if !ok {
f.manager.ctx.handleTypeError("numberField requires a config object")
}
return f.createField("number", props)
}
func (f *Form) jsSelectField(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
f.manager.ctx.handleTypeError("selectField requires a config object")
}
props, ok := call.Argument(0).Export().(map[string]interface{})
if !ok {
f.manager.ctx.handleTypeError("selectField requires a config object")
}
return f.createField("select", props)
}
func (f *Form) jsCheckboxField(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
f.manager.ctx.handleTypeError("checkboxField requires a config object")
}
props, ok := call.Argument(0).Export().(map[string]interface{})
if !ok {
f.manager.ctx.handleTypeError("checkboxField requires a config object")
}
return f.createField("checkbox", props)
}
func (f *Form) jsSwitchField(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
f.manager.ctx.handleTypeError("switchField requires a config object")
}
props, ok := call.Argument(0).Export().(map[string]interface{})
if !ok {
f.manager.ctx.handleTypeError("switchField requires a config object")
}
return f.createField("switch", props)
}
func (f *Form) jsRadioField(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
f.manager.ctx.handleTypeError("radioField requires a config object")
}
props, ok := call.Argument(0).Export().(map[string]interface{})
if !ok {
f.manager.ctx.handleTypeError("radioField requires a config object")
}
return f.createField("radio", props)
}
func (f *Form) jsDateField(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
f.manager.ctx.handleTypeError("dateField requires a config object")
}
props, ok := call.Argument(0).Export().(map[string]interface{})
if !ok {
f.manager.ctx.handleTypeError("dateField requires a config object")
}
return f.createField("date", props)
}
func (f *Form) jsSubmitButton(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
f.manager.ctx.handleTypeError("submitButton requires a config object")
}
props, ok := call.Argument(0).Export().(map[string]interface{})
if !ok {
f.manager.ctx.handleTypeError("submitButton requires a config object")
}
return f.createField("submit", props)
}

View File

@@ -0,0 +1,36 @@
package plugin_ui
import (
"seanime/internal/notifier"
"github.com/dop251/goja"
)
type NotificationManager struct {
ctx *Context
}
func NewNotificationManager(ctx *Context) *NotificationManager {
return &NotificationManager{
ctx: ctx,
}
}
func (n *NotificationManager) bind(contextObj *goja.Object) {
notificationObj := n.ctx.vm.NewObject()
_ = notificationObj.Set("send", n.jsNotify)
_ = contextObj.Set("notification", notificationObj)
}
func (n *NotificationManager) jsNotify(call goja.FunctionCall) goja.Value {
message, ok := call.Argument(0).Export().(string)
if !ok {
n.ctx.handleTypeError("notification: notify requires a string message")
return goja.Undefined()
}
notifier.GlobalNotifier.Notify(notifier.Notification(n.ctx.ext.Name), message)
return goja.Undefined()
}

View File

@@ -0,0 +1,104 @@
package plugin_ui
import (
"net/url"
"strings"
"sync"
"github.com/dop251/goja"
)
type ScreenManager struct {
ctx *Context
mu sync.RWMutex
}
func NewScreenManager(ctx *Context) *ScreenManager {
return &ScreenManager{
ctx: ctx,
}
}
// bind binds 'screen' to the ctx object
//
// Example:
// ctx.screen.navigateTo("/entry?id=21");
func (s *ScreenManager) bind(ctxObj *goja.Object) {
screenObj := s.ctx.vm.NewObject()
_ = screenObj.Set("onNavigate", s.jsOnNavigate)
_ = screenObj.Set("navigateTo", s.jsNavigateTo)
_ = screenObj.Set("reload", s.jsReload)
_ = screenObj.Set("loadCurrent", s.jsLoadCurrent)
_ = ctxObj.Set("screen", screenObj)
}
// jsNavigateTo navigates to a new screen
//
// Example:
// ctx.screen.navigateTo("/entry?id=21");
func (s *ScreenManager) jsNavigateTo(path string, searchParams map[string]string) {
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
queryString := ""
if len(searchParams) > 0 {
query := url.Values{}
for key, value := range searchParams {
query.Add(key, value)
}
queryString = "?" + query.Encode()
}
finalPath := path + queryString
s.ctx.SendEventToClient(ServerScreenNavigateToEvent, ServerScreenNavigateToEventPayload{
Path: finalPath,
})
}
// jsReload reloads the current screen
func (s *ScreenManager) jsReload() {
s.ctx.SendEventToClient(ServerScreenReloadEvent, ServerScreenReloadEventPayload{})
}
// jsLoadCurrent calls onNavigate with the current screen data
func (s *ScreenManager) jsLoadCurrent() {
s.ctx.SendEventToClient(ServerScreenGetCurrentEvent, ServerScreenGetCurrentEventPayload{})
}
// jsOnNavigate registers a callback to be called when the current screen changes
//
// Example:
// const onNavigate = (event) => {
// console.log(event.screen);
// };
// ctx.screen.onNavigate(onNavigate);
func (s *ScreenManager) jsOnNavigate(callback goja.Callable) goja.Value {
eventListener := s.ctx.RegisterEventListener(ClientScreenChangedEvent)
eventListener.SetCallback(func(event *ClientPluginEvent) {
var payload ClientScreenChangedEventPayload
if event.ParsePayloadAs(ClientScreenChangedEvent, &payload) {
s.ctx.scheduler.ScheduleAsync(func() error {
parsedQuery, _ := url.ParseQuery(strings.TrimPrefix(payload.Query, "?"))
queryMap := make(map[string]string)
for key, value := range parsedQuery {
queryMap[key] = strings.Join(value, ",")
}
ret := map[string]interface{}{
"pathname": payload.Pathname,
"searchParams": queryMap,
}
_, err := callback(goja.Undefined(), s.ctx.vm.ToValue(ret))
return err
})
}
})
return goja.Undefined()
}

View File

@@ -0,0 +1,71 @@
package plugin_ui
import (
"seanime/internal/events"
"github.com/dop251/goja"
)
type ToastManager struct {
ctx *Context
}
func NewToastManager(ctx *Context) *ToastManager {
return &ToastManager{
ctx: ctx,
}
}
func (t *ToastManager) bind(contextObj *goja.Object) {
toastObj := t.ctx.vm.NewObject()
_ = toastObj.Set("success", t.jsToastSuccess)
_ = toastObj.Set("error", t.jsToastError)
_ = toastObj.Set("info", t.jsToastInfo)
_ = toastObj.Set("warning", t.jsToastWarning)
_ = contextObj.Set("toast", toastObj)
}
func (t *ToastManager) jsToastSuccess(call goja.FunctionCall) goja.Value {
message, ok := call.Argument(0).Export().(string)
if !ok {
t.ctx.handleTypeError("toast: success requires a string message")
return goja.Undefined()
}
t.ctx.wsEventManager.SendEvent(events.SuccessToast, message)
return goja.Undefined()
}
func (t *ToastManager) jsToastError(call goja.FunctionCall) goja.Value {
message, ok := call.Argument(0).Export().(string)
if !ok {
t.ctx.handleTypeError("toast: error requires a string message")
return goja.Undefined()
}
t.ctx.wsEventManager.SendEvent(events.ErrorToast, message)
return goja.Undefined()
}
func (t *ToastManager) jsToastInfo(call goja.FunctionCall) goja.Value {
message, ok := call.Argument(0).Export().(string)
if !ok {
t.ctx.handleTypeError("toast: info requires a string message")
return goja.Undefined()
}
t.ctx.wsEventManager.SendEvent(events.InfoToast, message)
return goja.Undefined()
}
func (t *ToastManager) jsToastWarning(call goja.FunctionCall) goja.Value {
message, ok := call.Argument(0).Export().(string)
if !ok {
t.ctx.handleTypeError("toast: warning requires a string message")
return goja.Undefined()
}
t.ctx.wsEventManager.SendEvent(events.WarningToast, message)
return goja.Undefined()
}

View File

@@ -0,0 +1,361 @@
package plugin_ui
import (
"sync"
"time"
"github.com/dop251/goja"
"github.com/samber/mo"
)
type TrayManager struct {
ctx *Context
tray mo.Option[*Tray]
lastUpdatedAt time.Time
updateMutex sync.Mutex
componentManager *ComponentManager
}
func NewTrayManager(ctx *Context) *TrayManager {
return &TrayManager{
ctx: ctx,
tray: mo.None[*Tray](),
componentManager: &ComponentManager{ctx: ctx},
}
}
// renderTrayScheduled renders the new component tree.
// This function is unsafe because it is not thread-safe and should be scheduled.
func (t *TrayManager) renderTrayScheduled() {
t.updateMutex.Lock()
defer t.updateMutex.Unlock()
tray, registered := t.tray.Get()
if !registered {
return
}
if !tray.WithContent {
return
}
// Rate limit updates
//if time.Since(t.lastUpdatedAt) < time.Millisecond*200 {
// return
//}
t.lastUpdatedAt = time.Now()
t.ctx.scheduler.ScheduleAsync(func() error {
// t.ctx.logger.Trace().Msg("plugin: Rendering tray")
newComponents, err := t.componentManager.renderComponents(tray.renderFunc)
if err != nil {
t.ctx.logger.Error().Err(err).Msg("plugin: Failed to render tray")
t.ctx.handleException(err)
return nil
}
// t.ctx.logger.Trace().Msg("plugin: Sending tray update to client")
// Send the JSON value to the client
t.ctx.SendEventToClient(ServerTrayUpdatedEvent, ServerTrayUpdatedEventPayload{
Components: newComponents,
})
return nil
})
}
// sendIconToClient sends the tray icon to the client after it's been requested.
func (t *TrayManager) sendIconToClient() {
if tray, registered := t.tray.Get(); registered {
t.ctx.SendEventToClient(ServerTrayIconEvent, ServerTrayIconEventPayload{
ExtensionID: t.ctx.ext.ID,
ExtensionName: t.ctx.ext.Name,
IconURL: tray.IconURL,
WithContent: tray.WithContent,
TooltipText: tray.TooltipText,
BadgeNumber: tray.BadgeNumber,
BadgeIntent: tray.BadgeIntent,
Width: tray.Width,
MinHeight: tray.MinHeight,
})
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Tray
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
type Tray struct {
// WithContent is used to determine if the tray has any content
// If false, only the tray icon will be rendered and tray.render() will be ignored
WithContent bool `json:"withContent"`
IconURL string `json:"iconUrl"`
TooltipText string `json:"tooltipText"`
BadgeNumber int `json:"badgeNumber"`
BadgeIntent string `json:"badgeIntent"`
Width string `json:"width,omitempty"`
MinHeight string `json:"minHeight,omitempty"`
renderFunc func(goja.FunctionCall) goja.Value
trayManager *TrayManager
}
type Component struct {
ID string `json:"id"`
Type string `json:"type"`
Props map[string]interface{} `json:"props"`
Key string `json:"key,omitempty"`
}
// jsNewTray
//
// Example:
// const tray = ctx.newTray()
func (t *TrayManager) jsNewTray(call goja.FunctionCall) goja.Value {
tray := &Tray{
renderFunc: nil,
trayManager: t,
WithContent: true,
}
props := call.Arguments
if len(props) > 0 {
propsObj := props[0].Export().(map[string]interface{})
if propsObj["withContent"] != nil {
tray.WithContent, _ = propsObj["withContent"].(bool)
}
if propsObj["iconUrl"] != nil {
tray.IconURL, _ = propsObj["iconUrl"].(string)
}
if propsObj["tooltipText"] != nil {
tray.TooltipText, _ = propsObj["tooltipText"].(string)
}
if propsObj["width"] != nil {
tray.Width, _ = propsObj["width"].(string)
}
if propsObj["minHeight"] != nil {
tray.MinHeight, _ = propsObj["minHeight"].(string)
}
}
t.tray = mo.Some(tray)
// Create a new tray object
trayObj := t.ctx.vm.NewObject()
_ = trayObj.Set("render", tray.jsRender)
_ = trayObj.Set("update", tray.jsUpdate)
_ = trayObj.Set("onOpen", tray.jsOnOpen)
_ = trayObj.Set("onClose", tray.jsOnClose)
_ = trayObj.Set("onClick", tray.jsOnClick)
_ = trayObj.Set("open", tray.jsOpen)
_ = trayObj.Set("close", tray.jsClose)
_ = trayObj.Set("updateBadge", tray.jsUpdateBadge)
// Register components
_ = trayObj.Set("div", t.componentManager.jsDiv)
_ = trayObj.Set("flex", t.componentManager.jsFlex)
_ = trayObj.Set("stack", t.componentManager.jsStack)
_ = trayObj.Set("text", t.componentManager.jsText)
_ = trayObj.Set("button", t.componentManager.jsButton)
_ = trayObj.Set("anchor", t.componentManager.jsAnchor)
_ = trayObj.Set("input", t.componentManager.jsInput)
_ = trayObj.Set("radioGroup", t.componentManager.jsRadioGroup)
_ = trayObj.Set("switch", t.componentManager.jsSwitch)
_ = trayObj.Set("checkbox", t.componentManager.jsCheckbox)
_ = trayObj.Set("select", t.componentManager.jsSelect)
return trayObj
}
/////
// jsRender registers a function to be called when the tray is rendered/updated
//
// Example:
// tray.render(() => flex)
func (t *Tray) jsRender(call goja.FunctionCall) goja.Value {
funcRes, ok := call.Argument(0).Export().(func(goja.FunctionCall) goja.Value)
if !ok {
t.trayManager.ctx.handleTypeError("render requires a function")
}
// Set the render function
t.renderFunc = funcRes
return goja.Undefined()
}
// jsUpdate schedules a re-render on the client
//
// Example:
// tray.update()
func (t *Tray) jsUpdate(call goja.FunctionCall) goja.Value {
// Update the context's lastUIUpdateAt to prevent duplicate updates
t.trayManager.ctx.uiUpdateMu.Lock()
t.trayManager.ctx.lastUIUpdateAt = time.Now()
t.trayManager.ctx.uiUpdateMu.Unlock()
t.trayManager.renderTrayScheduled()
return goja.Undefined()
}
// jsOpen
//
// Example:
// tray.open()
func (t *Tray) jsOpen(call goja.FunctionCall) goja.Value {
t.trayManager.ctx.SendEventToClient(ServerTrayOpenEvent, ServerTrayOpenEventPayload{
ExtensionID: t.trayManager.ctx.ext.ID,
})
return goja.Undefined()
}
// jsClose
//
// Example:
// tray.close()
func (t *Tray) jsClose(call goja.FunctionCall) goja.Value {
t.trayManager.ctx.SendEventToClient(ServerTrayCloseEvent, ServerTrayCloseEventPayload{
ExtensionID: t.trayManager.ctx.ext.ID,
})
return goja.Undefined()
}
// jsUpdateBadge
//
// Example:
// tray.updateBadge({ number: 1, intent: "success" })
func (t *Tray) jsUpdateBadge(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
t.trayManager.ctx.handleTypeError("updateBadge requires a callback function")
}
propsObj, ok := call.Argument(0).Export().(map[string]interface{})
if !ok {
t.trayManager.ctx.handleTypeError("updateBadge requires a callback function")
}
number, ok := propsObj["number"].(int64)
if !ok {
t.trayManager.ctx.handleTypeError("updateBadge: number must be an integer")
}
intent, ok := propsObj["intent"].(string)
if !ok {
intent = "info"
}
t.BadgeNumber = int(number)
t.BadgeIntent = intent
t.trayManager.ctx.SendEventToClient(ServerTrayBadgeUpdatedEvent, ServerTrayBadgeUpdatedEventPayload{
BadgeNumber: t.BadgeNumber,
BadgeIntent: t.BadgeIntent,
})
return goja.Undefined()
}
// jsOnOpen
//
// Example:
// tray.onOpen(() => {
// console.log("tray opened by the user")
// })
func (t *Tray) jsOnOpen(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
t.trayManager.ctx.handleTypeError("onOpen requires a callback function")
}
callback, ok := goja.AssertFunction(call.Argument(0))
if !ok {
t.trayManager.ctx.handleTypeError("onOpen requires a callback function")
}
eventListener := t.trayManager.ctx.RegisterEventListener(ClientTrayOpenedEvent)
payload := ClientTrayOpenedEventPayload{}
eventListener.SetCallback(func(event *ClientPluginEvent) {
if event.ParsePayloadAs(ClientTrayOpenedEvent, &payload) {
t.trayManager.ctx.scheduler.ScheduleAsync(func() error {
_, err := callback(goja.Undefined(), t.trayManager.ctx.vm.ToValue(map[string]interface{}{}))
if err != nil {
t.trayManager.ctx.logger.Error().Err(err).Msg("plugin: Error running tray open callback")
}
return err
})
}
})
return goja.Undefined()
}
// jsOnClick
//
// Example:
// tray.onClick(() => {
// console.log("tray clicked by the user")
// })
func (t *Tray) jsOnClick(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
t.trayManager.ctx.handleTypeError("onClick requires a callback function")
}
callback, ok := goja.AssertFunction(call.Argument(0))
if !ok {
t.trayManager.ctx.handleTypeError("onClick requires a callback function")
}
eventListener := t.trayManager.ctx.RegisterEventListener(ClientTrayClickedEvent)
payload := ClientTrayClickedEventPayload{}
eventListener.SetCallback(func(event *ClientPluginEvent) {
if event.ParsePayloadAs(ClientTrayClickedEvent, &payload) {
t.trayManager.ctx.scheduler.ScheduleAsync(func() error {
_, err := callback(goja.Undefined(), t.trayManager.ctx.vm.ToValue(map[string]interface{}{}))
if err != nil {
t.trayManager.ctx.logger.Error().Err(err).Msg("plugin: Error running tray click callback")
}
return err
})
}
})
return goja.Undefined()
}
// jsOnClose
//
// Example:
// tray.onClose(() => {
// console.log("tray closed by the user")
// })
func (t *Tray) jsOnClose(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
t.trayManager.ctx.handleTypeError("onClose requires a callback function")
}
callback, ok := goja.AssertFunction(call.Argument(0))
if !ok {
t.trayManager.ctx.handleTypeError("onClose requires a callback function")
}
eventListener := t.trayManager.ctx.RegisterEventListener(ClientTrayClosedEvent)
payload := ClientTrayClosedEventPayload{}
eventListener.SetCallback(func(event *ClientPluginEvent) {
if event.ParsePayloadAs(ClientTrayClosedEvent, &payload) {
t.trayManager.ctx.scheduler.ScheduleAsync(func() error {
_, err := callback(goja.Undefined(), t.trayManager.ctx.vm.ToValue(map[string]interface{}{}))
if err != nil {
t.trayManager.ctx.logger.Error().Err(err).Msg("plugin: Error running tray close callback")
}
return err
})
}
})
return goja.Undefined()
}

View File

@@ -0,0 +1,325 @@
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)
}
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
package plugin_ui
type WebviewManager struct {
ctx *Context
}
func NewWebviewManager(ctx *Context) *WebviewManager {
return &WebviewManager{
ctx: ctx,
}
}