Files
wehub-resource-sync ead81af521
CI / go (push) Waiting to run
CI / build (darwin, macos-14) (push) Waiting to run
CI / build (windows, windows-2025) (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

184 lines
4.8 KiB
Go

package luaplugin
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"sync"
lua "github.com/yuin/gopher-lua"
"github.com/bjarneo/cliamp/internal/appdir"
)
// pluginStore is a per-plugin key/value store persisted as a single JSON file
// at ~/.local/share/cliamp/plugins/<name>/store.json. Values round-trip through
// the same Lua<->JSON conversion as cliamp.json, so tables, numbers, strings,
// and booleans all survive a restart.
//
// The store is scoped to one plugin: a plugin can never read another plugin's
// keys, which preserves the no-inter-plugin-communication invariant.
type pluginStore struct {
mu sync.Mutex
path string // store.json path; empty if the data dir is unavailable
data map[string]any // in-memory state, lazily loaded on first access
loaded bool
}
// newPluginStore resolves the store path for a plugin. A failure to resolve the
// data dir yields a store with an empty path: reads return nil and writes are
// silently dropped, so plugins degrade gracefully when there is no home dir.
func newPluginStore(pluginName string) *pluginStore {
s := &pluginStore{data: map[string]any{}}
if dir, err := appdir.DataDir(); err == nil {
s.path = filepath.Join(dir, "plugins", pluginName, "store.json")
}
return s
}
// load reads the JSON file into memory on first access. Caller must hold s.mu.
func (s *pluginStore) load() {
if s.loaded {
return
}
s.loaded = true
if s.path == "" {
return
}
data, err := os.ReadFile(s.path)
if err != nil {
return // missing file (first run) or unreadable — start empty
}
var m map[string]any
if json.Unmarshal(data, &m) == nil && m != nil {
s.data = m
}
}
// save writes the in-memory state back to disk. Caller must hold s.mu.
//
// The store may hold plugin credentials (API keys, tokens), so the directory
// and file are owner-only (0o700/0o600). The write is atomic: a temp file in
// the same directory is renamed into place so a crash mid-write can never leave
// a truncated or partially written store.
func (s *pluginStore) save() error {
if s.path == "" {
return nil
}
dir := filepath.Dir(s.path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
data, err := json.Marshal(s.data)
if err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".store-*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // no-op once renamed; cleans up on any error path
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
return err
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, s.path)
}
// registerStoreAPI adds cliamp.store.{get,set,delete,keys,clear} to the cliamp
// table, backed by a per-plugin JSON file. No permission is required: a plugin
// can only ever touch its own namespace.
func registerStoreAPI(L *lua.LState, cliamp *lua.LTable, pluginName string) {
store := newPluginStore(pluginName)
tbl := L.NewTable()
// cliamp.store.get(key) -> value or nil
L.SetField(tbl, "get", L.NewFunction(func(L *lua.LState) int {
key := L.CheckString(1)
store.mu.Lock()
store.load()
v, ok := store.data[key]
store.mu.Unlock()
if !ok {
L.Push(lua.LNil)
return 1
}
L.Push(jsonToLua(L, v))
return 1
}))
// cliamp.store.set(key, value) -> true or (nil, error)
L.SetField(tbl, "set", L.NewFunction(func(L *lua.LState) int {
key := L.CheckString(1)
val := luaToGo(L.CheckAny(2))
store.mu.Lock()
store.load()
store.data[key] = val
err := store.save()
store.mu.Unlock()
return pushStoreResult(L, err)
}))
// cliamp.store.delete(key) -> true or (nil, error)
L.SetField(tbl, "delete", L.NewFunction(func(L *lua.LState) int {
key := L.CheckString(1)
store.mu.Lock()
store.load()
delete(store.data, key)
err := store.save()
store.mu.Unlock()
return pushStoreResult(L, err)
}))
// cliamp.store.keys() -> array of keys (sorted for stable iteration)
L.SetField(tbl, "keys", L.NewFunction(func(L *lua.LState) int {
store.mu.Lock()
store.load()
keys := make([]string, 0, len(store.data))
for k := range store.data {
keys = append(keys, k)
}
store.mu.Unlock()
sort.Strings(keys)
out := L.NewTable()
for i, k := range keys {
out.RawSetInt(i+1, lua.LString(k))
}
L.Push(out)
return 1
}))
// cliamp.store.clear() -> true or (nil, error)
L.SetField(tbl, "clear", L.NewFunction(func(L *lua.LState) int {
store.mu.Lock()
store.data = map[string]any{}
store.loaded = true
err := store.save()
store.mu.Unlock()
return pushStoreResult(L, err)
}))
L.SetField(cliamp, "store", tbl)
}
// pushStoreResult pushes true on success or (nil, error) on failure, matching
// the convention used by cliamp.fs.
func pushStoreResult(L *lua.LState, err error) int {
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
L.Push(lua.LTrue)
return 1
}