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:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user