chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:13 +08:00
commit ead81af521
414 changed files with 73946 additions and 0 deletions
+508
View File
@@ -0,0 +1,508 @@
// Package luaplugin provides a Lua 5.1 scripting engine for cliamp plugins.
// Each plugin runs in an isolated GopherLua VM. Plugins are loaded from
// ~/.config/cliamp/plugins/*.lua at startup.
package luaplugin
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
lua "github.com/yuin/gopher-lua"
"github.com/bjarneo/cliamp/internal/appdir"
"github.com/bjarneo/cliamp/internal/plugintrust"
)
// Plugin represents a single loaded Lua plugin.
type Plugin struct {
Name string
Version string
Description string
Type string // "hook" or "visualizer"
L *lua.LState
mu sync.Mutex // serializes all LState access (LState is not thread-safe)
config map[string]string // per-plugin config from config.toml
perms map[string]bool // declared permissions (e.g. "control")
}
// StateProvider supplies read-only access to player/playlist state.
// Functions are set by the caller after model construction so the Lua API
// can query live state without importing the ui package.
type StateProvider struct {
PlayerState func() string // "playing", "paused", "stopped"
Position func() float64 // seconds
Duration func() float64 // seconds
Volume func() float64 // dB
Speed func() float64 // ratio (1.0 = normal)
Mono func() bool
RepeatMode func() string // "off", "all", "one"
Shuffle func() bool
EQBands func() [10]float64
TrackTitle func() string
TrackArtist func() string
TrackAlbum func() string
TrackGenre func() string
TrackYear func() int
TrackNumber func() int
TrackPath func() string
TrackIsStream func() bool
TrackDuration func() int // seconds
PlaylistCount func() int
CurrentIndex func() int // 0-based
QueueList func() []QueueEntry // full playlist in play order
}
// QueueEntry is one track in the playlist as exposed to plugins via
// cliamp.queue.list(). Index is 0-based and matches CurrentIndex; Queued is
// true when the track sits in the explicit play-next queue.
type QueueEntry struct {
Title string
Artist string
Album string
Path string
Index int
Queued bool
}
// ControlProvider supplies write access to player controls.
// Only available to plugins that declare permissions = {"control"}.
type ControlProvider struct {
SetVolume func(db float64)
SetSpeed func(ratio float64)
SetEQBand func(band int, db float64)
ToggleMono func()
TogglePause func()
Stop func()
Seek func(secs float64)
SetEQPreset func(name string, bands *[10]float64) // injected via prog.Send
Next func() // injected via prog.Send
Prev func() // injected via prog.Send
// Queue mutators, all injected via prog.Send so the model's Update loop
// applies them and keeps derived state (cursor, current index) consistent.
QueueAdd func(path string) // resolve path/URL and append
QueueJump func(index int) // make index current and play it
QueueRemove func(index int) // remove track at index
QueueMove func(from, to int) // reorder
}
// UIProvider supplies callbacks that surface plugin output in the TUI.
// Not permission-gated — these are low-risk, output-only operations.
type UIProvider struct {
ShowMessage func(text string, duration time.Duration) // injected via prog.Send
}
// Manager owns all loaded plugins and dispatches events to them.
type Manager struct {
plugins []*Plugin
hooks map[string][]*luaHook // event name -> handlers
keyBinds map[string][]*luaHook // key string -> handlers (global, non-overlay)
keyBindDescs map[string]KeyBinding // key string -> UI overlay entry (only for binds that supplied a description)
reservedKeys map[string]bool // core-reserved keys; plugins may not bind these
commands map[string]map[string]*luaHook // plugin name -> command name -> handler
visPlugs []*luaVis // Lua visualizers in registration order
visMap map[string]*luaVis // name -> Lua visualizer
state StateProvider
control ControlProvider
ui UIProvider
timers *timerManager
execs *execManager
logger *pluginLogger
mu sync.RWMutex
closing bool // set under mu.Lock during Close; blocks new async dispatch
wg sync.WaitGroup // tracks in-flight async Emit goroutines
}
// New scans the plugin directory and loads all .lua files.
// pluginCfg maps plugin names to their [plugins.<name>] config keys.
// Returns a Manager (possibly with 0 plugins) and any non-fatal load error.
func New(pluginCfg map[string]map[string]string) (*Manager, error) {
m := &Manager{
hooks: make(map[string][]*luaHook),
keyBinds: make(map[string][]*luaHook),
keyBindDescs: make(map[string]KeyBinding),
commands: make(map[string]map[string]*luaHook),
visMap: make(map[string]*luaVis),
timers: newTimerManager(),
execs: newExecManager(resolveAllowedBinaries(pluginCfg)),
}
dir, err := appdir.PluginDir()
if err != nil {
return m, nil // no config dir — fine, just no plugins
}
// Initialize plugin logger.
logDir, _ := appdir.Dir()
m.logger = newPluginLogger(filepath.Join(logDir, "plugins.log"))
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return m, nil
}
return m, fmt.Errorf("read plugin dir: %w", err)
}
trustManifest, err := plugintrust.Load(dir)
if err != nil {
return m, err
}
// Collect plugin files: *.lua and directories with init.lua.
type pluginFile struct {
name string
path string
}
var files []pluginFile
for _, e := range entries {
if e.IsDir() {
init := filepath.Join(dir, e.Name(), "init.lua")
if _, err := os.Stat(init); err == nil {
files = append(files, pluginFile{name: e.Name(), path: init})
}
} else if before, ok := strings.CutSuffix(e.Name(), ".lua"); ok {
files = append(files, pluginFile{
name: before,
path: filepath.Join(dir, e.Name()),
})
}
}
sort.Slice(files, func(i, j int) bool { return files[i].name < files[j].name })
// Check disabled list.
disabled := make(map[string]bool)
if pluginCfg != nil {
if topLevel, ok := pluginCfg[""]; ok {
if list, ok := topLevel["disabled"]; ok {
for name := range strings.SplitSeq(list, ",") {
disabled[strings.TrimSpace(name)] = true
}
}
}
}
var loadErrs []string
for _, f := range files {
if disabled[f.name] {
continue
}
cfg := pluginCfg[f.name]
// Check per-plugin enabled flag.
if cfg != nil {
if v, ok := cfg["enabled"]; ok && v == "false" {
continue
}
}
if err := plugintrust.Verify(trustManifest, f.name, f.path); err != nil {
loadErrs = append(loadErrs, fmt.Sprintf("%s: %v; run `cliamp plugins trust %s`", f.name, err, f.name))
continue
}
p, err := m.loadPlugin(f.path, f.name, cfg)
if err != nil {
loadErrs = append(loadErrs, fmt.Sprintf("%s: %v", f.name, err))
continue
}
if p != nil {
m.plugins = append(m.plugins, p)
}
}
m.finalizeVisualizers()
if len(loadErrs) > 0 {
return m, fmt.Errorf("plugin load errors: %s", strings.Join(loadErrs, "; "))
}
return m, nil
}
// loadPlugin creates an isolated Lua VM, registers the cliamp API,
// and executes the plugin file. Returns nil (no error) if the file
// doesn't call plugin.register().
func (m *Manager) loadPlugin(path, name string, cfg map[string]string) (*Plugin, error) {
L := lua.NewState(lua.Options{
SkipOpenLibs: false,
})
sandbox(L)
p := &Plugin{
Name: name,
L: L,
config: cfg,
}
// Register the plugin.register() global.
m.registerPluginAPI(L, p)
// Register all cliamp.* API tables.
m.registerCliampAPI(L, p)
p.mu.Lock()
err := L.DoFile(path)
if err != nil {
m.cleanupPlugin(p)
p.mu.Unlock()
L.Close()
return nil, err
}
// If plugin.register() was never called, skip this file.
if p.Type == "" {
m.cleanupPlugin(p)
p.mu.Unlock()
L.Close()
return nil, nil
}
p.mu.Unlock()
return p, nil
}
func (m *Manager) cleanupPlugin(p *Plugin) {
m.mu.Lock()
for event, hooks := range m.hooks {
m.hooks[event] = filterOutPlugin(hooks, p)
}
for key, hooks := range m.keyBinds {
filtered := filterOutPlugin(hooks, p)
if len(filtered) == 0 {
delete(m.keyBinds, key)
} else {
m.keyBinds[key] = filtered
}
}
for key, desc := range m.keyBindDescs {
if desc.Plugin == p.Name {
delete(m.keyBindDescs, key)
}
}
delete(m.commands, p.Name)
filteredVis := m.visPlugs[:0]
for _, vis := range m.visPlugs {
if vis.plugin != p {
filteredVis = append(filteredVis, vis)
}
}
for i := len(filteredVis); i < len(m.visPlugs); i++ {
m.visPlugs[i] = nil
}
m.visPlugs = filteredVis
for name, vis := range m.visMap {
if vis.plugin == p {
delete(m.visMap, name)
}
}
m.mu.Unlock()
m.timers.stopPlugin(p)
m.execs.stopPlugin(p)
}
// registerPluginAPI sets up the global "plugin" table with register() and
// the plugin object's on() and config() methods.
func (m *Manager) registerPluginAPI(L *lua.LState, p *Plugin) {
pluginTbl := L.NewTable()
// plugin.register(opts) -> plugin object
L.SetField(pluginTbl, "register", L.NewFunction(func(L *lua.LState) int {
opts := L.CheckTable(1)
if name := opts.RawGetString("name"); name != lua.LNil {
p.Name = name.String()
}
if version := opts.RawGetString("version"); version != lua.LNil {
p.Version = version.String()
}
if desc := opts.RawGetString("description"); desc != lua.LNil {
p.Description = desc.String()
}
if typ := opts.RawGetString("type"); typ != lua.LNil {
p.Type = typ.String()
}
// Parse permissions = {"control", ...}
if perms := opts.RawGetString("permissions"); perms != lua.LNil {
if tbl, ok := perms.(*lua.LTable); ok {
p.perms = make(map[string]bool)
tbl.ForEach(func(_, v lua.LValue) {
permission := v.String()
switch permission {
case PermControl, PermExec, PermKeymap:
p.perms[permission] = true
default:
L.RaiseError("unknown permission %q", permission)
}
})
} else {
L.RaiseError("permissions must be an array")
}
}
// Return a plugin object with on() and config() methods.
obj := L.NewTable()
// p:on(event, callback) — colon call puts self at arg 1
L.SetField(obj, "on", L.NewFunction(func(L *lua.LState) int {
event := L.CheckString(2)
fn := L.CheckFunction(3)
m.mu.Lock()
m.hooks[event] = append(m.hooks[event], &luaHook{
plugin: p,
fn: fn,
})
m.mu.Unlock()
return 0
}))
// p:config(key) -> string or nil — colon call puts self at arg 1
L.SetField(obj, "config", L.NewFunction(func(L *lua.LState) int {
key := L.CheckString(2)
if p.config != nil {
if v, ok := p.config[key]; ok {
L.Push(lua.LString(v))
return 1
}
}
L.Push(lua.LNil)
return 1
}))
m.registerKeymapAPI(L, obj, p)
m.registerCommandAPI(L, obj, p)
// For visualizer plugins, add init/render registration.
if p.Type == "visualizer" {
m.registerVisPlugin(L, obj, p)
}
L.Push(obj)
return 1
}))
L.SetGlobal("plugin", pluginTbl)
}
// registerCliampAPI sets up the "cliamp" global table with all sub-modules.
func (m *Manager) registerCliampAPI(L *lua.LState, p *Plugin) {
cliamp := L.NewTable()
registerLogAPI(L, cliamp, m.logger, p.Name)
registerJSONAPI(L, cliamp)
registerStoreAPI(L, cliamp, p.Name)
registerCryptoAPI(L, cliamp)
registerFSAPI(L, cliamp)
registerHTTPAPI(L, cliamp)
registerPlayerAPI(L, cliamp, &m.state)
registerTrackAPI(L, cliamp, &m.state)
registerTimerAPI(L, cliamp, m.timers, p)
registerQueueAPI(L, cliamp, &m.state, &m.control, p, m.logger)
registerNotifyAPI(L, cliamp, m.logger, p.Name)
registerControlAPI(L, cliamp, &m.control, p, m.logger)
registerMessageAPI(L, cliamp, &m.ui)
registerSleepAPI(L, cliamp)
registerExecAPI(L, cliamp, m.execs, p, m.logger)
L.SetGlobal("cliamp", cliamp)
}
// resolveAllowedBinaries merges defaultAllowedBinaries with any user-supplied
// entries under [plugins] allowed_binaries = "name1,name2". An empty or
// missing value falls back to the default set.
func resolveAllowedBinaries(pluginCfg map[string]map[string]string) []string {
if pluginCfg == nil {
return defaultAllowedBinaries
}
topLevel, ok := pluginCfg[""]
if !ok {
return defaultAllowedBinaries
}
raw, ok := topLevel["allowed_binaries"]
if !ok || strings.TrimSpace(raw) == "" {
return defaultAllowedBinaries
}
seen := make(map[string]bool)
var out []string
for _, b := range defaultAllowedBinaries {
if !seen[b] {
seen[b] = true
out = append(out, b)
}
}
for _, name := range strings.Split(raw, ",") {
name = strings.TrimSpace(name)
if name == "" || seen[name] {
continue
}
seen[name] = true
out = append(out, name)
}
return out
}
// SetStateProvider sets the function pointers used by the Lua API to
// query live player/playlist state.
func (m *Manager) SetStateProvider(sp StateProvider) {
m.state = sp
}
// SetControlProvider sets the function pointers for player control.
// Only plugins with permissions = {"control"} can use these.
func (m *Manager) SetControlProvider(cp ControlProvider) {
m.control = cp
}
// SetUIProvider sets the function pointers for UI output (status messages).
func (m *Manager) SetUIProvider(up UIProvider) {
m.ui = up
}
// Close fires the "app.quit" event synchronously and shuts down all Lua VMs.
func (m *Manager) Close() {
// Block new async dispatch before tearing anything down.
m.mu.Lock()
m.closing = true
m.mu.Unlock()
m.EmitSync(EventAppQuit, nil)
m.timers.stopAll()
m.execs.stopAll()
// Wait for any in-flight async hook goroutines to finish before closing
// the LStates they call into.
m.wg.Wait()
if m.logger != nil {
m.logger.close()
}
for _, p := range m.plugins {
p.L.Close()
}
}
// PluginCount returns the number of loaded plugins.
func (m *Manager) PluginCount() int {
return len(m.plugins)
}
// HasHooks reports whether any plugins have registered hooks.
func (m *Manager) HasHooks() bool {
m.mu.RLock()
defer m.mu.RUnlock()
for _, hooks := range m.hooks {
if len(hooks) > 0 {
return true
}
}
return false
}
// HasHook reports whether any plugin registered for a specific event. Callers
// use this to skip building event payloads (and any locks they require) when no
// plugin is listening for that particular event.
func (m *Manager) HasHook(event string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.hooks[event]) > 0
}