feat: add app settings, vault plugin state, and first-run vault selection

- 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
This commit is contained in:
mirivlad 2026-06-17 03:37:15 +08:00
parent c0ea1972f6
commit 04dbfa056e
6 changed files with 992 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

37
main.go
View File

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