plugin.go

  1package plugin
  2
  3import (
  4	"log"
  5	"os"
  6	"path/filepath"
  7	"strings"
  8
  9	lua "github.com/yuin/gopher-lua"
 10)
 11
 12// KeyBinding represents a plugin-registered keyboard shortcut.
 13type KeyBinding struct {
 14	Key         string
 15	Area        string // "inbox", "email_view", or "composer"
 16	Description string
 17	Fn          *lua.LFunction
 18	Plugin      string
 19}
 20
 21// Manager manages the Lua VM and loaded plugins.
 22//
 23// Manager is not safe for concurrent use. The Lua VM itself is single-
 24// threaded, and all hook callbacks, key-binding invocations, and API calls
 25// must be dispatched from the same goroutine that owns the Manager (the
 26// orchestrator). Mutable Manager state (hooks, stores, bindings,
 27// currentPlugin, pending* fields) is therefore unprotected by design; callers
 28// that need to drive plugin events from multiple goroutines must serialize
 29// access externally.
 30type Manager struct {
 31	state         *lua.LState
 32	hooks         map[string][]registeredHook
 33	plugins       []string
 34	currentPlugin string
 35	stores        map[string]*pluginStore
 36	// statuses holds persistent status strings per view area, shown in the UI.
 37	statuses map[string]string
 38	// pendingNotification is set by matcha.notify() and consumed by the orchestrator.
 39	pendingNotification string
 40	pendingDuration     float64 // seconds, 0 means default (2s)
 41	// pendingFields holds compose field updates set by matcha.set_compose_field().
 42	pendingFields map[string]string
 43	// bindings holds plugin-registered keyboard shortcuts.
 44	bindings []KeyBinding
 45	// pendingPrompt is set by matcha.prompt() and consumed by the orchestrator.
 46	pendingPrompt *PendingPrompt
 47
 48	// pluginSchemas holds settings declarations per plugin.
 49	pluginSchemas map[string][]SettingDef
 50	// pluginValues holds current setting values per plugin.
 51	pluginValues map[string]map[string]interface{}
 52}
 53
 54// NewManager creates a new plugin manager with a Lua VM.
 55func NewManager() *Manager {
 56	m := &Manager{
 57		hooks:         make(map[string][]registeredHook),
 58		statuses:      make(map[string]string),
 59		pendingFields: make(map[string]string),
 60		pluginSchemas: make(map[string][]SettingDef),
 61		pluginValues:  make(map[string]map[string]interface{}),
 62	}
 63
 64	L := lua.NewState(lua.Options{
 65		SkipOpenLibs: true,
 66	})
 67
 68	// Open only safe standard libraries (no os, io, debug)
 69	for _, lib := range []struct {
 70		name string
 71		fn   lua.LGFunction
 72	}{
 73		{lua.LoadLibName, lua.OpenPackage},
 74		{lua.BaseLibName, lua.OpenBase},
 75		{lua.TabLibName, lua.OpenTable},
 76		{lua.StringLibName, lua.OpenString},
 77		{lua.MathLibName, lua.OpenMath},
 78	} {
 79		L.Push(L.NewFunction(lib.fn))
 80		L.Push(lua.LString(lib.name))
 81		L.Call(1, 0)
 82	}
 83
 84	m.state = L
 85	m.registerAPI()
 86
 87	return m
 88}
 89
 90// LoadPlugins discovers and loads plugins from ~/.config/matcha/plugins/.
 91func (m *Manager) LoadPlugins() {
 92	home, err := os.UserHomeDir()
 93	if err != nil {
 94		return
 95	}
 96
 97	pluginsDir := filepath.Join(home, ".config", "matcha", "plugins")
 98	entries, err := os.ReadDir(pluginsDir)
 99	if err != nil {
100		return
101	}
102
103	for _, entry := range entries {
104		path := filepath.Join(pluginsDir, entry.Name())
105
106		if entry.IsDir() {
107			// Directory plugin: look for init.lua
108			initPath := filepath.Join(path, "init.lua")
109			if _, err := os.Stat(initPath); err == nil {
110				m.loadPlugin(entry.Name(), initPath)
111			}
112		} else if strings.HasSuffix(entry.Name(), ".lua") {
113			// Single-file plugin
114			name := strings.TrimSuffix(entry.Name(), ".lua")
115			m.loadPlugin(name, path)
116		}
117	}
118}
119
120func (m *Manager) loadPlugin(name, path string) {
121	previousPlugin := m.currentPlugin
122	m.currentPlugin = name
123	defer func() {
124		m.currentPlugin = previousPlugin
125	}()
126
127	if err := m.state.DoFile(path); err != nil {
128		log.Printf("plugin %q: load error: %v", name, err)
129		return
130	}
131	m.plugins = append(m.plugins, name)
132	log.Printf("plugin %q: loaded", name)
133}
134
135// Plugins returns the names of all loaded plugins.
136func (m *Manager) Plugins() []string {
137	return m.plugins
138}
139
140// PendingNotification holds a notification message and its display duration.
141type PendingNotification struct {
142	Message  string
143	Duration float64 // seconds, 0 means default
144}
145
146// TakePendingNotification returns and clears any pending notification.
147func (m *Manager) TakePendingNotification() (PendingNotification, bool) {
148	if m.pendingNotification == "" {
149		return PendingNotification{}, false
150	}
151	n := PendingNotification{
152		Message:  m.pendingNotification,
153		Duration: m.pendingDuration,
154	}
155	m.pendingNotification = ""
156	m.pendingDuration = 0
157	return n, true
158}
159
160// TakePendingFields returns and clears any pending compose field updates.
161func (m *Manager) TakePendingFields() map[string]string {
162	if len(m.pendingFields) == 0 {
163		return nil
164	}
165	fields := m.pendingFields
166	m.pendingFields = make(map[string]string)
167	return fields
168}
169
170// Bindings returns all plugin-registered key bindings for the given view area.
171func (m *Manager) Bindings(area string) []KeyBinding {
172	var result []KeyBinding
173	for _, b := range m.bindings {
174		if b.Area == area {
175			result = append(result, b)
176		}
177	}
178	return result
179}
180
181// StatusText returns the plugin status string for the given view area.
182func (m *Manager) StatusText(area string) string {
183	return m.statuses[area]
184}
185
186// LuaState returns the Lua VM state for building tables.
187func (m *Manager) LuaState() *lua.LState {
188	return m.state
189}
190
191// Close shuts down the Lua VM.
192func (m *Manager) Close() {
193	if m.state != nil {
194		m.state.Close()
195	}
196}