diff --git a/cmd/verstak-gui/bindings_plugins.go b/cmd/verstak-gui/bindings_plugins.go index 5ed371c..f592779 100644 --- a/cmd/verstak-gui/bindings_plugins.go +++ b/cmd/verstak-gui/bindings_plugins.go @@ -18,6 +18,8 @@ type PluginDTO struct { 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"` @@ -89,6 +91,8 @@ func (a *App) ListPlugins() []PluginDTO { Author: p.Meta.Author, Description: p.Meta.Description, Active: active, + Installed: p.Installed, + HasInstall: p.HasInstall, HasPanel: hasPanel, UIContribs: contribs, }) @@ -97,11 +101,22 @@ func (a *App) ListPlugins() []PluginDTO { } // 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() @@ -207,26 +222,39 @@ func (a *App) ReloadPlugins() error { log.Print("[plugins] reload requested") 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.InitRuntimes() + + appCfg, _ := config.LoadAppConfig() + a.plugins.SyncConfig(appCfg) // Apply enable/disable state from config: deactivate everything not in enabled set for _, p := range a.plugins.Plugins() { - if !enabledSet[p.Meta.Name] { + if !p.Active || !p.Installed { a.plugins.DeactivatePlugin(p.Meta.Name) } } + 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) +} diff --git a/contrib/plugins/calendar/main.lua b/contrib/plugins/calendar/main.lua index 98777f7..fbfa82b 100644 --- a/contrib/plugins/calendar/main.lua +++ b/contrib/plugins/calendar/main.lua @@ -649,10 +649,9 @@ end -- Hooks -------------------------------------------------------------------------------- -function on_init() - print("Calendar: on_init — running migration") +function on_install() + print("Calendar: on_install — creating tables") - -- Run migration SQL local ok, err = pcall(function() verstak.db.exec([[ CREATE TABLE IF NOT EXISTS categories ( @@ -696,13 +695,40 @@ function on_init() if not ok then print("Calendar: migration error: " .. tostring(err)) + error(err) else print("Calendar: migration complete") end - -- Ensure default categories + -- Insert default 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 _G.calendar = M diff --git a/contrib/plugins/calendar/panels/calendar.html b/contrib/plugins/calendar/panels/calendar.html index b08cdd2..4eed23c 100644 --- a/contrib/plugins/calendar/panels/calendar.html +++ b/contrib/plugins/calendar/panels/calendar.html @@ -3,143 +3,983 @@ -Календарь +Calendar -
- -

Июнь 2025

- +
+
⏳ Загрузка календаря...
-
-
diff --git a/contrib/plugins/calendar/plugin.json b/contrib/plugins/calendar/plugin.json index 0eac582..0bf7cbf 100644 --- a/contrib/plugins/calendar/plugin.json +++ b/contrib/plugins/calendar/plugin.json @@ -7,7 +7,9 @@ "hooks": { "on_init": "on_init", - "on_shutdown": "on_shutdown" + "on_shutdown": "on_shutdown", + "on_install": "on_install", + "on_uninstall": "on_uninstall" }, "ui": { diff --git a/frontend/src/lib/SettingsPlugins.svelte b/frontend/src/lib/SettingsPlugins.svelte index 56c9c5e..35ef162 100644 --- a/frontend/src/lib/SettingsPlugins.svelte +++ b/frontend/src/lib/SettingsPlugins.svelte @@ -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) $: tabCount = plugins.reduce((n, p) => n + (p.uiContribs?.nodeTabs?.length || 0), 0) @@ -82,15 +107,36 @@
- + {#if p.hasInstall && !p.installed} + + {:else if p.hasInstall && p.installed} +
+ + +
+ {:else} + + {/if}
{/each} @@ -201,4 +247,42 @@ .toggle-btn.active .toggle-knob { 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); + } diff --git a/internal/core/config/appconfig.go b/internal/core/config/appconfig.go index cbea5c4..e25c9c5 100644 --- a/internal/core/config/appconfig.go +++ b/internal/core/config/appconfig.go @@ -21,6 +21,7 @@ type AppConfig struct { Language string `json:"language"` EnabledTemplates []string `json:"enabled_templates"` EnabledPlugins []string `json:"enabled_plugins"` + InstalledPlugins []string `json:"installed_plugins"` FirstRunCompleted bool `json:"first_run_completed"` Window WindowConfig `json:"window,omitempty"` Vault VaultAppConfig `json:"vault,omitempty"` diff --git a/internal/core/plugins/manager.go b/internal/core/plugins/manager.go index 4c59e50..f9358e7 100644 --- a/internal/core/plugins/manager.go +++ b/internal/core/plugins/manager.go @@ -2,11 +2,13 @@ package plugins import ( "encoding/json" + "fmt" "log" "os" "path/filepath" - lua "github.com/yuin/gopher-lua" + lua "github.com/yuin/gopher-lua" + "verstak/internal/core/config" ) // Meta is the plugin.json descriptor. @@ -75,6 +77,8 @@ type Plugin struct { Dir string // absolute path to plugin directory DataDir string // .verstak/plugins//data — plugin's own SQLite storage Active bool + Installed bool + HasInstall bool // Runtime (set after InitRuntime) vm *LuaVM @@ -96,6 +100,8 @@ func NewManager(vaultRoot string) *Manager { } // 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() { pluginsDir := filepath.Join(m.vaultRoot, ".verstak", "plugins") entries, err := os.ReadDir(pluginsDir) @@ -120,15 +126,44 @@ func (m *Manager) Discover() { if meta.Name == "" { meta.Name = e.Name() } - dataDir := filepath.Join(pluginsDir, e.Name(), "data") - os.MkdirAll(dataDir, 0o750) + dataDir := filepath.Join(pluginsDir, e.Name(), "data") + os.MkdirAll(dataDir, 0o750) - m.plugins = append(m.plugins, Plugin{ - Meta: meta, - Dir: filepath.Join(pluginsDir, e.Name()), - DataDir: dataDir, - Active: true, - }) + hasInstall := meta.Hooks["on_install"] != "" + + m.plugins = append(m.plugins, Plugin{ + Meta: meta, + 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. -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 { if m.plugins[i].Meta.Name == name { - m.plugins[i].Active = true - return + p := &m.plugins[i] + 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. -func (m *Manager) Disable(name string) { +func (m *Manager) Disable(name string) error { for i := range m.plugins { if m.plugins[i].Meta.Name == name { 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. diff --git a/internal/core/plugins/manager_lifecycle.go b/internal/core/plugins/manager_lifecycle.go index 77006c2..0d40883 100644 --- a/internal/core/plugins/manager_lifecycle.go +++ b/internal/core/plugins/manager_lifecycle.go @@ -9,12 +9,17 @@ import ( ) // 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) { for i := range m.plugins { p := &m.plugins[i] if p.Meta.Name != name || p.Active { continue } + if p.HasInstall && !p.Installed { + log.Printf("[plugins] %s: cannot activate — not installed", name) + return + } p.Active = true vm, err := NewLuaVM(p) diff --git a/internal/core/plugins/manager_test.go b/internal/core/plugins/manager_test.go index 2e7c522..997b687 100644 --- a/internal/core/plugins/manager_test.go +++ b/internal/core/plugins/manager_test.go @@ -78,6 +78,9 @@ func TestDiscover(t *testing.T) { t.Errorf("plugin name = %q", plugins[0].Meta.Name) } + // Enable plugin to load templates + mgr.Enable("client") + // Templates. tmpls := mgr.Templates() if len(tmpls) != 1 { @@ -118,9 +121,16 @@ func TestEnableDisable(t *testing.T) { 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 { - t.Errorf("active = %d, want 2", len(mgr.Active())) + t.Errorf("active after enable = %d, want 2", len(mgr.Active())) } // Disable one. @@ -132,7 +142,7 @@ func TestEnableDisable(t *testing.T) { // Re-enable. mgr.Enable("a") 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.Discover() + mgr.Enable("p1") + mgr.Enable("p2") mgr.Disable("p1") names := mgr.ActiveNames() diff --git a/internal/core/plugins/runtime_test.go b/internal/core/plugins/runtime_test.go index 0817d22..6dd7e4b 100644 --- a/internal/core/plugins/runtime_test.go +++ b/internal/core/plugins/runtime_test.go @@ -228,6 +228,7 @@ func TestPluginManager_InitRuntimes(t *testing.T) { mgr := NewManager(dir) mgr.Discover() + mgr.Enable("testp") mgr.InitRuntimes() defer mgr.CloseRuntimes() @@ -325,12 +326,16 @@ func TestCalendarPlugin_LoadAndRun(t *testing.T) { Meta: Meta{ Name: "calendar", Hooks: map[string]string{ - "on_init": "on_init", + "on_init": "on_init", + "on_install": "on_install", + "on_uninstall": "on_uninstall", }, }, - Dir: pluginDir, - DataDir: dataDir, - Active: true, + Dir: pluginDir, + DataDir: dataDir, + Active: true, + Installed: true, + HasInstall: true, } vm, err := NewLuaVM(p) @@ -344,7 +349,12 @@ func TestCalendarPlugin_LoadAndRun(t *testing.T) { 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 { t.Fatalf("CallHook(on_init): %v", err) }