chore: import upstream snapshot with attribution
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled

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
+126
View File
@@ -0,0 +1,126 @@
package luaplugin
import lua "github.com/yuin/gopher-lua"
// registerControlAPI adds cliamp.player control methods (next, prev, play_pause,
// stop, set_volume, set_speed, seek, toggle_mono, set_eq_band) to the cliamp table.
// These are only functional if the plugin declared permissions = {"control"}.
func registerControlAPI(L *lua.LState, cliamp *lua.LTable, ctrl *ControlProvider, p *Plugin, logger *pluginLogger) {
playerTbl := L.GetField(cliamp, "player")
tbl, ok := playerTbl.(*lua.LTable)
if !ok {
return
}
warned := false
guard := func(name string) bool {
if !p.perms[PermControl] {
if !warned {
logger.log(p.Name, "warn", "%s requires permissions = {\"control\"} — further warnings suppressed", name)
warned = true
}
return false
}
return true
}
L.SetField(tbl, "next", L.NewFunction(func(L *lua.LState) int {
if guard("next") {
ctrl.Next()
}
return 0
}))
L.SetField(tbl, "prev", L.NewFunction(func(L *lua.LState) int {
if guard("prev") {
ctrl.Prev()
}
return 0
}))
L.SetField(tbl, "play_pause", L.NewFunction(func(L *lua.LState) int {
if guard("play_pause") {
ctrl.TogglePause()
}
return 0
}))
L.SetField(tbl, "stop", L.NewFunction(func(L *lua.LState) int {
if guard("stop") {
ctrl.Stop()
}
return 0
}))
L.SetField(tbl, "set_volume", L.NewFunction(func(L *lua.LState) int {
if !guard("set_volume") {
return 0
}
db := float64(L.CheckNumber(1))
ctrl.SetVolume(max(min(db, 6), -30))
return 0
}))
L.SetField(tbl, "set_speed", L.NewFunction(func(L *lua.LState) int {
if !guard("set_speed") {
return 0
}
ratio := float64(L.CheckNumber(1))
ctrl.SetSpeed(max(min(ratio, 2.0), 0.25))
return 0
}))
L.SetField(tbl, "seek", L.NewFunction(func(L *lua.LState) int {
if !guard("seek") {
return 0
}
ctrl.Seek(float64(L.CheckNumber(1)))
return 0
}))
L.SetField(tbl, "toggle_mono", L.NewFunction(func(L *lua.LState) int {
if guard("toggle_mono") {
ctrl.ToggleMono()
}
return 0
}))
// set_eq_preset("name") or set_eq_preset("name", {band1, band2, ..., band10})
L.SetField(tbl, "set_eq_preset", L.NewFunction(func(L *lua.LState) int {
if !guard("set_eq_preset") {
return 0
}
name := L.CheckString(1)
var bands *[10]float64
if tbl := L.OptTable(2, nil); tbl != nil {
b := [10]float64{}
for i := range 10 {
v := tbl.RawGetInt(i + 1)
if v == lua.LNil {
// A partial table would silently zero the unset bands;
// require all 10 so the caller's intent is explicit.
L.ArgError(2, "eq bands table must contain all 10 values")
return 0
}
b[i] = max(min(float64(lua.LVAsNumber(v)), 12), -12)
}
bands = &b
}
ctrl.SetEQPreset(name, bands)
return 0
}))
L.SetField(tbl, "set_eq_band", L.NewFunction(func(L *lua.LState) int {
if !guard("set_eq_band") {
return 0
}
band := L.CheckInt(1) - 1 // Lua 1-indexed → Go 0-indexed
if band < 0 || band > 9 {
L.ArgError(1, "band must be 1-10")
return 0
}
db := float64(L.CheckNumber(2))
ctrl.SetEQBand(band, max(min(db, 12), -12))
return 0
}))
}
+40
View File
@@ -0,0 +1,40 @@
package luaplugin
import (
"crypto/hmac"
"crypto/md5"
"crypto/sha256"
"encoding/hex"
lua "github.com/yuin/gopher-lua"
)
// registerCryptoAPI adds cliamp.crypto.{md5,sha256,hmac_sha256} to the cliamp table.
func registerCryptoAPI(L *lua.LState, cliamp *lua.LTable) {
tbl := L.NewTable()
L.SetField(tbl, "md5", L.NewFunction(func(L *lua.LState) int {
s := L.CheckString(1)
h := md5.Sum([]byte(s))
L.Push(lua.LString(hex.EncodeToString(h[:])))
return 1
}))
L.SetField(tbl, "sha256", L.NewFunction(func(L *lua.LState) int {
s := L.CheckString(1)
h := sha256.Sum256([]byte(s))
L.Push(lua.LString(hex.EncodeToString(h[:])))
return 1
}))
L.SetField(tbl, "hmac_sha256", L.NewFunction(func(L *lua.LState) int {
key := L.CheckString(1)
msg := L.CheckString(2)
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(msg))
L.Push(lua.LString(hex.EncodeToString(mac.Sum(nil))))
return 1
}))
L.SetField(cliamp, "crypto", tbl)
}
+61
View File
@@ -0,0 +1,61 @@
package luaplugin
import (
"testing"
lua "github.com/yuin/gopher-lua"
)
func TestCryptoMD5(t *testing.T) {
L := lua.NewState()
defer L.Close()
cliamp := L.NewTable()
registerCryptoAPI(L, cliamp)
L.SetGlobal("cliamp", cliamp)
err := L.DoString(`_G.hash = cliamp.crypto.md5("hello")`)
if err != nil {
t.Fatal(err)
}
want := "5d41402abc4b2a76b9719d911017c592"
if got := L.GetGlobal("hash").String(); got != want {
t.Fatalf("md5('hello') = %q, want %q", got, want)
}
}
func TestCryptoSHA256(t *testing.T) {
L := lua.NewState()
defer L.Close()
cliamp := L.NewTable()
registerCryptoAPI(L, cliamp)
L.SetGlobal("cliamp", cliamp)
err := L.DoString(`_G.hash = cliamp.crypto.sha256("hello")`)
if err != nil {
t.Fatal(err)
}
want := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
if got := L.GetGlobal("hash").String(); got != want {
t.Fatalf("sha256('hello') = %q, want %q", got, want)
}
}
func TestCryptoHMACSHA256(t *testing.T) {
L := lua.NewState()
defer L.Close()
cliamp := L.NewTable()
registerCryptoAPI(L, cliamp)
L.SetGlobal("cliamp", cliamp)
err := L.DoString(`_G.hash = cliamp.crypto.hmac_sha256("key", "message")`)
if err != nil {
t.Fatal(err)
}
want := "6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011976917343065f58ed4a"
if got := L.GetGlobal("hash").String(); got != want {
t.Fatalf("hmac_sha256('key','message') = %q, want %q", got, want)
}
}
+380
View File
@@ -0,0 +1,380 @@
package luaplugin
import (
"bufio"
"context"
"errors"
"io"
"os"
"os/exec"
"sync"
"sync/atomic"
"time"
lua "github.com/yuin/gopher-lua"
)
// Default binaries plugins may invoke via cliamp.exec.run(). Users can widen
// this via [plugins] allowed_binaries = "yt-dlp,ffmpeg,ffprobe" in config.toml.
var defaultAllowedBinaries = []string{"yt-dlp", "ffmpeg"}
// Per-process output cap (stdout+stderr). Once exceeded, further output is
// dropped and the process is still allowed to run to completion. Prevents a
// chatty subprocess from OOMing the player.
const execMaxOutputBytes = 4 << 20 // 4 MiB
// Per-plugin concurrency cap. Runaway plugins can't fork-bomb past this.
const execMaxPerPlugin = 4
// Hard timeout cap. Plugins may pass a smaller value; larger values clamp.
const execMaxTimeout = 30 * time.Minute
// execEntry tracks a single running subprocess.
type execEntry struct {
id int64
plugin *Plugin
cmd *exec.Cmd
cancel context.CancelFunc
done chan struct{}
}
// execManager owns all running plugin subprocesses.
type execManager struct {
mu sync.Mutex
entries map[int64]*execEntry
nextID atomic.Int64
perPlug map[*Plugin]int
allowed map[string]struct{}
allowMu sync.RWMutex
}
func newExecManager(allowed []string) *execManager {
em := &execManager{
entries: make(map[int64]*execEntry),
perPlug: make(map[*Plugin]int),
allowed: make(map[string]struct{}),
}
em.setAllowed(allowed)
return em
}
func (em *execManager) setAllowed(names []string) {
em.allowMu.Lock()
defer em.allowMu.Unlock()
em.allowed = make(map[string]struct{}, len(names))
for _, n := range names {
em.allowed[n] = struct{}{}
}
}
func (em *execManager) isAllowed(name string) bool {
em.allowMu.RLock()
defer em.allowMu.RUnlock()
_, ok := em.allowed[name]
return ok
}
func (em *execManager) add(e *execEntry) {
em.mu.Lock()
em.entries[e.id] = e
em.perPlug[e.plugin]++
em.mu.Unlock()
}
func (em *execManager) remove(e *execEntry) {
em.mu.Lock()
if _, ok := em.entries[e.id]; ok {
delete(em.entries, e.id)
em.perPlug[e.plugin]--
if em.perPlug[e.plugin] <= 0 {
delete(em.perPlug, e.plugin)
}
}
em.mu.Unlock()
}
// canStart returns true if the plugin is under its concurrency cap.
func (em *execManager) canStart(p *Plugin) bool {
em.mu.Lock()
defer em.mu.Unlock()
return em.perPlug[p] < execMaxPerPlugin
}
// stopPlugin cancels every process owned by the given plugin and blocks
// until each one has exited. Called from Manager.cleanupPlugin.
func (em *execManager) stopPlugin(p *Plugin) {
em.mu.Lock()
var victims []*execEntry
for _, e := range em.entries {
if e.plugin == p {
victims = append(victims, e)
}
}
em.mu.Unlock()
for _, e := range victims {
e.cancel()
}
for _, e := range victims {
<-e.done
}
}
// stopAll cancels every process. Called from Manager.Close.
func (em *execManager) stopAll() {
em.mu.Lock()
var all []*execEntry
for _, e := range em.entries {
all = append(all, e)
}
em.mu.Unlock()
for _, e := range all {
e.cancel()
}
for _, e := range all {
<-e.done
}
}
// registerExecAPI adds cliamp.exec.run(binary, args, opts?) -> handle, err.
// The exec API is only functional for plugins declaring permissions = {"exec"}.
// Without the permission, cliamp.exec is a no-op table that logs once.
func registerExecAPI(L *lua.LState, cliamp *lua.LTable, em *execManager, p *Plugin, logger *pluginLogger) {
tbl := L.NewTable()
warned := false
guard := func() bool {
if p.perms[PermExec] {
return true
}
if !warned {
logger.log(p.Name, "warn", "cliamp.exec requires permissions = {\"exec\"} — further warnings suppressed")
warned = true
}
return false
}
L.SetField(tbl, "run", L.NewFunction(func(L *lua.LState) int {
if !guard() {
L.Push(lua.LNil)
L.Push(lua.LString("exec permission required"))
return 2
}
binary := L.CheckString(1)
argsTbl := L.CheckTable(2)
optsTbl := L.OptTable(3, nil)
if !em.isAllowed(binary) {
L.Push(lua.LNil)
L.Push(lua.LString("binary not in allowlist: " + binary))
return 2
}
path, err := exec.LookPath(binary)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString("binary not found on PATH: " + binary))
return 2
}
// Flatten argv. Every entry must be a string; reject non-strings rather
// than coercing, so plugins can't sneak nested tables into argv.
var argv []string
var argErr error
argsTbl.ForEach(func(_, v lua.LValue) {
if argErr != nil {
return
}
if v.Type() != lua.LTString {
argErr = errors.New("args must all be strings")
return
}
argv = append(argv, v.String())
})
if argErr != nil {
L.Push(lua.LNil)
L.Push(lua.LString(argErr.Error()))
return 2
}
var onStdout, onStderr, onExit *lua.LFunction
cwd := ""
timeout := execMaxTimeout
if optsTbl != nil {
if fn, ok := optsTbl.RawGetString("on_stdout").(*lua.LFunction); ok {
onStdout = fn
}
if fn, ok := optsTbl.RawGetString("on_stderr").(*lua.LFunction); ok {
onStderr = fn
}
if fn, ok := optsTbl.RawGetString("on_exit").(*lua.LFunction); ok {
onExit = fn
}
if s, ok := optsTbl.RawGetString("cwd").(lua.LString); ok {
cwd = string(s)
}
if n, ok := optsTbl.RawGetString("timeout").(lua.LNumber); ok && float64(n) > 0 {
t := time.Duration(float64(n) * float64(time.Second))
if t < timeout {
timeout = t
}
}
}
if cwd != "" && !isWriteAllowed(cwd) {
L.Push(lua.LNil)
L.Push(lua.LString("cwd not in write allowlist"))
return 2
}
if !em.canStart(p) {
L.Push(lua.LNil)
L.Push(lua.LString("per-plugin exec concurrency cap reached"))
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
cmd := exec.CommandContext(ctx, path, argv...)
if cwd != "" {
cmd.Dir = cwd
}
// Empty env by default — plugins should not inherit secrets like
// AWS_*, SSH_*, etc. yt-dlp and ffmpeg both run fine with a minimal env.
cmd.Env = minimalExecEnv()
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
stderr, err := cmd.StderrPipe()
if err != nil {
cancel()
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
if err := cmd.Start(); err != nil {
cancel()
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
id := em.nextID.Add(1)
entry := &execEntry{
id: id,
plugin: p,
cmd: cmd,
cancel: cancel,
done: make(chan struct{}),
}
em.add(entry)
// Shared output budget across stdout+stderr.
var outUsed atomic.Int64
pipeStream := func(r io.Reader, fn *lua.LFunction) {
scanner := bufio.NewScanner(r)
// Allow longer lines than default 64KiB for noisy tools like ffmpeg.
scanner.Buffer(make([]byte, 64*1024), 1<<20)
for scanner.Scan() {
line := scanner.Text()
if outUsed.Add(int64(len(line)+1)) > execMaxOutputBytes {
// Budget exhausted — drain silently.
for scanner.Scan() {
}
return
}
if fn == nil {
continue
}
p.mu.Lock()
_ = p.L.CallByParam(lua.P{
Fn: fn,
NRet: 0,
Protect: true,
}, lua.LString(line))
p.mu.Unlock()
}
}
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); pipeStream(stdout, onStdout) }()
go func() { defer wg.Done(); pipeStream(stderr, onStderr) }()
go func() {
wg.Wait()
waitErr := cmd.Wait()
ctxErr := ctx.Err()
cancel()
em.remove(entry)
code := 0
if waitErr != nil {
if ctxErr != nil {
code = -1 // cancelled or timed out
} else {
var exitErr *exec.ExitError
if errors.As(waitErr, &exitErr) {
code = exitErr.ExitCode()
} else {
code = -2 // other error
}
}
}
if onExit != nil {
p.mu.Lock()
_ = p.L.CallByParam(lua.P{
Fn: onExit,
NRet: 0,
Protect: true,
}, lua.LNumber(code))
p.mu.Unlock()
}
close(entry.done)
}()
// Build a Lua handle with :cancel() and :alive().
handle := L.NewTable()
L.SetField(handle, "cancel", L.NewFunction(func(L *lua.LState) int {
entry.cancel()
return 0
}))
L.SetField(handle, "alive", L.NewFunction(func(L *lua.LState) int {
select {
case <-entry.done:
L.Push(lua.LFalse)
default:
L.Push(lua.LTrue)
}
return 1
}))
L.SetField(handle, "id", lua.LNumber(id))
L.Push(handle)
return 1
}))
L.SetField(cliamp, "exec", tbl)
}
// homeEnv returns the user's home directory for subprocess HOME, preferring
// $HOME, then os.UserHomeDir(), falling back to os.TempDir() when unset.
// yt-dlp and ffmpeg both read HOME (~/.cache, ~/.config).
func homeEnv() string {
if home, ok := os.LookupEnv("HOME"); ok && home != "" {
return home
}
if h, err := os.UserHomeDir(); err == nil {
return h
}
return os.TempDir()
}
+403
View File
@@ -0,0 +1,403 @@
package luaplugin
import (
"fmt"
"os"
"runtime"
"strconv"
"strings"
"sync"
"testing"
"time"
lua "github.com/yuin/gopher-lua"
)
// newExecTestState builds a minimal plugin + manager wired up for exec tests.
// The binary allowlist is scoped to a few harmless OS-native tools so tests
// don't need yt-dlp or ffmpeg.
func newExecTestState(t *testing.T, perms []string) (*lua.LState, *Plugin, *execManager, func()) {
t.Helper()
L := lua.NewState()
p := &Plugin{Name: "test", L: L}
if len(perms) > 0 {
p.perms = make(map[string]bool)
for _, perm := range perms {
p.perms[perm] = true
}
}
em := newExecManager(execTestAllowedBinaries())
cliamp := L.NewTable()
registerExecAPI(L, cliamp, em, p, newPluginLogger(""))
L.SetGlobal("cliamp", cliamp)
return L, p, em, func() { em.stopAll(); L.Close() }
}
func execTestAllowedBinaries() []string {
if runtime.GOOS == "windows" {
return []string{"cmd", "powershell"}
}
return []string{"echo", "false", "sleep", "sh", "cat"}
}
func execOutputCommand() (binary string, args []string, wantLine string) {
if runtime.GOOS == "windows" {
return "powershell", []string{"-NoProfile", "-Command", "Write-Output 'hello world'"}, "hello world"
}
return "echo", []string{"hello", "world"}, "hello world"
}
func execFailureCommand() (binary string, args []string) {
if runtime.GOOS == "windows" {
return "powershell", []string{"-NoProfile", "-Command", "exit 1"}
}
return "false", nil
}
func execSleepCommand(seconds int) (binary string, args []string) {
if runtime.GOOS == "windows" {
return "powershell", []string{"-NoProfile", "-Command", fmt.Sprintf("Start-Sleep -Seconds %d", seconds)}
}
return "sleep", []string{strconv.Itoa(seconds)}
}
func execDisallowedCWD() string {
if runtime.GOOS == "windows" {
if root := os.Getenv("WINDIR"); root != "" {
return root
}
return `C:\Windows`
}
return "/etc"
}
func luaStringList(items []string) string {
if len(items) == 0 {
return ""
}
parts := make([]string, 0, len(items))
for _, item := range items {
parts = append(parts, fmt.Sprintf("%q", item))
}
return strings.Join(parts, ", ")
}
// waitExec polls for a Lua global to be set. The plugin mutex must be held
// while touching LState — exec goroutines also take it before calling into
// Lua, so reading without the lock would race on LState itself.
func waitExec(t *testing.T, p *Plugin, L *lua.LState, name string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
p.mu.Lock()
v := L.GetGlobal(name)
p.mu.Unlock()
if v != lua.LNil {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for %q to be set", name)
}
func TestExecRunsAllowedBinary(t *testing.T) {
L, p, _, cleanup := newExecTestState(t, []string{"exec"})
defer cleanup()
binary, args, wantLine := execOutputCommand()
// Drive callbacks into globals so the test can assert state, but acquire
// the plugin mutex first — the exec goroutines also take it before calling
// Lua, so without locking here we'd race on LState itself.
p.mu.Lock()
err := L.DoString(fmt.Sprintf(`
_G.lines = {}
_G.exit_code = nil
local h, err = cliamp.exec.run(%q, {%s}, {
on_stdout = function(line) table.insert(_G.lines, line) end,
on_exit = function(code) _G.exit_code = code end,
})
assert(h, tostring(err))
`, binary, luaStringList(args)))
p.mu.Unlock()
if err != nil {
t.Fatal(err)
}
waitExec(t, p, L, "exit_code", 2*time.Second)
p.mu.Lock()
defer p.mu.Unlock()
if code := L.GetGlobal("exit_code").(lua.LNumber); code != 0 {
t.Fatalf("exit_code = %v, want 0", code)
}
lines := L.GetGlobal("lines").(*lua.LTable)
matched := false
for i := 1; i <= lines.Len(); i++ {
if strings.TrimSpace(lines.RawGetInt(i).String()) == wantLine {
matched = true
break
}
}
if !matched {
t.Fatalf("unexpected stdout: %v", lines)
}
}
func TestExecRejectsUnallowedBinary(t *testing.T) {
L, _, _, cleanup := newExecTestState(t, []string{"exec"})
defer cleanup()
err := L.DoString(`
local h, err = cliamp.exec.run("rm", {"-rf", "/"}, {})
_G.handle = h
_G.err = err
`)
if err != nil {
t.Fatal(err)
}
if L.GetGlobal("handle") != lua.LNil {
t.Fatal("expected nil handle for unallowed binary")
}
if s := L.GetGlobal("err").String(); !strings.Contains(s, "allowlist") {
t.Fatalf("err = %q, want allowlist mention", s)
}
}
func TestExecRequiresPermission(t *testing.T) {
L, _, _, cleanup := newExecTestState(t, nil)
defer cleanup()
err := L.DoString(`
local h, err = cliamp.exec.run("echo", {"hi"}, {})
_G.handle = h
_G.err = err
`)
if err != nil {
t.Fatal(err)
}
if L.GetGlobal("handle") != lua.LNil {
t.Fatal("expected nil handle without exec permission")
}
if s := L.GetGlobal("err").String(); !strings.Contains(s, "permission") {
t.Fatalf("err = %q, want permission mention", s)
}
}
func TestExecPropagatesExitCode(t *testing.T) {
L, p, _, cleanup := newExecTestState(t, []string{"exec"})
defer cleanup()
binary, args := execFailureCommand()
p.mu.Lock()
err := L.DoString(fmt.Sprintf(`
_G.exit_code = nil
cliamp.exec.run(%q, {%s}, {
on_exit = function(code) _G.exit_code = code end,
})
`, binary, luaStringList(args)))
p.mu.Unlock()
if err != nil {
t.Fatal(err)
}
waitExec(t, p, L, "exit_code", 2*time.Second)
p.mu.Lock()
defer p.mu.Unlock()
if code := L.GetGlobal("exit_code").(lua.LNumber); code != 1 {
t.Fatalf("exit_code = %v, want 1", code)
}
}
func TestExecCancel(t *testing.T) {
L, p, _, cleanup := newExecTestState(t, []string{"exec"})
defer cleanup()
binary, args := execSleepCommand(10)
p.mu.Lock()
err := L.DoString(fmt.Sprintf(`
_G.exit_code = nil
_G.handle = cliamp.exec.run(%q, {%s}, {
on_exit = function(code) _G.exit_code = code end,
})
`, binary, luaStringList(args)))
p.mu.Unlock()
if err != nil {
t.Fatal(err)
}
// Cancel from Go side to avoid re-entering Lua while exec goroutines are running.
time.Sleep(20 * time.Millisecond)
p.mu.Lock()
handle := L.GetGlobal("handle").(*lua.LTable)
cancelFn := L.GetField(handle, "cancel").(*lua.LFunction)
_ = L.CallByParam(lua.P{Fn: cancelFn, NRet: 0, Protect: true}, handle)
p.mu.Unlock()
waitExec(t, p, L, "exit_code", 2*time.Second)
p.mu.Lock()
defer p.mu.Unlock()
if code := L.GetGlobal("exit_code").(lua.LNumber); code >= 0 {
t.Fatalf("expected negative exit code for cancelled process, got %v", code)
}
}
func TestExecConcurrencyCap(t *testing.T) {
L, p, _, cleanup := newExecTestState(t, []string{"exec"})
defer cleanup()
binary, args := execSleepCommand(2)
p.mu.Lock()
err := L.DoString(fmt.Sprintf(`
_G.errs = {}
_G.handles = {}
for i = 1, 6 do
local h, err = cliamp.exec.run(%q, {%s}, {})
if h then
table.insert(_G.handles, h)
else
table.insert(_G.errs, err or "?")
end
end
`, binary, luaStringList(args)))
p.mu.Unlock()
if err != nil {
t.Fatal(err)
}
p.mu.Lock()
errs := L.GetGlobal("errs").(*lua.LTable)
handles := L.GetGlobal("handles").(*lua.LTable)
p.mu.Unlock()
if handles.Len() != execMaxPerPlugin {
t.Fatalf("handles = %d, want %d", handles.Len(), execMaxPerPlugin)
}
if errs.Len() != 6-execMaxPerPlugin {
t.Fatalf("errs = %d, want %d", errs.Len(), 6-execMaxPerPlugin)
}
// Cancel everything to release the goroutines before the test exits.
for i := 1; i <= handles.Len(); i++ {
p.mu.Lock()
h := handles.RawGetInt(i).(*lua.LTable)
cancelFn := L.GetField(h, "cancel").(*lua.LFunction)
_ = L.CallByParam(lua.P{Fn: cancelFn, NRet: 0, Protect: true}, h)
p.mu.Unlock()
}
}
func TestExecStopPluginKillsChildren(t *testing.T) {
L, p, em, cleanup := newExecTestState(t, []string{"exec"})
defer cleanup()
binary, args := execSleepCommand(10)
var exits sync.WaitGroup
exits.Add(1)
p.mu.Lock()
L.SetGlobal("notify", L.NewFunction(func(*lua.LState) int {
exits.Done()
return 0
}))
err := L.DoString(fmt.Sprintf(`
cliamp.exec.run(%q, {%s}, {
on_exit = function() notify() end,
})
`, binary, luaStringList(args)))
p.mu.Unlock()
if err != nil {
t.Fatal(err)
}
// Give the goroutine a moment to actually start the process.
time.Sleep(30 * time.Millisecond)
em.stopPlugin(p)
done := make(chan struct{})
go func() { exits.Wait(); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("stopPlugin didn't terminate child process")
}
}
func TestResolveAllowedBinaries(t *testing.T) {
tests := []struct {
name string
cfg map[string]map[string]string
want []string
}{
{"nil cfg", nil, defaultAllowedBinaries},
{"no top-level", map[string]map[string]string{"x": {"k": "v"}}, defaultAllowedBinaries},
{"empty allowed_binaries", map[string]map[string]string{"": {"allowed_binaries": " "}}, defaultAllowedBinaries},
{"extends default", map[string]map[string]string{"": {"allowed_binaries": "ffprobe, yt-dlp ,curl"}},
[]string{"yt-dlp", "ffmpeg", "ffprobe", "curl"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveAllowedBinaries(tt.cfg)
if len(got) != len(tt.want) {
t.Fatalf("got %v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("got %v, want %v", got, tt.want)
}
}
})
}
}
func TestExecCwdMustBeAllowed(t *testing.T) {
L, p, _, cleanup := newExecTestState(t, []string{"exec"})
defer cleanup()
binary, args, _ := execOutputCommand()
cwd := execDisallowedCWD()
p.mu.Lock()
err := L.DoString(fmt.Sprintf(`
local h, err = cliamp.exec.run(%q, {%s}, {cwd = %q})
_G.handle = h
_G.err = err
`, binary, luaStringList(args), cwd))
p.mu.Unlock()
if err != nil {
t.Fatal(err)
}
if L.GetGlobal("handle") != lua.LNil {
t.Fatal("expected nil handle for disallowed cwd")
}
if s := L.GetGlobal("err").String(); !strings.Contains(s, "cwd") {
t.Fatalf("err = %q, want cwd mention", s)
}
}
// Guard against a regression where a missing binary silently returns a handle.
func TestExecBinaryNotOnPath(t *testing.T) {
L := lua.NewState()
defer L.Close()
p := &Plugin{Name: "test", L: L, perms: map[string]bool{"exec": true}}
em := newExecManager([]string{"definitely-not-a-real-binary-xyz"})
cliamp := L.NewTable()
registerExecAPI(L, cliamp, em, p, newPluginLogger(""))
L.SetGlobal("cliamp", cliamp)
err := L.DoString(`
local h, err = cliamp.exec.run("definitely-not-a-real-binary-xyz", {}, {})
_G.handle = h
_G.err = err
`)
if err != nil {
t.Fatal(err)
}
if L.GetGlobal("handle") != lua.LNil {
t.Fatal("expected nil handle for missing binary")
}
if s := L.GetGlobal("err").String(); !strings.Contains(s, "PATH") {
t.Fatalf("err = %q, want PATH mention", s)
}
}
+247
View File
@@ -0,0 +1,247 @@
package luaplugin
import (
"io"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
lua "github.com/yuin/gopher-lua"
"github.com/bjarneo/cliamp/internal/appdir"
)
var (
allowDirsOnce sync.Once
allowDirs []string
)
// writeAllowDirs returns the directories where plugins can write files, with
// symlinks resolved so the prefix check in isWriteAllowed cannot be bypassed
// by a symlinked allow dir (e.g. /tmp -> /private/tmp on macOS). Entries are
// normalized via normalizeWritePath so isWriteAllowed can compare directly.
// The result is cached since these paths never change at runtime.
func writeAllowDirs() []string {
allowDirsOnce.Do(func() {
raw := []string{"/tmp", os.TempDir()}
if configDir, err := appdir.Dir(); err == nil {
raw = append(raw, configDir)
}
if home, err := os.UserHomeDir(); err == nil {
raw = append(raw, filepath.Join(home, ".local", "share", "cliamp"))
raw = append(raw, filepath.Join(home, "Music", "cliamp"))
}
for _, d := range raw {
abs, err := filepath.Abs(d)
if err != nil {
continue
}
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
abs = resolved
}
allowDirs = append(allowDirs, normalizeWritePath(abs))
}
})
return allowDirs
}
// canonicalExistingPath resolves symlinks on the deepest existing ancestor of
// path, re-appending any non-existent tail (e.g. a file about to be created).
// This prevents a symlink planted inside an allowed dir from redirecting a
// write to a target outside it; a purely lexical check cannot catch that.
func canonicalExistingPath(path string) (string, bool) {
abs, err := filepath.Abs(path)
if err != nil {
return "", false
}
suffix := ""
cur := abs
for {
if resolved, err := filepath.EvalSymlinks(cur); err == nil {
if suffix != "" {
resolved = filepath.Join(resolved, suffix)
}
return resolved, true
}
parent := filepath.Dir(cur)
if parent == cur {
return abs, true // nothing along the path exists yet
}
suffix = filepath.Join(filepath.Base(cur), suffix)
cur = parent
}
}
// isWriteAllowed checks if a path is within one of the allowed write
// directories, resolving symlinks on both sides first.
func isWriteAllowed(path string) bool {
abs, ok := canonicalExistingPath(path)
if !ok {
return false
}
abs = normalizeWritePath(abs)
for _, dir := range writeAllowDirs() {
if abs == dir || strings.HasPrefix(abs, dir+string(os.PathSeparator)) {
return true
}
}
return false
}
// normalizeWritePath canonicalizes an absolute path for prefix comparison:
// cleaned and, on Windows, case-folded (Windows paths are case-insensitive).
func normalizeWritePath(path string) string {
path = filepath.Clean(path)
if runtime.GOOS == "windows" {
path = strings.ToLower(path)
}
return path
}
// registerFSAPI adds cliamp.fs.{write,append,read,remove,exists} to the cliamp table.
func registerFSAPI(L *lua.LState, cliamp *lua.LTable) {
tbl := L.NewTable()
// cliamp.fs.write(path, content)
L.SetField(tbl, "write", L.NewFunction(func(L *lua.LState) int {
path := L.CheckString(1)
content := L.CheckString(2)
if !isWriteAllowed(path) {
L.ArgError(1, "write not allowed to this path")
return 0
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
L.Push(lua.LTrue)
return 1
}))
// cliamp.fs.append(path, content)
L.SetField(tbl, "append", L.NewFunction(func(L *lua.LState) int {
path := L.CheckString(1)
content := L.CheckString(2)
if !isWriteAllowed(path) {
L.ArgError(1, "write not allowed to this path")
return 0
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
_, err = f.WriteString(content)
f.Close()
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
L.Push(lua.LTrue)
return 1
}))
// cliamp.fs.read(path) -> string (max 1MB)
L.SetField(tbl, "read", L.NewFunction(func(L *lua.LState) int {
path := L.CheckString(1)
f, err := os.Open(path)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
defer f.Close()
const maxSize = 1 << 20 // 1MB
// Read one byte past the cap so an oversized file is detected without
// pulling the whole thing into memory, then reject it explicitly
// rather than returning a silently truncated value.
data, err := io.ReadAll(io.LimitReader(f, maxSize+1))
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
if len(data) > maxSize {
L.Push(lua.LNil)
L.Push(lua.LString("file exceeds 1MB read limit"))
return 2
}
L.Push(lua.LString(string(data)))
return 1
}))
// cliamp.fs.remove(path)
L.SetField(tbl, "remove", L.NewFunction(func(L *lua.LState) int {
path := L.CheckString(1)
if !isWriteAllowed(path) {
L.ArgError(1, "remove not allowed for this path")
return 0
}
if err := os.Remove(path); err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
L.Push(lua.LTrue)
return 1
}))
// cliamp.fs.exists(path) -> boolean
L.SetField(tbl, "exists", L.NewFunction(func(L *lua.LState) int {
path := L.CheckString(1)
_, err := os.Stat(path)
L.Push(lua.LBool(err == nil))
return 1
}))
// cliamp.fs.mkdir(path) — recursive; path must be in write allowlist.
L.SetField(tbl, "mkdir", L.NewFunction(func(L *lua.LState) int {
path := L.CheckString(1)
if !isWriteAllowed(path) {
L.ArgError(1, "mkdir not allowed for this path")
return 0
}
if err := os.MkdirAll(path, 0o755); err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
L.Push(lua.LTrue)
return 1
}))
// cliamp.fs.listdir(path) -> {names}, err
// Reading is unrestricted (matches cliamp.fs.read); returns entry names only.
L.SetField(tbl, "listdir", L.NewFunction(func(L *lua.LState) int {
path := L.CheckString(1)
entries, err := os.ReadDir(path)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
result := L.NewTable()
for i, e := range entries {
result.RawSetInt(i+1, lua.LString(e.Name()))
}
L.Push(result)
return 1
}))
L.SetField(cliamp, "fs", tbl)
}
+207
View File
@@ -0,0 +1,207 @@
package luaplugin
import (
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
lua "github.com/yuin/gopher-lua"
)
func fsAllowedPath(name string) string {
return filepath.Join(os.TempDir(), name)
}
func fsDisallowedPath() string {
if runtime.GOOS == "windows" {
if root := os.Getenv("WINDIR"); root != "" {
return filepath.Join(root, "System32", "drivers", "etc", "hosts")
}
return `C:\Windows\System32\drivers\etc\hosts`
}
return "/etc/passwd"
}
func TestFSWriteAndRead(t *testing.T) {
L := lua.NewState()
defer L.Close()
cliamp := L.NewTable()
registerFSAPI(L, cliamp)
L.SetGlobal("cliamp", cliamp)
tmp := fsAllowedPath("cliamp-test-" + t.Name())
defer os.Remove(tmp)
L.SetGlobal("path", lua.LString(tmp))
err := L.DoString(`
local ok = cliamp.fs.write(path, "hello world")
_G.write_ok = ok
local content = cliamp.fs.read(path)
_G.content = content
`)
if err != nil {
t.Fatal(err)
}
if L.GetGlobal("write_ok") != lua.LTrue {
t.Fatal("fs.write returned non-true")
}
if L.GetGlobal("content").String() != "hello world" {
t.Fatalf("fs.read = %q, want %q", L.GetGlobal("content").String(), "hello world")
}
}
func TestFSAppend(t *testing.T) {
L := lua.NewState()
defer L.Close()
cliamp := L.NewTable()
registerFSAPI(L, cliamp)
L.SetGlobal("cliamp", cliamp)
tmp := fsAllowedPath("cliamp-test-append-" + t.Name())
defer os.Remove(tmp)
L.SetGlobal("path", lua.LString(tmp))
err := L.DoString(`
cliamp.fs.write(path, "hello")
cliamp.fs.append(path, " world")
_G.content = cliamp.fs.read(path)
`)
if err != nil {
t.Fatal(err)
}
if got := L.GetGlobal("content").String(); got != "hello world" {
t.Fatalf("after append, content = %q, want %q", got, "hello world")
}
}
func TestFSExists(t *testing.T) {
L := lua.NewState()
defer L.Close()
cliamp := L.NewTable()
registerFSAPI(L, cliamp)
L.SetGlobal("cliamp", cliamp)
tmp := fsAllowedPath("cliamp-test-exists-" + t.Name())
os.WriteFile(tmp, []byte("x"), 0o644)
defer os.Remove(tmp)
L.SetGlobal("path", lua.LString(tmp))
L.SetGlobal("fake", lua.LString(fsAllowedPath("cliamp-definitely-not-here")))
err := L.DoString(`
_G.exists = cliamp.fs.exists(path)
_G.not_exists = cliamp.fs.exists(fake)
`)
if err != nil {
t.Fatal(err)
}
if L.GetGlobal("exists") != lua.LTrue {
t.Fatal("fs.exists returned false for existing file")
}
if L.GetGlobal("not_exists") != lua.LFalse {
t.Fatal("fs.exists returned true for non-existing file")
}
}
func TestFSRemove(t *testing.T) {
L := lua.NewState()
defer L.Close()
cliamp := L.NewTable()
registerFSAPI(L, cliamp)
L.SetGlobal("cliamp", cliamp)
tmp := fsAllowedPath("cliamp-test-remove-" + t.Name())
os.WriteFile(tmp, []byte("x"), 0o644)
L.SetGlobal("path", lua.LString(tmp))
err := L.DoString(`
_G.remove_ok = cliamp.fs.remove(path)
_G.exists_after = cliamp.fs.exists(path)
`)
if err != nil {
t.Fatal(err)
}
if L.GetGlobal("remove_ok") != lua.LTrue {
t.Fatal("fs.remove returned non-true")
}
if L.GetGlobal("exists_after") != lua.LFalse {
t.Fatal("file still exists after remove")
}
}
func TestIsWriteAllowed(t *testing.T) {
tests := []struct {
path string
want bool
}{
{fsAllowedPath("test.txt"), true},
{fsDisallowedPath(), false},
}
for _, tt := range tests {
if got := isWriteAllowed(tt.path); got != tt.want {
t.Errorf("isWriteAllowed(%q) = %v, want %v", tt.path, got, tt.want)
}
}
}
func TestFSMkdirAndListdir(t *testing.T) {
L := lua.NewState()
defer L.Close()
cliamp := L.NewTable()
registerFSAPI(L, cliamp)
L.SetGlobal("cliamp", cliamp)
base := fsAllowedPath("cliamp-test-mkdir-" + t.Name())
defer os.RemoveAll(base)
L.SetGlobal("base", lua.LString(base))
err := L.DoString(`
_G.mkdir_ok = cliamp.fs.mkdir(base .. "/sub")
cliamp.fs.write(base .. "/a.txt", "a")
cliamp.fs.write(base .. "/b.txt", "b")
local names, err = cliamp.fs.listdir(base)
_G.names = names
_G.err = err
`)
if err != nil {
t.Fatal(err)
}
if L.GetGlobal("mkdir_ok") != lua.LTrue {
t.Fatal("fs.mkdir returned non-true")
}
names, ok := L.GetGlobal("names").(*lua.LTable)
if !ok {
t.Fatalf("listdir returned %T, want table", L.GetGlobal("names"))
}
if n := names.Len(); n != 3 {
t.Fatalf("listdir returned %d entries, want 3", n)
}
}
func TestFSMkdirRejectsOutsideAllowlist(t *testing.T) {
L := lua.NewState()
defer L.Close()
cliamp := L.NewTable()
registerFSAPI(L, cliamp)
L.SetGlobal("cliamp", cliamp)
err := L.DoString(fmt.Sprintf("cliamp.fs.mkdir(%q)", fsDisallowedPath()))
if err == nil {
t.Fatal("expected error for path outside allowlist")
}
}
func TestMusicDirIsAllowed(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
path := filepath.Join(home, "Music", "cliamp", "album", "01.mp3")
if !isWriteAllowed(path) {
t.Errorf("~/Music/cliamp/... should be writable")
}
}
+134
View File
@@ -0,0 +1,134 @@
package luaplugin
import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"syscall"
"time"
lua "github.com/yuin/gopher-lua"
)
// ssrfGuard rejects connections to non-public addresses. It runs as the
// dialer's Control hook, so it sees the resolved IP for every connection
// attempt, including ones reached via HTTP redirects or DNS rebinding.
func ssrfGuard(network, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip := net.ParseIP(host)
if ip == nil {
return fmt.Errorf("cannot resolve dial address %q", address)
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
return fmt.Errorf("blocked request to non-public address %s", ip)
}
return nil
}
var httpClient = &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
Control: ssrfGuard,
}).DialContext,
},
}
// registerHTTPAPI adds cliamp.http.{get,post} to the cliamp table.
func registerHTTPAPI(L *lua.LState, cliamp *lua.LTable) {
tbl := L.NewTable()
// cliamp.http.get(url, opts?) -> body, status
L.SetField(tbl, "get", L.NewFunction(func(L *lua.LState) int {
return doHTTP(L, "GET")
}))
// cliamp.http.post(url, opts?) -> body, status
L.SetField(tbl, "post", L.NewFunction(func(L *lua.LState) int {
return doHTTP(L, "POST")
}))
L.SetField(cliamp, "http", tbl)
}
const maxResponseBody = 1 << 20 // 1MB
func doHTTP(L *lua.LState, method string) int {
rawURL := L.CheckString(1)
if u, err := url.Parse(rawURL); err != nil || (u.Scheme != "http" && u.Scheme != "https") {
L.Push(lua.LNil)
L.Push(lua.LString("only http and https URLs are allowed"))
return 2
}
opts := L.OptTable(2, nil)
var bodyReader io.Reader
if opts != nil {
// JSON body: cliamp.http.post(url, {json = {...}})
if jsonVal := opts.RawGetString("json"); jsonVal != lua.LNil {
goVal := luaToGo(jsonVal)
data, err := json.Marshal(goVal)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
bodyReader = strings.NewReader(string(data))
}
// Raw body: cliamp.http.post(url, {body = "..."})
if body := opts.RawGetString("body"); body != lua.LNil {
bodyReader = strings.NewReader(body.String())
}
}
req, err := http.NewRequest(method, rawURL, bodyReader)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
// Apply headers from opts.
if opts != nil {
if hdrs := opts.RawGetString("headers"); hdrs != lua.LNil {
if tbl, ok := hdrs.(*lua.LTable); ok {
tbl.ForEach(func(k, v lua.LValue) {
req.Header.Set(k.String(), v.String())
})
}
}
// Auto-set Content-Type for JSON if not explicitly set.
if opts.RawGetString("json") != lua.LNil && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
}
resp, err := httpClient.Do(req)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
L.Push(lua.LString(string(body)))
L.Push(lua.LNumber(resp.StatusCode))
return 2
}
+100
View File
@@ -0,0 +1,100 @@
package luaplugin
import (
"encoding/json"
lua "github.com/yuin/gopher-lua"
)
// registerJSONAPI adds cliamp.json.{encode,decode} to the cliamp table.
func registerJSONAPI(L *lua.LState, cliamp *lua.LTable) {
tbl := L.NewTable()
// cliamp.json.decode(str) -> table
L.SetField(tbl, "decode", L.NewFunction(func(L *lua.LState) int {
str := L.CheckString(1)
var v any
if err := json.Unmarshal([]byte(str), &v); err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
L.Push(jsonToLua(L, v))
return 1
}))
// cliamp.json.encode(table) -> string
L.SetField(tbl, "encode", L.NewFunction(func(L *lua.LState) int {
val := L.Get(1)
goVal := luaToGo(val)
data, err := json.Marshal(goVal)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
L.Push(lua.LString(string(data)))
return 1
}))
L.SetField(cliamp, "json", tbl)
}
func jsonToLua(L *lua.LState, v any) lua.LValue {
switch val := v.(type) {
case nil:
return lua.LNil
case bool:
return lua.LBool(val)
case float64:
return lua.LNumber(val)
case string:
return lua.LString(val)
case []any:
tbl := L.NewTable()
for i, item := range val {
tbl.RawSetInt(i+1, jsonToLua(L, item))
}
return tbl
case map[string]any:
tbl := L.NewTable()
for k, item := range val {
tbl.RawSetString(k, jsonToLua(L, item))
}
return tbl
default:
return lua.LNil
}
}
func luaToGo(val lua.LValue) any {
switch v := val.(type) {
case *lua.LNilType:
return nil
case lua.LBool:
return bool(v)
case lua.LNumber:
return float64(v)
case lua.LString:
return string(v)
case *lua.LTable:
// Detect if it's an array (sequential integer keys starting at 1).
maxN := v.MaxN()
if maxN > 0 {
arr := make([]any, 0, maxN)
for i := 1; i <= maxN; i++ {
arr = append(arr, luaToGo(v.RawGetInt(i)))
}
return arr
}
m := make(map[string]any)
v.ForEach(func(key, value lua.LValue) {
if ks, ok := key.(lua.LString); ok {
m[string(ks)] = luaToGo(value)
}
})
return m
default:
return nil
}
}
+115
View File
@@ -0,0 +1,115 @@
package luaplugin
import (
"testing"
lua "github.com/yuin/gopher-lua"
)
func TestJSONEncodeDecode(t *testing.T) {
L := lua.NewState()
defer L.Close()
cliamp := L.NewTable()
registerJSONAPI(L, cliamp)
L.SetGlobal("cliamp", cliamp)
err := L.DoString(`
local encoded = cliamp.json.encode({name = "test", count = 42})
local decoded = cliamp.json.decode(encoded)
_G.name = decoded.name
_G.count = decoded.count
`)
if err != nil {
t.Fatal(err)
}
if L.GetGlobal("name").String() != "test" {
t.Fatalf("name = %q", L.GetGlobal("name").String())
}
if float64(L.GetGlobal("count").(lua.LNumber)) != 42 {
t.Fatalf("count = %v", L.GetGlobal("count"))
}
}
func TestJSONDecodeInvalid(t *testing.T) {
L := lua.NewState()
defer L.Close()
cliamp := L.NewTable()
registerJSONAPI(L, cliamp)
L.SetGlobal("cliamp", cliamp)
err := L.DoString(`
local result, errmsg = cliamp.json.decode("not json")
_G.result = result
_G.errmsg = errmsg
`)
if err != nil {
t.Fatal(err)
}
if L.GetGlobal("result") != lua.LNil {
t.Fatalf("result = %v, want nil", L.GetGlobal("result"))
}
if L.GetGlobal("errmsg") == lua.LNil {
t.Fatal("errmsg should not be nil")
}
}
func TestJSONEncodeArray(t *testing.T) {
L := lua.NewState()
defer L.Close()
cliamp := L.NewTable()
registerJSONAPI(L, cliamp)
L.SetGlobal("cliamp", cliamp)
err := L.DoString(`
_G.result = cliamp.json.encode({1, 2, 3})
`)
if err != nil {
t.Fatal(err)
}
if got := L.GetGlobal("result").String(); got != "[1,2,3]" {
t.Fatalf("encode([1,2,3]) = %q, want %q", got, "[1,2,3]")
}
}
func TestLuaToGoRoundtrip(t *testing.T) {
L := lua.NewState()
defer L.Close()
tests := []struct {
name string
val lua.LValue
want any
}{
{"nil", lua.LNil, nil},
{"bool", lua.LTrue, true},
{"number", lua.LNumber(3.14), 3.14},
{"string", lua.LString("hi"), "hi"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := luaToGo(tt.val)
switch w := tt.want.(type) {
case nil:
if got != nil {
t.Fatalf("got %v, want nil", got)
}
case bool:
if got != w {
t.Fatalf("got %v, want %v", got, w)
}
case float64:
if got != w {
t.Fatalf("got %v, want %v", got, w)
}
case string:
if got != w {
t.Fatalf("got %v, want %v", got, w)
}
}
})
}
}
+272
View File
@@ -0,0 +1,272 @@
package luaplugin
import (
"context"
"sort"
"strings"
"time"
lua "github.com/yuin/gopher-lua"
)
// SetReservedKeys records the set of keys owned by cliamp's core UI. Plugins
// attempting to bind one of these keys get a logged warning and their bind
// call returns false. Called once during startup from main.go.
func (m *Manager) SetReservedKeys(keys map[string]bool) {
m.mu.Lock()
defer m.mu.Unlock()
m.reservedKeys = keys
}
// KeyBinding describes a plugin-registered keybinding for the Ctrl+K overlay.
type KeyBinding struct {
Key string
Plugin string
Description string
}
// KeyBindings returns a snapshot of every plugin-registered keybinding that
// has a description, sorted by key for stable overlay ordering. Bindings
// registered without a description are omitted — plugins can opt out of
// surfacing a key simply by skipping the description argument.
//
// Called once per Ctrl+K overlay open, so the sort cost is noise.
func (m *Manager) KeyBindings() []KeyBinding {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]KeyBinding, 0, len(m.keyBindDescs))
for _, b := range m.keyBindDescs {
out = append(out, b)
}
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
return out
}
// EmitKey invokes every plugin callback registered for the given key string.
// Returns true if at least one callback fired. Called by the UI's main key
// dispatcher for keys the core doesn't handle.
func (m *Manager) EmitKey(key string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
if m.closing {
return false
}
hooks := m.keyBinds[key]
if len(hooks) == 0 {
return false
}
label := "keybind " + key
for _, h := range hooks {
// Tracked in wg and gated on closing (same as Emit) so a keypress
// during shutdown can't call into a closed LState.
m.wg.Add(1)
go func(h *luaHook) {
defer m.wg.Done()
m.invokeHook(h, label, lua.LString(key))
}(h)
}
return true
}
// normalizeKey lowercases and strips whitespace so "Ctrl+X" and "ctrl+x"
// collide at registration and dispatch.
func normalizeKey(key string) string {
return strings.ToLower(strings.TrimSpace(key))
}
// registerKeymapAPI attaches :bind() / :unbind() to the plugin object returned
// by plugin.register(). Gated on permissions = {"keymap"}.
func (m *Manager) registerKeymapAPI(L *lua.LState, obj *lua.LTable, p *Plugin) {
warned := false
guard := func() bool {
if p.perms[PermKeymap] {
return true
}
if !warned && m.logger != nil {
m.logger.log(p.Name, "warn", "plugin:bind requires permissions = {\"keymap\"} — further warnings suppressed")
warned = true
}
return false
}
// p:bind(key, fn) → no entry in Ctrl+K overlay
// p:bind(key, description, fn) → with description (shown in overlay)
// Returns true on success; false, reason on failure.
L.SetField(obj, "bind", L.NewFunction(func(L *lua.LState) int {
key := normalizeKey(L.CheckString(2))
// Lua has no function overloading, so disambiguate by inspecting arg 3:
// function → old 2-arg form; anything else → (key, description, fn).
var description string
var fn *lua.LFunction
if L.Get(3).Type() == lua.LTFunction {
fn = L.CheckFunction(3)
} else {
description = strings.TrimSpace(L.CheckString(3))
fn = L.CheckFunction(4)
}
if !guard() {
L.Push(lua.LFalse)
L.Push(lua.LString("keymap permission required"))
return 2
}
if key == "" {
L.Push(lua.LFalse)
L.Push(lua.LString("empty key"))
return 2
}
m.mu.Lock()
if m.reservedKeys[key] {
m.mu.Unlock()
if m.logger != nil {
m.logger.log(p.Name, "warn", "refusing to bind %q: reserved by cliamp core", key)
}
L.Push(lua.LFalse)
L.Push(lua.LString("key reserved by cliamp: " + key))
return 2
}
m.keyBinds[key] = append(m.keyBinds[key], &luaHook{plugin: p, fn: fn})
if description != "" {
m.keyBindDescs[key] = KeyBinding{Key: key, Plugin: p.Name, Description: description}
}
m.mu.Unlock()
L.Push(lua.LTrue)
return 1
}))
// p:unbind(key)
L.SetField(obj, "unbind", L.NewFunction(func(L *lua.LState) int {
key := normalizeKey(L.CheckString(2))
m.mu.Lock()
m.keyBinds[key] = filterOutPlugin(m.keyBinds[key], p)
if len(m.keyBinds[key]) == 0 {
delete(m.keyBinds, key)
}
if desc, ok := m.keyBindDescs[key]; ok && desc.Plugin == p.Name {
delete(m.keyBindDescs, key)
}
m.mu.Unlock()
return 0
}))
}
// EmitCommand dispatches a plugin command invoked over IPC and blocks up to
// commandTimeout for the handler to return a result. A missing plugin/command
// returns ("", err); a handler error returns ("", err); success returns
// (result, nil). The result is whatever the handler returned as a string
// (nil or false stringifies to "").
func (m *Manager) EmitCommand(pluginName, cmdName string, args []string) (string, error) {
m.mu.RLock()
plugCmds, ok := m.commands[pluginName]
var hook *luaHook
if ok {
hook = plugCmds[cmdName]
}
m.mu.RUnlock()
if hook == nil {
return "", errCommandNotFound(pluginName, cmdName)
}
type result struct {
out string
err error
}
done := make(chan result, 1)
go func() {
hook.plugin.mu.Lock()
defer hook.plugin.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel()
hook.plugin.L.SetContext(ctx)
defer hook.plugin.L.RemoveContext()
argsTbl := hook.plugin.L.NewTable()
for i, a := range args {
argsTbl.RawSetInt(i+1, lua.LString(a))
}
err := hook.plugin.L.CallByParam(lua.P{
Fn: hook.fn,
NRet: 1,
Protect: true,
}, argsTbl)
if err != nil {
done <- result{err: err}
return
}
ret := hook.plugin.L.Get(-1)
hook.plugin.L.Pop(1)
done <- result{out: luaValueToString(ret)}
}()
select {
case r := <-done:
return r.out, r.err
case <-time.After(commandTimeout + time.Second):
return "", errCommandTimeout(pluginName, cmdName)
}
}
// CommandList returns a flat list of "<plugin> <command>" strings. Used by
// `cliamp plugin commands`. Order is unspecified.
func (m *Manager) CommandList() []string {
m.mu.RLock()
defer m.mu.RUnlock()
var out []string
for plug, cmds := range m.commands {
for cmd := range cmds {
out = append(out, plug+" "+cmd)
}
}
return out
}
// commandTimeout caps how long a plugin command handler may run before we
// return an error to the IPC client. Five minutes is generous — enough for
// yt-dlp downloads — while ensuring the socket client doesn't hang forever.
const commandTimeout = 5 * time.Minute
func errCommandNotFound(plug, cmd string) error {
return &commandError{msg: "no such plugin command: " + plug + " " + cmd}
}
func errCommandTimeout(plug, cmd string) error {
return &commandError{msg: "plugin command timed out: " + plug + " " + cmd}
}
type commandError struct{ msg string }
func (e *commandError) Error() string { return e.msg }
func luaValueToString(v lua.LValue) string {
if v == lua.LNil || v == lua.LFalse {
return ""
}
return v.String()
}
// registerCommandAPI attaches :command() to the plugin object. Unlike keymap,
// commands don't need a permission — they're user-initiated from the shell.
func (m *Manager) registerCommandAPI(L *lua.LState, obj *lua.LTable, p *Plugin) {
// p:command(name, fn) — fn(args) -> optional result string
L.SetField(obj, "command", L.NewFunction(func(L *lua.LState) int {
name := strings.TrimSpace(L.CheckString(2))
fn := L.CheckFunction(3)
if name == "" {
L.ArgError(2, "empty command name")
return 0
}
m.mu.Lock()
if m.commands[p.Name] == nil {
m.commands[p.Name] = make(map[string]*luaHook)
}
m.commands[p.Name][name] = &luaHook{plugin: p, fn: fn}
m.mu.Unlock()
return 0
}))
}
+229
View File
@@ -0,0 +1,229 @@
package luaplugin
import (
"strings"
"sync/atomic"
"testing"
"time"
lua "github.com/yuin/gopher-lua"
)
func TestPluginBindAndEmit(t *testing.T) {
m := newTestManager()
m.SetReservedKeys(map[string]bool{"q": true, "ctrl+c": true})
var fired atomic.Int64
p := loadTestPlugin(t, m, "kb", `
local p = plugin.register({name = "kb", type = "hook", permissions = {"keymap"}})
_G.result = p:bind("X", function(key) bump() end)
`)
if p == nil {
t.Fatal("plugin failed to load")
}
p.mu.Lock()
p.L.SetGlobal("bump", p.L.NewFunction(func(L *lua.LState) int {
fired.Add(1)
return 0
}))
p.mu.Unlock()
if ok := m.EmitKey("x"); !ok {
t.Fatal("EmitKey returned false for bound key")
}
waitAtomic(t, &fired, 1, 2*time.Second)
}
func TestPluginBindRejectsReservedKey(t *testing.T) {
m := newTestManager()
m.SetReservedKeys(map[string]bool{"q": true})
p := loadTestPlugin(t, m, "kb", `
local p = plugin.register({name = "kb", type = "hook", permissions = {"keymap"}})
_G.ok, _G.err = p:bind("q", function() end)
`)
if p == nil {
t.Fatal("plugin failed to load")
}
p.mu.Lock()
defer p.mu.Unlock()
if p.L.GetGlobal("ok") != lua.LFalse {
t.Fatal("expected ok=false for reserved key")
}
if s := p.L.GetGlobal("err").String(); !strings.Contains(s, "reserved") {
t.Fatalf("err = %q, want reserved", s)
}
}
func TestPluginBindRequiresKeymapPermission(t *testing.T) {
m := newTestManager()
p := loadTestPlugin(t, m, "kb", `
local p = plugin.register({name = "kb", type = "hook"})
_G.ok, _G.err = p:bind("x", function() end)
`)
if p == nil {
t.Fatal("plugin failed to load")
}
p.mu.Lock()
defer p.mu.Unlock()
if p.L.GetGlobal("ok") != lua.LFalse {
t.Fatal("expected ok=false without permission")
}
}
func TestEmitKeyUnboundReturnsFalse(t *testing.T) {
m := newTestManager()
if m.EmitKey("nothing-bound-here") {
t.Fatal("EmitKey should return false when nothing is bound")
}
}
func TestPluginUnbind(t *testing.T) {
m := newTestManager()
m.SetReservedKeys(map[string]bool{})
p := loadTestPlugin(t, m, "kb", `
local p = plugin.register({name = "kb", type = "hook", permissions = {"keymap"}})
p:bind("x", function() end)
p:unbind("x")
`)
if p == nil {
t.Fatal("plugin failed to load")
}
if m.EmitKey("x") {
t.Fatal("key should be unbound")
}
}
func TestPluginBindWithDescriptionSurfacesInKeyBindings(t *testing.T) {
m := newTestManager()
m.SetReservedKeys(map[string]bool{})
loadTestPlugin(t, m, "kb", `
local p = plugin.register({name = "kb", type = "hook", permissions = {"keymap"}})
p:bind("x", "Extract chapters", function() end)
p:bind("y", function() end) -- no description → not surfaced
`)
bindings := m.KeyBindings()
if len(bindings) != 1 {
t.Fatalf("KeyBindings() returned %d entries, want 1: %+v", len(bindings), bindings)
}
if bindings[0].Key != "x" || bindings[0].Plugin != "kb" || bindings[0].Description != "Extract chapters" {
t.Fatalf("unexpected binding: %+v", bindings[0])
}
}
func TestKeyBindingsRemovedOnCleanup(t *testing.T) {
m := newTestManager()
m.SetReservedKeys(map[string]bool{})
p := loadTestPlugin(t, m, "kb", `
local p = plugin.register({name = "kb", type = "hook", permissions = {"keymap"}})
p:bind("x", "Do thing", function() end)
`)
if p == nil {
t.Fatal("plugin failed to load")
}
if len(m.KeyBindings()) != 1 {
t.Fatal("expected 1 binding before cleanup")
}
m.cleanupPlugin(p)
if len(m.KeyBindings()) != 0 {
t.Fatal("expected 0 bindings after cleanup")
}
}
func TestCleanupPluginRemovesBinds(t *testing.T) {
m := newTestManager()
m.SetReservedKeys(map[string]bool{})
p := loadTestPlugin(t, m, "kb", `
local p = plugin.register({name = "kb", type = "hook", permissions = {"keymap"}})
p:bind("x", function() end)
`)
if p == nil {
t.Fatal("plugin failed to load")
}
if !m.EmitKey("x") {
t.Fatal("expected x to be bound")
}
m.cleanupPlugin(p)
if m.EmitKey("x") {
t.Fatal("bind should be removed when plugin is cleaned up")
}
}
func TestCommandRegisterAndEmit(t *testing.T) {
m := newTestManager()
p := loadTestPlugin(t, m, "cmd", `
local p = plugin.register({name = "cmd", type = "hook"})
p:command("hello", function(args)
return "hi " .. (args[1] or "world")
end)
`)
if p == nil {
t.Fatal("plugin failed to load")
}
out, err := m.EmitCommand("cmd", "hello", []string{"friend"})
if err != nil {
t.Fatal(err)
}
if out != "hi friend" {
t.Fatalf("output = %q, want %q", out, "hi friend")
}
}
func TestCommandNotFound(t *testing.T) {
m := newTestManager()
_, err := m.EmitCommand("nope", "nope", nil)
if err == nil {
t.Fatal("expected error for unknown command")
}
if !strings.Contains(err.Error(), "no such") {
t.Fatalf("err = %q", err)
}
}
func TestCommandListIncludesRegistered(t *testing.T) {
m := newTestManager()
loadTestPlugin(t, m, "cmd", `
local p = plugin.register({name = "cmd", type = "hook"})
p:command("a", function() end)
p:command("b", function() end)
`)
list := m.CommandList()
if len(list) != 2 {
t.Fatalf("expected 2 commands, got %d: %v", len(list), list)
}
}
func TestCleanupPluginRemovesCommands(t *testing.T) {
m := newTestManager()
p := loadTestPlugin(t, m, "cmd", `
local p = plugin.register({name = "cmd", type = "hook"})
p:command("a", function() end)
`)
if p == nil {
t.Fatal("plugin failed to load")
}
m.cleanupPlugin(p)
if len(m.CommandList()) != 0 {
t.Fatal("commands should be removed when plugin is cleaned up")
}
}
func waitAtomic(t *testing.T, counter *atomic.Int64, target int64, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if counter.Load() >= target {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("counter reached %d, want %d", counter.Load(), target)
}
+64
View File
@@ -0,0 +1,64 @@
package luaplugin
import (
"fmt"
"os"
"sync"
"time"
lua "github.com/yuin/gopher-lua"
)
// pluginLogger writes plugin log messages to ~/.config/cliamp/plugins.log.
type pluginLogger struct {
mu sync.Mutex
path string
f *os.File
}
func newPluginLogger(path string) *pluginLogger {
return &pluginLogger{path: path}
}
func (l *pluginLogger) log(plugin, level, format string, args ...any) {
if l == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
if l.f == nil {
f, err := os.OpenFile(l.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return
}
l.f = f
}
msg := fmt.Sprintf(format, args...)
ts := time.Now().Format("2006-01-02 15:04:05")
fmt.Fprintf(l.f, "%s [%s] %s: %s\n", ts, plugin, level, msg)
}
func (l *pluginLogger) close() {
if l == nil || l.f == nil {
return
}
l.mu.Lock()
l.f.Close()
l.f = nil
l.mu.Unlock()
}
// registerLogAPI adds cliamp.log.{info,warn,error,debug} to the cliamp table.
func registerLogAPI(L *lua.LState, cliamp *lua.LTable, logger *pluginLogger, pluginName string) {
tbl := L.NewTable()
for _, level := range []string{"info", "warn", "error", "debug"} {
L.SetField(tbl, level, L.NewFunction(func(L *lua.LState) int {
msg := L.CheckString(1)
logger.log(pluginName, level, "%s", msg)
return 0
}))
}
L.SetField(cliamp, "log", tbl)
}
+30
View File
@@ -0,0 +1,30 @@
package luaplugin
import (
"time"
lua "github.com/yuin/gopher-lua"
)
// messageMaxDuration caps how long a plugin-supplied status message can stay on
// screen. Plugins can request longer durations, but they get clamped so a
// runaway script cannot pin the status bar indefinitely.
const messageMaxDuration = 60 * time.Second
// registerMessageAPI adds cliamp.message(text, duration_secs?) which displays a
// temporary message in the status bar at the bottom of the UI. A missing or
// non-positive duration falls back to the default status TTL (set by the UI).
func registerMessageAPI(L *lua.LState, cliamp *lua.LTable, ui *UIProvider) {
L.SetField(cliamp, "message", L.NewFunction(func(L *lua.LState) int {
if ui.ShowMessage == nil {
return 0
}
text := L.CheckString(1)
var dur time.Duration
if secs := float64(L.OptNumber(2, 0)); secs > 0 {
dur = min(time.Duration(secs*float64(time.Second)), messageMaxDuration)
}
ui.ShowMessage(text, dur)
return 0
}))
}
+81
View File
@@ -0,0 +1,81 @@
package luaplugin
import (
"testing"
"time"
)
func TestMessageAPIDeliversTextAndDuration(t *testing.T) {
m := newTestManager()
var gotText string
var gotDur time.Duration
m.SetUIProvider(UIProvider{
ShowMessage: func(text string, duration time.Duration) {
gotText = text
gotDur = duration
},
})
loadTestPlugin(t, m, "msg-test", `
plugin.register({name = "msg-test", type = "hook"})
cliamp.message("Scrobble Sent", 2)
`)
if gotText != "Scrobble Sent" {
t.Fatalf("text = %q, want %q", gotText, "Scrobble Sent")
}
if gotDur != 2*time.Second {
t.Fatalf("duration = %v, want 2s", gotDur)
}
}
func TestMessageAPIDefaultsDurationToZero(t *testing.T) {
m := newTestManager()
var gotDur time.Duration
seen := false
m.SetUIProvider(UIProvider{
ShowMessage: func(_ string, duration time.Duration) {
gotDur = duration
seen = true
},
})
loadTestPlugin(t, m, "msg-default", `
plugin.register({name = "msg-default", type = "hook"})
cliamp.message("hello")
`)
if !seen {
t.Fatal("ShowMessage was not called")
}
if gotDur != 0 {
t.Fatalf("duration = %v, want 0 (UI decides default)", gotDur)
}
}
func TestMessageAPIClampsMaxDuration(t *testing.T) {
m := newTestManager()
var gotDur time.Duration
m.SetUIProvider(UIProvider{
ShowMessage: func(_ string, duration time.Duration) { gotDur = duration },
})
loadTestPlugin(t, m, "msg-clamp", `
plugin.register({name = "msg-clamp", type = "hook"})
cliamp.message("long", 9999)
`)
if gotDur != messageMaxDuration {
t.Fatalf("duration = %v, want %v (clamped)", gotDur, messageMaxDuration)
}
}
func TestMessageAPIWithoutProviderIsNoop(t *testing.T) {
m := newTestManager()
// No SetUIProvider call — ShowMessage is nil.
loadTestPlugin(t, m, "msg-noop", `
plugin.register({name = "msg-noop", type = "hook"})
cliamp.message("nobody listening")
`)
// Success = no panic / no error from loadPlugin above.
}
+34
View File
@@ -0,0 +1,34 @@
package luaplugin
import (
"os/exec"
lua "github.com/yuin/gopher-lua"
)
// registerNotifyAPI adds cliamp.notify(title, body) which sends a desktop
// notification via notify-send. Safe alternative to os.execute for this
// specific use case.
func registerNotifyAPI(L *lua.LState, cliamp *lua.LTable, logger *pluginLogger, pluginName string) {
L.SetField(cliamp, "notify", L.NewFunction(func(L *lua.LState) int {
title := L.CheckString(1)
body := L.OptString(2, "")
args := []string{title}
if body != "" {
args = append(args, body)
}
path, err := exec.LookPath("notify-send")
if err != nil {
logger.log(pluginName, "warn", "notify-send not found: %v", err)
return 0
}
cmd := exec.Command(path, args...)
if err := cmd.Run(); err != nil {
logger.log(pluginName, "error", "notify-send failed: %v", err)
}
return 0
}))
}
+103
View File
@@ -0,0 +1,103 @@
package luaplugin
import lua "github.com/yuin/gopher-lua"
// registerPlayerAPI adds the read-only cliamp.player.* table.
func registerPlayerAPI(L *lua.LState, cliamp *lua.LTable, state *StateProvider) {
tbl := L.NewTable()
// cliamp.player.state() -> "playing" | "paused" | "stopped"
L.SetField(tbl, "state", L.NewFunction(func(L *lua.LState) int {
if state.PlayerState != nil {
L.Push(lua.LString(state.PlayerState()))
} else {
L.Push(lua.LString("stopped"))
}
return 1
}))
// cliamp.player.position() -> number (seconds)
L.SetField(tbl, "position", L.NewFunction(func(L *lua.LState) int {
if state.Position != nil {
L.Push(lua.LNumber(state.Position()))
} else {
L.Push(lua.LNumber(0))
}
return 1
}))
// cliamp.player.duration() -> number (seconds)
L.SetField(tbl, "duration", L.NewFunction(func(L *lua.LState) int {
if state.Duration != nil {
L.Push(lua.LNumber(state.Duration()))
} else {
L.Push(lua.LNumber(0))
}
return 1
}))
// cliamp.player.volume() -> number (dB)
L.SetField(tbl, "volume", L.NewFunction(func(L *lua.LState) int {
if state.Volume != nil {
L.Push(lua.LNumber(state.Volume()))
} else {
L.Push(lua.LNumber(0))
}
return 1
}))
// cliamp.player.speed() -> number (ratio)
L.SetField(tbl, "speed", L.NewFunction(func(L *lua.LState) int {
if state.Speed != nil {
L.Push(lua.LNumber(state.Speed()))
} else {
L.Push(lua.LNumber(1))
}
return 1
}))
// cliamp.player.mono() -> boolean
L.SetField(tbl, "mono", L.NewFunction(func(L *lua.LState) int {
if state.Mono != nil {
L.Push(lua.LBool(state.Mono()))
} else {
L.Push(lua.LFalse)
}
return 1
}))
// cliamp.player.repeat_mode() -> "off" | "all" | "one"
L.SetField(tbl, "repeat_mode", L.NewFunction(func(L *lua.LState) int {
if state.RepeatMode != nil {
L.Push(lua.LString(state.RepeatMode()))
} else {
L.Push(lua.LString("off"))
}
return 1
}))
// cliamp.player.shuffle() -> boolean
L.SetField(tbl, "shuffle", L.NewFunction(func(L *lua.LState) int {
if state.Shuffle != nil {
L.Push(lua.LBool(state.Shuffle()))
} else {
L.Push(lua.LFalse)
}
return 1
}))
// cliamp.player.eq_bands() -> table of 10 dB values
L.SetField(tbl, "eq_bands", L.NewFunction(func(L *lua.LState) int {
t := L.NewTable()
if state.EQBands != nil {
bands := state.EQBands()
for i, b := range bands {
t.RawSetInt(i+1, lua.LNumber(b))
}
}
L.Push(t)
return 1
}))
L.SetField(cliamp, "player", tbl)
}
+104
View File
@@ -0,0 +1,104 @@
package luaplugin
import lua "github.com/yuin/gopher-lua"
// registerQueueAPI adds cliamp.queue.* to the cliamp table.
//
// Reads (list/count/current) need no permission and pull from the StateProvider.
// Mutators (add/jump/remove/move) require permissions = {"control"} and route
// through the ControlProvider, which dispatches them onto the UI loop.
//
// All indices are 0-based, matching cliamp.queue.current().
func registerQueueAPI(L *lua.LState, cliamp *lua.LTable, state *StateProvider, ctrl *ControlProvider, p *Plugin, logger *pluginLogger) {
tbl := L.NewTable()
// cliamp.queue.list() -> array of {title, artist, album, path, index, queued}
L.SetField(tbl, "list", L.NewFunction(func(L *lua.LState) int {
out := L.NewTable()
if state.QueueList != nil {
for i, e := range state.QueueList() {
row := L.NewTable()
row.RawSetString("title", lua.LString(e.Title))
row.RawSetString("artist", lua.LString(e.Artist))
row.RawSetString("album", lua.LString(e.Album))
row.RawSetString("path", lua.LString(e.Path))
row.RawSetString("index", lua.LNumber(e.Index))
row.RawSetString("queued", lua.LBool(e.Queued))
out.RawSetInt(i+1, row)
}
}
L.Push(out)
return 1
}))
// cliamp.queue.count() -> number of tracks
L.SetField(tbl, "count", L.NewFunction(func(L *lua.LState) int {
n := 0
if state.PlaylistCount != nil {
n = state.PlaylistCount()
}
L.Push(lua.LNumber(n))
return 1
}))
// cliamp.queue.current() -> 0-based index of the current track
L.SetField(tbl, "current", L.NewFunction(func(L *lua.LState) int {
idx := 0
if state.CurrentIndex != nil {
idx = state.CurrentIndex()
}
L.Push(lua.LNumber(idx))
return 1
}))
warned := false
guard := func(name string) bool {
if !p.perms[PermControl] {
if !warned {
logger.log(p.Name, "warn", "queue.%s requires permissions = {\"control\"} — further warnings suppressed", name)
warned = true
}
return false
}
return true
}
// cliamp.queue.add(path) — resolve a file/dir/URL and append to the playlist.
L.SetField(tbl, "add", L.NewFunction(func(L *lua.LState) int {
path := L.CheckString(1)
if guard("add") && ctrl.QueueAdd != nil {
ctrl.QueueAdd(path)
}
return 0
}))
// cliamp.queue.jump(index) — make index the current track and play it.
L.SetField(tbl, "jump", L.NewFunction(func(L *lua.LState) int {
index := L.CheckInt(1)
if guard("jump") && ctrl.QueueJump != nil {
ctrl.QueueJump(index)
}
return 0
}))
// cliamp.queue.remove(index) — remove the track at index.
L.SetField(tbl, "remove", L.NewFunction(func(L *lua.LState) int {
index := L.CheckInt(1)
if guard("remove") && ctrl.QueueRemove != nil {
ctrl.QueueRemove(index)
}
return 0
}))
// cliamp.queue.move(from, to) — reorder a track.
L.SetField(tbl, "move", L.NewFunction(func(L *lua.LState) int {
from := L.CheckInt(1)
to := L.CheckInt(2)
if guard("move") && ctrl.QueueMove != nil {
ctrl.QueueMove(from, to)
}
return 0
}))
L.SetField(cliamp, "queue", tbl)
}
+140
View File
@@ -0,0 +1,140 @@
package luaplugin
import (
"testing"
lua "github.com/yuin/gopher-lua"
)
// newQueueState builds an LState with cliamp.queue registered against the given
// providers and permission set. logger is a discard logger.
func newQueueState(t *testing.T, state *StateProvider, ctrl *ControlProvider, perms map[string]bool) *lua.LState {
t.Helper()
L := lua.NewState()
t.Cleanup(L.Close)
cliamp := L.NewTable()
p := &Plugin{Name: "q", perms: perms}
registerQueueAPI(L, cliamp, state, ctrl, p, newPluginLogger(""))
L.SetGlobal("cliamp", cliamp)
return L
}
func TestQueueReads(t *testing.T) {
state := &StateProvider{
PlaylistCount: func() int { return 3 },
CurrentIndex: func() int { return 1 },
QueueList: func() []QueueEntry {
return []QueueEntry{
{Title: "A", Artist: "X", Path: "/a.mp3", Index: 0, Queued: false},
{Title: "B", Artist: "Y", Path: "/b.mp3", Index: 1, Queued: true},
}
},
}
L := newQueueState(t, state, &ControlProvider{}, nil)
if err := L.DoString(`
_G.count = cliamp.queue.count()
_G.cur = cliamp.queue.current()
local list = cliamp.queue.list()
_G.n = #list
_G.title2 = list[2].title
_G.queued2 = list[2].queued
_G.idx1 = list[1].index
`); err != nil {
t.Fatal(err)
}
if got := float64(L.GetGlobal("count").(lua.LNumber)); got != 3 {
t.Errorf("count = %v", got)
}
if got := float64(L.GetGlobal("cur").(lua.LNumber)); got != 1 {
t.Errorf("current = %v", got)
}
if got := float64(L.GetGlobal("n").(lua.LNumber)); got != 2 {
t.Errorf("list len = %v", got)
}
if got := L.GetGlobal("title2").String(); got != "B" {
t.Errorf("list[2].title = %q", got)
}
if got := bool(L.GetGlobal("queued2").(lua.LBool)); !got {
t.Errorf("list[2].queued = %v", got)
}
if got := float64(L.GetGlobal("idx1").(lua.LNumber)); got != 0 {
t.Errorf("list[1].index = %v (want 0-based)", got)
}
}
func TestQueueMutatorsRequireControl(t *testing.T) {
var calls []string
ctrl := &ControlProvider{
QueueAdd: func(string) { calls = append(calls, "add") },
QueueJump: func(int) { calls = append(calls, "jump") },
QueueRemove: func(int) { calls = append(calls, "remove") },
QueueMove: func(int, int) { calls = append(calls, "move") },
}
// Without the control permission, every mutator is a no-op.
L := newQueueState(t, &StateProvider{}, ctrl, nil)
if err := L.DoString(`
cliamp.queue.add("/x.mp3")
cliamp.queue.jump(2)
cliamp.queue.remove(0)
cliamp.queue.move(1, 0)
`); err != nil {
t.Fatal(err)
}
if len(calls) != 0 {
t.Fatalf("mutators ran without control permission: %v", calls)
}
// With the control permission, they dispatch to the provider.
calls = nil
L2 := newQueueState(t, &StateProvider{}, ctrl, map[string]bool{PermControl: true})
if err := L2.DoString(`
cliamp.queue.add("/x.mp3")
cliamp.queue.jump(2)
cliamp.queue.remove(0)
cliamp.queue.move(1, 0)
`); err != nil {
t.Fatal(err)
}
want := []string{"add", "jump", "remove", "move"}
if len(calls) != len(want) {
t.Fatalf("calls = %v, want %v", calls, want)
}
for i := range want {
if calls[i] != want[i] {
t.Fatalf("calls = %v, want %v", calls, want)
}
}
}
func TestQueueMutatorArgsForwarded(t *testing.T) {
var (
gotPath string
gotJump int
gotFrom, to int
)
ctrl := &ControlProvider{
QueueAdd: func(p string) { gotPath = p },
QueueJump: func(i int) { gotJump = i },
QueueMove: func(f, t int) { gotFrom, to = f, t },
}
L := newQueueState(t, &StateProvider{}, ctrl, map[string]bool{PermControl: true})
if err := L.DoString(`
cliamp.queue.add("https://example.com/song.mp3")
cliamp.queue.jump(5)
cliamp.queue.move(3, 1)
`); err != nil {
t.Fatal(err)
}
if gotPath != "https://example.com/song.mp3" {
t.Errorf("add path = %q", gotPath)
}
if gotJump != 5 {
t.Errorf("jump index = %d", gotJump)
}
if gotFrom != 3 || to != 1 {
t.Errorf("move = (%d,%d), want (3,1)", gotFrom, to)
}
}
+183
View File
@@ -0,0 +1,183 @@
package luaplugin
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"sync"
lua "github.com/yuin/gopher-lua"
"github.com/bjarneo/cliamp/internal/appdir"
)
// pluginStore is a per-plugin key/value store persisted as a single JSON file
// at ~/.local/share/cliamp/plugins/<name>/store.json. Values round-trip through
// the same Lua<->JSON conversion as cliamp.json, so tables, numbers, strings,
// and booleans all survive a restart.
//
// The store is scoped to one plugin: a plugin can never read another plugin's
// keys, which preserves the no-inter-plugin-communication invariant.
type pluginStore struct {
mu sync.Mutex
path string // store.json path; empty if the data dir is unavailable
data map[string]any // in-memory state, lazily loaded on first access
loaded bool
}
// newPluginStore resolves the store path for a plugin. A failure to resolve the
// data dir yields a store with an empty path: reads return nil and writes are
// silently dropped, so plugins degrade gracefully when there is no home dir.
func newPluginStore(pluginName string) *pluginStore {
s := &pluginStore{data: map[string]any{}}
if dir, err := appdir.DataDir(); err == nil {
s.path = filepath.Join(dir, "plugins", pluginName, "store.json")
}
return s
}
// load reads the JSON file into memory on first access. Caller must hold s.mu.
func (s *pluginStore) load() {
if s.loaded {
return
}
s.loaded = true
if s.path == "" {
return
}
data, err := os.ReadFile(s.path)
if err != nil {
return // missing file (first run) or unreadable — start empty
}
var m map[string]any
if json.Unmarshal(data, &m) == nil && m != nil {
s.data = m
}
}
// save writes the in-memory state back to disk. Caller must hold s.mu.
//
// The store may hold plugin credentials (API keys, tokens), so the directory
// and file are owner-only (0o700/0o600). The write is atomic: a temp file in
// the same directory is renamed into place so a crash mid-write can never leave
// a truncated or partially written store.
func (s *pluginStore) save() error {
if s.path == "" {
return nil
}
dir := filepath.Dir(s.path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
data, err := json.Marshal(s.data)
if err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".store-*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // no-op once renamed; cleans up on any error path
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
return err
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, s.path)
}
// registerStoreAPI adds cliamp.store.{get,set,delete,keys,clear} to the cliamp
// table, backed by a per-plugin JSON file. No permission is required: a plugin
// can only ever touch its own namespace.
func registerStoreAPI(L *lua.LState, cliamp *lua.LTable, pluginName string) {
store := newPluginStore(pluginName)
tbl := L.NewTable()
// cliamp.store.get(key) -> value or nil
L.SetField(tbl, "get", L.NewFunction(func(L *lua.LState) int {
key := L.CheckString(1)
store.mu.Lock()
store.load()
v, ok := store.data[key]
store.mu.Unlock()
if !ok {
L.Push(lua.LNil)
return 1
}
L.Push(jsonToLua(L, v))
return 1
}))
// cliamp.store.set(key, value) -> true or (nil, error)
L.SetField(tbl, "set", L.NewFunction(func(L *lua.LState) int {
key := L.CheckString(1)
val := luaToGo(L.CheckAny(2))
store.mu.Lock()
store.load()
store.data[key] = val
err := store.save()
store.mu.Unlock()
return pushStoreResult(L, err)
}))
// cliamp.store.delete(key) -> true or (nil, error)
L.SetField(tbl, "delete", L.NewFunction(func(L *lua.LState) int {
key := L.CheckString(1)
store.mu.Lock()
store.load()
delete(store.data, key)
err := store.save()
store.mu.Unlock()
return pushStoreResult(L, err)
}))
// cliamp.store.keys() -> array of keys (sorted for stable iteration)
L.SetField(tbl, "keys", L.NewFunction(func(L *lua.LState) int {
store.mu.Lock()
store.load()
keys := make([]string, 0, len(store.data))
for k := range store.data {
keys = append(keys, k)
}
store.mu.Unlock()
sort.Strings(keys)
out := L.NewTable()
for i, k := range keys {
out.RawSetInt(i+1, lua.LString(k))
}
L.Push(out)
return 1
}))
// cliamp.store.clear() -> true or (nil, error)
L.SetField(tbl, "clear", L.NewFunction(func(L *lua.LState) int {
store.mu.Lock()
store.data = map[string]any{}
store.loaded = true
err := store.save()
store.mu.Unlock()
return pushStoreResult(L, err)
}))
L.SetField(cliamp, "store", tbl)
}
// pushStoreResult pushes true on success or (nil, error) on failure, matching
// the convention used by cliamp.fs.
func pushStoreResult(L *lua.LState, err error) int {
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
L.Push(lua.LTrue)
return 1
}
+125
View File
@@ -0,0 +1,125 @@
package luaplugin
import (
"testing"
lua "github.com/yuin/gopher-lua"
)
// newStoreState returns an LState with cliamp.store registered for pluginName.
func newStoreState(t *testing.T, pluginName string) *lua.LState {
t.Helper()
L := lua.NewState()
t.Cleanup(L.Close)
cliamp := L.NewTable()
registerStoreAPI(L, cliamp, pluginName)
L.SetGlobal("cliamp", cliamp)
return L
}
func TestStoreSetGetRoundTrip(t *testing.T) {
t.Setenv("HOME", t.TempDir())
L := newStoreState(t, "rt")
if err := L.DoString(`
cliamp.store.set("str", "hello")
cliamp.store.set("num", 42)
cliamp.store.set("flag", true)
cliamp.store.set("tbl", {a = 1, b = {2, 3}})
_G.s = cliamp.store.get("str")
_G.n = cliamp.store.get("num")
_G.f = cliamp.store.get("flag")
_G.t_a = cliamp.store.get("tbl").a
_G.t_b2 = cliamp.store.get("tbl").b[2]
_G.missing = cliamp.store.get("nope")
`); err != nil {
t.Fatal(err)
}
if got := L.GetGlobal("s").String(); got != "hello" {
t.Errorf("str = %q", got)
}
if got := float64(L.GetGlobal("n").(lua.LNumber)); got != 42 {
t.Errorf("num = %v", got)
}
if got := bool(L.GetGlobal("f").(lua.LBool)); !got {
t.Errorf("flag = %v", got)
}
if got := float64(L.GetGlobal("t_a").(lua.LNumber)); got != 1 {
t.Errorf("tbl.a = %v", got)
}
if got := float64(L.GetGlobal("t_b2").(lua.LNumber)); got != 3 {
t.Errorf("tbl.b[2] = %v", got)
}
if L.GetGlobal("missing") != lua.LNil {
t.Errorf("missing key should be nil, got %v", L.GetGlobal("missing"))
}
}
func TestStorePersistsAcrossInstances(t *testing.T) {
t.Setenv("HOME", t.TempDir())
L1 := newStoreState(t, "persist")
if err := L1.DoString(`cliamp.store.set("count", 7)`); err != nil {
t.Fatal(err)
}
// Fresh LState + fresh store object for the same plugin name reads from disk.
L2 := newStoreState(t, "persist")
if err := L2.DoString(`_G.v = cliamp.store.get("count")`); err != nil {
t.Fatal(err)
}
if got := float64(L2.GetGlobal("v").(lua.LNumber)); got != 7 {
t.Fatalf("persisted count = %v, want 7", got)
}
}
func TestStoreNamespaceIsolation(t *testing.T) {
t.Setenv("HOME", t.TempDir())
La := newStoreState(t, "plugin-a")
if err := La.DoString(`cliamp.store.set("secret", "a-only")`); err != nil {
t.Fatal(err)
}
Lb := newStoreState(t, "plugin-b")
if err := Lb.DoString(`_G.v = cliamp.store.get("secret")`); err != nil {
t.Fatal(err)
}
if Lb.GetGlobal("v") != lua.LNil {
t.Fatalf("plugin-b read plugin-a key: %v", Lb.GetGlobal("v"))
}
}
func TestStoreKeysAndClear(t *testing.T) {
t.Setenv("HOME", t.TempDir())
L := newStoreState(t, "kc")
if err := L.DoString(`
cliamp.store.set("b", 1)
cliamp.store.set("a", 2)
cliamp.store.set("c", 3)
_G.keys = cliamp.store.keys()
cliamp.store.delete("b")
_G.afterDelete = #cliamp.store.keys()
cliamp.store.clear()
_G.afterClear = #cliamp.store.keys()
`); err != nil {
t.Fatal(err)
}
keys := L.GetGlobal("keys").(*lua.LTable)
if keys.Len() != 3 {
t.Fatalf("keys len = %d, want 3", keys.Len())
}
// keys() is sorted.
if keys.RawGetInt(1).String() != "a" || keys.RawGetInt(3).String() != "c" {
t.Errorf("keys not sorted: %v, %v", keys.RawGetInt(1), keys.RawGetInt(3))
}
if got := float64(L.GetGlobal("afterDelete").(lua.LNumber)); got != 2 {
t.Errorf("after delete = %v, want 2", got)
}
if got := float64(L.GetGlobal("afterClear").(lua.LNumber)); got != 0 {
t.Errorf("after clear = %v, want 0", got)
}
}
+188
View File
@@ -0,0 +1,188 @@
package luaplugin
import (
"sync"
"sync/atomic"
"time"
lua "github.com/yuin/gopher-lua"
)
type timerEntry struct {
id int64
plugin *Plugin
ticker *time.Ticker
timer *time.Timer
done chan struct{}
}
type timerManager struct {
mu sync.Mutex
timers map[int64]*timerEntry
nextID atomic.Int64
}
func newTimerManager() *timerManager {
return &timerManager{
timers: make(map[int64]*timerEntry),
}
}
func (tm *timerManager) add(e *timerEntry) {
tm.mu.Lock()
tm.timers[e.id] = e
tm.mu.Unlock()
}
func (tm *timerManager) cancel(id int64) {
tm.mu.Lock()
e, ok := tm.timers[id]
if ok {
close(e.done)
if e.ticker != nil {
e.ticker.Stop()
}
if e.timer != nil {
e.timer.Stop()
}
delete(tm.timers, id)
}
tm.mu.Unlock()
}
func (tm *timerManager) stopAll() {
tm.mu.Lock()
for id, e := range tm.timers {
close(e.done)
if e.ticker != nil {
e.ticker.Stop()
}
if e.timer != nil {
e.timer.Stop()
}
delete(tm.timers, id)
}
tm.mu.Unlock()
}
func (tm *timerManager) stopPlugin(p *Plugin) {
tm.mu.Lock()
for id, e := range tm.timers {
if e.plugin != p {
continue
}
close(e.done)
if e.ticker != nil {
e.ticker.Stop()
}
if e.timer != nil {
e.timer.Stop()
}
delete(tm.timers, id)
}
tm.mu.Unlock()
}
func (tm *timerManager) take(id int64) bool {
tm.mu.Lock()
defer tm.mu.Unlock()
if _, ok := tm.timers[id]; !ok {
return false
}
delete(tm.timers, id)
return true
}
func (tm *timerManager) active(id int64) bool {
tm.mu.Lock()
defer tm.mu.Unlock()
_, ok := tm.timers[id]
return ok
}
// registerTimerAPI adds cliamp.timer.{after,every,cancel} to the cliamp table.
// p is the owning plugin whose mutex protects all LState calls.
func registerTimerAPI(L *lua.LState, cliamp *lua.LTable, tm *timerManager, p *Plugin) {
tbl := L.NewTable()
// cliamp.timer.after(secs, callback) -> id
L.SetField(tbl, "after", L.NewFunction(func(L *lua.LState) int {
secs := L.CheckNumber(1)
fn := L.CheckFunction(2)
id := tm.nextID.Add(1)
d := time.Duration(float64(secs) * float64(time.Second))
t := time.NewTimer(d)
e := &timerEntry{id: id, plugin: p, timer: t, done: make(chan struct{})}
tm.add(e)
go func() {
select {
case <-t.C:
p.mu.Lock()
if !tm.take(id) {
p.mu.Unlock()
return
}
_ = p.callBounded(0, fn)
p.mu.Unlock()
case <-e.done:
}
}()
L.Push(lua.LNumber(id))
return 1
}))
// cliamp.timer.every(secs, callback) -> id
L.SetField(tbl, "every", L.NewFunction(func(L *lua.LState) int {
secs := L.CheckNumber(1)
fn := L.CheckFunction(2)
id := tm.nextID.Add(1)
d := time.Duration(float64(secs) * float64(time.Second))
ticker := time.NewTicker(d)
e := &timerEntry{id: id, plugin: p, ticker: ticker, done: make(chan struct{})}
tm.add(e)
go func() {
for {
select {
case <-ticker.C:
p.mu.Lock()
if !tm.active(id) {
p.mu.Unlock()
return
}
_ = p.callBounded(0, fn)
p.mu.Unlock()
case <-e.done:
return
}
}
}()
L.Push(lua.LNumber(id))
return 1
}))
// cliamp.timer.cancel(id)
L.SetField(tbl, "cancel", L.NewFunction(func(L *lua.LState) int {
id := L.CheckInt64(1)
tm.cancel(id)
return 0
}))
L.SetField(cliamp, "timer", tbl)
}
// registerSleepAPI adds cliamp.sleep(secs) — a blocking sleep.
// Note: this blocks the plugin's Lua VM, so other hooks for the same
// plugin will be queued until the sleep completes. Max 10 seconds.
func registerSleepAPI(L *lua.LState, cliamp *lua.LTable) {
L.SetField(cliamp, "sleep", L.NewFunction(func(L *lua.LState) int {
secs := float64(L.CheckNumber(1))
if secs > 0 && secs <= 10 {
time.Sleep(time.Duration(secs * float64(time.Second)))
}
return 0
}))
}
+91
View File
@@ -0,0 +1,91 @@
package luaplugin
import lua "github.com/yuin/gopher-lua"
// registerTrackAPI adds the read-only cliamp.track.* table.
func registerTrackAPI(L *lua.LState, cliamp *lua.LTable, state *StateProvider) {
tbl := L.NewTable()
L.SetField(tbl, "title", L.NewFunction(func(L *lua.LState) int {
if state.TrackTitle != nil {
L.Push(lua.LString(state.TrackTitle()))
} else {
L.Push(lua.LString(""))
}
return 1
}))
L.SetField(tbl, "artist", L.NewFunction(func(L *lua.LState) int {
if state.TrackArtist != nil {
L.Push(lua.LString(state.TrackArtist()))
} else {
L.Push(lua.LString(""))
}
return 1
}))
L.SetField(tbl, "album", L.NewFunction(func(L *lua.LState) int {
if state.TrackAlbum != nil {
L.Push(lua.LString(state.TrackAlbum()))
} else {
L.Push(lua.LString(""))
}
return 1
}))
L.SetField(tbl, "genre", L.NewFunction(func(L *lua.LState) int {
if state.TrackGenre != nil {
L.Push(lua.LString(state.TrackGenre()))
} else {
L.Push(lua.LString(""))
}
return 1
}))
L.SetField(tbl, "year", L.NewFunction(func(L *lua.LState) int {
if state.TrackYear != nil {
L.Push(lua.LNumber(state.TrackYear()))
} else {
L.Push(lua.LNumber(0))
}
return 1
}))
L.SetField(tbl, "track_number", L.NewFunction(func(L *lua.LState) int {
if state.TrackNumber != nil {
L.Push(lua.LNumber(state.TrackNumber()))
} else {
L.Push(lua.LNumber(0))
}
return 1
}))
L.SetField(tbl, "path", L.NewFunction(func(L *lua.LState) int {
if state.TrackPath != nil {
L.Push(lua.LString(state.TrackPath()))
} else {
L.Push(lua.LString(""))
}
return 1
}))
L.SetField(tbl, "is_stream", L.NewFunction(func(L *lua.LState) int {
if state.TrackIsStream != nil {
L.Push(lua.LBool(state.TrackIsStream()))
} else {
L.Push(lua.LFalse)
}
return 1
}))
L.SetField(tbl, "duration_secs", L.NewFunction(func(L *lua.LState) int {
if state.TrackDuration != nil {
L.Push(lua.LNumber(state.TrackDuration()))
} else {
L.Push(lua.LNumber(0))
}
return 1
}))
L.SetField(cliamp, "track", tbl)
}
+17
View File
@@ -0,0 +1,17 @@
//go:build !windows
package luaplugin
import "os"
func minimalExecEnv() []string {
path := os.Getenv("PATH")
if path == "" {
path = "/usr/local/bin:/usr/bin:/bin"
}
return []string{
"PATH=" + path,
"HOME=" + homeEnv(),
"LANG=C.UTF-8",
}
}
+22
View File
@@ -0,0 +1,22 @@
//go:build windows
package luaplugin
import "os"
func minimalExecEnv() []string {
home := homeEnv()
env := []string{
"PATH=" + os.Getenv("PATH"),
"HOME=" + home,
"USERPROFILE=" + home,
}
// Pass through the Windows variables subprocesses commonly need; skip
// any that are unset.
for _, key := range []string{"APPDATA", "LOCALAPPDATA", "ComSpec", "PATHEXT", "SystemRoot", "WINDIR", "TEMP", "TMP"} {
if v := os.Getenv(key); v != "" {
env = append(env, key+"="+v)
}
}
return env
}
+182
View File
@@ -0,0 +1,182 @@
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))
}
}
+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
}
+654
View File
@@ -0,0 +1,654 @@
package luaplugin
import (
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"time"
lua "github.com/yuin/gopher-lua"
)
// newTestManager returns a Manager ready for testing (no disk I/O).
func newTestManager() *Manager {
return &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(defaultAllowedBinaries),
}
}
// loadTestPlugin writes a Lua script to a temp file, loads it into the manager,
// and appends it to m.plugins if registration succeeded.
func loadTestPlugin(t *testing.T, m *Manager, name, code string) *Plugin {
return loadTestPluginWithConfig(t, m, name, code, nil)
}
func loadTestPluginWithConfig(t *testing.T, m *Manager, name, code string, cfg map[string]string) *Plugin {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, name+".lua")
if err := os.WriteFile(path, []byte(code), 0o644); err != nil {
t.Fatal(err)
}
p, err := m.loadPlugin(path, name, cfg)
if err != nil {
t.Fatalf("loadPlugin(%s): %v", name, err)
}
if p != nil {
m.plugins = append(m.plugins, p)
}
return p
}
func loadTestPluginExpectError(t *testing.T, m *Manager, name, code string) {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, name+".lua")
if err := os.WriteFile(path, []byte(code), 0o644); err != nil {
t.Fatal(err)
}
if _, err := m.loadPlugin(path, name, nil); err == nil {
t.Fatalf("expected error for %s", name)
}
}
func TestLoadPluginRegistersHookPlugin(t *testing.T) {
m := newTestManager()
p := loadTestPlugin(t, m, "test-hook", `
local p = plugin.register({
name = "test-hook",
type = "hook",
version = "1.0",
description = "a test plugin",
})
p:on("track.change", function(data) end)
`)
if p == nil {
t.Fatal("plugin is nil")
}
if p.Name != "test-hook" {
t.Fatalf("Name = %q, want %q", p.Name, "test-hook")
}
if p.Type != "hook" {
t.Fatalf("Type = %q, want %q", p.Type, "hook")
}
if p.Version != "1.0" {
t.Fatalf("Version = %q, want %q", p.Version, "1.0")
}
if len(m.hooks["track.change"]) != 1 {
t.Fatalf("hooks[track.change] = %d, want 1", len(m.hooks["track.change"]))
}
}
func TestLoadPluginWithoutRegisterReturnsNil(t *testing.T) {
m := newTestManager()
p := loadTestPlugin(t, m, "no-register", `-- does nothing`)
if p != nil {
t.Fatalf("expected nil plugin for script without register, got %+v", p)
}
}
func TestLoadPluginSyntaxError(t *testing.T) {
m := newTestManager()
dir := t.TempDir()
path := filepath.Join(dir, "bad.lua")
os.WriteFile(path, []byte(`this is not valid lua!!!`), 0o644)
_, err := m.loadPlugin(path, "bad", nil)
if err == nil {
t.Fatal("expected error for invalid Lua syntax")
}
}
func TestLoadPluginCleanupStopsPendingTimers(t *testing.T) {
cases := []struct {
name string
expectErr bool
code string
}{
{
name: "without register",
code: `
cliamp.timer.after(0.01, function()
cliamp.fs.write(%q, "fired")
end)
cliamp.sleep(0.05)
`,
},
{
name: "every",
code: `
cliamp.timer.every(0.01, function()
cliamp.fs.write(%q, "fired")
end)
cliamp.sleep(0.05)
`,
},
{
name: "on load error",
expectErr: true,
code: `
local p = plugin.register({name = "bad", type = "hook"})
cliamp.timer.after(0.01, function()
cliamp.fs.write(%q, "fired")
end)
cliamp.sleep(0.05)
error("boom")
`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := newTestManager()
path := filepath.Join(t.TempDir(), "fired")
code := fmt.Sprintf(tc.code, path)
if tc.expectErr {
loadTestPluginExpectError(t, m, "bad", code)
} else {
p := loadTestPlugin(t, m, "no-register-expired-timer", code)
if p != nil {
t.Fatalf("expected nil plugin for script without register, got %+v", p)
}
}
time.Sleep(50 * time.Millisecond)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("timer callback wrote %q after cleanup, err=%v", path, err)
}
})
}
}
func TestLoadPluginErrorRemovesHooks(t *testing.T) {
m := newTestManager()
loadTestPluginExpectError(t, m, "bad", `
local p = plugin.register({name = "bad", type = "hook"})
p:on("track.change", function() end)
error("boom")
`)
if len(m.hooks["track.change"]) != 0 {
t.Fatalf("hooks[track.change] = %d, want 0", len(m.hooks["track.change"]))
}
}
func TestLoadVisualizerErrorRemovesVisualizer(t *testing.T) {
m := newTestManager()
loadTestPluginExpectError(t, m, "bad", `
plugin.register({name = "bad", type = "visualizer"})
error("boom")
`)
if len(m.visPlugs) != 0 {
t.Fatalf("visualizer count = %d, want 0", len(m.visPlugs))
}
if _, ok := m.visMap["bad"]; ok {
t.Fatal("expected visualizer to be removed from visMap")
}
}
func TestPluginConfig(t *testing.T) {
m := newTestManager()
p := loadTestPluginWithConfig(t, m, "cfg", `
local p = plugin.register({name = "cfg", type = "hook"})
_G.got_url = p:config("url")
_G.got_missing = p:config("missing")
`, map[string]string{"url": "https://example.com"})
gotURL := p.L.GetGlobal("got_url")
if gotURL.String() != "https://example.com" {
t.Fatalf("config('url') = %q, want %q", gotURL.String(), "https://example.com")
}
gotMissing := p.L.GetGlobal("got_missing")
if gotMissing != lua.LNil {
t.Fatalf("config('missing') = %v, want nil", gotMissing)
}
}
func TestPluginPermissions(t *testing.T) {
m := newTestManager()
p := loadTestPlugin(t, m, "perm", `
plugin.register({
name = "perm",
type = "hook",
permissions = {"control"},
})
`)
if !p.perms["control"] {
t.Fatal("perms[control] = false, want true")
}
}
func TestEmitSync(t *testing.T) {
m := newTestManager()
loadTestPlugin(t, m, "sync-test", `
local p = plugin.register({name = "sync-test", type = "hook"})
_G.events = {}
p:on("test.event", function(data)
table.insert(_G.events, data.msg)
end)
`)
m.EmitSync("test.event", map[string]any{"msg": "hello"})
p := m.plugins[0]
events := p.L.GetGlobal("events").(*lua.LTable)
if events.Len() != 1 {
t.Fatalf("events length = %d, want 1", events.Len())
}
if events.RawGetInt(1).String() != "hello" {
t.Fatalf("events[1] = %q, want %q", events.RawGetInt(1).String(), "hello")
}
}
func TestEmitAsync(t *testing.T) {
m := newTestManager()
loadTestPlugin(t, m, "async-test", `
local p = plugin.register({name = "async-test", type = "hook"})
_G.called = false
p:on("test.event", function(data)
_G.called = true
end)
`)
m.Emit("test.event", nil)
// Wait for async handler to complete.
time.Sleep(100 * time.Millisecond)
p := m.plugins[0]
p.mu.Lock()
called := p.L.GetGlobal("called")
p.mu.Unlock()
if called != lua.LTrue {
t.Fatal("async event handler was not called")
}
}
func TestEmitMultipleHooks(t *testing.T) {
m := newTestManager()
loadTestPlugin(t, m, "multi", `
local p = plugin.register({name = "multi", type = "hook"})
_G.count = 0
p:on("test.event", function() _G.count = _G.count + 1 end)
p:on("test.event", function() _G.count = _G.count + 10 end)
`)
m.EmitSync("test.event", nil)
p := m.plugins[0]
count := p.L.GetGlobal("count")
if count.(lua.LNumber) != 11 {
t.Fatalf("count = %v, want 11", count)
}
}
func TestPluginCountAndHasHooks(t *testing.T) {
m := newTestManager()
if m.PluginCount() != 0 {
t.Fatalf("PluginCount() = %d, want 0", m.PluginCount())
}
if m.HasHooks() {
t.Fatal("HasHooks() = true, want false")
}
loadTestPlugin(t, m, "counter", `
local p = plugin.register({name = "counter", type = "hook"})
p:on("app.start", function() end)
`)
if m.PluginCount() != 1 {
t.Fatalf("PluginCount() = %d, want 1", m.PluginCount())
}
if !m.HasHooks() {
t.Fatal("HasHooks() = false, want true")
}
}
func TestClose(t *testing.T) {
m := newTestManager()
loadTestPlugin(t, m, "close-test", `
local p = plugin.register({name = "close-test", type = "hook"})
_G.quit = false
p:on("app.quit", function() _G.quit = true end)
`)
m.Close()
// After Close, the LState is shut down. We can't safely query it,
// but we verified it doesn't panic.
}
func TestManagerWithStateProvider(t *testing.T) {
m := newTestManager()
m.SetStateProvider(StateProvider{
PlayerState: func() string { return "playing" },
Volume: func() float64 { return -3.5 },
TrackTitle: func() string { return "Angel" },
TrackArtist: func() string { return "Massive Attack" },
})
p := loadTestPlugin(t, m, "state-test", `
local p = plugin.register({name = "state-test", type = "hook"})
_G.state = cliamp.player.state()
_G.vol = cliamp.player.volume()
_G.title = cliamp.track.title()
_G.artist = cliamp.track.artist()
`)
if p.L.GetGlobal("state").String() != "playing" {
t.Fatalf("state = %q, want %q", p.L.GetGlobal("state").String(), "playing")
}
if float64(p.L.GetGlobal("vol").(lua.LNumber)) != -3.5 {
t.Fatalf("vol = %v, want -3.5", p.L.GetGlobal("vol"))
}
if p.L.GetGlobal("title").String() != "Angel" {
t.Fatalf("title = %q", p.L.GetGlobal("title").String())
}
if p.L.GetGlobal("artist").String() != "Massive Attack" {
t.Fatalf("artist = %q", p.L.GetGlobal("artist").String())
}
}
func TestManagerWithControlProvider(t *testing.T) {
m := newTestManager()
var gotVol float64
m.SetControlProvider(ControlProvider{
SetVolume: func(db float64) { gotVol = db },
})
loadTestPlugin(t, m, "ctrl-test", `
plugin.register({
name = "ctrl-test",
type = "hook",
permissions = {"control"},
})
cliamp.player.set_volume(-10)
`)
if gotVol != -10 {
t.Fatalf("SetVolume called with %v, want -10", gotVol)
}
}
func TestControlClampsBounds(t *testing.T) {
m := newTestManager()
var gotVol float64
var gotSpeed float64
m.SetControlProvider(ControlProvider{
SetVolume: func(db float64) { gotVol = db },
SetSpeed: func(r float64) { gotSpeed = r },
})
loadTestPlugin(t, m, "clamp-test", `
plugin.register({
name = "clamp-test",
type = "hook",
permissions = {"control"},
})
cliamp.player.set_volume(100)
cliamp.player.set_speed(10)
`)
if gotVol != 6 {
t.Fatalf("set_volume(100) clamped to %v, want 6", gotVol)
}
if gotSpeed != 2.0 {
t.Fatalf("set_speed(10) clamped to %v, want 2.0", gotSpeed)
}
}
func TestControlWithoutPermissionIsNoop(t *testing.T) {
m := newTestManager()
m.logger = newPluginLogger(filepath.Join(t.TempDir(), "test.log"))
t.Cleanup(m.Close)
called := false
m.SetControlProvider(ControlProvider{
SetVolume: func(db float64) { called = true },
})
loadTestPlugin(t, m, "no-perm", `
plugin.register({name = "no-perm", type = "hook"})
cliamp.player.set_volume(-10)
`)
if called {
t.Fatal("SetVolume was called without control permission")
}
}
func TestStateProviderDefaultsWhenNil(t *testing.T) {
m := newTestManager()
// No state provider set — all callbacks are nil.
p := loadTestPlugin(t, m, "defaults", `
local p = plugin.register({name = "defaults", type = "hook"})
_G.state = cliamp.player.state()
_G.vol = cliamp.player.volume()
_G.speed = cliamp.player.speed()
_G.pos = cliamp.player.position()
_G.title = cliamp.track.title()
`)
if p.L.GetGlobal("state").String() != "stopped" {
t.Fatalf("default state = %q, want 'stopped'", p.L.GetGlobal("state").String())
}
if float64(p.L.GetGlobal("vol").(lua.LNumber)) != 0 {
t.Fatalf("default vol = %v, want 0", p.L.GetGlobal("vol"))
}
if float64(p.L.GetGlobal("speed").(lua.LNumber)) != 1 {
t.Fatalf("default speed = %v, want 1", p.L.GetGlobal("speed"))
}
if p.L.GetGlobal("title").String() != "" {
t.Fatalf("default title = %q, want empty", p.L.GetGlobal("title").String())
}
}
func TestTimerAfter(t *testing.T) {
m := newTestManager()
p := loadTestPlugin(t, m, "timer-test", `
local p = plugin.register({name = "timer-test", type = "hook"})
_G.fired = false
cliamp.timer.after(0.05, function()
_G.fired = true
end)
`)
time.Sleep(150 * time.Millisecond)
p.mu.Lock()
fired := p.L.GetGlobal("fired")
p.mu.Unlock()
if fired != lua.LTrue {
t.Fatal("timer.after callback was not fired")
}
}
func TestTimerCancel(t *testing.T) {
m := newTestManager()
p := loadTestPlugin(t, m, "cancel-test", `
local p = plugin.register({name = "cancel-test", type = "hook"})
_G.fired = false
local id = cliamp.timer.after(0.2, function()
_G.fired = true
end)
cliamp.timer.cancel(id)
`)
time.Sleep(300 * time.Millisecond)
p.mu.Lock()
fired := p.L.GetGlobal("fired")
p.mu.Unlock()
if fired == lua.LTrue {
t.Fatal("timer.after callback fired after cancel")
}
}
func TestVisualizerPlugin(t *testing.T) {
m := newTestManager()
loadTestPlugin(t, m, "test-vis", `
local v = plugin.register({name = "test-vis", type = "visualizer"})
_G.init_called = false
_G.destroy_called = false
v.init = function(self, rows, cols)
_G.init_called = true
_G.init_rows = rows
end
v.render = function(self, bands, frame, rows, cols)
return "frame-" .. tostring(frame)
end
v.destroy = function(self)
_G.destroy_called = true
end
`)
m.finalizeVisualizers()
names := m.Visualizers()
if len(names) != 1 || names[0] != "test-vis" {
t.Fatalf("Visualizers() = %v, want [test-vis]", names)
}
m.InitVis("test-vis", 8, 40)
vis := m.visMap["test-vis"]
vis.plugin.mu.Lock()
initCalled := vis.plugin.L.GetGlobal("init_called")
vis.plugin.mu.Unlock()
if initCalled != lua.LTrue {
t.Fatal("init callback was not called")
}
got := m.RenderVis("test-vis", [10]float64{}, 8, 40, 42)
if got != "frame-42" {
t.Fatalf("RenderVis() = %q, want %q", got, "frame-42")
}
m.DestroyVis("test-vis")
vis.plugin.mu.Lock()
destroyCalled := vis.plugin.L.GetGlobal("destroy_called")
vis.plugin.mu.Unlock()
if destroyCalled != lua.LTrue {
t.Fatal("destroy callback was not called")
}
}
func TestRenderVisReusesLastOnError(t *testing.T) {
m := newTestManager()
loadTestPlugin(t, m, "err-vis", `
local v = plugin.register({name = "err-vis", type = "visualizer"})
local calls = 0
v.render = function(self, bands, frame, rows, cols)
calls = calls + 1
if calls == 2 then
error("boom")
end
return "ok-" .. tostring(calls)
end
`)
m.finalizeVisualizers()
got1 := m.RenderVis("err-vis", [10]float64{}, 8, 40, 1)
if got1 != "ok-1" {
t.Fatalf("frame 1 = %q, want %q", got1, "ok-1")
}
// Frame 2 errors — should reuse last frame.
got2 := m.RenderVis("err-vis", [10]float64{}, 8, 40, 2)
if got2 != "ok-1" {
t.Fatalf("frame 2 (error) = %q, want %q (reused)", got2, "ok-1")
}
}
func TestDataToTableConversion(t *testing.T) {
L := lua.NewState()
defer L.Close()
tbl := dataToTable(L, map[string]any{
"str": "hello",
"num": 42,
"float": 3.14,
"bool": true,
"nil": nil,
})
if tbl.RawGetString("str").String() != "hello" {
t.Fatalf("str = %v", tbl.RawGetString("str"))
}
if float64(tbl.RawGetString("num").(lua.LNumber)) != 42 {
t.Fatalf("num = %v", tbl.RawGetString("num"))
}
if float64(tbl.RawGetString("float").(lua.LNumber)) != 3.14 {
t.Fatalf("float = %v", tbl.RawGetString("float"))
}
if tbl.RawGetString("bool") != lua.LTrue {
t.Fatalf("bool = %v", tbl.RawGetString("bool"))
}
if tbl.RawGetString("nil") != lua.LNil {
t.Fatalf("nil = %v", tbl.RawGetString("nil"))
}
}
func TestDataToTableNested(t *testing.T) {
L := lua.NewState()
defer L.Close()
tbl := dataToTable(L, map[string]any{
"nested": map[string]any{"key": "value"},
"floats": []float64{1.0, 2.0, 3.0},
})
nested := tbl.RawGetString("nested").(*lua.LTable)
if nested.RawGetString("key").String() != "value" {
t.Fatalf("nested.key = %v", nested.RawGetString("key"))
}
floats := tbl.RawGetString("floats").(*lua.LTable)
if float64(floats.RawGetInt(1).(lua.LNumber)) != 1.0 {
t.Fatalf("floats[1] = %v", floats.RawGetInt(1))
}
if floats.Len() != 3 {
t.Fatalf("floats length = %d, want 3", floats.Len())
}
}
func TestConcurrentEmitSafety(t *testing.T) {
m := newTestManager()
p := loadTestPlugin(t, m, "concurrent", `
local p = plugin.register({name = "concurrent", type = "hook"})
_G.count = 0
p:on("inc", function() _G.count = _G.count + 1 end)
`)
var wg sync.WaitGroup
for range 20 {
wg.Go(func() {
m.Emit("inc", nil)
})
}
wg.Wait()
time.Sleep(200 * time.Millisecond)
p.mu.Lock()
count := float64(p.L.GetGlobal("count").(lua.LNumber))
p.mu.Unlock()
if count != 20 {
t.Fatalf("count after 20 concurrent emits = %v, want 20", count)
}
}
+71
View File
@@ -0,0 +1,71 @@
package luaplugin
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/bjarneo/cliamp/internal/plugintrust"
)
// TestBundledPluginsLoad is the backward-compatibility guard for the plugin
// API. It loads every first-party plugin shipped in the repo's plugins/
// directory through a real Manager and asserts they all register cleanly.
//
// Any change that renames or removes an existing cliamp.* function, event, or
// permission will break one of these plugins and fail here. Keep it green by
// only ever ADDING to the plugin surface, never altering the existing shape.
func TestBundledPluginsLoad(t *testing.T) {
srcDir := filepath.Join("..", "plugins")
entries, err := os.ReadDir(srcDir)
if err != nil {
t.Fatalf("read bundled plugins dir: %v", err)
}
var luaFiles []string
for _, e := range entries {
if !e.IsDir() && filepath.Ext(e.Name()) == ".lua" {
luaFiles = append(luaFiles, e.Name())
}
}
if len(luaFiles) == 0 {
t.Fatal("no bundled .lua plugins found — expected at least one")
}
// Seed an isolated HOME so appdir.PluginDir() resolves into a temp tree.
home := t.TempDir()
t.Setenv("HOME", home)
pluginDir := filepath.Join(home, ".config", "cliamp", "plugins")
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatalf("mkdir plugin dir: %v", err)
}
for _, name := range luaFiles {
data, err := os.ReadFile(filepath.Join(srcDir, name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
if err := os.WriteFile(filepath.Join(pluginDir, name), data, 0o644); err != nil {
t.Fatalf("copy %s: %v", name, err)
}
pluginName := strings.TrimSuffix(name, ".lua")
if _, err := plugintrust.Approve(pluginDir, pluginName, filepath.Join(pluginDir, name)); err != nil {
t.Fatalf("approve %s: %v", name, err)
}
}
mgr, err := New(nil)
if err != nil {
t.Fatalf("bundled plugins failed to load (API compatibility broken): %v", err)
}
defer mgr.Close()
if got, want := mgr.PluginCount(), len(luaFiles); got != want {
t.Fatalf("loaded %d plugins, want %d (a bundled plugin failed to register)", got, want)
}
for _, p := range mgr.plugins {
if p.Type == "" {
t.Errorf("plugin %q registered with empty type", p.Name)
}
}
}
+50
View File
@@ -0,0 +1,50 @@
package luaplugin
import lua "github.com/yuin/gopher-lua"
// Sandbox applies the plugin sandbox to L. It is exported for tools that load
// plugin files outside the runtime (e.g. pluginmgr metadata extraction) and
// must apply the same stdlib restrictions before executing untrusted code.
func Sandbox(L *lua.LState) { sandbox(L) }
// sandbox removes dangerous Lua standard library functions from the VM,
// leaving only safe operations available to plugins. It also adds
// compatibility helpers missing from Lua 5.1 (e.g. utf8.char).
func sandbox(L *lua.LState) {
// Remove top-level functions that can load/execute arbitrary code.
for _, name := range []string{"dofile", "loadfile", "load", "loadstring", "require", "module"} {
L.SetGlobal(name, lua.LNil)
}
L.SetGlobal("package", lua.LNil)
L.SetGlobal("debug", lua.LNil)
// Remove the io module entirely (replaced by cliamp.fs).
L.SetGlobal("io", lua.LNil)
// Restrict the os module to a safe subset: time, date, clock, getenv.
if os := L.GetGlobal("os"); os != lua.LNil {
if tbl, ok := os.(*lua.LTable); ok {
for _, fn := range []string{"execute", "remove", "rename", "exit", "setlocale", "tmpname"} {
tbl.RawSetString(fn, lua.LNil)
}
}
}
// Provide utf8.char() for Lua 5.1 compatibility (GopherLua is 5.1).
// Plugins need this for Braille character rendering in visualizers.
utf8Tbl := L.NewTable()
L.SetField(utf8Tbl, "char", L.NewFunction(luaUTF8Char))
L.SetGlobal("utf8", utf8Tbl)
}
// luaUTF8Char converts one or more integer codepoints to a UTF-8 string.
// Equivalent to Lua 5.3's utf8.char().
func luaUTF8Char(L *lua.LState) int {
n := L.GetTop()
buf := make([]rune, n)
for i := 1; i <= n; i++ {
buf[i-1] = rune(L.CheckInt(i))
}
L.Push(lua.LString(string(buf)))
return 1
}
+75
View File
@@ -0,0 +1,75 @@
package luaplugin
import (
"testing"
lua "github.com/yuin/gopher-lua"
)
func TestSandboxBlocksDofile(t *testing.T) {
L := lua.NewState()
defer L.Close()
sandbox(L)
if L.GetGlobal("dofile") != lua.LNil {
t.Fatal("dofile should be nil after sandbox")
}
}
func TestSandboxBlocksLoadfile(t *testing.T) {
L := lua.NewState()
defer L.Close()
sandbox(L)
if L.GetGlobal("loadfile") != lua.LNil {
t.Fatal("loadfile should be nil after sandbox")
}
}
func TestSandboxRemovesIOModule(t *testing.T) {
L := lua.NewState()
defer L.Close()
sandbox(L)
if L.GetGlobal("io") != lua.LNil {
t.Fatal("io module should be nil after sandbox")
}
}
func TestSandboxRestrictsOS(t *testing.T) {
L := lua.NewState()
defer L.Close()
sandbox(L)
os := L.GetGlobal("os").(*lua.LTable)
blocked := []string{"execute", "remove", "rename", "exit", "setlocale", "tmpname"}
for _, name := range blocked {
if os.RawGetString(name) != lua.LNil {
t.Errorf("os.%s should be nil after sandbox", name)
}
}
// Safe functions should remain.
allowed := []string{"time", "date", "clock"}
for _, name := range allowed {
if os.RawGetString(name) == lua.LNil {
t.Errorf("os.%s should still be available after sandbox", name)
}
}
}
func TestSandboxProvidesUTF8Char(t *testing.T) {
L := lua.NewState()
defer L.Close()
sandbox(L)
err := L.DoString(`_G.result = utf8.char(72, 101, 108, 108, 111)`)
if err != nil {
t.Fatal(err)
}
if got := L.GetGlobal("result").String(); got != "Hello" {
t.Fatalf("utf8.char(72,101,108,108,111) = %q, want %q", got, "Hello")
}
}
+12
View File
@@ -0,0 +1,12 @@
package luaplugin
import (
"os"
"testing"
)
func TestMain(m *testing.M) {
os.Unsetenv("CLIAMP_CONFIG_DIR")
os.Unsetenv("XDG_CONFIG_HOME")
os.Exit(m.Run())
}
+124
View File
@@ -0,0 +1,124 @@
package luaplugin
import (
lua "github.com/yuin/gopher-lua"
)
// luaVis wraps a Lua visualizer plugin, caching function references
// for render() and optional init()/destroy() callbacks.
type luaVis struct {
name string
plugin *Plugin // owns the LState and mutex
obj *lua.LTable
render *lua.LFunction
init *lua.LFunction
destroy *lua.LFunction
last string // previous frame output (reused on error)
}
// registerVisPlugin is called during plugin.register() for type="visualizer".
func (m *Manager) registerVisPlugin(L *lua.LState, obj *lua.LTable, p *Plugin) {
vis := &luaVis{
name: p.Name,
plugin: p,
obj: obj,
}
m.mu.Lock()
m.visPlugs = append(m.visPlugs, vis)
m.visMap[p.Name] = vis
m.mu.Unlock()
}
// finalizeVisualizers is called after all plugins are loaded to resolve
// render/init/destroy function references from the plugin objects.
func (m *Manager) finalizeVisualizers() {
for _, vis := range m.visPlugs {
if fn, ok := vis.obj.RawGetString("render").(*lua.LFunction); ok {
vis.render = fn
}
if fn, ok := vis.obj.RawGetString("init").(*lua.LFunction); ok {
vis.init = fn
}
if fn, ok := vis.obj.RawGetString("destroy").(*lua.LFunction); ok {
vis.destroy = fn
}
}
}
// Visualizers returns the names of all Lua visualizer plugins.
func (m *Manager) Visualizers() []string {
m.mu.RLock()
defer m.mu.RUnlock()
names := make([]string, len(m.visPlugs))
for i, v := range m.visPlugs {
names[i] = v.name
}
return names
}
// InitVis calls a Lua visualizer's init(rows, cols) if it exists.
func (m *Manager) InitVis(name string, rows, cols int) {
m.mu.RLock()
vis, ok := m.visMap[name]
m.mu.RUnlock()
if !ok || vis.init == nil {
return
}
vis.plugin.mu.Lock()
defer vis.plugin.mu.Unlock()
_ = vis.plugin.callBounded(0, vis.init, vis.obj, lua.LNumber(rows), lua.LNumber(cols))
}
// DestroyVis calls a Lua visualizer's destroy() if it exists.
func (m *Manager) DestroyVis(name string) {
m.mu.RLock()
vis, ok := m.visMap[name]
m.mu.RUnlock()
if !ok || vis.destroy == nil {
return
}
vis.plugin.mu.Lock()
defer vis.plugin.mu.Unlock()
_ = vis.plugin.callBounded(0, vis.destroy, vis.obj)
}
// RenderVis calls a Lua visualizer's render(bands, frame) and returns
// the terminal text. On error, the previous frame is reused.
func (m *Manager) RenderVis(name string, bands [10]float64, rows, cols int, frame uint64) string {
m.mu.RLock()
vis, ok := m.visMap[name]
m.mu.RUnlock()
if !ok || vis.render == nil {
return ""
}
vis.plugin.mu.Lock()
defer vis.plugin.mu.Unlock()
L := vis.plugin.L
// Build bands table (1-indexed).
tbl := L.NewTable()
for i, b := range bands {
tbl.RawSetInt(i+1, lua.LNumber(b))
}
err := vis.plugin.callBounded(1, vis.render, vis.obj, tbl, lua.LNumber(frame), lua.LNumber(rows), lua.LNumber(cols))
if err != nil {
return vis.last
}
result := L.Get(-1)
L.Pop(1)
if str, ok := result.(lua.LString); ok {
vis.last = string(str)
return vis.last
}
return vis.last
}