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) }