package main import ( "encoding/json" "fmt" "log" "os" "path/filepath" "strings" "verstak/internal/core/config" lua "github.com/yuin/gopher-lua" ) // 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. // Returns error if the plugin is not installed but has install lifecycle. func (a *App) SetPluginEnabled(name string, enabled bool) error { if a.plugins == nil { return fmt.Errorf("plugin manager not ready") } if enabled { if err := a.plugins.Enable(name); err != nil { return err } } else { if err := a.plugins.Disable(name); err != nil { return err } } appCfg, _ := config.LoadAppConfig() if appCfg == nil { appCfg = config.DefaultAppConfig() } existing := make(map[string]bool) for _, n := range appCfg.EnabledPlugins { existing[n] = true } if enabled { existing[name] = true } else { delete(existing, name) } appCfg.EnabledPlugins = make([]string, 0, len(existing)) for n := range existing { appCfg.EnabledPlugins = append(appCfg.EnabledPlugins, n) } if err := config.SaveAppConfig(appCfg); err != nil { return fmt.Errorf("save config: %w", err) } if enabled { a.plugins.ActivatePlugin(name) } else { a.plugins.DeactivatePlugin(name) } return nil } // GetPluginPanelHTML returns the HTML panel content for a plugin. func (a *App) GetPluginPanelHTML(pluginName string) (string, error) { if a.plugins == nil { return "", fmt.Errorf("plugin manager not ready") } for _, p := range a.plugins.Plugins() { if p.Meta.Name != pluginName || !p.Active { continue } if p.Meta.Panel == "" { return "", nil } panelPath := filepath.Join(p.Dir, p.Meta.Panel) data, err := os.ReadFile(panelPath) if err != nil { return "", fmt.Errorf("read panel %s: %w", p.Meta.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 } // CallPluginFunction calls a global Lua function on an active plugin. // The funcName can use dots: "calendar.create_event" → _G.calendar.create_event // 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") } for _, p := range a.plugins.Plugins() { if p.Meta.Name != pluginName || !p.Active { continue } vm := p.VM() if vm == nil { continue } // Resolve dotted path: "calendar.create_event" → _G.calendar.create_event parts := strings.SplitN(funcName, ".", 2) var luaFn string if len(parts) == 2 { luaFn = fmt.Sprintf("_G.%s['%s']", parts[0], parts[1]) } else { luaFn = fmt.Sprintf("_G['%s']", funcName) } // Parse params var params interface{} if paramsJSON != "" && paramsJSON != "{}" { if err := json.Unmarshal([]byte(paramsJSON), ¶ms); err != nil { params = paramsJSON } } // Convert params to Lua value var luaArg lua.LValue switch v := params.(type) { case nil: luaArg = lua.LNil case string: luaArg = lua.LString(v) case float64: luaArg = lua.LNumber(v) case bool: luaArg = lua.LBool(v) case map[string]interface{}: tbl := vm.LState().NewTable() for key, val := range v { switch sv := val.(type) { case string: tbl.RawSetString(key, lua.LString(sv)) case float64: tbl.RawSetString(key, lua.LNumber(sv)) case bool: tbl.RawSetString(key, lua.LBool(sv)) } } luaArg = tbl default: luaArg = lua.LString(paramsJSON) } var script string if luaArg == lua.LNil { script = fmt.Sprintf("return %s()", luaFn) } else { script = fmt.Sprintf("return %s(%s)", luaFn, luaArg.String()) } result, err := vm.DoString(script) if err != nil { return "", fmt.Errorf("call %s: %w", funcName, err) } return result, nil } 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") 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) }