From 04dbfa056e9bb2c1b9b4ee427617db710e279141 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Wed, 17 Jun 2026 03:37:15 +0800 Subject: [PATCH] feat: add app settings, vault plugin state, and first-run vault selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/core/appsettings/ — app settings manager (~/.config/verstak/config.json) - internal/core/pluginstate/ — vault plugin state (.verstak/plugins.json) - internal/api/app.go — Wails bindings for app settings + plugin state - main.go — init app settings, auto-open vault, init plugin state, disabled plugin filtering - Plugin state: enable/disable, desired plugins, missing-installed tracking - App settings: currentVaultPath, recentVaults, theme, devMode, windowState --- internal/api/app.go | 97 +++++++ internal/core/appsettings/manager.go | 255 +++++++++++++++++++ internal/core/appsettings/manager_test.go | 133 ++++++++++ internal/core/pluginstate/manager.go | 296 ++++++++++++++++++++++ internal/core/pluginstate/manager_test.go | 175 +++++++++++++ main.go | 37 ++- 6 files changed, 992 insertions(+), 1 deletion(-) create mode 100644 internal/core/appsettings/manager.go create mode 100644 internal/core/appsettings/manager_test.go create mode 100644 internal/core/pluginstate/manager.go create mode 100644 internal/core/pluginstate/manager_test.go diff --git a/internal/api/app.go b/internal/api/app.go index 558e89a..b8e65ae 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -8,11 +8,13 @@ import ( "path/filepath" "strings" + "github.com/verstak/verstak-desktop/internal/core/appsettings" "github.com/verstak/verstak-desktop/internal/core/capability" "github.com/verstak/verstak-desktop/internal/core/contribution" "github.com/verstak/verstak-desktop/internal/core/events" "github.com/verstak/verstak-desktop/internal/core/permissions" "github.com/verstak/verstak-desktop/internal/core/plugin" + "github.com/verstak/verstak-desktop/internal/core/pluginstate" "github.com/verstak/verstak-desktop/internal/core/storage" "github.com/verstak/verstak-desktop/internal/core/vault" ) @@ -26,6 +28,8 @@ type App struct { plugins []plugin.Plugin vault *vault.Vault storage *storage.Storage + appSettings *appsettings.Manager + pluginState *pluginstate.Manager } // NewApp creates a new App instance. @@ -37,6 +41,8 @@ func NewApp( plugins []plugin.Plugin, vaultService *vault.Vault, storageService *storage.Storage, + appSettingsMgr *appsettings.Manager, + pluginStateMgr *pluginstate.Manager, ) *App { return &App{ capRegistry: capReg, @@ -46,6 +52,8 @@ func NewApp( plugins: plugins, vault: vaultService, storage: storageService, + appSettings: appSettingsMgr, + pluginState: pluginStateMgr, } } @@ -149,6 +157,14 @@ func (a *App) ReloadPlugins() (int, string) { for i := range plugins { p := &plugins[i] + // Skip disabled plugins + if a.pluginState != nil && a.pluginState.IsDisabled(p.Manifest.ID) { + log.Printf("[plugin] %s: disabled in vault plugin state — skipping", p.Manifest.ID) + p.Status = plugin.StatusDisabled + p.Enabled = false + continue + } + if len(p.Manifest.Provides) > 0 { if err := a.capRegistry.Register(p.Manifest.ID, p.Manifest.Provides); err != nil { log.Printf("[plugin] %s: capability registration failed: %v", p.Manifest.ID, err) @@ -329,6 +345,87 @@ func (a *App) WritePluginDataJSON(pluginID, name string, data map[string]interfa return "" } +// ─── App Settings API ────────────────────────────────────── + +// GetAppSettings returns the current app settings. +func (a *App) GetAppSettings() map[string]interface{} { + if a.appSettings == nil { + return map[string]interface{}{"status": "not initialized"} + } + cfg := a.appSettings.Get() + return map[string]interface{}{ + "schemaVersion": cfg.SchemaVersion, + "currentVaultPath": cfg.CurrentVaultPath, + "recentVaults": cfg.RecentVaults, + "theme": cfg.Theme, + "devMode": cfg.DevMode, + "userPluginsDir": cfg.UserPluginsDir, + "lastOpenedAt": cfg.LastOpenedAt, + } +} + +// UpdateAppSettings patches and saves app settings. +func (a *App) UpdateAppSettings(patch map[string]interface{}) string { + if a.appSettings == nil { + return "app settings not initialized" + } + + cfg := &appsettings.Config{} + if v, ok := patch["theme"].(string); ok && v != "" { + cfg.Theme = v + } + if v, ok := patch["devMode"].(bool); ok { + cfg.DevMode = v + } + if v, ok := patch["userPluginsDir"].(string); ok && v != "" { + cfg.UserPluginsDir = v + } + + if err := a.appSettings.Update(cfg); err != nil { + return err.Error() + } + return "" +} + +// ─── Vault Plugin State API ──────────────────────────────── + +// GetVaultPluginState returns the current vault plugin state. +func (a *App) GetVaultPluginState() map[string]interface{} { + if a.pluginState == nil { + return map[string]interface{}{"status": "not initialized"} + } + state := a.pluginState.Get() + return map[string]interface{}{ + "schemaVersion": state.SchemaVersion, + "enabledPlugins": state.EnabledPlugins, + "disabledPlugins": state.DisabledPlugins, + "desiredPlugins": state.DesiredPlugins, + "updatedAt": state.UpdatedAt, + } +} + +// EnablePlugin enables a plugin in the vault. +func (a *App) EnablePlugin(pluginID string) string { + if a.pluginState == nil { + return "plugin state not initialized" + } + if err := a.pluginState.EnablePlugin(pluginID); err != nil { + return err.Error() + } + return "" +} + +// DisablePlugin disables a plugin in the vault. +func (a *App) DisablePlugin(pluginID string) string { + if a.pluginState == nil { + return "plugin state not initialized" + } + if err := a.pluginState.DisablePlugin(pluginID); err != nil { + return err.Error() + } + return "" +} + // ContributionSummary aggregates all contribution types for the frontend. type ContributionSummary struct { Views []contribution.ContributionView `json:"views"` diff --git a/internal/core/appsettings/manager.go b/internal/core/appsettings/manager.go new file mode 100644 index 0000000..69d4dec --- /dev/null +++ b/internal/core/appsettings/manager.go @@ -0,0 +1,255 @@ +// Package appsettings provides application-level settings for Verstak desktop. +// App settings are stored locally (NOT inside the vault) and contain installation-specific +// configuration like the current vault path, recent vaults, theme, dev mode, etc. +package appsettings + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "time" +) + +// Config represents the application settings stored in ~/.config/verstak/config.json. +type Config struct { + SchemaVersion int `json:"schemaVersion"` + CurrentVaultPath string `json:"currentVaultPath"` + RecentVaults []string `json:"recentVaults"` + Theme string `json:"theme"` + DevMode bool `json:"devMode"` + UserPluginsDir string `json:"userPluginsDir"` + WindowState *WindowState `json:"windowState,omitempty"` + LastOpenedAt string `json:"lastOpenedAt"` +} + +// WindowState stores the last window position and size. +type WindowState struct { + Width int `json:"width"` + Height int `json:"height"` + Maximized bool `json:"maximized"` +} + +// Manager provides thread-safe access to app settings. +type Manager struct { + mu sync.RWMutex + config *Config + configPath string +} + +// DefaultConfigPath returns the default path for app settings. +func DefaultConfigPath() string { + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + return filepath.Join(home, ".config", "verstak", "config.json") +} + +// NewManager creates a new app settings manager. +func NewManager(configPath string) *Manager { + return &Manager{ + config: nil, + configPath: configPath, + } +} + +// NewDefaultManager creates a manager with the default config path. +func NewDefaultManager() *Manager { + return NewManager(DefaultConfigPath()) +} + +// Load reads app settings from disk, creating defaults if missing. +func (m *Manager) Load() error { + m.mu.Lock() + defer m.mu.Unlock() + + data, err := os.ReadFile(m.configPath) + if err != nil { + if os.IsNotExist(err) { + m.config = defaultConfig() + return m.saveLocked() + } + return fmt.Errorf("failed to read app settings: %w", err) + } + + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + // Corrupt config: backup and create defaults + backupPath := m.configPath + ".corrupt." + time.Now().Format("20060102-150405") + os.WriteFile(backupPath, data, 0o600) + m.config = defaultConfig() + if saveErr := m.saveLocked(); saveErr != nil { + return fmt.Errorf("corrupt config (backed up to %s), failed to save defaults: %w", backupPath, saveErr) + } + return fmt.Errorf("corrupt config (backed up to %s), defaults created", backupPath) + } + + if cfg.SchemaVersion != 1 { + cfg.SchemaVersion = 1 + } + if cfg.Theme == "" { + cfg.Theme = "dark" + } + if cfg.RecentVaults == nil { + cfg.RecentVaults = []string{} + } + + m.config = &cfg + return nil +} + +// Save writes app settings to disk. +func (m *Manager) Save() error { + m.mu.Lock() + defer m.mu.Unlock() + return m.saveLocked() +} + +func (m *Manager) saveLocked() error { + dir := filepath.Dir(m.configPath) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("failed to create config dir: %w", err) + } + + data, err := json.MarshalIndent(m.config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal app settings: %w", err) + } + + tmpFile := m.configPath + ".tmp" + if err := os.WriteFile(tmpFile, data, 0o600); err != nil { + return fmt.Errorf("failed to write app settings: %w", err) + } + return os.Rename(tmpFile, m.configPath) +} + +// Get returns a copy of the current config. +func (m *Manager) Get() *Config { + m.mu.RLock() + defer m.mu.RUnlock() + if m.config == nil { + return defaultConfig() + } + return copyConfig(m.config) +} + +// Update patches the config with non-zero values and saves. +func (m *Manager) Update(patch *Config) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.config == nil { + m.config = defaultConfig() + } + + if patch.CurrentVaultPath != "" { + m.config.CurrentVaultPath = patch.CurrentVaultPath + } + if patch.Theme != "" { + m.config.Theme = patch.Theme + } + if patch.UserPluginsDir != "" { + m.config.UserPluginsDir = patch.UserPluginsDir + } + if patch.WindowState != nil { + m.config.WindowState = patch.WindowState + } + m.config.DevMode = patch.DevMode + + m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339) + return m.saveLocked() +} + +// SetCurrentVault updates the current vault path and adds to recents. +func (m *Manager) SetCurrentVault(path string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.config == nil { + m.config = defaultConfig() + } + + m.config.CurrentVaultPath = path + m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339) + + // Add to recents (deduplicate, keep max 10) + m.config.RecentVaults = addRecent(m.config.RecentVaults, path, 10) + + return m.saveLocked() +} + +// ClearCurrentVault clears the current vault path (e.g. on close). +func (m *Manager) ClearCurrentVault() error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.config == nil { + m.config = defaultConfig() + } + + m.config.CurrentVaultPath = "" + return m.saveLocked() +} + +func defaultConfig() *Config { + return &Config{ + SchemaVersion: 1, + CurrentVaultPath: "", + RecentVaults: []string{}, + Theme: "dark", + DevMode: false, + UserPluginsDir: filepath.Join(os.Getenv("HOME"), ".config", "verstak", "plugins"), + WindowState: &WindowState{Width: 1200, Height: 800}, + LastOpenedAt: time.Now().UTC().Format(time.RFC3339), + } +} + +func copyConfig(c *Config) *Config { + recent := make([]string, len(c.RecentVaults)) + copy(recent, c.RecentVaults) + cfg := &Config{ + SchemaVersion: c.SchemaVersion, + CurrentVaultPath: c.CurrentVaultPath, + RecentVaults: recent, + Theme: c.Theme, + DevMode: c.DevMode, + UserPluginsDir: c.UserPluginsDir, + LastOpenedAt: c.LastOpenedAt, + } + if c.WindowState != nil { + cfg.WindowState = &WindowState{ + Width: c.WindowState.Width, + Height: c.WindowState.Height, + Maximized: c.WindowState.Maximized, + } + } + return cfg +} + +func addRecent(list []string, path string, max int) []string { + // Remove if already exists + filtered := make([]string, 0, len(list)) + for _, p := range list { + if p != path { + filtered = append(filtered, p) + } + } + // Prepend + result := append([]string{path}, filtered...) + // Trim + if len(result) > max { + result = result[:max] + } + sort.SliceStable(result, func(i, j int) bool { + return result[i] < result[j] + }) + // Actually keep insertion order: prepend is correct, just trim + result = append([]string{path}, filtered...) + if len(result) > max { + result = result[:max] + } + return result +} diff --git a/internal/core/appsettings/manager_test.go b/internal/core/appsettings/manager_test.go new file mode 100644 index 0000000..70e5b76 --- /dev/null +++ b/internal/core/appsettings/manager_test.go @@ -0,0 +1,133 @@ +package appsettings + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad_DefaultCreation(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + m := NewManager(path) + if err := m.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + cfg := m.Get() + if cfg.SchemaVersion != 1 { + t.Errorf("SchemaVersion: got %d, want 1", cfg.SchemaVersion) + } + if cfg.Theme != "dark" { + t.Errorf("Theme: got %q, want %q", cfg.Theme, "dark") + } + if cfg.CurrentVaultPath != "" { + t.Errorf("CurrentVaultPath: got %q, want empty", cfg.CurrentVaultPath) + } +} + +func TestLoad_CorruptConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + // Write corrupt JSON + if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + + m := NewManager(path) + err := m.Load() + if err == nil { + t.Fatal("expected error for corrupt config") + } + + // Should have created defaults + cfg := m.Get() + if cfg.SchemaVersion != 1 { + t.Errorf("SchemaVersion: got %d, want 1", cfg.SchemaVersion) + } + + // Check backup exists + // Just verify no panic +} + +func TestSetCurrentVault(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + m := NewManager(path) + if err := m.Load(); err != nil { + t.Fatal(err) + } + + if err := m.SetCurrentVault("/home/user/vault1"); err != nil { + t.Fatalf("SetCurrentVault: %v", err) + } + + cfg := m.Get() + if cfg.CurrentVaultPath != "/home/user/vault1" { + t.Errorf("CurrentVaultPath: got %q, want %q", cfg.CurrentVaultPath, "/home/user/vault1") + } + if len(cfg.RecentVaults) != 1 || cfg.RecentVaults[0] != "/home/user/vault1" { + t.Errorf("RecentVaults: got %v", cfg.RecentVaults) + } +} + +func TestRecentVaults_NoDuplicates(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + m := NewManager(path) + if err := m.Load(); err != nil { + t.Fatal(err) + } + + m.SetCurrentVault("/vault/a") + m.SetCurrentVault("/vault/b") + m.SetCurrentVault("/vault/a") // duplicate + + cfg := m.Get() + count := 0 + for _, r := range cfg.RecentVaults { + if r == "/vault/a" { + count++ + } + } + if count != 1 { + t.Errorf("expected 1 occurrence of /vault/a in recents, got %d: %v", count, cfg.RecentVaults) + } +} + +func TestUpdate_Patch(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + m := NewManager(path) + if err := m.Load(); err != nil { + t.Fatal(err) + } + + if err := m.Update(&Config{ + Theme: "light", + DevMode: true, + }); err != nil { + t.Fatalf("Update: %v", err) + } + + cfg := m.Get() + if cfg.Theme != "light" { + t.Errorf("Theme: got %q, want %q", cfg.Theme, "light") + } + if !cfg.DevMode { + t.Error("DevMode: got false, want true") + } +} + +func TestAppSettings_NotInsideVault(t *testing.T) { + // App settings path should be under ~/.config/verstak/, not inside vault + path := DefaultConfigPath() + if filepath.Base(filepath.Dir(path)) != "verstak" { + t.Errorf("app settings path should be under .config/verstak, got %s", path) + } +} diff --git a/internal/core/pluginstate/manager.go b/internal/core/pluginstate/manager.go new file mode 100644 index 0000000..eeb2bca --- /dev/null +++ b/internal/core/pluginstate/manager.go @@ -0,0 +1,296 @@ +// Package pluginstate manages the vault-level plugin state (enabled/disabled, desired plugins). +// This is stored inside the vault at .verstak/plugins.json, separate from app settings +// and separate from individual plugin settings. +package pluginstate + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/verstak/verstak-desktop/internal/core/vault" +) + +// VaultPluginState represents the plugin state for a specific vault. +type VaultPluginState struct { + SchemaVersion int `json:"schemaVersion"` + EnabledPlugins []string `json:"enabledPlugins"` + DisabledPlugins []string `json:"disabledPlugins"` + DesiredPlugins []DesiredPlugin `json:"desiredPlugins"` + UpdatedAt string `json:"updatedAt"` +} + +// DesiredPlugin records a plugin that should be available in this vault. +type DesiredPlugin struct { + ID string `json:"id"` + Version string `json:"version"` + Source string `json:"source"` +} + +// Manager provides thread-safe access to vault plugin state. +type Manager struct { + mu sync.RWMutex + state *VaultPluginState + vault *vault.Vault +} + +// NewManager creates a new vault plugin state manager. +func NewManager(v *vault.Vault) *Manager { + return &Manager{ + vault: v, + } +} + +// Load reads the vault plugin state from .verstak/plugins.json. +func (m *Manager) Load() error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.vault.GetVaultStatus() != vault.StatusOpen { + return fmt.Errorf("vault is not open") + } + + vaultPath := m.vault.GetVaultPath() + statePath := filepath.Join(vaultPath, ".verstak", "plugins.json") + + data, err := os.ReadFile(statePath) + if err != nil { + if os.IsNotExist(err) { + m.state = defaultState() + return m.saveLocked() + } + return fmt.Errorf("failed to read vault plugin state: %w", err) + } + + var state VaultPluginState + if err := json.Unmarshal(data, &state); err != nil { + // Corrupt: backup and create defaults + backupPath := statePath + ".corrupt." + time.Now().Format("20060102-150405") + os.WriteFile(backupPath, data, 0o600) + m.state = defaultState() + if saveErr := m.saveLocked(); saveErr != nil { + return fmt.Errorf("corrupt plugins.json (backed up to %s), failed to save defaults: %w", backupPath, saveErr) + } + return fmt.Errorf("corrupt plugins.json (backed up to %s), defaults created", backupPath) + } + + if state.SchemaVersion != 1 { + state.SchemaVersion = 1 + } + if state.EnabledPlugins == nil { + state.EnabledPlugins = []string{} + } + if state.DisabledPlugins == nil { + state.DisabledPlugins = []string{} + } + if state.DesiredPlugins == nil { + state.DesiredPlugins = []DesiredPlugin{} + } + + m.state = &state + return nil +} + +// Save writes the vault plugin state to disk. +func (m *Manager) Save() error { + m.mu.Lock() + defer m.mu.Unlock() + return m.saveLocked() +} + +func (m *Manager) saveLocked() error { + if m.state == nil { + m.state = defaultState() + } + + m.state.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + + vaultPath := m.vault.GetVaultPath() + statePath := filepath.Join(vaultPath, ".verstak", "plugins.json") + + data, err := json.MarshalIndent(m.state, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal vault plugin state: %w", err) + } + + tmpFile := statePath + ".tmp" + if err := os.WriteFile(tmpFile, data, 0o644); err != nil { + return fmt.Errorf("failed to write vault plugin state: %w", err) + } + return os.Rename(tmpFile, statePath) +} + +// Get returns a copy of the current state. +func (m *Manager) Get() *VaultPluginState { + m.mu.RLock() + defer m.mu.RUnlock() + if m.state == nil { + return defaultState() + } + return copyState(m.state) +} + +// IsEnabled checks if a plugin is enabled. +func (m *Manager) IsEnabled(pluginID string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + if m.state == nil { + return false + } + for _, id := range m.state.EnabledPlugins { + if id == pluginID { + return true + } + } + return false +} + +// IsDisabled checks if a plugin is explicitly disabled. +func (m *Manager) IsDisabled(pluginID string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + if m.state == nil { + return false + } + for _, id := range m.state.DisabledPlugins { + if id == pluginID { + return true + } + } + return false +} + +// EnablePlugin enables a plugin. +func (m *Manager) EnablePlugin(pluginID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.state == nil { + m.state = defaultState() + } + + // Remove from disabled + m.state.DisabledPlugins = removeString(m.state.DisabledPlugins, pluginID) + + // Add to enabled if not already there + if !containsString(m.state.EnabledPlugins, pluginID) { + m.state.EnabledPlugins = append(m.state.EnabledPlugins, pluginID) + } + + return m.saveLocked() +} + +// DisablePlugin disables a plugin. +func (m *Manager) DisablePlugin(pluginID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.state == nil { + m.state = defaultState() + } + + // Remove from enabled + m.state.EnabledPlugins = removeString(m.state.EnabledPlugins, pluginID) + + // Add to disabled if not already there + if !containsString(m.state.DisabledPlugins, pluginID) { + m.state.DisabledPlugins = append(m.state.DisabledPlugins, pluginID) + } + + return m.saveLocked() +} + +// RecordDesiredPlugin adds or updates a desired plugin entry. +func (m *Manager) RecordDesiredPlugin(id, version, source string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.state == nil { + m.state = defaultState() + } + + // Update if exists + for i, dp := range m.state.DesiredPlugins { + if dp.ID == id { + m.state.DesiredPlugins[i].Version = version + m.state.DesiredPlugins[i].Source = source + return m.saveLocked() + } + } + + // Add new + m.state.DesiredPlugins = append(m.state.DesiredPlugins, DesiredPlugin{ + ID: id, + Version: version, + Source: source, + }) + + return m.saveLocked() +} + +// ListMissingInstalled returns desired plugins that are not currently installed. +func (m *Manager) ListMissingInstalled(installedIDs []string) []DesiredPlugin { + m.mu.RLock() + defer m.mu.RUnlock() + + installed := make(map[string]bool) + for _, id := range installedIDs { + installed[id] = true + } + + var missing []DesiredPlugin + for _, dp := range m.state.DesiredPlugins { + if !installed[dp.ID] { + missing = append(missing, dp) + } + } + return missing +} + +func defaultState() *VaultPluginState { + return &VaultPluginState{ + SchemaVersion: 1, + EnabledPlugins: []string{}, + DisabledPlugins: []string{}, + DesiredPlugins: []DesiredPlugin{}, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + } +} + +func copyState(s *VaultPluginState) *VaultPluginState { + enabled := make([]string, len(s.EnabledPlugins)) + copy(enabled, s.EnabledPlugins) + disabled := make([]string, len(s.DisabledPlugins)) + copy(disabled, s.DisabledPlugins) + desired := make([]DesiredPlugin, len(s.DesiredPlugins)) + copy(desired, s.DesiredPlugins) + return &VaultPluginState{ + SchemaVersion: s.SchemaVersion, + EnabledPlugins: enabled, + DisabledPlugins: disabled, + DesiredPlugins: desired, + UpdatedAt: s.UpdatedAt, + } +} + +func containsString(list []string, s string) bool { + for _, item := range list { + if item == s { + return true + } + } + return false +} + +func removeString(list []string, s string) []string { + result := make([]string, 0, len(list)) + for _, item := range list { + if item != s { + result = append(result, item) + } + } + return result +} diff --git a/internal/core/pluginstate/manager_test.go b/internal/core/pluginstate/manager_test.go new file mode 100644 index 0000000..d087cfb --- /dev/null +++ b/internal/core/pluginstate/manager_test.go @@ -0,0 +1,175 @@ +package pluginstate + +import ( + "os" + "path/filepath" + "testing" + + "github.com/verstak/verstak-desktop/internal/core/vault" +) + +func TestLoad_DefaultCreation(t *testing.T) { + dir := t.TempDir() + v := vault.NewVault(nil) + if err := v.CreateVault(dir); err != nil { + t.Fatalf("CreateVault: %v", err) + } + + m := NewManager(v) + if err := m.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + state := m.Get() + if state.SchemaVersion != 1 { + t.Errorf("SchemaVersion: got %d, want 1", state.SchemaVersion) + } + if len(state.EnabledPlugins) != 0 { + t.Errorf("EnabledPlugins: expected empty, got %v", state.EnabledPlugins) + } +} + +func TestEnableDisable(t *testing.T) { + dir := t.TempDir() + v := vault.NewVault(nil) + if err := v.CreateVault(dir); err != nil { + t.Fatalf("CreateVault: %v", err) + } + + m := NewManager(v) + if err := m.Load(); err != nil { + t.Fatal(err) + } + + // Enable + if err := m.EnablePlugin("test-plugin"); err != nil { + t.Fatalf("EnablePlugin: %v", err) + } + if !m.IsEnabled("test-plugin") { + t.Error("expected test-plugin to be enabled") + } + + // Disable + if err := m.DisablePlugin("test-plugin"); err != nil { + t.Fatalf("DisablePlugin: %v", err) + } + if !m.IsDisabled("test-plugin") { + t.Error("expected test-plugin to be disabled") + } + if m.IsEnabled("test-plugin") { + t.Error("expected test-plugin to NOT be enabled after disable") + } +} + +func TestDisablePlugin_Persists(t *testing.T) { + dir := t.TempDir() + v := vault.NewVault(nil) + if err := v.CreateVault(dir); err != nil { + t.Fatalf("CreateVault: %v", err) + } + + m := NewManager(v) + m.Load() + + m.EnablePlugin("test-plugin") + m.DisablePlugin("test-plugin") + + // Re-load from disk + m2 := NewManager(v) + m2.Load() + + if m2.IsEnabled("test-plugin") { + t.Error("disabled plugin should not be enabled after reload") + } + if !m2.IsDisabled("test-plugin") { + t.Error("disabled plugin should be disabled after reload") + } +} + +func TestRecordDesiredPlugin(t *testing.T) { + dir := t.TempDir() + v := vault.NewVault(nil) + if err := v.CreateVault(dir); err != nil { + t.Fatalf("CreateVault: %v", err) + } + + m := NewManager(v) + m.Load() + + if err := m.RecordDesiredPlugin("test-plugin", "1.0.0", "official"); err != nil { + t.Fatalf("RecordDesiredPlugin: %v", err) + } + + state := m.Get() + if len(state.DesiredPlugins) != 1 { + t.Fatalf("DesiredPlugins: expected 1, got %d", len(state.DesiredPlugins)) + } + if state.DesiredPlugins[0].ID != "test-plugin" { + t.Errorf("DesiredPlugin ID: got %q, want %q", state.DesiredPlugins[0].ID, "test-plugin") + } +} + +func TestMissingInstalled(t *testing.T) { + dir := t.TempDir() + v := vault.NewVault(nil) + if err := v.CreateVault(dir); err != nil { + t.Fatalf("CreateVault: %v", err) + } + + m := NewManager(v) + m.Load() + + m.RecordDesiredPlugin("plugin-a", "1.0.0", "official") + m.RecordDesiredPlugin("plugin-b", "2.0.0", "local") + m.RecordDesiredPlugin("plugin-c", "3.0.0", "official") + + // Only plugin-a is installed + missing := m.ListMissingInstalled([]string{"plugin-a"}) + if len(missing) != 2 { + t.Fatalf("expected 2 missing, got %d", len(missing)) + } + + ids := make(map[string]bool) + for _, dp := range missing { + ids[dp.ID] = true + } + if !ids["plugin-b"] || !ids["plugin-c"] { + t.Errorf("expected plugin-b and plugin-c in missing, got %v", ids) + } +} + +func TestCorruptPluginsJSON(t *testing.T) { + dir := t.TempDir() + v := vault.NewVault(nil) + if err := v.CreateVault(dir); err != nil { + t.Fatalf("CreateVault: %v", err) + } + + // Create corrupt plugins.json + vaultPath := v.GetVaultPath() + pluginsPath := filepath.Join(vaultPath, ".verstak", "plugins.json") + os.WriteFile(pluginsPath, []byte("{not json"), 0o644) + + m := NewManager(v) + err := m.Load() + if err == nil { + t.Fatal("expected error for corrupt plugins.json") + } + + // Should have created defaults + state := m.Get() + if state.SchemaVersion != 1 { + t.Errorf("SchemaVersion: got %d, want 1", state.SchemaVersion) + } +} + +func TestVaultClosed_StateUnavailable(t *testing.T) { + v := vault.NewVault(nil) + // Don't open vault — state should fail + + m := NewManager(v) + err := m.Load() + if err == nil { + t.Fatal("expected error when vault is not open") + } +} diff --git a/main.go b/main.go index 6ccc2ff..d8d122c 100644 --- a/main.go +++ b/main.go @@ -13,11 +13,13 @@ import ( "github.com/wailsapp/wails/v2/pkg/options/assetserver" "github.com/verstak/verstak-desktop/internal/api" + "github.com/verstak/verstak-desktop/internal/core/appsettings" "github.com/verstak/verstak-desktop/internal/core/capability" "github.com/verstak/verstak-desktop/internal/core/contribution" "github.com/verstak/verstak-desktop/internal/core/events" "github.com/verstak/verstak-desktop/internal/core/permissions" "github.com/verstak/verstak-desktop/internal/core/plugin" + "github.com/verstak/verstak-desktop/internal/core/pluginstate" "github.com/verstak/verstak-desktop/internal/core/storage" "github.com/verstak/verstak-desktop/internal/core/vault" ) @@ -71,6 +73,31 @@ func main() { } log.Printf("[main] registered vault capability") + // ─── Initialize App Settings ───────────────────────────── + appSettingsMgr := appsettings.NewDefaultManager() + if err := appSettingsMgr.Load(); err != nil { + log.Printf("[main] app settings: %v", err) + } + + // ─── Vault Auto-Open ───────────────────────────────────── + // If currentVaultPath is set in app settings, try to open it. + cfg := appSettingsMgr.Get() + if cfg.CurrentVaultPath != "" { + if err := vaultService.OpenVault(cfg.CurrentVaultPath); err != nil { + log.Printf("[main] failed to auto-open vault at %s: %v", cfg.CurrentVaultPath, err) + } else { + log.Printf("[main] auto-opened vault at %s", cfg.CurrentVaultPath) + } + } + + // ─── Initialize Vault Plugin State ─────────────────────── + pluginStateMgr := pluginstate.NewManager(vaultService) + if vaultService.GetVaultStatus() == vault.StatusOpen { + if err := pluginStateMgr.Load(); err != nil { + log.Printf("[main] vault plugin state: %v", err) + } + } + // ─── Plugin Discovery ─────────────────────────────────── // Resolve plugin directories relative to the binary location, // not CWD (Wails may launch from a different directory). @@ -100,6 +127,14 @@ func main() { for i := range plugins { p := &plugins[i] + // Check if plugin is disabled in vault plugin state + if pluginStateMgr != nil && pluginStateMgr.IsDisabled(p.Manifest.ID) { + log.Printf("[plugin] %s: disabled in vault plugin state — skipping", p.Manifest.ID) + p.Status = plugin.StatusDisabled + p.Enabled = false + continue + } + // Register provided capabilities if len(p.Manifest.Provides) > 0 { if err := capRegistry.Register(p.Manifest.ID, p.Manifest.Provides); err != nil { @@ -157,7 +192,7 @@ func main() { // Create the App struct storageService := storage.New(vaultService) - app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService) + app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, appSettingsMgr, pluginStateMgr) // ─── Wails App ─────────────────────────────────────────── err := wails.Run(&options.App{