Files
wehub-resource-sync ead81af521
CI / go (push) Waiting to run
CI / build (darwin, macos-14) (push) Waiting to run
CI / build (windows, windows-2025) (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

183 lines
5.2 KiB
Go

package luaplugin
import (
"context"
"fmt"
"log"
"time"
lua "github.com/yuin/gopher-lua"
)
const hookTimeout = 5 * time.Second
// Event name constants.
const (
EventAppStart = "app.start"
EventAppQuit = "app.quit"
EventPlaybackState = "playback.state"
EventTrackChange = "track.change"
EventTrackScrobble = "track.scrobble"
EventPlayerSeek = "player.seek" // data: position, duration (seconds)
EventPlayerVolume = "player.volume" // data: db
EventPlayerEQ = "player.eq" // data: bands (10-array), preset
EventPlayerMode = "player.mode" // data: shuffle (bool), repeat ("Off"/"All"/"One")
EventQueueChange = "queue.change" // data: count, index, queued
)
// Permission strings declared via plugin.register({ permissions = {...} }).
// Kept as named constants so the guard call sites and docs don't drift.
const (
PermControl = "control"
PermExec = "exec"
PermKeymap = "keymap"
)
// luaHook is a single event callback registered by a plugin.
type luaHook struct {
plugin *Plugin
fn *lua.LFunction
}
// callBounded runs fn on the plugin's LState under hookTimeout so a runaway
// callback cannot hold the plugin mutex forever. The caller must already hold
// p.mu. Results (if nret > 0) are left on the stack for the caller to read.
func (p *Plugin) callBounded(nret int, fn *lua.LFunction, args ...lua.LValue) error {
ctx, cancel := context.WithTimeout(context.Background(), hookTimeout)
defer cancel()
p.L.SetContext(ctx)
defer p.L.RemoveContext()
return p.L.CallByParam(lua.P{Fn: fn, NRet: nret, Protect: true}, args...)
}
// logHookErr records a callback error to stderr and the plugin log.
func (m *Manager) logHookErr(name, label string, err error) {
log.Printf("[lua:%s] %s error: %v", name, label, err)
if m.logger != nil {
m.logger.log(name, "error", "%s error: %v", label, err)
}
}
// invokeHook calls a plugin's Lua callback under the plugin's mutex with a
// bounded context. Logs any error to the plugin log. Used by every dispatch
// site that fires Lua from Go (events, key binds, command handlers).
func (m *Manager) invokeHook(h *luaHook, label string, args ...lua.LValue) {
h.plugin.mu.Lock()
defer h.plugin.mu.Unlock()
if err := h.plugin.callBounded(0, h.fn, args...); err != nil {
m.logHookErr(h.plugin.Name, label, err)
}
}
// filterOutPlugin returns hooks with all entries owned by p removed. Reuses
// the existing backing slice and zeroes the tail so dropped LFunction pointers
// become garbage-collectible.
func filterOutPlugin(hooks []*luaHook, p *Plugin) []*luaHook {
filtered := hooks[:0]
for _, h := range hooks {
if h.plugin != p {
filtered = append(filtered, h)
}
}
for i := len(filtered); i < len(hooks); i++ {
hooks[i] = nil
}
return filtered
}
// Emit dispatches an event to all plugins that registered for it.
// Each callback runs in its own goroutine with a timeout. The plugin's
// mutex serializes all LState access so concurrent events are safe.
func (m *Manager) Emit(event string, data map[string]any) {
m.mu.RLock()
defer m.mu.RUnlock()
if m.closing {
return
}
hooks := m.hooks[event]
label := event + " handler"
for _, h := range hooks {
// Add under RLock so Close (which sets closing under Lock, then Wait)
// can never miss an in-flight goroutine: either we register it before
// closing is set, or we observe closing and skip.
m.wg.Add(1)
go func(h *luaHook) {
defer m.wg.Done()
m.invokeHookWithData(h, label, data)
}(h)
}
}
// invokeHookWithData is Emit's per-hook goroutine: builds the arg table on the
// plugin's LState (which requires holding plugin.mu) and then fires the
// callback under the same lock via invokeHook's contract.
func (m *Manager) invokeHookWithData(h *luaHook, label string, data map[string]any) {
h.plugin.mu.Lock()
defer h.plugin.mu.Unlock()
arg := dataToTable(h.plugin.L, data)
if err := h.plugin.callBounded(0, h.fn, arg); err != nil {
m.logHookErr(h.plugin.Name, label, err)
}
}
// EmitSync dispatches an event synchronously, blocking until all callbacks finish.
// Used during shutdown to ensure all handlers complete before LStates are closed.
func (m *Manager) EmitSync(event string, data map[string]any) {
m.mu.RLock()
hooks := m.hooks[event]
m.mu.RUnlock()
for _, h := range hooks {
h.plugin.mu.Lock()
arg := dataToTable(h.plugin.L, data)
_ = h.plugin.L.CallByParam(lua.P{
Fn: h.fn,
NRet: 0,
Protect: true,
}, arg)
h.plugin.mu.Unlock()
}
}
// dataToTable converts a Go map to a Lua table.
func dataToTable(L *lua.LState, data map[string]any) *lua.LTable {
tbl := L.NewTable()
if data == nil {
return tbl
}
for k, v := range data {
tbl.RawSetString(k, goToLua(L, v))
}
return tbl
}
// goToLua converts a Go value to a Lua value.
func goToLua(L *lua.LState, v any) lua.LValue {
switch val := v.(type) {
case nil:
return lua.LNil
case string:
return lua.LString(val)
case int:
return lua.LNumber(val)
case int64:
return lua.LNumber(val)
case float64:
return lua.LNumber(val)
case bool:
return lua.LBool(val)
case map[string]any:
return dataToTable(L, val)
case []float64:
tbl := L.NewTable()
for i, f := range val {
tbl.RawSetInt(i+1, lua.LNumber(f))
}
return tbl
default:
return lua.LString(fmt.Sprintf("%v", val))
}
}