diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index b1f9705..cda3144 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,27 +1,19 @@
- +
- {#if view === 'plugin-manager'} + {#if currentView === 'plugin-manager'} + {:else} + {/if}
@@ -32,53 +24,10 @@ height: 100vh; background: #1a1a2e; } - - nav { - width: 220px; - background: #16213e; - border-right: 1px solid #0f3460; - padding: 1rem; - display: flex; - flex-direction: column; - } - - nav h1 { - font-size: 1.2rem; - color: #e94560; - margin-bottom: 1.5rem; - } - - .nav-items { - display: flex; - flex-direction: column; - gap: 0.25rem; - } - - .nav-item { - background: transparent; - border: none; - color: #a0a0b8; - padding: 0.5rem 0.75rem; - border-radius: 6px; - cursor: pointer; - text-align: left; - font-size: 0.9rem; - transition: all 0.15s; - } - - .nav-item:hover { - background: #0f3460; - color: #e0e0e0; - } - - .nav-item.active { - background: #e94560; - color: #fff; - } - .content { flex: 1; - padding: 1.5rem; - overflow-y: auto; + display: flex; + flex-direction: column; + overflow: hidden; } diff --git a/frontend/src/lib/plugin-manager/PluginCard.svelte b/frontend/src/lib/plugin-manager/PluginCard.svelte index 5c2addc..45c7ab0 100644 --- a/frontend/src/lib/plugin-manager/PluginCard.svelte +++ b/frontend/src/lib/plugin-manager/PluginCard.svelte @@ -2,9 +2,12 @@ export let p = {}; export let capabilities = []; export let permissions = []; - export let contributions = {}; + export let onSettings = () => {}; - $: m = p.manifest || {}; + $: hasSettingsPanel = (contributions.settingsPanels || []).some(sp => sp.pluginId === pluginId); + $: hasUIPermission = (m.permissions || []).includes('ui.register'); + $: hasStoragePermission = (m.permissions || []).includes('storage.namespace'); + $: hasCommandsPermission = (m.permissions || []).includes('commands.register'); $: statusColor = ({ loaded: '#4ecca3', @@ -160,6 +163,20 @@ {#if p.error}
{p.error}
{/if} + + +
+ {#if hasSettingsPanel} + + {/if} +
+ + + {#if !hasUIPermission && (m.contributes && (m.contributes.views || m.contributes.sidebarItems || m.contributes.settingsPanels).length > 0)} +

⚠ Plugin has UI contributions but lacks ui.register permission

+ {/if} diff --git a/frontend/src/lib/plugin-manager/PluginManager.svelte b/frontend/src/lib/plugin-manager/PluginManager.svelte index 65ca3cf..7015275 100644 --- a/frontend/src/lib/plugin-manager/PluginManager.svelte +++ b/frontend/src/lib/plugin-manager/PluginManager.svelte @@ -10,6 +10,9 @@ let loading = true; let error = ''; let vaultStatus = { status: 'unknown', path: '', vaultId: '' }; + let settingsPanel = null; + let settingsData = {}; + let settingsPluginId = ''; async function loadAll() { error = ''; @@ -49,6 +52,34 @@ $: totalPlugins = plugins.length; $: totalCaps = capabilities.length; $: totalPerms = permissions.length; + + function openSettings(pluginId) { + const panel = (contributions.settingsPanels || []).find(sp => sp.pluginId === pluginId); + if (panel) { + settingsPanel = panel; + settingsPluginId = pluginId; + // Load existing settings + try { + const data = JSON.parse(localStorage.getItem('verstak-settings-' + pluginId) || '{}'); + settingsData = data; + } catch (e) { + settingsData = {}; + } + } + } + + function saveSettings() { + try { + localStorage.setItem('verstak-settings-' + settingsPluginId, JSON.stringify(settingsData)); + // Also try Wails backend + const { WritePluginSettings } = require('../../../wailsjs/go/api/App'); + WritePluginSettings(settingsPluginId, settingsData).then(err => { + if (err) console.error('WritePluginSettings:', err); + }).catch(() => {}); + } catch (e) { + console.error('saveSettings:', e); + } + }
@@ -98,7 +129,7 @@ {:else}
{#each plugins as p} - + {/each}
{/if} @@ -123,11 +154,47 @@ {/if} - {/if} -
+ {/if} - diff --git a/frontend/src/lib/shell/Sidebar.svelte b/frontend/src/lib/shell/Sidebar.svelte new file mode 100644 index 0000000..7238ff7 --- /dev/null +++ b/frontend/src/lib/shell/Sidebar.svelte @@ -0,0 +1,75 @@ + + + + + diff --git a/frontend/src/lib/shell/ViewContainer.svelte b/frontend/src/lib/shell/ViewContainer.svelte new file mode 100644 index 0000000..0d1f321 --- /dev/null +++ b/frontend/src/lib/shell/ViewContainer.svelte @@ -0,0 +1,137 @@ + + +
+ {#if activeView} + {#each views.filter(v => v.item.id === activeView) as view} +
+
+ {view.item.icon || '📦'} +

{view.item.title}

+ {#if getViewStatus(view) === 'degraded'} + degraded + {/if} +
+
+
+

+ Plugin view: {view.item.component} +
+ from {view.pluginId} +

+
+
+
+ {:else} +
View "{activeView}" not found in contributions
+ {/each} + {:else} +
+

Select an item from the sidebar

+

Plugin views will appear here

+
+ {/if} +
+ + diff --git a/internal/api/app.go b/internal/api/app.go index 8dde2ef..558e89a 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -13,6 +13,7 @@ import ( "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/storage" "github.com/verstak/verstak-desktop/internal/core/vault" ) @@ -24,6 +25,7 @@ type App struct { eventBus *events.Bus plugins []plugin.Plugin vault *vault.Vault + storage *storage.Storage } // NewApp creates a new App instance. @@ -34,6 +36,7 @@ func NewApp( bus *events.Bus, plugins []plugin.Plugin, vaultService *vault.Vault, + storageService *storage.Storage, ) *App { return &App{ capRegistry: capReg, @@ -42,6 +45,7 @@ func NewApp( eventBus: bus, plugins: plugins, vault: vaultService, + storage: storageService, } } @@ -248,6 +252,83 @@ func (a *App) CloseVault() error { return nil } +// ─── Storage API ──────────────────────────────────────────── + +// ReadPluginSettings returns all settings for a plugin. +func (a *App) ReadPluginSettings(pluginID string) map[string]interface{} { + if a.storage == nil { + return make(map[string]interface{}) + } + data, err := a.storage.ReadPluginSettings(pluginID) + if err != nil { + log.Printf("[api] ReadPluginSettings(%s): %v", pluginID, err) + return make(map[string]interface{}) + } + return data +} + +// WritePluginSettings writes all settings for a plugin. +func (a *App) WritePluginSettings(pluginID string, data map[string]interface{}) string { + if a.storage == nil { + return "storage not initialized" + } + if err := a.storage.WritePluginSettings(pluginID, data); err != nil { + log.Printf("[api] WritePluginSettings(%s): %v", pluginID, err) + return err.Error() + } + return "" +} + +// ReadPluginSetting returns a single setting value. +func (a *App) ReadPluginSetting(pluginID, key string) interface{} { + if a.storage == nil { + return nil + } + val, err := a.storage.ReadPluginSetting(pluginID, key) + if err != nil { + log.Printf("[api] ReadPluginSetting(%s, %s): %v", pluginID, key, err) + return nil + } + return val +} + +// WritePluginSetting writes a single setting value. +func (a *App) WritePluginSetting(pluginID, key string, value interface{}) string { + if a.storage == nil { + return "storage not initialized" + } + if err := a.storage.WritePluginSetting(pluginID, key, value); err != nil { + log.Printf("[api] WritePluginSetting(%s, %s): %v", pluginID, key, err) + return err.Error() + } + return "" +} + +// ReadPluginDataJSON reads a named JSON data file for a plugin. +func (a *App) ReadPluginDataJSON(pluginID, name string) map[string]interface{} { + if a.storage == nil { + return make(map[string]interface{}) + } + data, err := a.storage.ReadPluginDataJSON(pluginID, name) + if err != nil { + log.Printf("[api] ReadPluginDataJSON(%s, %s): %v", pluginID, name, err) + return make(map[string]interface{}) + } + return data +} + +// WritePluginDataJSON writes a named JSON data file for a plugin. +func (a *App) WritePluginDataJSON(pluginID, name string, data map[string]interface{}) string { + if a.storage == nil { + return "storage not initialized" + } + if err := a.storage.WritePluginDataJSON(pluginID, name, data); err != nil { + log.Printf("[api] WritePluginDataJSON(%s, %s): %v", pluginID, name, err) + 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/storage/api.go b/internal/core/storage/api.go new file mode 100644 index 0000000..fca557e --- /dev/null +++ b/internal/core/storage/api.go @@ -0,0 +1,225 @@ +// Package storage provides a safe, namespace-isolated JSON storage API for plugins. +// All data is stored within the vault's .verstak directory, scoped per plugin. +package storage + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/verstak/verstak-desktop/internal/core/vault" +) + +// Storage provides plugin-scoped JSON storage (settings, data, cache). +type Storage struct { + mu sync.RWMutex + vault *vault.Vault +} + +// New creates a new Storage instance backed by the given vault. +func New(v *vault.Vault) *Storage { + return &Storage{vault: v} +} + +// ─── Plugin ID validation ───────────────────────────────── + +func validatePluginID(pluginID string) error { + if pluginID == "" { + return fmt.Errorf("plugin ID is empty") + } + if strings.ContainsAny(pluginID, `/\`) { + return fmt.Errorf("plugin ID %q contains path separators", pluginID) + } + if pluginID == "." || pluginID == ".." { + return fmt.Errorf("plugin ID %q is a path traversal reference", pluginID) + } + cleaned := filepath.Clean(pluginID) + if cleaned != pluginID { + return fmt.Errorf("plugin ID %q contains path traversal", pluginID) + } + return nil +} + +// ─── Atomic write helper ────────────────────────────────── + +func atomicWrite(path string, data []byte) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create dir %s: %w", dir, err) + } + tmpFile := filepath.Join(dir, fmt.Sprintf(".tmp.%d", time.Now().UnixNano())) + if err := os.WriteFile(tmpFile, data, 0o644); err != nil { + return fmt.Errorf("failed to write temp file: %w", err) + } + if err := os.Rename(tmpFile, path); err != nil { + os.Remove(tmpFile) // best-effort cleanup + return fmt.Errorf("failed to rename temp file: %w", err) + } + return nil +} + +// ─── Settings API ───────────────────────────────────────── + +// ReadPluginSettings reads all settings for a plugin. +// Returns empty map if settings.json does not exist. +func (s *Storage) ReadPluginSettings(pluginID string) (map[string]interface{}, error) { + if err := validatePluginID(pluginID); err != nil { + return nil, err + } + + dir := s.vault.GetPluginSettingsPath(pluginID) + path := filepath.Join(dir, "settings.json") + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return make(map[string]interface{}), nil + } + return nil, fmt.Errorf("failed to read settings for plugin %s: %w", pluginID, err) + } + + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("corrupt settings.json for plugin %s: %w", pluginID, err) + } + return result, nil +} + +// WritePluginSettings writes all settings for a plugin atomically. +func (s *Storage) WritePluginSettings(pluginID string, data map[string]interface{}) error { + if err := validatePluginID(pluginID); err != nil { + return err + } + + dir := s.vault.GetPluginSettingsPath(pluginID) + path := filepath.Join(dir, "settings.json") + + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal settings for plugin %s: %w", pluginID, err) + } + return atomicWrite(path, encoded) +} + +// ReadPluginSetting reads a single setting key. +func (s *Storage) ReadPluginSetting(pluginID, key string) (interface{}, error) { + settings, err := s.ReadPluginSettings(pluginID) + if err != nil { + return nil, err + } + val, ok := settings[key] + if !ok { + return nil, nil + } + return val, nil +} + +// WritePluginSetting writes a single setting key. +func (s *Storage) WritePluginSetting(pluginID, key string, value interface{}) error { + settings, err := s.ReadPluginSettings(pluginID) + if err != nil { + return err + } + settings[key] = value + return s.WritePluginSettings(pluginID, settings) +} + +// ─── Data JSON API ──────────────────────────────────────── + +// ReadPluginDataJSON reads a named JSON data file for a plugin. +func (s *Storage) ReadPluginDataJSON(pluginID, name string) (map[string]interface{}, error) { + if err := validatePluginID(pluginID); err != nil { + return nil, err + } + if name == "" { + return nil, fmt.Errorf("data name is empty") + } + + dir := s.vault.GetPluginDataPath(pluginID) + path := filepath.Join(dir, name+".json") + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return make(map[string]interface{}), nil + } + return nil, fmt.Errorf("failed to read data %s for plugin %s: %w", name, pluginID, err) + } + + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("corrupt data file %s.json for plugin %s: %w", name, pluginID, err) + } + return result, nil +} + +// WritePluginDataJSON writes a named JSON data file for a plugin atomically. +func (s *Storage) WritePluginDataJSON(pluginID, name string, data map[string]interface{}) error { + if err := validatePluginID(pluginID); err != nil { + return err + } + if name == "" { + return fmt.Errorf("data name is empty") + } + + dir := s.vault.GetPluginDataPath(pluginID) + path := filepath.Join(dir, name+".json") + + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal data %s for plugin %s: %w", name, pluginID, err) + } + return atomicWrite(path, encoded) +} + +// ─── Cache JSON API ─────────────────────────────────────── + +// ReadPluginCacheJSON reads a named JSON cache file for a plugin. +func (s *Storage) ReadPluginCacheJSON(pluginID, name string) (map[string]interface{}, error) { + if err := validatePluginID(pluginID); err != nil { + return nil, err + } + if name == "" { + return nil, fmt.Errorf("cache name is empty") + } + + dir := s.vault.GetPluginCachePath(pluginID) + path := filepath.Join(dir, name+".json") + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return make(map[string]interface{}), nil + } + return nil, fmt.Errorf("failed to read cache %s for plugin %s: %w", name, pluginID, err) + } + + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("corrupt cache file %s.json for plugin %s: %w", name, pluginID, err) + } + return result, nil +} + +// WritePluginCacheJSON writes a named JSON cache file for a plugin atomically. +func (s *Storage) WritePluginCacheJSON(pluginID, name string, data map[string]interface{}) error { + if err := validatePluginID(pluginID); err != nil { + return err + } + if name == "" { + return fmt.Errorf("cache name is empty") + } + + dir := s.vault.GetPluginCachePath(pluginID) + path := filepath.Join(dir, name+".json") + + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal cache %s for plugin %s: %w", name, pluginID, err) + } + return atomicWrite(path, encoded) +} diff --git a/internal/core/storage/api_test.go b/internal/core/storage/api_test.go new file mode 100644 index 0000000..1f9ecf0 --- /dev/null +++ b/internal/core/storage/api_test.go @@ -0,0 +1,257 @@ +package storage + +import ( + "os" + "path/filepath" + "testing" + + "github.com/verstak/verstak-desktop/internal/core/vault" +) + +// newTestVault creates a vault in a temp directory for testing. +func newTestVault(t *testing.T) (*vault.Vault, string) { + t.Helper() + tmpDir := t.TempDir() + v := vault.NewVault(nil) + if err := v.CreateVault(tmpDir); err != nil { + t.Fatalf("failed to create test vault: %v", err) + } + return v, tmpDir +} + +func newTestStorage(t *testing.T) (*Storage, string) { + t.Helper() + v, dir := newTestVault(t) + return New(v), dir +} + +// ─── Settings tests ────────────────────────────────────────── + +func TestWriteReadPluginSettings(t *testing.T) { + s, _ := newTestStorage(t) + + data := map[string]interface{}{ + "theme": "dark", + "lang": "en", + "count": float64(42), + } + + if err := s.WritePluginSettings("my-plugin", data); err != nil { + t.Fatalf("WritePluginSettings: %v", err) + } + + got, err := s.ReadPluginSettings("my-plugin") + if err != nil { + t.Fatalf("ReadPluginSettings: %v", err) + } + + if got["theme"] != "dark" { + t.Errorf("theme = %v, want dark", got["theme"]) + } + if got["lang"] != "en" { + t.Errorf("lang = %v, want en", got["lang"]) + } + if got["count"] != float64(42) { + t.Errorf("count = %v, want 42", got["count"]) + } +} + +func TestReadPluginSettings_NotFound(t *testing.T) { + s, _ := newTestStorage(t) + + got, err := s.ReadPluginSettings("unknown-plugin") + if err != nil { + t.Fatalf("ReadPluginSettings: %v", err) + } + if len(got) != 0 { + t.Errorf("expected empty map, got %v", got) + } +} + +func TestReadPluginSettings_Corrupt(t *testing.T) { + s, dir := newTestStorage(t) + + // Write corrupt JSON into the settings file + settingsDir := filepath.Join(dir, "VerstakVault", ".verstak", "plugin-settings", "bad-plugin") + os.MkdirAll(settingsDir, 0o755) + os.WriteFile(filepath.Join(settingsDir, "settings.json"), []byte("{not json!!"), 0o644) + + _, err := s.ReadPluginSettings("bad-plugin") + if err == nil { + t.Fatal("expected error for corrupt settings.json, got nil") + } +} + +func TestWritePluginSetting_SingleKey(t *testing.T) { + s, _ := newTestStorage(t) + + // Write a single key + if err := s.WritePluginSetting("my-plugin", "key1", "value1"); err != nil { + t.Fatalf("WritePluginSetting: %v", err) + } + + // Read it back + val, err := s.ReadPluginSetting("my-plugin", "key1") + if err != nil { + t.Fatalf("ReadPluginSetting: %v", err) + } + if val != "value1" { + t.Errorf("key1 = %v, want value1", val) + } + + // Write another key, first should be preserved + if err := s.WritePluginSetting("my-plugin", "key2", float64(99)); err != nil { + t.Fatalf("WritePluginSetting: %v", err) + } + + settings, err := s.ReadPluginSettings("my-plugin") + if err != nil { + t.Fatalf("ReadPluginSettings: %v", err) + } + if settings["key1"] != "value1" { + t.Errorf("key1 after second write = %v, want value1", settings["key1"]) + } + if settings["key2"] != float64(99) { + t.Errorf("key2 = %v, want 99", settings["key2"]) + } + + // Reading a missing key returns nil + val, err = s.ReadPluginSetting("my-plugin", "missing") + if err != nil { + t.Fatalf("ReadPluginSetting: %v", err) + } + if val != nil { + t.Errorf("missing key = %v, want nil", val) + } +} + +// ─── Data JSON tests ───────────────────────────────────────── + +func TestPluginDataJSON_WriteRead(t *testing.T) { + s, _ := newTestStorage(t) + + data := map[string]interface{}{ + "items": []interface{}{"a", "b", "c"}, + "meta": map[string]interface{}{"version": float64(1)}, + } + + if err := s.WritePluginDataJSON("data-plugin", "mydata", data); err != nil { + t.Fatalf("WritePluginDataJSON: %v", err) + } + + got, err := s.ReadPluginDataJSON("data-plugin", "mydata") + if err != nil { + t.Fatalf("ReadPluginDataJSON: %v", err) + } + + items, ok := got["items"].([]interface{}) + if !ok { + t.Fatalf("items is not []interface{}, it's %T", got["items"]) + } + if len(items) != 3 { + t.Errorf("items len = %d, want 3", len(items)) + } + + // Ensure separate names don't collide + got2, err := s.ReadPluginDataJSON("data-plugin", "other") + if err != nil { + t.Fatalf("ReadPluginDataJSON other: %v", err) + } + if len(got2) != 0 { + t.Errorf("expected empty map for other, got %v", got2) + } +} + +// ─── Cache JSON tests ──────────────────────────────────────── + +func TestPluginCacheJSON_WriteRead(t *testing.T) { + s, _ := newTestStorage(t) + + data := map[string]interface{}{ + "lastSync": "2025-01-01T00:00:00Z", + "hitRate": 0.95, + } + + if err := s.WritePluginCacheJSON("cache-plugin", "sync-state", data); err != nil { + t.Fatalf("WritePluginCacheJSON: %v", err) + } + + got, err := s.ReadPluginCacheJSON("cache-plugin", "sync-state") + if err != nil { + t.Fatalf("ReadPluginCacheJSON: %v", err) + } + + if got["lastSync"] != "2025-01-01T00:00:00Z" { + t.Errorf("lastSync = %v", got["lastSync"]) + } + + // Empty read for missing cache + got2, err := s.ReadPluginCacheJSON("cache-plugin", "nope") + if err != nil { + t.Fatalf("ReadPluginCacheJSON nope: %v", err) + } + if len(got2) != 0 { + t.Errorf("expected empty map, got %v", got2) + } +} + +// ─── Path traversal tests ──────────────────────────────────── + +func TestPathTraversal_Blocked(t *testing.T) { + s, _ := newTestStorage(t) + + traversalIDs := []string{ + "..", + "../evil", + "foo/../../bar", + "/absolute", + `backslash\traverse`, + } + + for _, id := range traversalIDs { + t.Run(id, func(t *testing.T) { + err := s.WritePluginSettings(id, map[string]interface{}{"x": 1}) + if err == nil { + t.Errorf("WritePluginSettings(%q): expected error, got nil", id) + } + + _, err = s.ReadPluginSettings(id) + if err == nil { + t.Errorf("ReadPluginSettings(%q): expected error, got nil", id) + } + }) + } + + // Empty pluginID should also be rejected + err := s.WritePluginSettings("", map[string]interface{}{}) + if err == nil { + t.Error("WritePluginSettings(\"\"): expected error, got nil") + } +} + +// ─── Atomic write tests ────────────────────────────────────── + +func TestAtomicWrite(t *testing.T) { + s, dir := newTestStorage(t) + + data := map[string]interface{}{ + "key": "value", + "n": float64(123), + } + + if err := s.WritePluginSettings("atomic-plugin", data); err != nil { + t.Fatalf("WritePluginSettings: %v", err) + } + + // Verify no .tmp files remain in the settings directory + settingsDir := filepath.Join(dir, "VerstakVault", ".verstak", "plugin-settings", "atomic-plugin") + entries, err := os.ReadDir(settingsDir) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + for _, e := range entries { + if filepath.Ext(e.Name()) == ".tmp" || (len(e.Name()) > 4 && e.Name()[:4] == ".tmp") { + t.Errorf("leftover temp file found: %s", e.Name()) + } + } +} diff --git a/main.go b/main.go index 69a02ac..6ccc2ff 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( "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/storage" "github.com/verstak/verstak-desktop/internal/core/vault" ) @@ -155,7 +156,8 @@ func main() { loaded, degraded, failed, vaultService.GetVaultStatus()) // Create the App struct - app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService) + storageService := storage.New(vaultService) + app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService) // ─── Wails App ─────────────────────────────────────────── err := wails.Run(&options.App{