feat(plugins): add retained event pub/sub over IPC (#303)

* feat(ipc): add retained event subscriptions

* feat(plugins): expose namespaced event publishing

* feat(app): wire plugin events to IPC

* docs(plugins): document local event pubsub

* fix(ipc): clear request deadline for subscriptions

* fix(luaplugin): install event publisher before plugins load

* fix(luaplugin): clear retained events after publishers stop

* fix(ipc): keep event sequence gap-free when retention is rejected

* fix(luaplugin): break table cycles and cap depth in Lua value conversion

* docs(plugins): document conversion limits and subscription stream rules

* refactor(ipc): report a missing server with a sentinel error

* fix(luaplugin): fold event namespace into one topic segment

* docs(site): mention plugin event publishing and subscriptions

* fix(luaplugin): keep event namespaces unique across plugins

* fix(luaplugin): release event namespace by installed filename

* refactor(ipc): render the not-running message in the command layer

* docs(plugins): clarify namespace prefixes and JSON conversion limits
This commit is contained in:
Faza Iman Imron
2026-08-18 22:19:28 +07:00
committed by GitHub
parent 35f7339216
commit f373776d4c
21 changed files with 1335 additions and 32 deletions
+1 -1
View File
@@ -801,7 +801,7 @@ func visStreamCommand() *cli.Command {
if fps > 60 {
fps = 60
}
return ipc.StreamBands(ctx, ipc.DefaultSocketPath(), time.Second/time.Duration(fps), os.Stdout)
return userIPCError(ipc.StreamBands(ctx, ipc.DefaultSocketPath(), time.Second/time.Duration(fps), os.Stdout))
},
}
}
+16
View File
@@ -2,6 +2,8 @@ package main
import (
"context"
"errors"
"fmt"
"testing"
"time"
@@ -233,3 +235,17 @@ func TestDaemonClearsHistory(t *testing.T) {
t.Fatalf("history after clear = %#v, err=%v", entries, err)
}
}
// The ipc package returns a bare sentinel; the CLI wording is added here.
func TestUserIPCErrorRendersNotRunning(t *testing.T) {
rendered := userIPCError(fmt.Errorf("dial: %w", ipc.ErrNotRunning))
want := fmt.Sprintf("cliamp is not running (no socket at %s)", ipc.DefaultSocketPath())
if rendered.Error() != want {
t.Errorf("rendered = %q, want %q", rendered.Error(), want)
}
other := errors.New("connect: permission denied")
if got := userIPCError(other); got != other {
t.Errorf("unrelated error rewritten to %v", got)
}
}
+54
View File
@@ -153,6 +153,56 @@ The returned object `p` provides two methods:
|--------|-------------|
| `p:on(event, callback)` | Subscribe to a playback event |
| `p:config(key)` | Read a config value from `[plugins.myplugin]` in config.toml |
| `p:publish(topic, payload, options)` | Publish a namespaced event to local IPC subscribers |
## Plugin event pub/sub
Plugins can publish JSON-compatible values to external programs connected to
Cliamp's owner-only IPC socket. Topics are automatically isolated beneath the
installed plugin name; a plugin installed as `myplugin.lua` publishing
`"playback"` produces the topic `plugin.myplugin.playback`. The name contributes
exactly one topic segment: any character outside letters, digits, `_`, and `-`
becomes `_`, so `my.plugin.lua` publishes under the prefix `plugin.my_plugin.*`
(publishing `"playback"` from it gives `plugin.my_plugin.playback`) and can never
collide with topics from a plugin named `my`. Because that folding is lossy,
namespaces are unique per session: if `my.plugin.lua` and `my_plugin.lua` are
both installed, the first one loaded (plugins load in name order) owns the
`plugin.my_plugin.*` prefix and `p:publish()` in the other returns `nil, err`
naming the owner. Rename one of them to publish from both. Subscribers must name
complete topics, not prefixes.
```lua
p:publish("playback", {
status = cliamp.player.state(),
title = cliamp.track.title(),
}, { retain = true })
```
With `retain = true`, Cliamp keeps the latest value in memory and sends it to
new subscribers immediately. Retained values are discarded when Cliamp exits;
no event data is persisted to disk. Publishing is non-blocking. A subscriber
that cannot keep up is disconnected rather than blocking the player.
Subscribe by opening `cliamp.sock` and sending one NDJSON request:
```json
{"cmd":"subscribe","topics":["plugin.myplugin.playback"]}
```
After `{"ok":true}`, the connection becomes a server-to-client event stream:
```json
{"event":"plugin.myplugin.playback","seq":42,"time":1786685741,"retained":true,"data":{"status":"playing","title":"Track"}}
```
Subscriptions use exact topic matches, accept at most 32 topics, and are
streaming-only; use another IPC connection for ordinary commands. Sending any
further bytes on a subscription closes it. Payloads are limited to 64 KiB and
are converted with the same nesting and cycle rules as
[`cliamp.json`](#cliampjson). Topic segments may contain letters, digits, `.`,
`_`, and `-`. `p:publish()` requires no permission because IPC remains local to
the same user and the existing `status` command already exposes playback
metadata.
## Events
@@ -315,6 +365,10 @@ local tbl = cliamp.json.decode('{"key": "value"}')
local str = cliamp.json.encode({ key = "value" })
```
Tables are encoded up to 64 levels deep; anything deeper, and any cyclic
reference, becomes `null` instead of failing. The same conversion is used by
`p:publish()` and `cliamp.store`.
### cliamp.store
A persistent per-plugin key/value store. Values (strings, numbers, booleans,
+15
View File
@@ -116,6 +116,21 @@ Response format:
{"ok": false, "error": "cliamp is not running"}
```
## Plugin Event Subscriptions
Lua plugins can publish retained or transient events over the same IPC socket.
A subscriber sends one request:
```json
{"cmd":"subscribe","topics":["plugin.myplugin.playback"]}
```
After the `{"ok":true}` acknowledgment, that connection is a streaming-only
NDJSON event feed; sending anything else on it closes the subscription.
Retained events are replayed immediately and exist only in Cliamp memory. See
[Lua Plugins](plugins.md#plugin-event-pubsub) for the `p:publish()` API and
event envelope.
## Socket Details
- **Path**: `~/.config/cliamp/cliamp.sock` (or `%APPDATA%\cliamp\cliamp.sock` on Windows when `HOME` is unset; created on TUI start, removed on shutdown)
+1 -1
View File
@@ -33,7 +33,7 @@ func SendWithDeadline(sockPath string, req Request, deadline time.Duration) (Res
conn, err := dialSocket(sockPath, 3*time.Second)
if err != nil {
if isSocketUnavailable(err) {
return Response{}, fmt.Errorf("cliamp is not running (no socket at %s)", sockPath)
return Response{}, fmt.Errorf("no socket at %s: %w", sockPath, ErrNotRunning)
}
return Response{}, fmt.Errorf("connect: %w", err)
}
+24 -2
View File
@@ -1,6 +1,9 @@
package ipc
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"runtime"
@@ -58,8 +61,27 @@ func TestSendNoServer(t *testing.T) {
if err == nil {
t.Fatal("Send to missing socket should error")
}
if !strings.Contains(err.Error(), "not running") {
t.Errorf("error = %q, want to mention 'not running'", err.Error())
if !errors.Is(err, ErrNotRunning) {
t.Errorf("error = %v, want ErrNotRunning", err)
}
if !strings.Contains(err.Error(), sock) {
t.Errorf("error = %q, want the socket path", err.Error())
}
}
// Every entry point reports a missing server through the same sentinel so
// callers can branch without matching message text.
func TestEntryPointsReportErrNotRunning(t *testing.T) {
sock := filepath.Join(shortTempDir(t), "missing.sock")
if _, err := Send(sock, Request{Cmd: "status"}); !errors.Is(err, ErrNotRunning) {
t.Errorf("Send error = %v, want ErrNotRunning", err)
}
if _, err := Subscribe(sock, []string{"plugin.test.playback"}); !errors.Is(err, ErrNotRunning) {
t.Errorf("Subscribe error = %v, want ErrNotRunning", err)
}
if err := StreamBands(context.Background(), sock, time.Millisecond, io.Discard); !errors.Is(err, ErrNotRunning) {
t.Errorf("StreamBands error = %v, want ErrNotRunning", err)
}
}
+5
View File
@@ -9,6 +9,11 @@ import (
"time"
)
// ErrNotRunning reports that nothing is listening on the IPC socket. It carries
// no CLI wording on purpose: command-layer callers match it with errors.Is and
// render the user-facing message themselves.
var ErrNotRunning = errors.New("no listener on socket")
func dialSocket(sockPath string, timeout time.Duration) (net.Conn, error) {
return net.DialTimeout("unix", sockPath, timeout)
}
+1
View File
@@ -28,6 +28,7 @@ type Request struct {
Limit int `json:"limit,omitempty"`
NewName string `json:"new_name,omitempty"`
Track *TrackInfo `json:"track,omitempty"`
Topics []string `json:"topics,omitempty"`
}
// Response is the JSON response sent by the server.
+221
View File
@@ -0,0 +1,221 @@
package ipc
import (
"encoding/json"
"errors"
"sort"
"strings"
"sync"
"time"
)
const (
maxTopicsPerSubscription = 32
maxRetainedTopics = 256
maxEventPayloadSize = 64 << 10
subscriberBufferSize = 32
)
var (
ErrInvalidTopic = errors.New("invalid event topic")
ErrTooManyTopics = errors.New("too many subscription topics")
ErrPayloadTooLarge = errors.New("event payload exceeds 64 KiB")
ErrTooManyRetained = errors.New("too many retained event topics")
)
// Event is one message delivered over an IPC subscription. Sequence numbers
// are process-local and monotonically increasing. Retained marks events replayed
// to a newly connected subscriber.
type Event struct {
Event string `json:"event"`
Sequence uint64 `json:"seq"`
Time int64 `json:"time"`
Retained bool `json:"retained,omitempty"`
Data json.RawMessage `json:"data"`
}
// Subscription is an in-memory event stream owned by a Broker.
type Subscription struct {
broker *Broker
id uint64
events <-chan Event
once sync.Once
}
func (s *Subscription) Events() <-chan Event { return s.events }
// Close unregisters the subscription. It is safe to call more than once.
func (s *Subscription) Close() {
if s == nil || s.broker == nil {
return
}
s.once.Do(func() { s.broker.unsubscribe(s.id) })
}
type subscriber struct {
topics map[string]struct{}
events chan Event
}
// Broker distributes process-local events and optionally retains the latest
// event per topic. Publish never waits for a subscriber: a slow subscriber is
// disconnected rather than being allowed to block Cliamp or a Lua callback.
type Broker struct {
mu sync.Mutex
nextEvent uint64
nextSub uint64
retained map[string]Event
subscribers map[uint64]*subscriber
closed bool
}
func NewBroker() *Broker {
return &Broker{
retained: make(map[string]Event),
subscribers: make(map[uint64]*subscriber),
}
}
// Publish sends data to current subscribers and, when retain is true, stores
// the latest value in memory for replay to future subscribers.
func (b *Broker) Publish(topic string, data json.RawMessage, retain bool) error {
if !validTopic(topic) {
return ErrInvalidTopic
}
if len(data) > maxEventPayloadSize {
return ErrPayloadTooLarge
}
if !json.Valid(data) {
return errors.New("event payload is not valid JSON")
}
b.mu.Lock()
defer b.mu.Unlock()
if b.closed {
return errors.New("event broker is closed")
}
// Reject before consuming a sequence number so numbering stays gap-free.
if retain {
if _, exists := b.retained[topic]; !exists && len(b.retained) >= maxRetainedTopics {
return ErrTooManyRetained
}
}
b.nextEvent++
event := Event{
Event: topic,
Sequence: b.nextEvent,
Time: time.Now().Unix(),
Data: append(json.RawMessage(nil), data...),
}
if retain {
b.retained[topic] = event
}
for id, sub := range b.subscribers {
if _, ok := sub.topics[topic]; !ok {
continue
}
select {
case sub.events <- event:
default:
delete(b.subscribers, id)
close(sub.events)
}
}
return nil
}
// Subscribe registers an exact-topic subscription and queues retained values
// before any subsequently published events. Topic ordering makes retained
// replay deterministic.
func (b *Broker) Subscribe(topics []string) (*Subscription, error) {
if len(topics) == 0 {
return nil, errors.New("at least one topic is required")
}
if len(topics) > maxTopicsPerSubscription {
return nil, ErrTooManyTopics
}
set := make(map[string]struct{}, len(topics))
for _, topic := range topics {
if !validTopic(topic) {
return nil, ErrInvalidTopic
}
set[topic] = struct{}{}
}
b.mu.Lock()
defer b.mu.Unlock()
if b.closed {
return nil, errors.New("event broker is closed")
}
b.nextSub++
id := b.nextSub
capacity := subscriberBufferSize
if len(set) > capacity {
capacity = len(set)
}
ch := make(chan Event, capacity)
sub := &subscriber{topics: set, events: ch}
b.subscribers[id] = sub
sorted := make([]string, 0, len(set))
for topic := range set {
sorted = append(sorted, topic)
}
sort.Strings(sorted)
for _, topic := range sorted {
if event, ok := b.retained[topic]; ok {
event.Retained = true
ch <- event
}
}
return &Subscription{broker: b, id: id, events: ch}, nil
}
// ClearPrefix removes retained values under a plugin namespace. Live
// subscribers remain connected and will receive future publications.
func (b *Broker) ClearPrefix(prefix string) {
b.mu.Lock()
for topic := range b.retained {
if strings.HasPrefix(topic, prefix) {
delete(b.retained, topic)
}
}
b.mu.Unlock()
}
// Close disconnects all subscribers and rejects future publications and
// subscriptions. It is safe to call more than once.
func (b *Broker) Close() {
b.mu.Lock()
if !b.closed {
b.closed = true
for id, sub := range b.subscribers {
delete(b.subscribers, id)
close(sub.events)
}
clear(b.retained)
}
b.mu.Unlock()
}
func (b *Broker) unsubscribe(id uint64) {
b.mu.Lock()
if sub, ok := b.subscribers[id]; ok {
delete(b.subscribers, id)
close(sub.events)
}
b.mu.Unlock()
}
func validTopic(topic string) bool {
if topic == "" || len(topic) > 256 || strings.HasPrefix(topic, ".") || strings.HasSuffix(topic, ".") || strings.Contains(topic, "..") {
return false
}
for _, r := range topic {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' {
continue
}
return false
}
return true
}
+175
View File
@@ -0,0 +1,175 @@
package ipc
import (
"encoding/json"
"errors"
"fmt"
"testing"
"time"
)
func TestBrokerRetainsAndReplaysLatestEvent(t *testing.T) {
broker := NewBroker()
if err := broker.Publish("plugin.discord-rpc.playback", json.RawMessage(`{"status":"playing"}`), true); err != nil {
t.Fatal(err)
}
if err := broker.Publish("plugin.discord-rpc.playback", json.RawMessage(`{"status":"paused"}`), true); err != nil {
t.Fatal(err)
}
sub, err := broker.Subscribe([]string{"plugin.discord-rpc.playback"})
if err != nil {
t.Fatal(err)
}
defer sub.Close()
select {
case event := <-sub.Events():
if !event.Retained || string(event.Data) != `{"status":"paused"}` || event.Sequence != 2 {
t.Fatalf("retained event = %#v", event)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for retained event")
}
}
func TestBrokerFiltersTopics(t *testing.T) {
broker := NewBroker()
sub, err := broker.Subscribe([]string{"plugin.one.playback"})
if err != nil {
t.Fatal(err)
}
defer sub.Close()
if err := broker.Publish("plugin.two.playback", json.RawMessage(`{}`), false); err != nil {
t.Fatal(err)
}
select {
case event := <-sub.Events():
t.Fatalf("received unrelated event: %#v", event)
case <-time.After(25 * time.Millisecond):
}
if err := broker.Publish("plugin.one.playback", json.RawMessage(`{"ok":true}`), false); err != nil {
t.Fatal(err)
}
select {
case event := <-sub.Events():
if event.Event != "plugin.one.playback" || event.Retained {
t.Fatalf("event = %#v", event)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for event")
}
}
func TestBrokerRejectsInvalidInput(t *testing.T) {
broker := NewBroker()
for _, topic := range []string{"", ".bad", "bad.", "bad..topic", "bad/topic", "bad topic"} {
if err := broker.Publish(topic, json.RawMessage(`{}`), false); !errors.Is(err, ErrInvalidTopic) {
t.Errorf("Publish(%q) error = %v, want ErrInvalidTopic", topic, err)
}
}
if err := broker.Publish("valid.topic", json.RawMessage(`not-json`), false); err == nil {
t.Fatal("expected invalid JSON error")
}
if err := broker.Publish("valid.topic", make(json.RawMessage, maxEventPayloadSize+1), false); !errors.Is(err, ErrPayloadTooLarge) {
t.Fatalf("oversized payload error = %v", err)
}
}
func TestBrokerClearsRetainedNamespace(t *testing.T) {
broker := NewBroker()
_ = broker.Publish("plugin.one.playback", json.RawMessage(`{}`), true)
_ = broker.Publish("plugin.two.playback", json.RawMessage(`{}`), true)
broker.ClearPrefix("plugin.one.")
one, err := broker.Subscribe([]string{"plugin.one.playback"})
if err != nil {
t.Fatal(err)
}
defer one.Close()
select {
case event := <-one.Events():
t.Fatalf("cleared event replayed: %#v", event)
case <-time.After(25 * time.Millisecond):
}
two, err := broker.Subscribe([]string{"plugin.two.playback"})
if err != nil {
t.Fatal(err)
}
defer two.Close()
select {
case <-two.Events():
case <-time.After(time.Second):
t.Fatal("unrelated retained event was removed")
}
}
func TestBrokerCloseDisconnectsAndRejectsWork(t *testing.T) {
broker := NewBroker()
sub, err := broker.Subscribe([]string{"plugin.test.events"})
if err != nil {
t.Fatal(err)
}
broker.Close()
if _, ok := <-sub.Events(); ok {
t.Fatal("subscription remained open")
}
if err := broker.Publish("plugin.test.events", json.RawMessage(`{}`), false); err == nil {
t.Fatal("publish succeeded after close")
}
if _, err := broker.Subscribe([]string{"plugin.test.events"}); err == nil {
t.Fatal("subscribe succeeded after close")
}
broker.Close()
sub.Close()
}
func TestBrokerDisconnectsSlowSubscriber(t *testing.T) {
broker := NewBroker()
sub, err := broker.Subscribe([]string{"plugin.test.events"})
if err != nil {
t.Fatal(err)
}
defer sub.Close()
for i := 0; i < subscriberBufferSize+1; i++ {
if err := broker.Publish("plugin.test.events", json.RawMessage(`{}`), false); err != nil {
t.Fatal(err)
}
}
for range sub.Events() {
}
}
func TestBrokerRejectedRetainDoesNotConsumeSequence(t *testing.T) {
broker := NewBroker()
for i := range maxRetainedTopics {
topic := fmt.Sprintf("plugin.filler.topic%d", i)
if err := broker.Publish(topic, json.RawMessage(`{}`), true); err != nil {
t.Fatal(err)
}
}
if err := broker.Publish("plugin.filler.overflow", json.RawMessage(`{}`), true); !errors.Is(err, ErrTooManyRetained) {
t.Fatalf("retained overflow error = %v, want ErrTooManyRetained", err)
}
sub, err := broker.Subscribe([]string{"plugin.seq.check"})
if err != nil {
t.Fatal(err)
}
defer sub.Close()
if err := broker.Publish("plugin.seq.check", json.RawMessage(`{}`), false); err != nil {
t.Fatal(err)
}
select {
case event := <-sub.Events():
if event.Sequence != uint64(maxRetainedTopics)+1 {
t.Fatalf("sequence = %d, want %d", event.Sequence, maxRetainedTopics+1)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for event")
}
}
+75 -5
View File
@@ -23,12 +23,15 @@ type Dispatcher interface {
Send(msg any)
}
const ipcRequestReadTimeout = 60 * time.Second
// Server listens on a Unix socket and dispatches IPC commands.
type Server struct {
listener net.Listener
sockPath string
disp Dispatcher
plugins PluginDispatcher
broker *Broker
done chan struct{}
wg sync.WaitGroup
@@ -72,9 +75,17 @@ func (s *Server) SetPluginDispatcher(p PluginDispatcher) {
s.plugins = p
}
// NewServer creates and starts the IPC server. It cleans up stale sockets
// before binding. The socket is created with 0600 permissions (owner only).
// NewServer creates and starts the IPC server with a new event broker.
func NewServer(sockPath string, disp Dispatcher) (*Server, error) {
return NewServerWithBroker(sockPath, disp, nil)
}
// NewServerWithBroker creates and starts the IPC server using broker. A new
// broker is allocated when broker is nil.
func NewServerWithBroker(sockPath string, disp Dispatcher, broker *Broker) (*Server, error) {
if broker == nil {
broker = NewBroker()
}
if err := cleanStaleSocket(sockPath); err != nil {
return nil, err
}
@@ -108,6 +119,7 @@ func NewServer(sockPath string, disp Dispatcher) (*Server, error) {
listener: ln,
sockPath: sockPath,
disp: disp,
broker: broker,
done: make(chan struct{}),
conns: make(map[net.Conn]struct{}),
}
@@ -173,7 +185,7 @@ func (s *Server) handleConn(conn net.Conn) {
// Per-request deadline so long-lived streaming clients (e.g. vis bands
// polling) aren't killed at a fixed wall clock, but idle clients still
// time out.
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
conn.SetReadDeadline(time.Now().Add(ipcRequestReadTimeout))
if !scanner.Scan() {
return
}
@@ -188,12 +200,64 @@ func (s *Server) handleConn(conn net.Conn) {
continue
}
if strings.EqualFold(req.Cmd, "subscribe") {
s.streamSubscription(conn, req.Topics)
return
}
resp := s.dispatch(req)
conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
writeResponse(conn, resp)
}
}
func (s *Server) streamSubscription(conn net.Conn, topics []string) {
// handleConn sets a per-request read deadline. Subscriptions are idle,
// server-to-client streams after the initial request, so they must not
// inherit that deadline or they will be closed every request timeout.
if err := conn.SetReadDeadline(time.Time{}); err != nil {
writeResponse(conn, Response{OK: false, Error: "clear subscription deadline: " + err.Error()})
return
}
sub, err := s.broker.Subscribe(topics)
if err != nil {
writeResponse(conn, Response{OK: false, Error: err.Error()})
return
}
defer sub.Close()
conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
if !writeJSONLine(conn, Response{OK: true}) {
return
}
// A subscription is server-to-client after its acknowledgment. Keep a read
// pending solely to detect client disconnects even when no events publish.
peerClosed := make(chan struct{})
go func() {
var one [1]byte
_, _ = conn.Read(one[:])
close(peerClosed)
}()
for {
select {
case <-s.done:
return
case <-peerClosed:
return
case event, ok := <-sub.Events():
if !ok {
return
}
conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
if !writeJSONLine(conn, event) {
return
}
}
}
}
// dispatch handles a single parsed request.
func (s *Server) dispatch(req Request) Response {
switch strings.ToLower(req.Cmd) {
@@ -392,14 +456,20 @@ func (s *Server) handleStatus() Response {
// writeResponse marshals a Response as JSON and writes it followed by a newline.
func writeResponse(conn net.Conn, resp Response) {
data, err := json.Marshal(resp)
_ = writeJSONLine(conn, resp)
}
func writeJSONLine(conn net.Conn, value any) bool {
data, err := json.Marshal(value)
if err != nil {
return
return false
}
data = append(data, '\n')
if _, err := conn.Write(data); err != nil {
applog.Warn("ipc: write response: %v", err)
return false
}
return true
}
// cleanStaleSocket removes a leftover socket and PID file from a dead process.
+1 -1
View File
@@ -20,7 +20,7 @@ func StreamBands(ctx context.Context, sockPath string, interval time.Duration, o
conn, err := dialSocket(sockPath, 3*time.Second)
if err != nil {
if isSocketUnavailable(err) {
return fmt.Errorf("cliamp is not running (no socket at %s)", sockPath)
return fmt.Errorf("no socket at %s: %w", sockPath, ErrNotRunning)
}
return fmt.Errorf("connect: %w", err)
}
+84
View File
@@ -0,0 +1,84 @@
package ipc
import (
"bufio"
"encoding/json"
"fmt"
"net"
"time"
)
// EventStream is a client-side subscription to IPC events.
type EventStream struct {
conn net.Conn
scanner *bufio.Scanner
}
// Subscribe opens a streaming-only IPC connection for exact topic names.
// The caller must close the stream when it is no longer needed.
func Subscribe(sockPath string, topics []string) (*EventStream, error) {
conn, err := dialSocket(sockPath, 3*time.Second)
if err != nil {
if isSocketUnavailable(err) {
return nil, fmt.Errorf("no socket at %s: %w", sockPath, ErrNotRunning)
}
return nil, fmt.Errorf("connect: %w", err)
}
fail := func(err error) (*EventStream, error) {
_ = conn.Close()
return nil, err
}
conn.SetDeadline(time.Now().Add(5 * time.Second))
request, err := json.Marshal(Request{Cmd: "subscribe", Topics: topics})
if err != nil {
return fail(fmt.Errorf("marshal subscribe request: %w", err))
}
if _, err := conn.Write(append(request, '\n')); err != nil {
return fail(fmt.Errorf("write subscribe request: %w", err))
}
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return fail(fmt.Errorf("read subscribe response: %w", err))
}
return fail(fmt.Errorf("no subscribe response from server"))
}
var response Response
if err := json.Unmarshal(scanner.Bytes(), &response); err != nil {
return fail(fmt.Errorf("decode subscribe response: %w", err))
}
if !response.OK {
return fail(fmt.Errorf("subscribe: %s", response.Error))
}
_ = conn.SetDeadline(time.Time{})
return &EventStream{conn: conn, scanner: scanner}, nil
}
// Next blocks until the next event arrives. A returned error means the stream
// ended and should be closed and reconnected.
func (s *EventStream) Next() (Event, error) {
if s == nil || s.conn == nil {
return Event{}, fmt.Errorf("subscription is closed")
}
if !s.scanner.Scan() {
if err := s.scanner.Err(); err != nil {
return Event{}, fmt.Errorf("read event: %w", err)
}
return Event{}, fmt.Errorf("event stream closed")
}
var event Event
if err := json.Unmarshal(s.scanner.Bytes(), &event); err != nil {
return Event{}, fmt.Errorf("decode event: %w", err)
}
return event, nil
}
func (s *EventStream) Close() error {
if s == nil || s.conn == nil {
return nil
}
return s.conn.Close()
}
+145
View File
@@ -0,0 +1,145 @@
package ipc
import (
"bufio"
"encoding/json"
"net"
"path/filepath"
"testing"
"time"
)
func TestSubscribeStreamsRetainedAndLiveEvents(t *testing.T) {
broker := NewBroker()
if err := broker.Publish("plugin.test.playback", json.RawMessage(`{"state":"stopped"}`), true); err != nil {
t.Fatal(err)
}
sock := filepath.Join(shortTempDir(t), "cliamp.sock")
server, err := NewServerWithBroker(sock, &captureDispatcher{}, broker)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = server.Close() })
stream, err := Subscribe(sock, []string{"plugin.test.playback"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = stream.Close() })
retained, err := stream.Next()
if err != nil {
t.Fatal(err)
}
if !retained.Retained || string(retained.Data) != `{"state":"stopped"}` {
t.Fatalf("retained event = %#v", retained)
}
if err := broker.Publish("plugin.test.playback", json.RawMessage(`{"state":"playing"}`), true); err != nil {
t.Fatal(err)
}
live, err := stream.Next()
if err != nil {
t.Fatal(err)
}
if live.Retained || string(live.Data) != `{"state":"playing"}` || live.Sequence <= retained.Sequence {
t.Fatalf("live event = %#v after %#v", live, retained)
}
}
func TestSubscribeRejectsMissingTopics(t *testing.T) {
sock := filepath.Join(shortTempDir(t), "cliamp.sock")
server, err := NewServer(sock, &captureDispatcher{})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = server.Close() })
if _, err := Subscribe(sock, nil); err == nil {
t.Fatal("expected subscription error")
}
}
func TestSubscriptionClearsRequestReadDeadline(t *testing.T) {
serverConn, clientConn := net.Pipe()
broker := NewBroker()
server := &Server{broker: broker, done: make(chan struct{})}
if err := serverConn.SetReadDeadline(time.Now().Add(25 * time.Millisecond)); err != nil {
t.Fatal(err)
}
returned := make(chan struct{})
go func() {
defer close(returned)
defer serverConn.Close()
server.streamSubscription(serverConn, []string{"plugin.test.playback"})
}()
defer clientConn.Close()
scanner := bufio.NewScanner(clientConn)
if !scanner.Scan() {
t.Fatalf("read acknowledgment: %v", scanner.Err())
}
var acknowledgment Response
if err := json.Unmarshal(scanner.Bytes(), &acknowledgment); err != nil || !acknowledgment.OK {
t.Fatalf("acknowledgment = %q, err=%v", scanner.Bytes(), err)
}
// Wait beyond the inherited request deadline, then prove the subscription
// is still alive by delivering a newly published event.
time.Sleep(75 * time.Millisecond)
if err := broker.Publish("plugin.test.playback", json.RawMessage(`{"state":"playing"}`), false); err != nil {
t.Fatal(err)
}
if err := clientConn.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
t.Fatal(err)
}
if !scanner.Scan() {
t.Fatalf("read event after request deadline: %v", scanner.Err())
}
var event Event
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
t.Fatal(err)
}
if event.Event != "plugin.test.playback" {
t.Fatalf("event = %#v", event)
}
_ = clientConn.Close()
select {
case <-returned:
case <-time.After(time.Second):
t.Fatal("subscription did not stop after client close")
}
}
func TestSubscriptionEndsWhenServerCloses(t *testing.T) {
sock := filepath.Join(shortTempDir(t), "cliamp.sock")
server, err := NewServer(sock, &captureDispatcher{})
if err != nil {
t.Fatal(err)
}
stream, err := Subscribe(sock, []string{"plugin.test.playback"})
if err != nil {
t.Fatal(err)
}
defer stream.Close()
if err := server.Close(); err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() {
_, err := stream.Next()
done <- err
}()
select {
case err := <-done:
if err == nil {
t.Fatal("expected closed stream error")
}
case <-time.After(time.Second):
t.Fatal("stream did not close with server")
}
}
+28 -2
View File
@@ -67,7 +67,19 @@ func jsonToLua(L *lua.LState, v any) lua.LValue {
}
}
// maxLuaConvertDepth bounds table nesting accepted by luaToGo. Lua can build a
// deeply nested table cheaply, and unbounded recursion here would overflow the
// Go stack — a fatal error the plugin sandbox cannot contain.
const maxLuaConvertDepth = 64
func luaToGo(val lua.LValue) any {
return luaToGoValue(val, nil, 0)
}
// luaToGoValue converts val to its Go equivalent. Tables already being
// converted on the current path (cycles) and tables nested deeper than
// maxLuaConvertDepth become nil instead of recursing forever.
func luaToGoValue(val lua.LValue, path map[*lua.LTable]struct{}, depth int) any {
switch v := val.(type) {
case *lua.LNilType:
return nil
@@ -78,19 +90,33 @@ func luaToGo(val lua.LValue) any {
case lua.LString:
return string(v)
case *lua.LTable:
if depth >= maxLuaConvertDepth {
return nil
}
if _, cyclic := path[v]; cyclic {
return nil
}
if path == nil {
path = make(map[*lua.LTable]struct{})
}
// Tracking only the active path keeps repeated sibling references
// (a DAG) convertible while still catching true cycles.
path[v] = struct{}{}
defer delete(path, v)
// 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)))
arr = append(arr, luaToGoValue(v.RawGetInt(i), path, depth+1))
}
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)
m[string(ks)] = luaToGoValue(value, path, depth+1)
}
})
return m
+105
View File
@@ -1,6 +1,7 @@
package luaplugin
import (
"encoding/json"
"testing"
lua "github.com/yuin/gopher-lua"
@@ -113,3 +114,107 @@ func TestLuaToGoRoundtrip(t *testing.T) {
})
}
}
func TestLuaToGoBreaksTableCycles(t *testing.T) {
L := lua.NewState()
defer L.Close()
self := L.NewTable()
self.RawSetString("name", lua.LString("self"))
self.RawSetString("self", self)
left := L.NewTable()
right := L.NewTable()
left.RawSetString("other", right)
right.RawSetString("other", left)
self.RawSetString("left", left)
// A cycle must not recurse until the Go stack overflows, which would kill
// cliamp instead of failing inside the plugin sandbox.
got, ok := luaToGo(self).(map[string]any)
if !ok {
t.Fatalf("luaToGo returned %T, want map", luaToGo(self))
}
if got["name"] != "self" {
t.Fatalf("name = %v, want self", got["name"])
}
if got["self"] != nil {
t.Fatalf("cyclic self reference = %v, want nil", got["self"])
}
if _, err := json.Marshal(got); err != nil {
t.Fatalf("marshal cyclic table: %v", err)
}
}
func TestLuaToGoKeepsRepeatedReferences(t *testing.T) {
L := lua.NewState()
defer L.Close()
shared := L.NewTable()
shared.RawSetString("id", lua.LString("shared"))
root := L.NewTable()
root.RawSetString("first", shared)
root.RawSetString("second", shared)
// The same table reached twice on different paths is not a cycle.
got, ok := luaToGo(root).(map[string]any)
if !ok {
t.Fatalf("luaToGo returned %T, want map", luaToGo(root))
}
for _, key := range []string{"first", "second"} {
child, ok := got[key].(map[string]any)
if !ok || child["id"] != "shared" {
t.Fatalf("%s = %#v, want shared table", key, got[key])
}
}
}
func TestLuaToGoLimitsNestingDepth(t *testing.T) {
L := lua.NewState()
defer L.Close()
root := L.NewTable()
deepest := root
for range maxLuaConvertDepth + 10 {
next := L.NewTable()
deepest.RawSetString("next", next)
deepest = next
}
current, ok := luaToGo(root).(map[string]any)
if !ok {
t.Fatalf("luaToGo returned %T, want map", luaToGo(root))
}
depth := 1
for {
next, isTable := current["next"].(map[string]any)
if !isTable {
break
}
current = next
depth++
}
if depth != maxLuaConvertDepth {
t.Fatalf("converted depth = %d, want %d", depth, maxLuaConvertDepth)
}
}
func TestJSONEncodeSurvivesCyclicTable(t *testing.T) {
L := lua.NewState()
defer L.Close()
cliamp := L.NewTable()
registerJSONAPI(L, cliamp)
L.SetGlobal("cliamp", cliamp)
if err := L.DoString(`
local t = {name = "loop"}
t.self = t
_G.result = cliamp.json.encode(t)
`); err != nil {
t.Fatal(err)
}
// The cyclic reference becomes null instead of crashing the process.
if got := L.GetGlobal("result").String(); got != `{"name":"loop","self":null}` {
t.Fatalf("encode(cyclic) = %q", got)
}
}
+228
View File
@@ -0,0 +1,228 @@
package luaplugin
import (
"encoding/json"
"strings"
"sync"
"testing"
"time"
lua "github.com/yuin/gopher-lua"
)
type capturedEvent struct {
topic string
data json.RawMessage
retain bool
}
// capturePublisher records calls in order and is safe for concurrent use, since
// async hooks publish from their own goroutines.
type capturePublisher struct {
mu sync.Mutex
events []capturedEvent
calls []string
}
func (p *capturePublisher) Publish(topic string, data json.RawMessage, retain bool) error {
p.mu.Lock()
defer p.mu.Unlock()
p.events = append(p.events, capturedEvent{topic: topic, data: append(json.RawMessage(nil), data...), retain: retain})
p.calls = append(p.calls, "publish "+topic)
return nil
}
func (p *capturePublisher) ClearPrefix(prefix string) {
p.mu.Lock()
defer p.mu.Unlock()
p.calls = append(p.calls, "clear "+prefix)
}
func (p *capturePublisher) captured() ([]capturedEvent, []string) {
p.mu.Lock()
defer p.mu.Unlock()
return p.events, p.calls
}
func TestPluginPublishUsesInstalledNamespaceAndRetention(t *testing.T) {
manager := newTestManager()
publisher := &capturePublisher{}
manager.SetEventPublisher(publisher)
loadTestPlugin(t, manager, "installed-name", `
local p = plugin.register({name = "spoofed-name", type = "hook"})
local ok, err = p:publish("playback", {status = "playing", position = 12}, {retain = true})
if not ok then error(err) end
`)
events, _ := publisher.captured()
if len(events) != 1 {
t.Fatalf("published events = %d, want 1", len(events))
}
event := events[0]
if event.topic != "plugin.installed-name.playback" || !event.retain {
t.Fatalf("event = %#v", event)
}
var payload map[string]any
if err := json.Unmarshal(event.data, &payload); err != nil {
t.Fatal(err)
}
if payload["status"] != "playing" || payload["position"] != float64(12) {
t.Fatalf("payload = %#v", payload)
}
}
func TestPluginPublishReportsUnavailablePublisher(t *testing.T) {
manager := newTestManager()
plugin := loadTestPlugin(t, manager, "test", `
local p = plugin.register({name = "test", type = "hook"})
_G.ok, _G.err = p:publish("playback", {})
`)
if plugin.L.GetGlobal("ok") != lua.LNil {
t.Fatal("publish unexpectedly succeeded")
}
if plugin.L.GetGlobal("err").String() != "plugin event publisher is unavailable" {
t.Fatalf("error = %q", plugin.L.GetGlobal("err").String())
}
}
// A dotted install name must not be able to produce the same topic as another
// plugin publishing a dotted topic.
func TestPluginPublishNamespaceAvoidsDottedNameCollision(t *testing.T) {
manager := newTestManager()
publisher := &capturePublisher{}
manager.SetEventPublisher(publisher)
loadTestPlugin(t, manager, "foo.bar", `
local p = plugin.register({name = "foo.bar", type = "hook"})
p:publish("playback", {})
`)
loadTestPlugin(t, manager, "foo", `
local p = plugin.register({name = "foo", type = "hook"})
p:publish("bar.playback", {})
`)
events, _ := publisher.captured()
if len(events) != 2 {
t.Fatalf("published events = %d, want 2", len(events))
}
if events[0].topic != "plugin.foo_bar.playback" {
t.Errorf("dotted plugin topic = %q, want plugin.foo_bar.playback", events[0].topic)
}
if events[1].topic != "plugin.foo.bar.playback" {
t.Errorf("dotted topic = %q, want plugin.foo.bar.playback", events[1].topic)
}
if events[0].topic == events[1].topic {
t.Fatalf("namespaces collided on %q", events[0].topic)
}
}
// Folding "." to "_" is lossy, so two names that fold alike must not end up
// sharing a namespace: the first loaded keeps it, the second cannot publish.
func TestPluginPublishRejectsFoldedNamespaceClash(t *testing.T) {
manager := newTestManager()
publisher := &capturePublisher{}
manager.SetEventPublisher(publisher)
loadTestPlugin(t, manager, "my.plugin", `
local p = plugin.register({name = "my.plugin", type = "hook"})
local ok, err = p:publish("playback", {})
if not ok then error(err) end
`)
late := loadTestPlugin(t, manager, "my_plugin", `
local p = plugin.register({name = "my_plugin", type = "hook"})
_G.ok, _G.err = p:publish("playback", {})
`)
if late.L.GetGlobal("ok") != lua.LNil {
t.Fatal("second plugin published into a namespace it does not own")
}
if got := late.L.GetGlobal("err").String(); !strings.Contains(got, `already used by plugin "my.plugin"`) {
t.Errorf("error = %q, want the owning plugin named", got)
}
events, _ := publisher.captured()
if len(events) != 1 {
t.Fatalf("published events = %d, want 1", len(events))
}
if events[0].topic != "plugin.my_plugin.playback" {
t.Errorf("topic = %q, want plugin.my_plugin.playback", events[0].topic)
}
}
// A file that never registers must release its namespace for the next plugin,
// including when plugin.register() renamed it before the chunk failed.
func TestNamespaceReleasedWhenPluginFailsToLoad(t *testing.T) {
manager := newTestManager()
publisher := &capturePublisher{}
manager.SetEventPublisher(publisher)
loadTestPluginExpectError(t, manager, "my.plugin", `error("boom")`)
loadTestPlugin(t, manager, "my_plugin", `
local p = plugin.register({name = "my_plugin", type = "hook"})
local ok, err = p:publish("playback", {})
if not ok then error(err) end
`)
events, _ := publisher.captured()
if len(events) != 1 || events[0].topic != "plugin.my_plugin.playback" {
t.Fatalf("events = %#v, want one plugin.my_plugin.playback", events)
}
}
// plugin.register() renames Plugin.Name, so namespace release must key off the
// installed filename instead.
func TestNamespaceReleasedAfterRegisterRenamesPlugin(t *testing.T) {
manager := newTestManager()
publisher := &capturePublisher{}
manager.SetEventPublisher(publisher)
loadTestPluginExpectError(t, manager, "my.plugin", `
plugin.register({name = "renamed-after-claim", type = "hook"})
error("boom")
`)
if owner, taken := manager.namespaces["my_plugin"]; taken {
t.Fatalf("namespace still claimed by %q after failed load", owner)
}
late := loadTestPlugin(t, manager, "my_plugin", `
local p = plugin.register({name = "my_plugin", type = "hook"})
_G.ok, _G.err = p:publish("playback", {})
`)
if late.L.GetGlobal("ok") == lua.LNil {
t.Fatalf("publish failed: %s", late.L.GetGlobal("err").String())
}
events, _ := publisher.captured()
if len(events) != 1 || events[0].topic != "plugin.my_plugin.playback" {
t.Fatalf("events = %#v, want one plugin.my_plugin.playback", events)
}
}
func TestManagerCloseClearsRetainedAfterPublishersStop(t *testing.T) {
manager := newTestManager()
publisher := &capturePublisher{}
manager.SetEventPublisher(publisher)
loadTestPlugin(t, manager, "late", `plugin.register({name = "late", type = "hook"})`)
// Stand in for an async hook goroutine that is still running when Close
// starts and publishes a retained event just before it finishes. Close must
// wait for it, then clear the namespace — not the other way around.
manager.wg.Add(1)
go func() {
defer manager.wg.Done()
time.Sleep(20 * time.Millisecond)
_ = publisher.Publish("plugin.late.playback", json.RawMessage(`{}`), true)
}()
manager.Close()
_, calls := publisher.captured()
want := []string{"publish plugin.late.playback", "clear plugin.late."}
if len(calls) != len(want) {
t.Fatalf("calls = %v, want %v", calls, want)
}
for i, call := range calls {
if call != want[i] {
t.Fatalf("calls = %v, want %v", calls, want)
}
}
}
+135 -12
View File
@@ -4,6 +4,7 @@
package luaplugin
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
@@ -20,14 +21,17 @@ import (
// 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")
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")
namespaceOwner string // installed filename; plugin.register() cannot change it
namespace string // namespaceOwner reduced to one event topic segment
namespaceErr error // set when another plugin claimed that namespace first
}
// StateProvider supplies read-only access to player/playlist state.
@@ -96,6 +100,12 @@ type UIProvider struct {
ShowMessage func(text string, duration time.Duration) // injected via prog.Send
}
// EventPublisher accepts namespaced JSON events emitted by Lua plugins.
type EventPublisher interface {
Publish(topic string, data json.RawMessage, retain bool) error
ClearPrefix(prefix string)
}
// Manager owns all loaded plugins and dispatches events to them.
type Manager struct {
plugins []*Plugin
@@ -106,9 +116,11 @@ type Manager struct {
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
namespaces map[string]string // event namespace -> owning plugin name
state StateProvider
control ControlProvider
ui UIProvider
publisher EventPublisher
timers *timerManager
execs *execManager
logger *pluginLogger
@@ -119,16 +131,20 @@ type Manager struct {
// New scans the plugin directory and loads all .lua files.
// pluginCfg maps plugin names to their [plugins.<name>] config keys.
// publisher backs p:publish() and may be nil; it is installed before any plugin
// runs so a plugin can publish from its top-level chunk.
// Returns a Manager (possibly with 0 plugins) and any non-fatal load error.
func New(pluginCfg map[string]map[string]string) (*Manager, error) {
func New(pluginCfg map[string]map[string]string, publisher EventPublisher) (*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),
namespaces: make(map[string]string),
timers: newTimerManager(),
execs: newExecManager(resolveAllowedBinaries(pluginCfg)),
publisher: publisher,
}
dir, err := appdir.PluginDir()
@@ -230,10 +246,13 @@ func (m *Manager) loadPlugin(path, name string, cfg map[string]string) (*Plugin,
sandbox(L)
p := &Plugin{
Name: name,
L: L,
config: cfg,
Name: name,
namespaceOwner: name,
namespace: eventNamespace(name),
L: L,
config: cfg,
}
m.claimNamespace(p)
// Register the plugin.register() global.
m.registerPluginAPI(L, p)
@@ -264,6 +283,13 @@ func (m *Manager) loadPlugin(path, name string, cfg map[string]string) (*Plugin,
func (m *Manager) cleanupPlugin(p *Plugin) {
m.mu.Lock()
// Release the event namespace only if this plugin owns it. Compare against
// namespaceOwner, not Name: plugin.register() can rename Name after the
// claim, and a renamed plugin that then fails to load must not keep the
// namespace locked away from a later colliding plugin.
if owner, ok := m.namespaces[p.namespace]; ok && owner == p.namespaceOwner {
delete(m.namespaces, p.namespace)
}
for event, hooks := range m.hooks {
m.hooks[event] = filterOutPlugin(hooks, p)
}
@@ -373,6 +399,38 @@ func (m *Manager) registerPluginAPI(L *lua.LState, p *Plugin) {
return 1
}))
// p:publish(topic, payload, {retain=true}) publishes only inside the
// immutable namespace derived from the installed plugin filename.
L.SetField(obj, "publish", L.NewFunction(func(L *lua.LState) int {
topic := L.CheckString(2)
payload := luaToGo(L.Get(3))
retain := false
if options, ok := L.Get(4).(*lua.LTable); ok {
retain = lua.LVAsBool(options.RawGetString("retain"))
}
data, err := json.Marshal(payload)
if err == nil {
m.mu.RLock()
publisher := m.publisher
m.mu.RUnlock()
if publisher == nil {
err = fmt.Errorf("plugin event publisher is unavailable")
} else if p.namespaceErr != nil {
err = p.namespaceErr
} else {
fullTopic := "plugin." + p.namespace + "." + topic
err = publisher.Publish(fullTopic, data, retain)
}
}
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
L.Push(lua.LTrue)
return 1
}))
m.registerKeymapAPI(L, obj, p)
m.registerCommandAPI(L, obj, p)
@@ -443,6 +501,58 @@ func resolveAllowedBinaries(pluginCfg map[string]map[string]string) []string {
return out
}
// eventNamespace reduces an installed plugin name to a single event topic
// segment. Without this, a dot in the name makes topics ambiguous: a plugin
// installed as "foo.bar" publishing "playback" and one installed as "foo"
// publishing "bar.playback" would both produce plugin.foo.bar.playback.
// Characters that IPC topics reject are folded the same way so every installed
// plugin can publish, whatever its filename.
//
// Folding is lossy: "foo.bar" and "foo_bar" both yield "foo_bar". loadPlugin
// therefore lets only the first plugin claim a namespace and disables
// publishing for later ones, so two plugins can never share a topic.
func eventNamespace(name string) string {
var b strings.Builder
b.Grow(len(name))
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
b.WriteRune(r)
default:
b.WriteRune('_')
}
}
return b.String()
}
// claimNamespace records p as the owner of its event namespace, or marks p as
// unable to publish when another plugin already owns that namespace. Load order
// is sorted by installed name, so the winner is deterministic. Ownership is
// tracked by installed filename because plugin.register() can rename p.Name.
func (m *Manager) claimNamespace(p *Plugin) {
m.mu.Lock()
defer m.mu.Unlock()
if m.namespaces == nil {
m.namespaces = make(map[string]string)
}
if owner, taken := m.namespaces[p.namespace]; taken && owner != p.namespaceOwner {
p.namespaceErr = fmt.Errorf("event namespace %q is already used by plugin %q; rename this plugin to publish events", p.namespace, owner)
if m.logger != nil {
m.logger.log(p.namespaceOwner, "warn", "%v", p.namespaceErr)
}
return
}
m.namespaces[p.namespace] = p.namespaceOwner
}
// SetEventPublisher replaces the publisher backing p:publish(). New installs
// the publisher before plugins run; this is for callers that wire it later.
func (m *Manager) SetEventPublisher(publisher EventPublisher) {
m.mu.Lock()
m.publisher = publisher
m.mu.Unlock()
}
// SetStateProvider sets the function pointers used by the Lua API to
// query live player/playlist state.
func (m *Manager) SetStateProvider(sp StateProvider) {
@@ -473,6 +583,19 @@ func (m *Manager) Close() {
// Wait for any in-flight async hook goroutines to finish before closing
// the LStates they call into.
m.wg.Wait()
// Drop retained events only once every publisher has stopped, so a late
// async handler cannot leave a retained value behind.
m.mu.RLock()
publisher := m.publisher
m.mu.RUnlock()
if publisher != nil {
for _, p := range m.plugins {
if p.namespaceErr != nil {
continue // never owned the namespace, so nothing of its own is retained
}
publisher.ClearPrefix("plugin." + p.namespace + ".")
}
}
if m.logger != nil {
m.logger.close()
}
+1 -1
View File
@@ -54,7 +54,7 @@ func TestBundledPluginsLoad(t *testing.T) {
}
}
mgr, err := New(nil)
mgr, err := New(nil, nil)
if err != nil {
t.Fatalf("bundled plugins failed to load (API compatibility broken): %v", err)
}
+18 -5
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
@@ -303,13 +304,16 @@ func run(overrides config.Overrides, positional []string, daemon bool) error {
themes := theme.LoadAll()
luaMgr, luaErr := luaplugin.New(cfg.Plugins)
pluginBroker := ipc.NewBroker()
defer pluginBroker.Close()
luaMgr, luaErr := luaplugin.New(cfg.Plugins, pluginBroker)
if luaErr != nil {
fmt.Fprintf(os.Stderr, "lua plugins: %v\n", luaErr)
}
if luaMgr != nil {
defer luaMgr.Close()
luaMgr.SetReservedKeys(model.ReservedKeys())
defer luaMgr.Close()
}
m := model.New(p, pl, providers, defaultProvider, localProv, themes, luaMgr, config.SaveFunc{})
@@ -465,7 +469,7 @@ func run(overrides config.Overrides, positional []string, daemon bool) error {
})
}
ipcSrv, ipcErr := ipc.NewServer(ipc.DefaultSocketPath(), ipc.DispatcherFunc(func(msg any) { prog.Send(msg) }))
ipcSrv, ipcErr := ipc.NewServerWithBroker(ipc.DefaultSocketPath(), ipc.DispatcherFunc(func(msg any) { prog.Send(msg) }), pluginBroker)
if ipcErr != nil {
fmt.Fprintf(os.Stderr, "ipc: %v\n", ipcErr)
} else {
@@ -525,10 +529,19 @@ func wireMediaCtl(prog *tea.Program) (*mediactl.Service, error) {
return svc, nil
}
// userIPCError renders ipc.ErrNotRunning as the wording users see. The ipc
// package returns a bare sentinel, so all CLI copy stays in the command layer.
func userIPCError(err error) error {
if errors.Is(err, ipc.ErrNotRunning) {
return fmt.Errorf("cliamp is not running (no socket at %s)", ipc.DefaultSocketPath())
}
return err
}
func ipcSend(req ipc.Request) (ipc.Response, error) {
resp, err := ipc.Send(ipc.DefaultSocketPath(), req)
if err != nil {
return resp, err
return resp, userIPCError(err)
}
if !resp.OK {
return resp, fmt.Errorf("%s", resp.Error)
@@ -541,7 +554,7 @@ func ipcSend(req ipc.Request) (ipc.Response, error) {
func ipcSendLong(req ipc.Request, deadline time.Duration) (ipc.Response, error) {
resp, err := ipc.SendWithDeadline(ipc.DefaultSocketPath(), req, deadline)
if err != nil {
return resp, err
return resp, userIPCError(err)
}
if !resp.OK {
return resp, fmt.Errorf("%s", resp.Error)
+2 -2
View File
@@ -769,8 +769,8 @@ user_id = "your-account-user-id"</code></pre>
<div class="feature"><div class="feature-icon"></div><div class="feature-name">Diagnostic Logging</div><p>File logs at <span class="path">~/.config/cliamp/cliamp.log</span>. Set <code>log_level</code> in config or <code>--log-level</code> on the CLI: <code>debug</code>, <code>info</code>, <code>warn</code>, <code>error</code>.</p></div>
<div class="feature"><div class="feature-icon"></div><div class="feature-name">Self-Update</div><p>Run <code>--upgrade</code> to update to the latest release in-terminal.</p></div>
<div class="feature"><div class="feature-icon">$</div><div class="feature-name">Env-Interpolated Secrets</div><p>Reference any string in <code>config.toml</code> from the environment with <code>${VAR}</code> or <code>$VAR</code>. Keep passwords and tokens out of the file.</p></div>
<div class="feature"><div class="feature-icon"></div><div class="feature-name">Remote Control</div><p>Control a running instance from another terminal via local-socket IPC. Run with <code>--daemon</code> for headless playback driven entirely by scripts or Waybar.</p></div>
<div class="feature"><div class="feature-icon"></div><div class="feature-name">Lua Plugins</div><p>Lua 5.1 sandboxed plugin system. Hook events, add visualizers, push data.</p></div>
<div class="feature"><div class="feature-icon"></div><div class="feature-name">Remote Control</div><p>Control a running instance from another terminal via local-socket IPC. Run with <code>--daemon</code> for headless playback driven entirely by scripts or Waybar. Subscribe to plugin events on the same socket for a push NDJSON stream instead of polling.</p></div>
<div class="feature"><div class="feature-icon"></div><div class="feature-name">Lua Plugins</div><p>Lua 5.1 sandboxed plugin system. Hook events, add visualizers, push data. Publish namespaced events with <code>p:publish()</code>; retained events replay to new subscribers, so local companion apps get current state the moment they connect. Values convert to JSON up to 64 levels deep, and cyclic references become <code>null</code>.</p></div>
<div class="feature"><div class="feature-icon"></div><div class="feature-name">Save to Disk</div><p>Press <kbd>Ctrl+S</kbd> to save the current track to <code>~/Music</code>.</p></div>
<div class="feature"><div class="feature-icon"></div><div class="feature-name">Responsive TUI</div><p>Full, compact, and minimal layouts keep playback usable in terminal splits and small SSH sessions.</p></div>
<div class="feature"><div class="feature-icon"></div><div class="feature-name">Undo Queue Edits</div><p>Restore the last playlist removal or queue clear with <kbd>Ctrl+Z</kbd>.</p></div>