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