verstak-desktop/internal/api/app.go

1190 lines
38 KiB
Go

// Package api provides Wails-bound methods for the frontend.
package api
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"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"
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
"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"
coreworkbench "github.com/verstak/verstak-desktop/internal/core/workbench"
"github.com/verstak/verstak-desktop/internal/core/workspace"
"github.com/verstak/verstak-desktop/internal/shell/debug"
)
// App is the main application struct exposed to the Wails frontend.
type App struct {
ctx context.Context
capRegistry *capability.Registry
contribRegistry *contribution.Registry
permRegistry *permissions.Registry
eventBus *events.Bus
plugins []plugin.Plugin
vault *vault.Vault
storage *storage.Storage
files *corefiles.Service
appSettings *appsettings.Manager
pluginState *pluginstate.Manager
workbench *coreworkbench.Router
workspace *workspace.Manager
debug bool
}
// NewApp creates a new App instance.
func NewApp(
capReg *capability.Registry,
contribReg *contribution.Registry,
permReg *permissions.Registry,
bus *events.Bus,
plugins []plugin.Plugin,
vaultService *vault.Vault,
storageService *storage.Storage,
filesService *corefiles.Service,
appSettingsMgr *appsettings.Manager,
pluginStateMgr *pluginstate.Manager,
workspaceMgr *workspace.Manager,
debugEnabled bool,
) *App {
return &App{
capRegistry: capReg,
contribRegistry: contribReg,
permRegistry: permReg,
eventBus: bus,
plugins: plugins,
vault: vaultService,
storage: storageService,
files: filesService,
appSettings: appSettingsMgr,
pluginState: pluginStateMgr,
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
workspace: workspaceMgr,
debug: debugEnabled,
}
}
func workbenchPrefsFromSettings(m *appsettings.Manager) coreworkbench.Preferences {
if m == nil {
return coreworkbench.Preferences{}
}
cfg := m.Get()
return coreworkbench.Preferences{
DefaultTextEditorProvider: cfg.Workbench.DefaultTextEditorProvider,
DefaultMarkdownEditorProvider: cfg.Workbench.DefaultMarkdownEditorProvider,
DefaultNotesMarkdownEditorProvider: cfg.Workbench.DefaultNotesMarkdownEditorProvider,
}
}
func appSettingsWorkbenchPrefs(p coreworkbench.Preferences) appsettings.WorkbenchPreferences {
return appsettings.WorkbenchPreferences{
DefaultTextEditorProvider: p.DefaultTextEditorProvider,
DefaultMarkdownEditorProvider: p.DefaultMarkdownEditorProvider,
DefaultNotesMarkdownEditorProvider: p.DefaultNotesMarkdownEditorProvider,
}
}
func (a *App) ensureWorkbench() *coreworkbench.Router {
if a.workbench == nil {
a.workbench = coreworkbench.NewRouter(workbenchPrefsFromSettings(a.appSettings))
}
return a.workbench
}
// Startup is called when the app starts. Sets the Wails context for dialogs.
func (a *App) Startup(ctx context.Context) {
a.ctx = ctx
log.Printf("[api] App.Startup: initialized with %d plugins", len(a.plugins))
}
func (a *App) findPlugin(pluginID string) (*plugin.Plugin, error) {
for i := range a.plugins {
if a.plugins[i].Manifest.ID == pluginID {
return &a.plugins[i], nil
}
}
return nil, fmt.Errorf("plugin %q not found", pluginID)
}
func (a *App) requirePluginAccess(pluginID, permission string) (*plugin.Plugin, error) {
p, err := a.findPlugin(pluginID)
if err != nil {
return nil, err
}
if !p.Enabled || (p.Status != plugin.StatusLoaded && p.Status != plugin.StatusDegraded) {
return nil, fmt.Errorf("plugin %q is not enabled and loaded: status=%s enabled=%v", pluginID, p.Status, p.Enabled)
}
if permission != "" && !hasString(p.Manifest.Permissions, permission) {
return nil, fmt.Errorf("plugin %q lacks required permission %q", pluginID, permission)
}
return p, nil
}
func (a *App) requirePluginCapabilityAccess(pluginID, capabilityName string) (*plugin.Plugin, error) {
p, err := a.requirePluginAccess(pluginID, "")
if err != nil {
return nil, err
}
if !hasString(p.Manifest.Requires, capabilityName) && !hasString(p.Manifest.OptionalRequires, capabilityName) {
return nil, fmt.Errorf("plugin %q does not declare capability dependency %q", pluginID, capabilityName)
}
return p, nil
}
func hasString(items []string, want string) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
}
// ─── Plugin Manager API ─────────────────────────────────────
// GetPlugins returns all discovered plugins.
func (a *App) GetPlugins() []plugin.Plugin {
if a.debug {
debug.Logf("[api] GetPlugins: returning %d plugins", len(a.plugins))
for i, p := range a.plugins {
debug.Logf("[api] plugin[%d]: id=%s status=%s enabled=%v root=%s", i, p.Manifest.ID, p.Status, p.Enabled, p.RootPath)
}
}
return a.plugins
}
// GetCapabilities returns all registered capabilities.
func (a *App) GetCapabilities() []capability.Entry {
entries := a.capRegistry.List()
if a.debug {
debug.Logf("[api] GetCapabilities: returning %d entries", len(entries))
}
return entries
}
// GetPermissions returns all known permissions.
func (a *App) GetPermissions() []permissions.Entry {
entries := a.permRegistry.List()
if a.debug {
debug.Logf("[api] GetPermissions: returning %d entries", len(entries))
}
return entries
}
// ─── Flat contribution types for frontend ─────────────────
// FlatSidebarItem is a flattened sidebar item for the frontend.
type FlatSidebarItem struct {
PluginID string `json:"pluginId"`
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
View string `json:"view"`
Position int `json:"position,omitempty"`
}
// FlatView is a flattened view contribution for the frontend.
type FlatView struct {
PluginID string `json:"pluginId"`
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
Component string `json:"component"`
}
// FlatSettingsPanel is a flattened settings panel for the frontend.
type FlatSettingsPanel struct {
PluginID string `json:"pluginId"`
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
Component string `json:"component"`
}
// FlatCommand is a flattened command contribution for the frontend.
type FlatCommand struct {
PluginID string `json:"pluginId"`
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
Handler string `json:"handler,omitempty"`
}
type FlatOpenProviderSupport struct {
Kind string `json:"kind"`
Mime []string `json:"mime,omitempty"`
Extensions []string `json:"extensions,omitempty"`
Contexts []string `json:"contexts,omitempty"`
}
type FlatOpenProvider struct {
PluginID string `json:"pluginId"`
ID string `json:"id"`
Title string `json:"title"`
Priority int `json:"priority,omitempty"`
Component string `json:"component"`
Supports []FlatOpenProviderSupport `json:"supports"`
}
// ContributionSummary aggregates all contribution types for the frontend.
type ContributionSummary struct {
Views []FlatView `json:"views"`
Commands []FlatCommand `json:"commands"`
SettingsPanels []FlatSettingsPanel `json:"settingsPanels"`
SidebarItems []FlatSidebarItem `json:"sidebarItems"`
OpenProviders []FlatOpenProvider `json:"openProviders"`
}
// buildContributionSummary creates a ContributionSummary from the registry.
func buildContributionSummary(r *contribution.Registry) ContributionSummary {
if r == nil {
return ContributionSummary{}
}
regViews := r.Views()
regCmds := r.Commands()
regPanels := r.SettingsPanels()
regSidebar := r.SidebarItems()
regOpenProviders := r.OpenProviders()
views := make([]FlatView, len(regViews))
for i, v := range regViews {
views[i] = FlatView{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, Component: v.Item.Component}
}
cmds := make([]FlatCommand, len(regCmds))
for i, v := range regCmds {
cmds[i] = FlatCommand{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, Handler: v.Item.Handler}
}
panels := make([]FlatSettingsPanel, len(regPanels))
for i, v := range regPanels {
panels[i] = FlatSettingsPanel{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, Component: v.Item.Component}
}
sidebar := make([]FlatSidebarItem, len(regSidebar))
for i, v := range regSidebar {
sidebar[i] = FlatSidebarItem{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, View: v.Item.View, Position: v.Item.Position}
}
openProviders := make([]FlatOpenProvider, len(regOpenProviders))
for i, v := range regOpenProviders {
supports := make([]FlatOpenProviderSupport, len(v.Item.Supports))
for j, s := range v.Item.Supports {
supports[j] = FlatOpenProviderSupport{Kind: s.Kind, Mime: s.Mime, Extensions: s.Extensions, Contexts: s.Contexts}
}
openProviders[i] = FlatOpenProvider{
PluginID: v.PluginID,
ID: v.Item.ID,
Title: v.Item.Title,
Priority: v.Item.Priority,
Component: v.Item.Component,
Supports: supports,
}
}
return ContributionSummary{Views: views, Commands: cmds, SettingsPanels: panels, SidebarItems: sidebar, OpenProviders: openProviders}
}
// GetContributions returns all registered contributions flattened for the frontend.
func (a *App) GetContributions() ContributionSummary {
if a.contribRegistry == nil {
if a.debug {
debug.Logf("[api] GetContributions: contribRegistry is nil")
}
return ContributionSummary{}
}
summary := buildContributionSummary(a.contribRegistry)
if a.debug {
debug.Logf("[api] GetContributions: returning views=%d commands=%d sidebar=%d settings=%d openProviders=%d",
len(summary.Views), len(summary.Commands), len(summary.SidebarItems), len(summary.SettingsPanels), len(summary.OpenProviders))
}
return summary
}
// ReloadPlugins re-discovers plugins from disk and returns a summary.
func (a *App) ReloadPlugins() (int, string) {
discoveryDirs := plugin.DefaultDiscoveryDirs()
log.Printf("[api] ReloadPlugins: scanning dirs: %v", discoveryDirs)
// Unregister all non-core capabilities
a.capRegistry.UnregisterAll()
// Re-register core capabilities
coreCaps := []string{
"verstak/core/plugin-manager/v1",
"verstak/core/capability-registry/v1",
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
"verstak/core/files/v1",
"verstak/core/workbench/v1",
}
if err := a.capRegistry.Register("verstak-desktop", coreCaps); err != nil {
log.Printf("[api] ReloadPlugins: failed to re-register core capabilities: %v", err)
}
// Re-register vault capability if vault is open
if a.vault != nil && a.vault.GetVaultStatus() == vault.StatusOpen {
if err := a.capRegistry.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil {
log.Printf("[api] ReloadPlugins: failed to re-register vault capability: %v", err)
}
}
// Re-register workspace capability if workspace is initialized
if a.workspace != nil && a.workspace.IsInitialized() {
if err := a.capRegistry.Register("verstak-desktop", []string{"verstak/core/workspace/v1"}); err != nil {
log.Printf("[api] ReloadPlugins: failed to re-register workspace capability: %v", err)
}
}
plugins, errs := plugin.DiscoverPlugins(discoveryDirs)
// Plugin lifecycle: register capabilities + contributions
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)
p.Status = plugin.StatusFailed
p.Error = err.Error()
continue
}
}
missingRequired := a.capRegistry.CheckRequired(p.Manifest.Requires)
if len(missingRequired) > 0 {
p.Status = plugin.StatusMissingRequiredCapability
p.Error = fmt.Sprintf("missing required: %s", strings.Join(missingRequired, ", "))
continue
}
missingOptional := a.capRegistry.CheckRequired(p.Manifest.OptionalRequires)
if len(missingOptional) > 0 {
p.Status = plugin.StatusDegraded
} else {
p.Status = plugin.StatusLoaded
}
// Register contributions (unregister first to prevent duplicates)
if p.Manifest.Contributes != nil {
a.contribRegistry.Unregister(p.Manifest.ID)
a.contribRegistry.Register(p.Manifest.ID, p.Manifest.Contributes)
}
// Record as desired plugin in vault state (only if vault is open)
if a.pluginState != nil && a.vault != nil && a.vault.GetVaultStatus() == vault.StatusOpen {
source := p.Manifest.Source
if source == "" {
source = "unknown"
}
if err := a.pluginState.RecordDesiredPlugin(p.Manifest.ID, p.Manifest.Version, source); err != nil {
log.Printf("[plugin] %s: failed to record desired: %v", p.Manifest.ID, err)
}
}
}
a.plugins = plugins
var buf strings.Builder
buf.WriteString("discovery complete")
if len(plugins) > 0 {
buf.WriteString(": ")
buf.WriteString(plugin.FormatDiscoverySummary(plugins))
}
if len(errs) > 0 {
log.Printf("[api] ReloadPlugins: %d warning(s)", len(errs))
for _, e := range errs {
log.Printf("[api] discovery warning: %v", e)
}
}
log.Printf("[api] ReloadPlugins: discovered %d plugin(s)", len(plugins))
discoveryDirsStr := strings.Join(discoveryDirs, ", ")
summary := buf.String()
log.Printf("[api] ReloadPlugins: dirs=[%s] %s", discoveryDirsStr, summary)
return len(plugins), summary
}
// ─── Vault API ──────────────────────────────────────────────
// GetVaultStatus returns the current vault status, path, and vault ID.
func (a *App) GetVaultStatus() map[string]string {
status := "not-created"
path := ""
vaultID := ""
if a.vault != nil {
status = string(a.vault.GetVaultStatus())
path = a.vault.GetVaultPath()
meta := a.vault.GetVaultMeta()
if meta != nil {
vaultID = meta.VaultID
}
}
if a.debug {
debug.Logf("[api] GetVaultStatus: status=%s path=%s vaultId=%s", status, path, vaultID)
}
return map[string]string{
"status": status,
"path": path,
"vaultId": vaultID,
}
}
// CreateVault creates a new vault at the given path.
func (a *App) CreateVault(path string) error {
if a.vault == nil {
return fmt.Errorf("vault service not initialized")
}
return a.vault.CreateVault(path)
}
// OpenVault opens an existing vault at the given path.
func (a *App) OpenVault(path string) error {
if a.vault == nil {
return fmt.Errorf("vault service not initialized")
}
return a.vault.OpenVault(path)
}
// CloseVault closes the current vault.
func (a *App) CloseVault() error {
if a.vault == nil {
return fmt.Errorf("vault service not initialized")
}
a.vault.CloseVault()
return nil
}
// ─── Storage API ────────────────────────────────────────────
// ReadPluginSettings returns all settings for a plugin.
func (a *App) ReadPluginSettings(pluginID string) (map[string]interface{}, string) {
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
return make(map[string]interface{}), err.Error()
}
if a.storage == nil {
return make(map[string]interface{}), "storage not initialized"
}
data, err := a.storage.ReadPluginSettings(pluginID)
if err != nil {
log.Printf("[api] ReadPluginSettings(%s): %v", pluginID, err)
return make(map[string]interface{}), err.Error()
}
return data, ""
}
// WritePluginSettings writes all settings for a plugin.
func (a *App) WritePluginSettings(pluginID string, data map[string]interface{}) string {
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
return err.Error()
}
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 _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
log.Printf("[api] ReadPluginSetting(%s, %s): %v", pluginID, key, err)
return nil
}
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 _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
return err.Error()
}
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 _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
log.Printf("[api] ReadPluginDataJSON(%s, %s): %v", pluginID, name, err)
return make(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 _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
return err.Error()
}
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 ""
}
// ListVaultFiles lists a vault-relative directory for a plugin with files.read.
func (a *App) ListVaultFiles(pluginID, relativeDir string) ([]corefiles.FileEntry, string) {
if _, err := a.requirePluginAccess(pluginID, "files.read"); err != nil {
return nil, err.Error()
}
if a.files == nil {
return nil, "files service not initialized"
}
entries, err := a.files.ListVaultFiles(relativeDir)
if err != nil {
return nil, err.Error()
}
return entries, ""
}
// GetVaultFileMetadata returns metadata for a vault-relative path for a plugin with files.read.
func (a *App) GetVaultFileMetadata(pluginID, relativePath string) (corefiles.FileMetadata, string) {
if _, err := a.requirePluginAccess(pluginID, "files.read"); err != nil {
return corefiles.FileMetadata{}, err.Error()
}
if a.files == nil {
return corefiles.FileMetadata{}, "files service not initialized"
}
meta, err := a.files.GetVaultFileMetadata(relativePath)
if err != nil {
return corefiles.FileMetadata{}, err.Error()
}
return meta, ""
}
// ReadVaultTextFile reads a UTF-8 text file for a plugin with files.read.
func (a *App) ReadVaultTextFile(pluginID, relativePath string) (string, string) {
if _, err := a.requirePluginAccess(pluginID, "files.read"); err != nil {
return "", err.Error()
}
if a.files == nil {
return "", "files service not initialized"
}
text, err := a.files.ReadVaultTextFile(relativePath)
if err != nil {
return "", err.Error()
}
return text, ""
}
// WriteVaultTextFile atomically writes a UTF-8 text file for a plugin with files.write.
func (a *App) WriteVaultTextFile(pluginID, relativePath string, content string, options corefiles.WriteOptions) string {
if _, err := a.requirePluginAccess(pluginID, "files.write"); err != nil {
return err.Error()
}
if a.files == nil {
return "files service not initialized"
}
if err := a.files.WriteVaultTextFile(relativePath, content, options); err != nil {
return err.Error()
}
return ""
}
// CreateVaultFolder creates a vault-relative folder for a plugin with files.write.
func (a *App) CreateVaultFolder(pluginID, relativePath string) string {
if _, err := a.requirePluginAccess(pluginID, "files.write"); err != nil {
return err.Error()
}
if a.files == nil {
return "files service not initialized"
}
if err := a.files.CreateVaultFolder(relativePath); err != nil {
return err.Error()
}
return ""
}
// MoveVaultPath moves a vault-relative file or folder for a plugin with files.write.
func (a *App) MoveVaultPath(pluginID, fromRelativePath string, toRelativePath string, options corefiles.MoveOptions) string {
if _, err := a.requirePluginAccess(pluginID, "files.write"); err != nil {
return err.Error()
}
if a.files == nil {
return "files service not initialized"
}
if err := a.files.MoveVaultPath(fromRelativePath, toRelativePath, options); err != nil {
return err.Error()
}
return ""
}
// TrashVaultPath moves a vault-relative file or folder to internal trash for a plugin with files.delete.
func (a *App) TrashVaultPath(pluginID, relativePath string) (corefiles.TrashResult, string) {
if _, err := a.requirePluginAccess(pluginID, "files.delete"); err != nil {
return corefiles.TrashResult{}, err.Error()
}
if a.files == nil {
return corefiles.TrashResult{}, "files service not initialized"
}
result, err := a.files.TrashVaultPath(relativePath)
if err != nil {
return corefiles.TrashResult{}, err.Error()
}
return result, ""
}
func (a *App) activeOpenProviders() []contribution.ContributionOpenProvider {
if a.contribRegistry == nil {
return nil
}
providers := a.contribRegistry.OpenProviders()
active := make([]contribution.ContributionOpenProvider, 0, len(providers))
for _, provider := range providers {
p, err := a.findPlugin(provider.PluginID)
if err != nil {
continue
}
if !p.Enabled || (p.Status != plugin.StatusLoaded && p.Status != plugin.StatusDegraded) {
continue
}
active = append(active, provider)
}
return active
}
func decodeOpenResourceRequest(raw map[string]interface{}) (coreworkbench.OpenResourceRequest, error) {
data, err := json.Marshal(raw)
if err != nil {
return coreworkbench.OpenResourceRequest{}, err
}
var request coreworkbench.OpenResourceRequest
if err := json.Unmarshal(data, &request); err != nil {
return coreworkbench.OpenResourceRequest{}, err
}
if request.Kind == "" {
return request, fmt.Errorf("resource kind is empty")
}
if request.Path == "" {
return request, fmt.Errorf("resource path is empty")
}
return request, nil
}
func (a *App) OpenWorkbenchResource(pluginID string, rawRequest map[string]interface{}) (coreworkbench.OpenResourceResult, string) {
if _, err := a.requirePluginAccess(pluginID, "workbench.open"); err != nil {
return coreworkbench.OpenResourceResult{}, err.Error()
}
request, err := decodeOpenResourceRequest(rawRequest)
if err != nil {
return coreworkbench.OpenResourceResult{}, err.Error()
}
if request.Context.SourcePluginID == "" {
request.Context.SourcePluginID = pluginID
}
result, err := a.ensureWorkbench().OpenResource(request, a.activeOpenProviders())
if err != nil {
return coreworkbench.OpenResourceResult{}, err.Error()
}
return result, ""
}
func (a *App) EditWorkbenchResource(pluginID string, rawRequest map[string]interface{}) (coreworkbench.OpenResourceResult, string) {
if rawRequest == nil {
rawRequest = map[string]interface{}{}
}
rawRequest["mode"] = "edit"
return a.OpenWorkbenchResource(pluginID, rawRequest)
}
func (a *App) GetWorkbenchOpenedResources() []coreworkbench.OpenedResource {
return a.ensureWorkbench().OpenedResources()
}
func (a *App) GetWorkbenchPreferences() coreworkbench.Preferences {
return a.ensureWorkbench().Preferences()
}
func (a *App) UpdateWorkbenchPreferences(preferences coreworkbench.Preferences) string {
a.ensureWorkbench().SetPreferences(preferences)
if a.appSettings == nil {
return ""
}
if err := a.appSettings.Update(&appsettings.Config{Workbench: appSettingsWorkbenchPrefs(preferences)}); err != nil {
return err.Error()
}
return ""
}
// ListPluginCapabilities returns the current capability registry for an enabled plugin.
func (a *App) ListPluginCapabilities(pluginID string) ([]capability.Entry, string) {
if _, err := a.requirePluginCapabilityAccess(pluginID, "verstak/core/capability-registry/v1"); err != nil {
return nil, err.Error()
}
if a.capRegistry == nil {
return nil, "capability registry not initialized"
}
return a.capRegistry.List(), ""
}
// GetPluginCapability returns a single capability lookup for an enabled plugin.
func (a *App) GetPluginCapability(pluginID, capabilityName string) (map[string]interface{}, string) {
if _, err := a.requirePluginCapabilityAccess(pluginID, "verstak/core/capability-registry/v1"); err != nil {
return map[string]interface{}{"available": false}, err.Error()
}
if a.capRegistry == nil {
return map[string]interface{}{"available": false}, "capability registry not initialized"
}
entry := a.capRegistry.Get(capabilityName)
if entry == nil {
return map[string]interface{}{"available": false, "name": capabilityName}, ""
}
return map[string]interface{}{
"available": true,
"name": entry.Name,
"pluginId": entry.PluginID,
"status": entry.Status,
}, ""
}
// ExecutePluginCommand validates that a command is declared by the plugin.
// Actual handler execution is intentionally deferred until sidecar/RPC exists.
func (a *App) ExecutePluginCommand(pluginID, commandID string, args map[string]interface{}) (map[string]interface{}, string) {
if _, err := a.requirePluginAccess(pluginID, "commands.register"); err != nil {
return nil, err.Error()
}
if a.contribRegistry == nil {
return nil, "contribution registry not initialized"
}
for _, command := range a.contribRegistry.Commands() {
if command.PluginID == pluginID && command.Item.ID == commandID {
return map[string]interface{}{
"status": "declared",
"pluginId": pluginID,
"commandId": commandID,
"handler": command.Item.Handler,
"args": args,
}, ""
}
}
return nil, fmt.Sprintf("command %q is not declared by plugin %q", commandID, pluginID)
}
// PublishPluginEvent validates publish permission and emits to the in-process bus.
func (a *App) PublishPluginEvent(pluginID, eventName string, payload map[string]interface{}) string {
if _, err := a.requirePluginAccess(pluginID, "events.publish"); err != nil {
return err.Error()
}
if eventName == "" {
return "event name is empty"
}
if payload == nil {
payload = make(map[string]interface{})
}
payload["pluginId"] = pluginID
if a.eventBus != nil {
a.eventBus.Publish(events.Event{
Name: eventName,
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
Payload: payload,
})
}
return ""
}
// SubscribePluginEvent validates subscribe permission for a bundled frontend plugin.
// Actual bundled event dispatch is handled by the frontend plugin host event bus.
func (a *App) SubscribePluginEvent(pluginID, eventName string) string {
if _, err := a.requirePluginAccess(pluginID, "events.subscribe"); err != nil {
return err.Error()
}
if eventName == "" {
return "event name is empty"
}
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 ""
}
// SetCurrentVault sets the current vault path in app settings and re-opens the vault.
// Loads workspace and registers vault + workspace capabilities.
func (a *App) SetCurrentVault(path string) string {
if a.appSettings == nil {
return "app settings not initialized"
}
if a.vault == nil {
return "vault service not initialized"
}
// Try to open the vault first
if err := a.vault.OpenVault(path); err != nil {
return fmt.Sprintf("failed to open vault: %v", err)
}
// Save the actual vault path (normalized by OpenVault, includes VerstakVault/)
vaultPath := a.vault.GetVaultPath()
if err := a.appSettings.SetCurrentVault(vaultPath); err != nil {
return fmt.Sprintf("failed to save app settings: %v", err)
}
// Load plugin state for the vault
if a.pluginState != nil {
if err := a.pluginState.Load(); err != nil {
log.Printf("[api] SetCurrentVault: warning loading plugin state: %v", err)
}
}
// Load workspace for the vault. This also handles first-run startup,
// where no workspace manager exists until a vault is selected.
a.workspace = workspace.NewManager(vaultPath)
if err := a.workspace.Load(); err != nil {
log.Printf("[api] SetCurrentVault: warning loading workspace: %v", err)
}
// Register vault capability
if err := a.capRegistry.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil {
log.Printf("[api] SetCurrentVault: failed to register vault capability: %v", err)
}
// Register workspace capability
if a.workspace != nil && a.workspace.IsInitialized() {
if err := a.capRegistry.Register("verstak-desktop", []string{"verstak/core/workspace/v1"}); err != nil {
log.Printf("[api] SetCurrentVault: failed to register workspace capability: %v", err)
}
}
return ""
}
// ─── Workspace API ─────────────────────────────────────────
// GetWorkspaceTree returns the full workspace tree.
func (a *App) GetWorkspaceTree() map[string]interface{} {
if a.workspace == nil || !a.workspace.IsInitialized() {
return map[string]interface{}{"status": "not initialized"}
}
tree := a.workspace.GetTree()
return map[string]interface{}{
"schemaVersion": tree.SchemaVersion,
"nodes": tree.Nodes,
"currentNodeId": tree.CurrentNodeID,
"updatedAt": tree.UpdatedAt,
}
}
// CreateWorkspaceNode creates a new workspace node.
func (a *App) CreateWorkspaceNode(parentID, nodeType, title string) map[string]interface{} {
if a.workspace == nil {
return map[string]interface{}{"error": "workspace not initialized"}
}
node, err := a.workspace.CreateNode(parentID, workspace.NodeType(nodeType), title)
if err != nil {
return map[string]interface{}{"error": err.Error()}
}
return map[string]interface{}{
"id": node.ID,
"parentId": node.ParentID,
"type": string(node.Type),
"title": node.Title,
"status": string(node.Status),
"order": node.Order,
"createdAt": node.CreatedAt,
"updatedAt": node.UpdatedAt,
}
}
// RenameWorkspaceNode renames a workspace node.
func (a *App) RenameWorkspaceNode(id, title string) string {
if a.workspace == nil {
return "workspace not initialized"
}
if err := a.workspace.RenameNode(id, title); err != nil {
return err.Error()
}
return ""
}
// MoveWorkspaceNode moves a node to a new parent.
func (a *App) MoveWorkspaceNode(id, newParentID string) string {
if a.workspace == nil {
return "workspace not initialized"
}
if err := a.workspace.MoveNode(id, newParentID); err != nil {
return err.Error()
}
return ""
}
// ArchiveWorkspaceNode archives a workspace node.
func (a *App) ArchiveWorkspaceNode(id string) string {
if a.workspace == nil {
return "workspace not initialized"
}
if err := a.workspace.ArchiveNode(id); err != nil {
return err.Error()
}
return ""
}
// GetCurrentWorkspaceNode returns the currently selected node.
func (a *App) GetCurrentWorkspaceNode() map[string]interface{} {
if a.workspace == nil {
return map[string]interface{}{"status": "not initialized"}
}
node, err := a.workspace.GetCurrentNode()
if err != nil {
return map[string]interface{}{"error": err.Error()}
}
return map[string]interface{}{
"id": node.ID,
"type": string(node.Type),
"title": node.Title,
"status": string(node.Status),
}
}
// SetCurrentWorkspaceNode sets the currently selected node.
func (a *App) SetCurrentWorkspaceNode(id string) string {
if a.workspace == nil {
return "workspace not initialized"
}
if err := a.workspace.SetCurrentNode(id); 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 ""
}
// RecordDesiredPlugin records a plugin as desired for this vault.
func (a *App) RecordDesiredPlugin(pluginID, version, source string) string {
if a.pluginState == nil {
return "plugin state not initialized"
}
if err := a.pluginState.RecordDesiredPlugin(pluginID, version, source); err != nil {
return err.Error()
}
return ""
}
// WriteFrontendLog writes a frontend debug message to the backend debug log.
func (a *App) WriteFrontendLog(component, message string) {
if a.debug {
debug.Logf("[frontend][%s] %s", component, message)
}
}
// ─── Dialog API ─────────────────────────────────────────────
// SelectDirectory opens a native directory picker dialog.
// Returns the selected path or empty string if cancelled.
func (a *App) SelectDirectory() string {
home, _ := os.UserHomeDir()
selected, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Select Vault Directory",
DefaultDirectory: home,
})
if err != nil {
log.Printf("[api] SelectDirectory: %v", err)
return ""
}
return selected
}
// SelectVaultForOpen opens a directory picker for opening an existing vault.
func (a *App) SelectVaultForOpen() string {
home, _ := os.UserHomeDir()
selected, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Open Existing Vault",
DefaultDirectory: home,
})
if err != nil {
log.Printf("[api] SelectVaultForOpen: %v", err)
return ""
}
return selected
}
// ─── Plugin Frontend Asset API ───────────────────────────
// GetPluginFrontendInfo returns frontend metadata for a plugin.
// Returns empty map if plugin has no frontend bundle or is not found.
func (a *App) GetPluginFrontendInfo(pluginID string) map[string]interface{} {
for _, p := range a.plugins {
if p.Manifest.ID != pluginID {
continue
}
if p.Manifest.Frontend == nil {
return map[string]interface{}{"status": "no-frontend"}
}
return map[string]interface{}{
"pluginId": p.Manifest.ID,
"name": p.Manifest.Name,
"icon": p.Manifest.Icon,
"version": p.Manifest.Version,
"entry": p.Manifest.Frontend.Entry,
"style": p.Manifest.Frontend.Style,
"rootPath": p.RootPath,
}
}
return map[string]interface{}{"status": "not-found"}
}
// GetPluginAssetContent reads a frontend asset file from a plugin directory.
// Security: validates that the assetPath is relative and does not escape the plugin root.
func (a *App) GetPluginAssetContent(pluginID, assetPath string) (string, string) {
// Validate asset path — reject absolute paths and path traversal
if strings.HasPrefix(assetPath, "/") || strings.HasPrefix(assetPath, "\\") {
return "", "absolute paths not allowed"
}
if strings.Contains(assetPath, "..") {
return "", "path traversal not allowed"
}
// Find the plugin
var pluginRoot string
found := false
for _, p := range a.plugins {
if p.Manifest.ID == pluginID && p.Manifest.Frontend != nil {
pluginRoot = p.RootPath
found = true
break
}
}
if !found {
return "", "plugin not found or has no frontend"
}
// Resolve path relative to plugin root
fullPath := filepath.Join(pluginRoot, assetPath)
// Verify we haven't escaped plugin root
absRoot, _ := filepath.Abs(pluginRoot)
absPath, _ := filepath.Abs(fullPath)
if !strings.HasPrefix(absPath, absRoot+string(filepath.Separator)) && absPath != absRoot {
return "", "path escapes plugin root"
}
data, err := os.ReadFile(absPath)
if err != nil {
return "", fmt.Sprintf("failed to read asset: %v", err)
}
return string(data), ""
}