feat: plugin install/uninstall lifecycle + UI buttons
- AppConfig: add InstalledPlugins []string - Manager.Discover(): no config dependency, all plugins start inactive - Manager.SyncConfig(): apply installed/enabled state from AppConfig - Manager.Enable(): works for plugins without on_install hook - Manager.Install/Uninstall(): run on_install/on_uninstall hooks - ActivatePlugin: skip if HasInstall && !Installed - ReloadPlugins: Discover → SyncConfig → InitRuntimes - Bindings: InstallPlugin, UninstallPlugin - SettingsPlugins: install/uninstall buttons, toggle only after install - Calendar: migration moved from on_init to on_install, on_uninstall drops tables - Tests: all 12 pass (manager + runtime + calendar)
This commit is contained in:
parent
b80941f908
commit
e99ff984b1
|
|
@ -18,6 +18,8 @@ type PluginDTO struct {
|
||||||
Author string `json:"author,omitempty"`
|
Author string `json:"author,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Active bool `json:"active"`
|
Active bool `json:"active"`
|
||||||
|
Installed bool `json:"installed"`
|
||||||
|
HasInstall bool `json:"hasInstall"`
|
||||||
HasPanel bool `json:"hasPanel"`
|
HasPanel bool `json:"hasPanel"`
|
||||||
HasSettings bool `json:"hasSettings"`
|
HasSettings bool `json:"hasSettings"`
|
||||||
UIContribs UIContribDTO `json:"uiContribs"`
|
UIContribs UIContribDTO `json:"uiContribs"`
|
||||||
|
|
@ -89,6 +91,8 @@ func (a *App) ListPlugins() []PluginDTO {
|
||||||
Author: p.Meta.Author,
|
Author: p.Meta.Author,
|
||||||
Description: p.Meta.Description,
|
Description: p.Meta.Description,
|
||||||
Active: active,
|
Active: active,
|
||||||
|
Installed: p.Installed,
|
||||||
|
HasInstall: p.HasInstall,
|
||||||
HasPanel: hasPanel,
|
HasPanel: hasPanel,
|
||||||
UIContribs: contribs,
|
UIContribs: contribs,
|
||||||
})
|
})
|
||||||
|
|
@ -97,11 +101,22 @@ func (a *App) ListPlugins() []PluginDTO {
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetPluginEnabled persists the enabled/disabled state and applies it to the runtime.
|
// 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 {
|
func (a *App) SetPluginEnabled(name string, enabled bool) error {
|
||||||
if a.plugins == nil {
|
if a.plugins == nil {
|
||||||
return fmt.Errorf("plugin manager not ready")
|
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()
|
appCfg, _ := config.LoadAppConfig()
|
||||||
if appCfg == nil {
|
if appCfg == nil {
|
||||||
appCfg = config.DefaultAppConfig()
|
appCfg = config.DefaultAppConfig()
|
||||||
|
|
@ -207,26 +222,39 @@ func (a *App) ReloadPlugins() error {
|
||||||
log.Print("[plugins] reload requested")
|
log.Print("[plugins] reload requested")
|
||||||
a.plugins.CloseRuntimes()
|
a.plugins.CloseRuntimes()
|
||||||
|
|
||||||
appCfg, _ := config.LoadAppConfig()
|
|
||||||
if appCfg == nil {
|
|
||||||
appCfg = config.DefaultAppConfig()
|
|
||||||
}
|
|
||||||
enabledSet := make(map[string]bool)
|
|
||||||
for _, name := range appCfg.EnabledPlugins {
|
|
||||||
enabledSet[name] = true
|
|
||||||
}
|
|
||||||
a.plugins.Discover()
|
a.plugins.Discover()
|
||||||
a.plugins.InitRuntimes()
|
|
||||||
|
appCfg, _ := config.LoadAppConfig()
|
||||||
|
a.plugins.SyncConfig(appCfg)
|
||||||
|
|
||||||
// Apply enable/disable state from config: deactivate everything not in enabled set
|
// Apply enable/disable state from config: deactivate everything not in enabled set
|
||||||
for _, p := range a.plugins.Plugins() {
|
for _, p := range a.plugins.Plugins() {
|
||||||
if !enabledSet[p.Meta.Name] {
|
if !p.Active || !p.Installed {
|
||||||
a.plugins.DeactivatePlugin(p.Meta.Name)
|
a.plugins.DeactivatePlugin(p.Meta.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.plugins.InitRuntimes()
|
||||||
a.plugins.CallInitHooks()
|
a.plugins.CallInitHooks()
|
||||||
a.plugins.StartSchedulers()
|
a.plugins.StartSchedulers()
|
||||||
log.Print("[plugins] reload complete")
|
log.Print("[plugins] reload complete")
|
||||||
return nil
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -649,10 +649,9 @@ end
|
||||||
-- Hooks
|
-- Hooks
|
||||||
--------------------------------------------------------------------------------
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
function on_init()
|
function on_install()
|
||||||
print("Calendar: on_init — running migration")
|
print("Calendar: on_install — creating tables")
|
||||||
|
|
||||||
-- Run migration SQL
|
|
||||||
local ok, err = pcall(function()
|
local ok, err = pcall(function()
|
||||||
verstak.db.exec([[
|
verstak.db.exec([[
|
||||||
CREATE TABLE IF NOT EXISTS categories (
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
|
@ -696,13 +695,40 @@ function on_init()
|
||||||
|
|
||||||
if not ok then
|
if not ok then
|
||||||
print("Calendar: migration error: " .. tostring(err))
|
print("Calendar: migration error: " .. tostring(err))
|
||||||
|
error(err)
|
||||||
else
|
else
|
||||||
print("Calendar: migration complete")
|
print("Calendar: migration complete")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Ensure default categories
|
-- Insert default categories
|
||||||
M.ensure_categories()
|
M.ensure_categories()
|
||||||
|
|
||||||
|
print("Calendar: install complete")
|
||||||
|
end
|
||||||
|
|
||||||
|
function on_uninstall()
|
||||||
|
print("Calendar: on_uninstall — dropping tables")
|
||||||
|
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
verstak.db.exec("DROP TABLE IF EXISTS events")
|
||||||
|
verstak.db.exec("DROP TABLE IF EXISTS categories")
|
||||||
|
end)
|
||||||
|
|
||||||
|
if not ok then
|
||||||
|
print("Calendar: uninstall error: " .. tostring(err))
|
||||||
|
else
|
||||||
|
print("Calendar: tables dropped")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Clean up config
|
||||||
|
pcall(verstak.config.set, "categories", nil)
|
||||||
|
|
||||||
|
print("Calendar: uninstall complete")
|
||||||
|
end
|
||||||
|
|
||||||
|
function on_init()
|
||||||
|
print("Calendar: on_init — registering API")
|
||||||
|
|
||||||
-- Register global API for panel access
|
-- Register global API for panel access
|
||||||
_G.calendar = M
|
_G.calendar = M
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -7,7 +7,9 @@
|
||||||
|
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"on_init": "on_init",
|
"on_init": "on_init",
|
||||||
"on_shutdown": "on_shutdown"
|
"on_shutdown": "on_shutdown",
|
||||||
|
"on_install": "on_install",
|
||||||
|
"on_uninstall": "on_uninstall"
|
||||||
},
|
},
|
||||||
|
|
||||||
"ui": {
|
"ui": {
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,31 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function install(p) {
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
await wailsCall('InstallPlugin', p.name)
|
||||||
|
p.installed = true
|
||||||
|
// Refresh list to reflect new state
|
||||||
|
plugins = await wailsCall('ListPlugins') || []
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uninstall(p) {
|
||||||
|
if (!confirm(`Удалить плагин «${p.name}»? Все данные плагина будут очищены. Файлы плагина сохранятся.`)) return
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
await wailsCall('UninstallPlugin', p.name)
|
||||||
|
p.installed = false
|
||||||
|
p.active = false
|
||||||
|
plugins = await wailsCall('ListPlugins') || []
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$: sidebarCount = plugins.reduce((n, p) => n + (p.uiContribs?.sidebarItems?.length || 0), 0)
|
$: sidebarCount = plugins.reduce((n, p) => n + (p.uiContribs?.sidebarItems?.length || 0), 0)
|
||||||
$: tabCount = plugins.reduce((n, p) => n + (p.uiContribs?.nodeTabs?.length || 0), 0)
|
$: tabCount = plugins.reduce((n, p) => n + (p.uiContribs?.nodeTabs?.length || 0), 0)
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -82,15 +107,36 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="plugin-toggle">
|
<div class="plugin-toggle">
|
||||||
<button
|
{#if p.hasInstall && !p.installed}
|
||||||
class="toggle-btn"
|
<button class="install-btn" on:click={() => install(p)}>
|
||||||
class:active={p.active}
|
📦 Установить
|
||||||
on:click={() => toggle(p)}
|
</button>
|
||||||
role="switch"
|
{:else if p.hasInstall && p.installed}
|
||||||
aria-checked={p.active}
|
<div class="toggle-group">
|
||||||
>
|
<button
|
||||||
<span class="toggle-knob"></span>
|
class="toggle-btn"
|
||||||
</button>
|
class:active={p.active}
|
||||||
|
on:click={() => toggle(p)}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={p.active}
|
||||||
|
>
|
||||||
|
<span class="toggle-knob"></span>
|
||||||
|
</button>
|
||||||
|
<button class="uninstall-btn btn-sm" on:click={() => uninstall(p)} title="Удалить плагин (очистить данные)">
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="toggle-btn"
|
||||||
|
class:active={p.active}
|
||||||
|
on:click={() => toggle(p)}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={p.active}
|
||||||
|
>
|
||||||
|
<span class="toggle-knob"></span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -201,4 +247,42 @@
|
||||||
.toggle-btn.active .toggle-knob {
|
.toggle-btn.active .toggle-knob {
|
||||||
left: 22px;
|
left: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toggle-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.install-btn:hover { filter: brightness(1.15); }
|
||||||
|
|
||||||
|
.uninstall-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: var(--surface, #1a1a2e);
|
||||||
|
color: #ef4444;
|
||||||
|
border: 1px solid rgba(239,68,68,0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.uninstall-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(239,68,68,0.1);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ type AppConfig struct {
|
||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
EnabledTemplates []string `json:"enabled_templates"`
|
EnabledTemplates []string `json:"enabled_templates"`
|
||||||
EnabledPlugins []string `json:"enabled_plugins"`
|
EnabledPlugins []string `json:"enabled_plugins"`
|
||||||
|
InstalledPlugins []string `json:"installed_plugins"`
|
||||||
FirstRunCompleted bool `json:"first_run_completed"`
|
FirstRunCompleted bool `json:"first_run_completed"`
|
||||||
Window WindowConfig `json:"window,omitempty"`
|
Window WindowConfig `json:"window,omitempty"`
|
||||||
Vault VaultAppConfig `json:"vault,omitempty"`
|
Vault VaultAppConfig `json:"vault,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@ package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
lua "github.com/yuin/gopher-lua"
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
"verstak/internal/core/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Meta is the plugin.json descriptor.
|
// Meta is the plugin.json descriptor.
|
||||||
|
|
@ -75,6 +77,8 @@ type Plugin struct {
|
||||||
Dir string // absolute path to plugin directory
|
Dir string // absolute path to plugin directory
|
||||||
DataDir string // .verstak/plugins/<name>/data — plugin's own SQLite storage
|
DataDir string // .verstak/plugins/<name>/data — plugin's own SQLite storage
|
||||||
Active bool
|
Active bool
|
||||||
|
Installed bool
|
||||||
|
HasInstall bool
|
||||||
|
|
||||||
// Runtime (set after InitRuntime)
|
// Runtime (set after InitRuntime)
|
||||||
vm *LuaVM
|
vm *LuaVM
|
||||||
|
|
@ -96,6 +100,8 @@ func NewManager(vaultRoot string) *Manager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discover scans .verstak/plugins/* for plugin.json files.
|
// Discover scans .verstak/plugins/* for plugin.json files.
|
||||||
|
// Sets Installed=true for all plugins (they need Install call to set up DB).
|
||||||
|
// Active is always false after Discover — call SyncConfig or Enable to activate.
|
||||||
func (m *Manager) Discover() {
|
func (m *Manager) Discover() {
|
||||||
pluginsDir := filepath.Join(m.vaultRoot, ".verstak", "plugins")
|
pluginsDir := filepath.Join(m.vaultRoot, ".verstak", "plugins")
|
||||||
entries, err := os.ReadDir(pluginsDir)
|
entries, err := os.ReadDir(pluginsDir)
|
||||||
|
|
@ -120,15 +126,44 @@ func (m *Manager) Discover() {
|
||||||
if meta.Name == "" {
|
if meta.Name == "" {
|
||||||
meta.Name = e.Name()
|
meta.Name = e.Name()
|
||||||
}
|
}
|
||||||
dataDir := filepath.Join(pluginsDir, e.Name(), "data")
|
dataDir := filepath.Join(pluginsDir, e.Name(), "data")
|
||||||
os.MkdirAll(dataDir, 0o750)
|
os.MkdirAll(dataDir, 0o750)
|
||||||
|
|
||||||
m.plugins = append(m.plugins, Plugin{
|
hasInstall := meta.Hooks["on_install"] != ""
|
||||||
Meta: meta,
|
|
||||||
Dir: filepath.Join(pluginsDir, e.Name()),
|
m.plugins = append(m.plugins, Plugin{
|
||||||
DataDir: dataDir,
|
Meta: meta,
|
||||||
Active: true,
|
Dir: filepath.Join(pluginsDir, e.Name()),
|
||||||
})
|
DataDir: dataDir,
|
||||||
|
Active: false,
|
||||||
|
Installed: false,
|
||||||
|
HasInstall: hasInstall,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncConfig applies installed and enabled states from AppConfig.
|
||||||
|
// Call after Discover() and before InitRuntimes().
|
||||||
|
func (m *Manager) SyncConfig(cfg *config.AppConfig) {
|
||||||
|
if cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
installedSet := make(map[string]bool)
|
||||||
|
enabledSet := make(map[string]bool)
|
||||||
|
for _, name := range cfg.InstalledPlugins {
|
||||||
|
installedSet[name] = true
|
||||||
|
}
|
||||||
|
for _, name := range cfg.EnabledPlugins {
|
||||||
|
enabledSet[name] = true
|
||||||
|
}
|
||||||
|
for i := range m.plugins {
|
||||||
|
installed := installedSet[m.plugins[i].Meta.Name]
|
||||||
|
// Plugins without on_install hook are always "installed"
|
||||||
|
if !m.plugins[i].HasInstall {
|
||||||
|
installed = true
|
||||||
|
}
|
||||||
|
m.plugins[i].Installed = installed
|
||||||
|
m.plugins[i].Active = installed && enabledSet[m.plugins[i].Meta.Name]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -318,23 +353,177 @@ type NodeMeta struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable activates a plugin by name.
|
// Enable activates a plugin by name.
|
||||||
func (m *Manager) Enable(name string) {
|
// If the plugin has on_install hook, it must be installed first.
|
||||||
|
// Plugins without on_install hook are always considered "installed".
|
||||||
|
func (m *Manager) Enable(name string) error {
|
||||||
for i := range m.plugins {
|
for i := range m.plugins {
|
||||||
if m.plugins[i].Meta.Name == name {
|
if m.plugins[i].Meta.Name == name {
|
||||||
m.plugins[i].Active = true
|
p := &m.plugins[i]
|
||||||
return
|
if p.HasInstall && !p.Installed {
|
||||||
|
return fmt.Errorf("plugin %q must be installed first (use Install)", name)
|
||||||
|
}
|
||||||
|
p.Active = true
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return fmt.Errorf("plugin %q not found", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable deactivates a plugin by name.
|
// Disable deactivates a plugin by name.
|
||||||
func (m *Manager) Disable(name string) {
|
func (m *Manager) Disable(name string) error {
|
||||||
for i := range m.plugins {
|
for i := range m.plugins {
|
||||||
if m.plugins[i].Meta.Name == name {
|
if m.plugins[i].Meta.Name == name {
|
||||||
m.plugins[i].Active = false
|
m.plugins[i].Active = false
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return fmt.Errorf("plugin %q not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsInstalled returns true if a plugin is marked installed.
|
||||||
|
func (m *Manager) IsInstalled(name string) bool {
|
||||||
|
for _, p := range m.plugins {
|
||||||
|
if p.Meta.Name == name {
|
||||||
|
return p.Installed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install runs a plugin's on_install hook (creates tables, defaults, etc.)
|
||||||
|
// and marks it as installed in the config.
|
||||||
|
func (m *Manager) Install(name string) error {
|
||||||
|
for i := range m.plugins {
|
||||||
|
if m.plugins[i].Meta.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := &m.plugins[i]
|
||||||
|
if p.Installed {
|
||||||
|
return fmt.Errorf("plugin %q is already installed", name)
|
||||||
|
}
|
||||||
|
hookName := p.Meta.Hooks["on_install"]
|
||||||
|
if hookName == "" {
|
||||||
|
return fmt.Errorf("plugin %q does not support install lifecycle", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary VM to run on_install
|
||||||
|
vm, err := NewLuaVM(p)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create VM: %w", err)
|
||||||
|
}
|
||||||
|
defer vm.Close()
|
||||||
|
if m.Services != nil {
|
||||||
|
vm.SetServices(m.Services)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load main.lua so functions are available
|
||||||
|
mainPath := filepath.Join(p.Dir, "main.lua")
|
||||||
|
if _, err := os.Stat(mainPath); err == nil {
|
||||||
|
if err := vm.LoadScript("main.lua"); err != nil {
|
||||||
|
return fmt.Errorf("load main.lua: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call on_install hook
|
||||||
|
if err := vm.CallHook(hookName); err != nil {
|
||||||
|
return fmt.Errorf("on_install: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark installed in config
|
||||||
|
p.Installed = true
|
||||||
|
appCfg, _ := config.LoadAppConfig()
|
||||||
|
if appCfg == nil {
|
||||||
|
appCfg = config.DefaultAppConfig()
|
||||||
|
}
|
||||||
|
appCfg.InstalledPlugins = append(appCfg.InstalledPlugins, name)
|
||||||
|
if err := config.SaveAppConfig(appCfg); err != nil {
|
||||||
|
return fmt.Errorf("save config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("plugin %q not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uninstall runs a plugin's on_uninstall hook (drops tables, cleans data),
|
||||||
|
// disables it first, and removes it from the installed list.
|
||||||
|
// Does NOT delete plugin files from disk.
|
||||||
|
func (m *Manager) Uninstall(name string) error {
|
||||||
|
for i := range m.plugins {
|
||||||
|
if m.plugins[i].Meta.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := &m.plugins[i]
|
||||||
|
if !p.Installed {
|
||||||
|
return fmt.Errorf("plugin %q is not installed", name)
|
||||||
|
}
|
||||||
|
hookName := p.Meta.Hooks["on_uninstall"]
|
||||||
|
if hookName == "" {
|
||||||
|
return fmt.Errorf("plugin %q does not support install lifecycle", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First disable if active
|
||||||
|
if p.Active {
|
||||||
|
p.Active = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close existing runtime if any
|
||||||
|
if p.vm != nil {
|
||||||
|
p.vm.Close()
|
||||||
|
p.vm = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary VM to run on_uninstall
|
||||||
|
vm, err := NewLuaVM(p)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create VM: %w", err)
|
||||||
|
}
|
||||||
|
defer vm.Close()
|
||||||
|
if m.Services != nil {
|
||||||
|
vm.SetServices(m.Services)
|
||||||
|
}
|
||||||
|
|
||||||
|
mainPath := filepath.Join(p.Dir, "main.lua")
|
||||||
|
if _, err := os.Stat(mainPath); err == nil {
|
||||||
|
if err := vm.LoadScript("main.lua"); err != nil {
|
||||||
|
return fmt.Errorf("load main.lua: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call on_uninstall hook
|
||||||
|
if err := vm.CallHook(hookName); err != nil {
|
||||||
|
return fmt.Errorf("on_uninstall: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean plugin data directory
|
||||||
|
os.RemoveAll(p.DataDir + ".db") // remove SQLite file
|
||||||
|
os.MkdirAll(p.DataDir, 0o750) // recreate for future install
|
||||||
|
|
||||||
|
// Remove from installed list in config
|
||||||
|
p.Installed = false
|
||||||
|
appCfg, _ := config.LoadAppConfig()
|
||||||
|
if appCfg != nil {
|
||||||
|
var updated []string
|
||||||
|
for _, n := range appCfg.InstalledPlugins {
|
||||||
|
if n != name {
|
||||||
|
updated = append(updated, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appCfg.InstalledPlugins = updated
|
||||||
|
// Also remove from enabled (can't be enabled if not installed)
|
||||||
|
var enabled []string
|
||||||
|
for _, n := range appCfg.EnabledPlugins {
|
||||||
|
if n != name {
|
||||||
|
enabled = append(enabled, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appCfg.EnabledPlugins = enabled
|
||||||
|
config.SaveAppConfig(appCfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("plugin %q not found", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActiveNames returns names of active plugins.
|
// ActiveNames returns names of active plugins.
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,17 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// ActivatePlugin fully activates a plugin: creates Lua VM, loads main.lua, starts scheduler.
|
// ActivatePlugin fully activates a plugin: creates Lua VM, loads main.lua, starts scheduler.
|
||||||
|
// Only works if plugin is installed (if it has on_install hook, must be installed first).
|
||||||
func (m *Manager) ActivatePlugin(name string) {
|
func (m *Manager) ActivatePlugin(name string) {
|
||||||
for i := range m.plugins {
|
for i := range m.plugins {
|
||||||
p := &m.plugins[i]
|
p := &m.plugins[i]
|
||||||
if p.Meta.Name != name || p.Active {
|
if p.Meta.Name != name || p.Active {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if p.HasInstall && !p.Installed {
|
||||||
|
log.Printf("[plugins] %s: cannot activate — not installed", name)
|
||||||
|
return
|
||||||
|
}
|
||||||
p.Active = true
|
p.Active = true
|
||||||
|
|
||||||
vm, err := NewLuaVM(p)
|
vm, err := NewLuaVM(p)
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,9 @@ func TestDiscover(t *testing.T) {
|
||||||
t.Errorf("plugin name = %q", plugins[0].Meta.Name)
|
t.Errorf("plugin name = %q", plugins[0].Meta.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable plugin to load templates
|
||||||
|
mgr.Enable("client")
|
||||||
|
|
||||||
// Templates.
|
// Templates.
|
||||||
tmpls := mgr.Templates()
|
tmpls := mgr.Templates()
|
||||||
if len(tmpls) != 1 {
|
if len(tmpls) != 1 {
|
||||||
|
|
@ -118,9 +121,16 @@ func TestEnableDisable(t *testing.T) {
|
||||||
t.Fatalf("plugins = %d, want 2", len(mgr.Plugins()))
|
t.Fatalf("plugins = %d, want 2", len(mgr.Plugins()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// All active by default.
|
// All inactive by default after Discover.
|
||||||
|
if len(mgr.Active()) != 0 {
|
||||||
|
t.Errorf("active after discover = %d, want 0", len(mgr.Active()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable both.
|
||||||
|
mgr.Enable("a")
|
||||||
|
mgr.Enable("b")
|
||||||
if len(mgr.Active()) != 2 {
|
if len(mgr.Active()) != 2 {
|
||||||
t.Errorf("active = %d, want 2", len(mgr.Active()))
|
t.Errorf("active after enable = %d, want 2", len(mgr.Active()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable one.
|
// Disable one.
|
||||||
|
|
@ -132,7 +142,7 @@ func TestEnableDisable(t *testing.T) {
|
||||||
// Re-enable.
|
// Re-enable.
|
||||||
mgr.Enable("a")
|
mgr.Enable("a")
|
||||||
if len(mgr.Active()) != 2 {
|
if len(mgr.Active()) != 2 {
|
||||||
t.Errorf("active after enable = %d, want 2", len(mgr.Active()))
|
t.Errorf("active after re-enable = %d, want 2", len(mgr.Active()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,6 +154,8 @@ func TestActiveNames(t *testing.T) {
|
||||||
|
|
||||||
mgr := NewManager(root)
|
mgr := NewManager(root)
|
||||||
mgr.Discover()
|
mgr.Discover()
|
||||||
|
mgr.Enable("p1")
|
||||||
|
mgr.Enable("p2")
|
||||||
mgr.Disable("p1")
|
mgr.Disable("p1")
|
||||||
|
|
||||||
names := mgr.ActiveNames()
|
names := mgr.ActiveNames()
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,7 @@ func TestPluginManager_InitRuntimes(t *testing.T) {
|
||||||
|
|
||||||
mgr := NewManager(dir)
|
mgr := NewManager(dir)
|
||||||
mgr.Discover()
|
mgr.Discover()
|
||||||
|
mgr.Enable("testp")
|
||||||
mgr.InitRuntimes()
|
mgr.InitRuntimes()
|
||||||
defer mgr.CloseRuntimes()
|
defer mgr.CloseRuntimes()
|
||||||
|
|
||||||
|
|
@ -325,12 +326,16 @@ func TestCalendarPlugin_LoadAndRun(t *testing.T) {
|
||||||
Meta: Meta{
|
Meta: Meta{
|
||||||
Name: "calendar",
|
Name: "calendar",
|
||||||
Hooks: map[string]string{
|
Hooks: map[string]string{
|
||||||
"on_init": "on_init",
|
"on_init": "on_init",
|
||||||
|
"on_install": "on_install",
|
||||||
|
"on_uninstall": "on_uninstall",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Dir: pluginDir,
|
Dir: pluginDir,
|
||||||
DataDir: dataDir,
|
DataDir: dataDir,
|
||||||
Active: true,
|
Active: true,
|
||||||
|
Installed: true,
|
||||||
|
HasInstall: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
vm, err := NewLuaVM(p)
|
vm, err := NewLuaVM(p)
|
||||||
|
|
@ -344,7 +349,12 @@ func TestCalendarPlugin_LoadAndRun(t *testing.T) {
|
||||||
t.Fatalf("LoadScript(main.lua): %v", err)
|
t.Fatalf("LoadScript(main.lua): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run on_init hook — this runs the migration + default categories
|
// Run on_install — creates tables + default categories (skipped on re-install)
|
||||||
|
if err := vm.CallHook("on_install"); err != nil {
|
||||||
|
t.Fatalf("CallHook(on_install): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run on_init — registers API + state
|
||||||
if err := vm.CallHook("on_init"); err != nil {
|
if err := vm.CallHook("on_init"); err != nil {
|
||||||
t.Fatalf("CallHook(on_init): %v", err)
|
t.Fatalf("CallHook(on_init): %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue