1package plugin
2
3import (
4 "encoding/json"
5 "errors"
6 "io/fs"
7 "os"
8 "path/filepath"
9 "regexp"
10 "sort"
11 "sync"
12
13 "github.com/floatpane/matcha/config"
14 lua "github.com/yuin/gopher-lua"
15)
16
17var validPluginStoreName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
18
19type pluginStore struct {
20 path string
21 mu sync.Mutex
22 data map[string]string
23}
24
25func newPluginStore(pluginName string) (*pluginStore, error) {
26 if !validPluginStoreName.MatchString(pluginName) {
27 return nil, errors.New("invalid plugin name for storage")
28 }
29
30 cfgDir, err := config.GetConfigDir()
31 if err != nil {
32 return nil, err
33 }
34
35 dir := filepath.Join(cfgDir, "plugins", pluginName)
36 if err := os.MkdirAll(dir, 0o700); err != nil {
37 return nil, err
38 }
39
40 s := &pluginStore{
41 path: filepath.Join(dir, "data.json"),
42 data: map[string]string{},
43 }
44 if err := s.load(); err != nil {
45 return nil, err
46 }
47 return s, nil
48}
49
50func (s *pluginStore) load() error {
51 raw, err := os.ReadFile(s.path)
52 if errors.Is(err, fs.ErrNotExist) {
53 return nil
54 }
55 if err != nil {
56 return err
57 }
58 if err := json.Unmarshal(raw, &s.data); err != nil {
59 return err
60 }
61 if s.data == nil {
62 s.data = map[string]string{}
63 }
64 return nil
65}
66
67func (s *pluginStore) flush() error {
68 raw, err := json.MarshalIndent(s.data, "", " ")
69 if err != nil {
70 return err
71 }
72
73 tmp, err := os.CreateTemp(filepath.Dir(s.path), ".data-*.json")
74 if err != nil {
75 return err
76 }
77 tmpPath := tmp.Name()
78 defer os.Remove(tmpPath)
79
80 if _, err := tmp.Write(raw); err != nil {
81 tmp.Close()
82 return err
83 }
84 if err := os.Chmod(tmpPath, 0o600); err != nil {
85 tmp.Close()
86 return err
87 }
88 if err := tmp.Close(); err != nil {
89 return err
90 }
91 return os.Rename(tmpPath, s.path)
92}
93
94func (s *pluginStore) Get(k string) (string, bool) {
95 s.mu.Lock()
96 defer s.mu.Unlock()
97
98 v, ok := s.data[k]
99 return v, ok
100}
101
102func (s *pluginStore) Set(k, v string) error {
103 s.mu.Lock()
104 defer s.mu.Unlock()
105
106 s.data[k] = v
107 return s.flush()
108}
109
110func (s *pluginStore) Delete(k string) error {
111 s.mu.Lock()
112 defer s.mu.Unlock()
113
114 delete(s.data, k)
115 return s.flush()
116}
117
118// Keys returns the keys currently stored, sorted lexicographically so plugin
119// authors can rely on a stable iteration order across calls.
120func (s *pluginStore) Keys() []string {
121 s.mu.Lock()
122 defer s.mu.Unlock()
123
124 out := make([]string, 0, len(s.data))
125 for k := range s.data {
126 out = append(out, k)
127 }
128 sort.Strings(out)
129 return out
130}
131
132func (m *Manager) currentStore() (*pluginStore, error) {
133 if m.currentPlugin == "" {
134 return nil, nil
135 }
136 if m.stores == nil {
137 m.stores = make(map[string]*pluginStore)
138 }
139 if s, ok := m.stores[m.currentPlugin]; ok {
140 return s, nil
141 }
142
143 s, err := newPluginStore(m.currentPlugin)
144 if err != nil {
145 return nil, err
146 }
147 m.stores[m.currentPlugin] = s
148 return s, nil
149}
150
151func (m *Manager) luaStoreSet(L *lua.LState) int {
152 key := L.CheckString(1)
153 val := L.CheckString(2)
154
155 s, err := m.currentStore()
156 if err != nil {
157 L.RaiseError("store_set: %v", err)
158 return 0
159 }
160 if s == nil {
161 L.RaiseError("store_set: no plugin context")
162 return 0
163 }
164 if err := s.Set(key, val); err != nil {
165 L.RaiseError("store_set: %v", err)
166 }
167 return 0
168}
169
170func (m *Manager) luaStoreGet(L *lua.LState) int {
171 key := L.CheckString(1)
172
173 s, err := m.currentStore()
174 if err != nil {
175 L.RaiseError("store_get: %v", err)
176 return 0
177 }
178 if s == nil {
179 L.Push(lua.LNil)
180 return 1
181 }
182 if v, ok := s.Get(key); ok {
183 L.Push(lua.LString(v))
184 } else {
185 L.Push(lua.LNil)
186 }
187 return 1
188}
189
190func (m *Manager) luaStoreDelete(L *lua.LState) int {
191 key := L.CheckString(1)
192
193 s, err := m.currentStore()
194 if err != nil {
195 L.RaiseError("store_delete: %v", err)
196 return 0
197 }
198 // No plugin context: silently no-op, matching store_get's behavior so
199 // read+remove operations behave the same when called outside a plugin
200 // (e.g. from a non-plugin Lua chunk). store_set still raises so a
201 // missing-context write is surfaced loudly.
202 if s == nil {
203 return 0
204 }
205 if err := s.Delete(key); err != nil {
206 L.RaiseError("store_delete: %v", err)
207 }
208 return 0
209}
210
211func (m *Manager) luaStoreKeys(L *lua.LState) int {
212 s, err := m.currentStore()
213 if err != nil {
214 L.RaiseError("store_keys: %v", err)
215 return 0
216 }
217 if s == nil {
218 L.Push(L.NewTable())
219 return 1
220 }
221
222 t := L.NewTable()
223 for i, key := range s.Keys() {
224 t.RawSetInt(i+1, lua.LString(key))
225 }
226 L.Push(t)
227 return 1
228}