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:
mirivlad 2026-06-07 15:28:37 +08:00
parent b80941f908
commit e99ff984b1
10 changed files with 1358 additions and 161 deletions

View File

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

View File

@ -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

View File

@ -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": {

View File

@ -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>

View File

@ -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"`

View File

@ -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.

View File

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

View File

@ -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()

View File

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