chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user