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