297 lines
7.1 KiB
Go
297 lines
7.1 KiB
Go
// 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
|
|
}
|