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