348 lines
9.8 KiB
Go
348 lines
9.8 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"verstak/internal/core/config"
|
|
)
|
|
|
|
// PluginDTO represents a discovered plugin with its current state.
|
|
type PluginDTO struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
Author string `json:"author,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
Active bool `json:"active"`
|
|
Installed bool `json:"installed"`
|
|
HasInstall bool `json:"hasInstall"`
|
|
HasPanel bool `json:"hasPanel"`
|
|
HasSettings bool `json:"hasSettings"`
|
|
UIContribs UIContribDTO `json:"uiContribs"`
|
|
}
|
|
|
|
// UIContribDTO describes what a plugin adds to the UI.
|
|
type UIContribDTO struct {
|
|
SidebarItems []SidebarItemDTO `json:"sidebarItems"`
|
|
NodeTabs []NodeTabDTO `json:"nodeTabs"`
|
|
}
|
|
|
|
type SidebarItemDTO struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
Icon string `json:"icon,omitempty"`
|
|
}
|
|
|
|
type NodeTabDTO struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
Page string `json:"page"`
|
|
}
|
|
|
|
// ListPlugins returns all discovered plugins with their current enabled/disabled state.
|
|
func (a *App) ListPlugins() []PluginDTO {
|
|
if a.plugins == nil {
|
|
return nil
|
|
}
|
|
all := a.plugins.Plugins()
|
|
out := make([]PluginDTO, 0, len(all))
|
|
|
|
appCfg, _ := config.LoadAppConfig()
|
|
enabledSet := make(map[string]bool)
|
|
if appCfg != nil {
|
|
for _, name := range appCfg.EnabledPlugins {
|
|
enabledSet[name] = true
|
|
}
|
|
}
|
|
|
|
for _, p := range all {
|
|
active := enabledSet[p.Meta.Name] || p.Active
|
|
contribs := UIContribDTO{}
|
|
for _, item := range p.Meta.UI.SidebarItems {
|
|
contribs.SidebarItems = append(contribs.SidebarItems, SidebarItemDTO{
|
|
ID: item.ID,
|
|
Label: item.Label,
|
|
Icon: item.Icon,
|
|
})
|
|
}
|
|
for _, tab := range p.Meta.UI.NodeTabs {
|
|
contribs.NodeTabs = append(contribs.NodeTabs, NodeTabDTO{
|
|
ID: tab.ID,
|
|
Label: tab.Label,
|
|
Page: tab.Page,
|
|
})
|
|
}
|
|
|
|
hasPanel := false
|
|
if p.Meta.Panel != "" {
|
|
panelPath := filepath.Join(p.Dir, p.Meta.Panel)
|
|
if _, err := os.Stat(panelPath); err == nil {
|
|
hasPanel = true
|
|
}
|
|
}
|
|
|
|
out = append(out, PluginDTO{
|
|
Name: p.Meta.Name,
|
|
Version: p.Meta.Version,
|
|
Author: p.Meta.Author,
|
|
Description: p.Meta.Description,
|
|
Active: active,
|
|
Installed: p.Installed,
|
|
HasInstall: p.HasInstall,
|
|
HasPanel: hasPanel,
|
|
UIContribs: contribs,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// SetPluginEnabled persists the enabled/disabled state and applies it to the runtime.
|
|
// Enable: marks plugin as enabled, activates runtime, THEN persists to config (only on success).
|
|
// Disable: deactivates runtime, then marks plugin as disabled in config.
|
|
func (a *App) SetPluginEnabled(name string, enabled bool) error {
|
|
if a.plugins == nil {
|
|
return fmt.Errorf("plugin manager not ready")
|
|
}
|
|
|
|
if enabled {
|
|
// Enable first (sets Enabled=true on the plugin struct)
|
|
if err := a.plugins.Enable(name); err != nil {
|
|
return err
|
|
}
|
|
// Activate runtime — if this fails, do NOT persist to config
|
|
if err := a.plugins.ActivatePlugin(name); err != nil {
|
|
// Rollback: deactivate runtime AND un-enable in-memory state
|
|
a.plugins.DeactivatePlugin(name)
|
|
a.plugins.Disable(name)
|
|
return fmt.Errorf("activate %q: %w", name, err)
|
|
}
|
|
// Only persist to config after successful activation
|
|
if err := a.saveEnabledPlugin(name); err != nil {
|
|
// Config save failed — rollback runtime too
|
|
a.plugins.DeactivatePlugin(name)
|
|
a.plugins.Disable(name)
|
|
return fmt.Errorf("save config for %q: %w", name, err)
|
|
}
|
|
} else {
|
|
// Deactivate runtime first, then disable
|
|
a.plugins.DeactivatePlugin(name)
|
|
if err := a.plugins.Disable(name); err != nil {
|
|
return err
|
|
}
|
|
// Remove from config
|
|
if err := a.removeEnabledPlugin(name); err != nil {
|
|
return fmt.Errorf("save config for %q: %w", name, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// saveEnabledPlugin adds name to EnabledPlugins in config and saves.
|
|
func (a *App) saveEnabledPlugin(name string) error {
|
|
appCfg, _ := config.LoadAppConfig()
|
|
if appCfg == nil {
|
|
appCfg = config.DefaultAppConfig()
|
|
}
|
|
for _, n := range appCfg.EnabledPlugins {
|
|
if n == name {
|
|
return nil // already present
|
|
}
|
|
}
|
|
appCfg.EnabledPlugins = append(appCfg.EnabledPlugins, name)
|
|
return config.SaveAppConfig(appCfg)
|
|
}
|
|
|
|
// removeEnabledPlugin removes name from EnabledPlugins in config and saves.
|
|
func (a *App) removeEnabledPlugin(name string) error {
|
|
appCfg, _ := config.LoadAppConfig()
|
|
if appCfg == nil {
|
|
return nil // nothing to remove
|
|
}
|
|
var updated []string
|
|
for _, n := range appCfg.EnabledPlugins {
|
|
if n != name {
|
|
updated = append(updated, n)
|
|
}
|
|
}
|
|
appCfg.EnabledPlugins = updated
|
|
return config.SaveAppConfig(appCfg)
|
|
}
|
|
|
|
// GetPluginPanelHTML returns the HTML panel content for a plugin.
|
|
// Validates that the panel path is safe: no absolute paths, no .. traversal,
|
|
// must be within the plugin directory, and must end with .html.
|
|
func (a *App) GetPluginPanelHTML(pluginName string) (string, error) {
|
|
if a.plugins == nil {
|
|
return "", fmt.Errorf("plugin manager not ready")
|
|
}
|
|
appCfg, _ := config.LoadAppConfig()
|
|
enabledSet := make(map[string]bool)
|
|
if appCfg != nil {
|
|
for _, name := range appCfg.EnabledPlugins {
|
|
enabledSet[name] = true
|
|
}
|
|
}
|
|
for _, p := range a.plugins.Plugins() {
|
|
active := enabledSet[p.Meta.Name] || p.Active
|
|
if p.Meta.Name != pluginName || !active {
|
|
continue
|
|
}
|
|
if p.Meta.Panel == "" {
|
|
return "", nil
|
|
}
|
|
|
|
// Validate panel path: must be relative, no .., within plugin dir, .html only
|
|
panel := p.Meta.Panel
|
|
if filepath.IsAbs(panel) {
|
|
return "", fmt.Errorf("panel path %q must be relative", panel)
|
|
}
|
|
if strings.Contains(panel, "..") {
|
|
return "", fmt.Errorf("panel path %q must not contain ..", panel)
|
|
}
|
|
if !strings.HasSuffix(strings.ToLower(panel), ".html") {
|
|
return "", fmt.Errorf("panel path %q must end with .html", panel)
|
|
}
|
|
|
|
// Resolve and verify the path is within the plugin directory
|
|
panelPath := filepath.Join(p.Dir, panel)
|
|
absPanel, err := filepath.Abs(panelPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve panel path: %w", err)
|
|
}
|
|
absDir, err := filepath.Abs(p.Dir)
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve plugin dir: %w", err)
|
|
}
|
|
if !strings.HasPrefix(absPanel, absDir+string(filepath.Separator)) {
|
|
return "", fmt.Errorf("panel path %q escapes plugin directory", panel)
|
|
}
|
|
|
|
data, err := os.ReadFile(absPanel)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read panel %s: %w", panel, err)
|
|
}
|
|
return string(data), nil
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// ListSystemViewsWithPlugins returns system views + plugin sidebar items.
|
|
func (a *App) ListSystemViewsWithPlugins() []SystemViewDTO {
|
|
base := a.ListSystemViews()
|
|
if a.plugins == nil {
|
|
return base
|
|
}
|
|
|
|
appCfg, _ := config.LoadAppConfig()
|
|
enabledSet := make(map[string]bool)
|
|
if appCfg != nil {
|
|
for _, name := range appCfg.EnabledPlugins {
|
|
enabledSet[name] = true
|
|
}
|
|
}
|
|
|
|
for _, p := range a.plugins.Plugins() {
|
|
active := enabledSet[p.Meta.Name] || p.Active
|
|
if !active {
|
|
continue
|
|
}
|
|
for _, item := range p.Meta.UI.SidebarItems {
|
|
pageID := "plugin:" + p.Meta.Name + ":" + item.ID
|
|
base = append(base, SystemViewDTO{
|
|
ID: pageID,
|
|
Label: item.Label,
|
|
Icon: item.Icon,
|
|
})
|
|
}
|
|
}
|
|
return base
|
|
}
|
|
|
|
// validLuaIdent matches a safe Lua identifier segment: [a-zA-Z_][a-zA-Z0-9_]*
|
|
var validLuaIdent = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
|
|
|
|
// CallPluginFunction calls a global Lua function on an active plugin.
|
|
// The funcName can use dots: "calendar.create_event" → _G.calendar.create_event
|
|
// Only alphanumeric identifiers with underscores are allowed (no Lua injection).
|
|
// Returns JSON string or error.
|
|
func (a *App) CallPluginFunction(pluginName, funcName string, paramsJSON string) (string, error) {
|
|
if a.plugins == nil {
|
|
return "", fmt.Errorf("plugin manager not ready")
|
|
}
|
|
|
|
// Validate funcName: only [a-zA-Z0-9_.]+ allowed, each segment must be valid ident
|
|
if funcName == "" {
|
|
return "", fmt.Errorf("funcName is empty")
|
|
}
|
|
segments := strings.Split(funcName, ".")
|
|
if len(segments) > 3 {
|
|
return "", fmt.Errorf("funcName %q too deep (max 2 dots)", funcName)
|
|
}
|
|
for _, seg := range segments {
|
|
if !validLuaIdent.MatchString(seg) {
|
|
return "", fmt.Errorf("funcName %q contains invalid segment %q", funcName, seg)
|
|
}
|
|
}
|
|
|
|
for _, p := range a.plugins.Plugins() {
|
|
if p.Meta.Name != pluginName || !p.Active {
|
|
continue
|
|
}
|
|
vm := p.VM()
|
|
if vm == nil {
|
|
continue
|
|
}
|
|
|
|
// Call via fully thread-safe LuaVM.CallFunctionJSON
|
|
// (JSON→Lua conversion happens under vm.mu)
|
|
return vm.CallFunctionJSON(segments, paramsJSON)
|
|
}
|
|
return "", fmt.Errorf("plugin %q not active or not found", pluginName)
|
|
}
|
|
|
|
// ReloadPlugins re-scans the plugins directory and re-initializes runtimes.
|
|
func (a *App) ReloadPlugins() error {
|
|
if a.plugins == nil {
|
|
return fmt.Errorf("plugin manager not ready")
|
|
}
|
|
log.Print("[plugins] reload requested")
|
|
// Fully stop runtimes: schedulers first (they depend on VMs), then VMs
|
|
a.plugins.StopSchedulers()
|
|
a.plugins.CallShutdownHooks()
|
|
a.plugins.CloseRuntimes()
|
|
|
|
a.plugins.Discover()
|
|
|
|
appCfg, _ := config.LoadAppConfig()
|
|
a.plugins.SyncConfig(appCfg)
|
|
|
|
a.plugins.InitRuntimes()
|
|
a.plugins.CallInitHooks()
|
|
a.plugins.StartSchedulers()
|
|
log.Print("[plugins] reload complete")
|
|
return nil
|
|
}
|
|
|
|
// InstallPlugin creates plugin's database tables and defaults via on_install hook.
|
|
// Does NOT activate the plugin — use SetPluginEnabled after.
|
|
func (a *App) InstallPlugin(name string) error {
|
|
if a.plugins == nil {
|
|
return fmt.Errorf("plugin manager not ready")
|
|
}
|
|
return a.plugins.Install(name)
|
|
}
|
|
|
|
// UninstallPlugin drops plugin's database tables and cleans data via on_uninstall hook.
|
|
// Disables the plugin first if active. Does NOT delete plugin files from disk.
|
|
func (a *App) UninstallPlugin(name string) error {
|
|
if a.plugins == nil {
|
|
return fmt.Errorf("plugin manager not ready")
|
|
}
|
|
return a.plugins.Uninstall(name)
|
|
}
|