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}