hotfix: plugin manager infinite loading
- frontend: RPC timeout (8s), try/catch/finally on reload, proper UI states (loading/error/empty/list) - frontend: reload() now sets loading=true, catches errors - backend: tilde expansion (~/.config/verstak/plugins → /home/mirivlad/...) - backend: ReloadPlugins returns diagnostics (count, summary string) - backend: diagnostic logging in DiscoverPlugins (start/dirs/entries/results) - backend: FormatDiscoverySummary helper - testing: 11 headless tests for DiscoverPlugins (empty, missing, valid, broken JSON, duplicate ID, multiple dirs, nonexistent mix)
This commit is contained in:
parent
e39e249556
commit
3c613f0e44
Binary file not shown.
|
|
@ -7,16 +7,31 @@
|
||||||
let permissions = [];
|
let permissions = [];
|
||||||
let loading = true;
|
let loading = true;
|
||||||
let error = '';
|
let error = '';
|
||||||
|
let hasConfig = true;
|
||||||
|
|
||||||
|
// Wails binding call with timeout
|
||||||
|
async function rpc(promise, label, timeoutMs = 8000) {
|
||||||
|
const timeout = new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error(`${label}: timeout (${timeoutMs}ms)`)), timeoutMs)
|
||||||
|
);
|
||||||
|
return await Promise.race([promise, timeout]);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
plugins = await window.go.api.App.GetPlugins();
|
const [p, caps, perms] = await Promise.all([
|
||||||
capabilities = await window.go.api.App.GetCapabilities();
|
rpc(window.go.api.App.GetPlugins(), 'GetPlugins'),
|
||||||
permissions = await window.go.api.App.GetPermissions();
|
rpc(window.go.api.App.GetCapabilities(), 'GetCapabilities'),
|
||||||
|
rpc(window.go.api.App.GetPermissions(), 'GetPermissions'),
|
||||||
|
]);
|
||||||
|
plugins = p;
|
||||||
|
capabilities = caps;
|
||||||
|
permissions = perms;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = String(e);
|
error = String(e);
|
||||||
|
console.error('[PluginManager] load error:', e);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
@ -27,8 +42,16 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
async function reload() {
|
async function reload() {
|
||||||
await window.go.api.App.ReloadPlugins();
|
loading = true;
|
||||||
await loadData();
|
error = '';
|
||||||
|
try {
|
||||||
|
await rpc(window.go.api.App.ReloadPlugins(), 'ReloadPlugins');
|
||||||
|
await loadData();
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
console.error('[PluginManager] reload error:', e);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: totalPlugins = plugins.length;
|
$: totalPlugins = plugins.length;
|
||||||
|
|
@ -47,7 +70,11 @@
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="loading">Scanning plugin directories...</div>
|
<div class="loading">Scanning plugin directories...</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="error">Error: {error}</div>
|
<div class="error">
|
||||||
|
<div class="error-icon">⚠</div>
|
||||||
|
<div class="error-message">{error}</div>
|
||||||
|
<button class="retry-btn" on:click={loadData} type="button">⟳ Retry</button>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Plugin Count -->
|
<!-- Plugin Count -->
|
||||||
<div class="summary">
|
<div class="summary">
|
||||||
|
|
@ -59,8 +86,14 @@
|
||||||
<!-- Plugin List -->
|
<!-- Plugin List -->
|
||||||
{#if plugins.length === 0}
|
{#if plugins.length === 0}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<p>No plugins discovered.</p>
|
<div class="empty-icon">📂</div>
|
||||||
<p class="hint">Place plugins in <code>~/.config/verstak/plugins/</code> or <code>./plugins/</code></p>
|
<p>No plugins found</p>
|
||||||
|
<p class="hint">Plugin directories scanned:</p>
|
||||||
|
<ul class="hint-list">
|
||||||
|
<li><code>~/.config/verstak/plugins/</code> — user plugins</li>
|
||||||
|
<li><code>./plugins/</code> — bundled plugins (app directory)</li>
|
||||||
|
</ul>
|
||||||
|
<p class="hint">Place a plugin folder with <code>plugin.json</code> in one of these directories and click Reload.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="plugin-list">
|
<div class="plugin-list">
|
||||||
|
|
@ -143,10 +176,37 @@
|
||||||
color: #e94560;
|
color: #e94560;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
background: #0f3460;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border: 1px solid #533483;
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn:hover {
|
||||||
|
background: #533483;
|
||||||
|
}
|
||||||
|
|
||||||
.summary {
|
.summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
|
|
@ -167,12 +227,29 @@
|
||||||
border: 1px dashed #0f3460;
|
border: 1px dashed #0f3460;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hint-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-list li {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.hint code {
|
.hint code {
|
||||||
background: #0f3460;
|
background: #0f3460;
|
||||||
padding: 0.1rem 0.3rem;
|
padding: 0.1rem 0.3rem;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,6 @@ export function GetPermissions():Promise<Array<permissions.Entry>>;
|
||||||
|
|
||||||
export function GetPlugins():Promise<Array<plugin.Plugin>>;
|
export function GetPlugins():Promise<Array<plugin.Plugin>>;
|
||||||
|
|
||||||
export function ReloadPlugins():Promise<void>;
|
export function ReloadPlugins():Promise<number|string>;
|
||||||
|
|
||||||
export function Startup():Promise<void>;
|
export function Startup():Promise<void>;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/verstak/verstak-desktop/internal/core/capability"
|
"github.com/verstak/verstak-desktop/internal/core/capability"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/contribution"
|
"github.com/verstak/verstak-desktop/internal/core/contribution"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/events"
|
"github.com/verstak/verstak-desktop/internal/core/events"
|
||||||
|
|
@ -37,6 +42,7 @@ func NewApp(
|
||||||
|
|
||||||
// Startup is called when the app starts.
|
// Startup is called when the app starts.
|
||||||
func (a *App) Startup() error {
|
func (a *App) Startup() error {
|
||||||
|
log.Printf("[api] App.Startup: initialized with %d plugins", len(a.plugins))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,17 +50,22 @@ func (a *App) Startup() error {
|
||||||
|
|
||||||
// GetPlugins returns all discovered plugins.
|
// GetPlugins returns all discovered plugins.
|
||||||
func (a *App) GetPlugins() []plugin.Plugin {
|
func (a *App) GetPlugins() []plugin.Plugin {
|
||||||
|
log.Printf("[api] GetPlugins: returning %d plugins", len(a.plugins))
|
||||||
return a.plugins
|
return a.plugins
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCapabilities returns all registered capabilities.
|
// GetCapabilities returns all registered capabilities.
|
||||||
func (a *App) GetCapabilities() []capability.Entry {
|
func (a *App) GetCapabilities() []capability.Entry {
|
||||||
return a.capRegistry.List()
|
entries := a.capRegistry.List()
|
||||||
|
log.Printf("[api] GetCapabilities: returning %d entries", len(entries))
|
||||||
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPermissions returns all known permissions.
|
// GetPermissions returns all known permissions.
|
||||||
func (a *App) GetPermissions() []permissions.Entry {
|
func (a *App) GetPermissions() []permissions.Entry {
|
||||||
return a.permRegistry.List()
|
entries := a.permRegistry.List()
|
||||||
|
log.Printf("[api] GetPermissions: returning %d entries", len(entries))
|
||||||
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetContributions returns all registered contributions.
|
// GetContributions returns all registered contributions.
|
||||||
|
|
@ -70,14 +81,58 @@ func (a *App) GetContributions() ContributionSummary {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReloadPlugins re-discovers plugins from disk.
|
// expandPath resolves "~" to the user's home directory.
|
||||||
func (a *App) ReloadPlugins() {
|
func expandPath(path string) string {
|
||||||
|
if strings.HasPrefix(path, "~/") {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[api] expandPath: cannot get home dir: %v", err)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return filepath.Join(home, path[2:])
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReloadPlugins re-discovers plugins from disk and returns a summary.
|
||||||
|
func (a *App) ReloadPlugins() (int, string) {
|
||||||
discoveryDirs := []string{
|
discoveryDirs := []string{
|
||||||
"~/.config/verstak/plugins",
|
"~/.config/verstak/plugins",
|
||||||
"./plugins",
|
"./plugins",
|
||||||
}
|
}
|
||||||
plugins, _ := plugin.DiscoverPlugins(discoveryDirs)
|
|
||||||
|
// Expand tilde in all paths
|
||||||
|
for i, d := range discoveryDirs {
|
||||||
|
discoveryDirs[i] = expandPath(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[api] ReloadPlugins: scanning dirs: %v", discoveryDirs)
|
||||||
|
|
||||||
|
plugins, errs := plugin.DiscoverPlugins(discoveryDirs)
|
||||||
a.plugins = plugins
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContributionSummary aggregates all contribution types for the frontend.
|
// ContributionSummary aggregates all contribution types for the frontend.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ package plugin
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -231,6 +232,18 @@ func isAllowedInID(r rune) bool {
|
||||||
|
|
||||||
// ─── Discovery ──────────────────────────────────────────────
|
// ─── Discovery ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// FormatDiscoverySummary returns a human-readable summary of discovered plugins.
|
||||||
|
func FormatDiscoverySummary(plugins []Plugin) string {
|
||||||
|
if len(plugins) == 0 {
|
||||||
|
return "no plugins found"
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(plugins))
|
||||||
|
for _, p := range plugins {
|
||||||
|
ids = append(ids, p.Manifest.ID+"@"+p.Manifest.Version)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d plugin(s): %s", len(plugins), strings.Join(ids, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
// DiscoverPlugins scans the given directories for plugin.json manifests.
|
// DiscoverPlugins scans the given directories for plugin.json manifests.
|
||||||
func DiscoverPlugins(dirs []string) ([]Plugin, []error) {
|
func DiscoverPlugins(dirs []string) ([]Plugin, []error) {
|
||||||
var plugins []Plugin
|
var plugins []Plugin
|
||||||
|
|
@ -238,16 +251,22 @@ func DiscoverPlugins(dirs []string) ([]Plugin, []error) {
|
||||||
|
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
|
log.Printf("[discovery] start: %d dir(s): %v", len(dirs), dirs)
|
||||||
|
|
||||||
for _, dir := range dirs {
|
for _, dir := range dirs {
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
|
log.Printf("[discovery] dir %q: does not exist (skip)", dir)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errs = append(errs, fmt.Errorf("reading plugin directory %s: %w", dir, err))
|
errs = append(errs, fmt.Errorf("reading plugin directory %s: %w", dir, err))
|
||||||
|
log.Printf("[discovery] dir %q: error: %v", dir, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("[discovery] dir %q: %d entries", dir, len(entries))
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if !entry.IsDir() {
|
if !entry.IsDir() {
|
||||||
continue
|
continue
|
||||||
|
|
@ -257,24 +276,30 @@ func DiscoverPlugins(dirs []string) ([]Plugin, []error) {
|
||||||
manifestPath := filepath.Join(pluginDir, "plugin.json")
|
manifestPath := filepath.Join(pluginDir, "plugin.json")
|
||||||
|
|
||||||
if _, err := os.Stat(manifestPath); os.IsNotExist(err) {
|
if _, err := os.Stat(manifestPath); os.IsNotExist(err) {
|
||||||
|
log.Printf("[discovery] %s: no plugin.json (skip)", entry.Name())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
plugin, err := loadPlugin(pluginDir)
|
plugin, err := loadPlugin(pluginDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errs = append(errs, fmt.Errorf("plugin %s: %w", entry.Name(), err))
|
errs = append(errs, fmt.Errorf("plugin %s: %w", entry.Name(), err))
|
||||||
|
log.Printf("[discovery] %s: load error: %v", entry.Name(), err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if seen[plugin.Manifest.ID] {
|
if seen[plugin.Manifest.ID] {
|
||||||
errs = append(errs, fmt.Errorf("duplicate plugin ID %q in %s", plugin.Manifest.ID, pluginDir))
|
errs = append(errs, fmt.Errorf("duplicate plugin ID %q in %s", plugin.Manifest.ID, pluginDir))
|
||||||
|
log.Printf("[discovery] %s: duplicate ID %q (skip)", entry.Name(), plugin.Manifest.ID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[plugin.Manifest.ID] = true
|
seen[plugin.Manifest.ID] = true
|
||||||
plugins = append(plugins, plugin)
|
plugins = append(plugins, plugin)
|
||||||
|
log.Printf("[discovery] %s: ✅ %s@%s", entry.Name(), plugin.Manifest.ID, plugin.Manifest.Version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("[discovery] end: %d plugin(s) found, %d error(s)", len(plugins), len(errs))
|
||||||
|
|
||||||
return plugins, errs
|
return plugins, errs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createTempPlugin creates a minimal valid plugin directory for testing.
|
||||||
|
func createTempPlugin(t *testing.T, dir, id, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
pluginDir := filepath.Join(dir, id)
|
||||||
|
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manifest := `{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "` + id + `",
|
||||||
|
"name": "` + name + `",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"apiVersion": "1.0",
|
||||||
|
"provides": ["` + id + `.cap1"],
|
||||||
|
"permissions": ["vault.read"]
|
||||||
|
}`
|
||||||
|
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return pluginDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDiscoverPlugins_EmptyDir tests discovery on a directory that does not exist.
|
||||||
|
// It should return empty results, not hang or error out.
|
||||||
|
func TestDiscoverPlugins_EmptyDir(t *testing.T) {
|
||||||
|
plugins, errs := DiscoverPlugins([]string{"/tmp/nonexistent-plugin-dir-12345"})
|
||||||
|
if len(plugins) != 0 {
|
||||||
|
t.Errorf("expected 0 plugins, got %d", len(plugins))
|
||||||
|
}
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Errorf("expected 0 errors, got %d: %v", len(errs), errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDiscoverPlugins_MissingDir tests discovery where the directory does not exist.
|
||||||
|
// It should not be treated as an error — just skip.
|
||||||
|
func TestDiscoverPlugins_MissingDir(t *testing.T) {
|
||||||
|
plugins, errs := DiscoverPlugins([]string{"/tmp/missing-dir-99999"})
|
||||||
|
if len(plugins) != 0 {
|
||||||
|
t.Errorf("expected 0 plugins, got %d", len(plugins))
|
||||||
|
}
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Errorf("expected 0 errors for missing dir, got %d", len(errs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDiscoverPlugins_ValidPlugin tests discovery of a single valid plugin.
|
||||||
|
func TestDiscoverPlugins_ValidPlugin(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
createTempPlugin(t, dir, "test.plugin", "Test Plugin")
|
||||||
|
|
||||||
|
plugins, errs := DiscoverPlugins([]string{dir})
|
||||||
|
if len(plugins) != 1 {
|
||||||
|
t.Fatalf("expected 1 plugin, got %d", len(plugins))
|
||||||
|
}
|
||||||
|
if plugins[0].Manifest.ID != "test.plugin" {
|
||||||
|
t.Errorf("expected id 'test.plugin', got %q", plugins[0].Manifest.ID)
|
||||||
|
}
|
||||||
|
if !plugins[0].Enabled {
|
||||||
|
t.Error("expected plugin to be enabled by default")
|
||||||
|
}
|
||||||
|
if len(errs) > 0 {
|
||||||
|
t.Errorf("expected 0 errors, got %d: %v", len(errs), errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDiscoverPlugins_NoManifest ensures subdirs without plugin.json are skipped.
|
||||||
|
func TestDiscoverPlugins_NoManifest(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
noManifestDir := filepath.Join(dir, "no-manifest")
|
||||||
|
if err := os.MkdirAll(noManifestDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins, errs := DiscoverPlugins([]string{dir})
|
||||||
|
if len(plugins) != 0 {
|
||||||
|
t.Errorf("expected 0 plugins (no manifest), got %d", len(plugins))
|
||||||
|
}
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Errorf("expected 0 errors, got %d", len(errs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDiscoverPlugins_BrokenJSON ensures a corrupted manifest is reported as error
|
||||||
|
// but does not crash or hang discovery.
|
||||||
|
func TestDiscoverPlugins_BrokenJSON(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
pluginDir := filepath.Join(dir, "broken")
|
||||||
|
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte("{invalid json}"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins, errs := DiscoverPlugins([]string{dir})
|
||||||
|
if len(plugins) != 0 {
|
||||||
|
t.Errorf("expected 0 plugins (broken manifest), got %d", len(plugins))
|
||||||
|
}
|
||||||
|
if len(errs) == 0 {
|
||||||
|
t.Error("expected at least 1 error for broken manifest, got 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDiscoverPlugins_DuplicateID ensures duplicate plugin IDs are detected.
|
||||||
|
func TestDiscoverPlugins_DuplicateID(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
createTempPlugin(t, dir, "dup-one", "First")
|
||||||
|
|
||||||
|
// Create a second plugin with the same ID (duplicate)
|
||||||
|
pluginDir2 := filepath.Join(dir, "dup-two")
|
||||||
|
if err := os.MkdirAll(pluginDir2, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manifest := `{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "dup-one",
|
||||||
|
"name": "Second",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"apiVersion": "1.0",
|
||||||
|
"provides": ["dup-one.cap1"],
|
||||||
|
"permissions": ["vault.read"]
|
||||||
|
}`
|
||||||
|
if err := os.WriteFile(filepath.Join(pluginDir2, "plugin.json"), []byte(manifest), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins, errs := DiscoverPlugins([]string{dir})
|
||||||
|
if len(plugins) != 1 {
|
||||||
|
t.Errorf("expected 1 plugin (deduped), got %d", len(plugins))
|
||||||
|
}
|
||||||
|
hasDupError := false
|
||||||
|
for _, e := range errs {
|
||||||
|
if strings.Contains(e.Error(), "duplicate") {
|
||||||
|
hasDupError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasDupError {
|
||||||
|
t.Error("expected duplicate plugin ID error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDiscoverPlugins_MultipleDirs ensures discovery scans multiple directories.
|
||||||
|
func TestDiscoverPlugins_MultipleDirs(t *testing.T) {
|
||||||
|
dir1 := t.TempDir()
|
||||||
|
dir2 := t.TempDir()
|
||||||
|
createTempPlugin(t, dir1, "alpha.plugin", "Alpha")
|
||||||
|
createTempPlugin(t, dir2, "beta.plugin", "Beta")
|
||||||
|
|
||||||
|
plugins, errs := DiscoverPlugins([]string{dir1, dir2})
|
||||||
|
if len(plugins) != 2 {
|
||||||
|
t.Fatalf("expected 2 plugins, got %d", len(plugins))
|
||||||
|
}
|
||||||
|
if len(errs) > 0 {
|
||||||
|
t.Errorf("expected 0 errors, got %d: %v", len(errs), errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDiscoverPlugins_AllowsNonexistentDirs ensures that a mix of valid and
|
||||||
|
// nonexistent directories doesn't cause issues.
|
||||||
|
func TestDiscoverPlugins_AllowsNonexistentDirs(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
createTempPlugin(t, dir, "survivor.plugin", "Survivor")
|
||||||
|
|
||||||
|
plugins, errs := DiscoverPlugins([]string{
|
||||||
|
"/tmp/missing-aaa-88888",
|
||||||
|
dir,
|
||||||
|
"/tmp/missing-bbb-99999",
|
||||||
|
})
|
||||||
|
if len(plugins) != 1 {
|
||||||
|
t.Fatalf("expected 1 plugin, got %d", len(plugins))
|
||||||
|
}
|
||||||
|
if plugins[0].Manifest.ID != "survivor.plugin" {
|
||||||
|
t.Errorf("expected 'survivor.plugin', got %q", plugins[0].Manifest.ID)
|
||||||
|
}
|
||||||
|
if len(errs) > 0 {
|
||||||
|
t.Errorf("expected 0 errors, got %d: %v", len(errs), errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatDiscoverySummary on empty list.
|
||||||
|
func TestFormatDiscoverySummary_Empty(t *testing.T) {
|
||||||
|
s := FormatDiscoverySummary(nil)
|
||||||
|
if s != "no plugins found" {
|
||||||
|
t.Errorf("expected 'no plugins found', got %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatDiscoverySummary on populated list.
|
||||||
|
func TestFormatDiscoverySummary_Plugins(t *testing.T) {
|
||||||
|
plugins := []Plugin{
|
||||||
|
{
|
||||||
|
Manifest: Manifest{ID: "test.one", Version: "1.0.0"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Manifest: Manifest{ID: "test.two", Version: "2.0.0"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
s := FormatDiscoverySummary(plugins)
|
||||||
|
if s != "2 plugin(s): test.one@1.0.0, test.two@2.0.0" {
|
||||||
|
t.Errorf("unexpected summary: %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
23
main.go
23
main.go
|
|
@ -3,6 +3,9 @@ package main
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2"
|
"github.com/wailsapp/wails/v2"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options"
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
|
|
@ -19,6 +22,19 @@ import (
|
||||||
//go:embed frontend/dist
|
//go:embed frontend/dist
|
||||||
var assets embed.FS
|
var assets embed.FS
|
||||||
|
|
||||||
|
// expandPath resolves "~" to the user's home directory.
|
||||||
|
func expandPath(path string) string {
|
||||||
|
if strings.HasPrefix(path, "~/") {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[main] expandPath: cannot get home dir: %v", err)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return filepath.Join(home, path[2:])
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// ─── Initialize Core Registries ──────────────────────────
|
// ─── Initialize Core Registries ──────────────────────────
|
||||||
capRegistry := capability.NewRegistry()
|
capRegistry := capability.NewRegistry()
|
||||||
|
|
@ -32,6 +48,13 @@ func main() {
|
||||||
"./plugins",
|
"./plugins",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expand tilde in all paths
|
||||||
|
for i, d := range discoveryDirs {
|
||||||
|
discoveryDirs[i] = expandPath(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[main] plugin dirs: %v", discoveryDirs)
|
||||||
|
|
||||||
plugins, discErrors := plugin.DiscoverPlugins(discoveryDirs)
|
plugins, discErrors := plugin.DiscoverPlugins(discoveryDirs)
|
||||||
for _, err := range discErrors {
|
for _, err := range discErrors {
|
||||||
log.Printf("[plugin] discovery warning: %v", err)
|
log.Printf("[plugin] discovery warning: %v", err)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue