feat: milestone 5b — frontend bundle host + VerstakPluginAPI stub
- Bundle contract: window.VerstakPluginRegister(id, {components: {...}})
- PluginBundleHost.svelte: loads bundle via GetPluginAssetContent, mounts components
- VerstakPluginAPI.js: restricted API (capabilities, events, settings, commands — all stub)
- ViewContainer: PluginBundleHost replaces placeholder when frontend bundle exists
- PluginManager: settings panel via PluginBundleHost (removed hardcoded form)
- Backend: GetPluginFrontendInfo, GetPluginAssetContent with path security
- Security: reject absolute paths, path traversal, escape from plugin root
- Error boundary: bundle load/execute/mount errors show fallback, not crash
- Tests: 11 backend tests (asset API), frontend bundle checks in smoke
- Docs: bundle contract, VerstakPluginAPI, security constraints
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import * as App from '../../../wailsjs/go/api/App';
|
||||
|
||||
// Import the VerstakPluginAPI contract
|
||||
import './VerstakPluginAPI.js';
|
||||
|
||||
export let pluginId = null;
|
||||
export let componentId = null;
|
||||
export let viewPluginId = null;
|
||||
|
||||
let loadState = 'idle'; // idle | loading | loaded | error
|
||||
let pluginInfo = null;
|
||||
let errorText = '';
|
||||
let mountContainer = null;
|
||||
let currentPluginId = null;
|
||||
let currentComponent = null;
|
||||
|
||||
$: activePluginId = pluginId || viewPluginId;
|
||||
$: activeComponent = componentId;
|
||||
|
||||
// React to changes — reload on view change
|
||||
$: if (activePluginId && activeComponent) {
|
||||
loadAndMount(activePluginId, activeComponent);
|
||||
} else if (!activePluginId) {
|
||||
cleanup();
|
||||
loadState = 'idle';
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function cleanup() {
|
||||
const reg = window.__VERSTAK_PLUGIN_REGISTRY__;
|
||||
if (currentPluginId && currentComponent && reg && reg[currentPluginId]) {
|
||||
const comp = reg[currentPluginId][currentComponent];
|
||||
if (comp && comp.unmount && mountContainer) {
|
||||
try {
|
||||
comp.unmount(mountContainer);
|
||||
} catch (e) {
|
||||
console.error('[PluginBundleHost] unmount error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mountContainer) {
|
||||
mountContainer.innerHTML = '';
|
||||
}
|
||||
currentPluginId = null;
|
||||
currentComponent = null;
|
||||
}
|
||||
|
||||
async function loadAndMount(pId, compId) {
|
||||
// If same plugin+component and already mounted, skip
|
||||
if (currentPluginId === pId && currentComponent === compId && loadState === 'loaded') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cleanup previous
|
||||
cleanup();
|
||||
|
||||
loadState = 'loading';
|
||||
errorText = '';
|
||||
currentPluginId = pId;
|
||||
currentComponent = compId;
|
||||
|
||||
try {
|
||||
// Get plugin frontend info
|
||||
const info = await App.GetPluginFrontendInfo(pId);
|
||||
pluginInfo = info;
|
||||
|
||||
if (!info || info.status === 'no-frontend' || info.status === 'not-found') {
|
||||
loadState = 'error';
|
||||
errorText = info.status === 'no-frontend'
|
||||
? 'Plugin has no frontend bundle'
|
||||
: 'Plugin not found';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if bundle already loaded for this plugin
|
||||
const reg = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
|
||||
if (!reg[pId]) {
|
||||
// Load the bundle JS content via backend API
|
||||
const [content, err] = await App.GetPluginAssetContent(pId, info.entry);
|
||||
if (err || !content) {
|
||||
loadState = 'error';
|
||||
errorText = 'Failed to load bundle: ' + (err || 'empty content');
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute bundle via Function constructor (safe: no access to outer scope)
|
||||
// This is equivalent to eval but more explicit
|
||||
try {
|
||||
const fn = new Function(content);
|
||||
fn();
|
||||
} catch (e) {
|
||||
loadState = 'error';
|
||||
errorText = 'Bundle execution error: ' + e.message;
|
||||
console.error('[PluginBundleHost] bundle exec error:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify registration happened
|
||||
if (!window.__VERSTAK_PLUGIN_REGISTRY__[pId]) {
|
||||
loadState = 'error';
|
||||
errorText = 'Bundle loaded but no VerstakPluginRegister call detected';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the component
|
||||
const components = window.__VERSTAK_PLUGIN_REGISTRY__[pId];
|
||||
const comp = components[compId];
|
||||
if (!comp || !comp.mount) {
|
||||
loadState = 'error';
|
||||
errorText = 'Component "' + compId + '" not found in bundle. Available: '
|
||||
+ (Object.keys(components).join(', ') || 'none');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create API
|
||||
const api = window.VerstakPluginAPI(pId);
|
||||
|
||||
// Mount component
|
||||
if (!mountContainer) {
|
||||
// Container must exist in DOM — wait for next tick
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
}
|
||||
if (mountContainer) {
|
||||
try {
|
||||
comp.mount(mountContainer, { componentId: compId }, api);
|
||||
loadState = 'loaded';
|
||||
errorText = '';
|
||||
} catch (e) {
|
||||
loadState = 'error';
|
||||
errorText = 'Component mount error: ' + e.message;
|
||||
console.error('[PluginBundleHost] mount error:', e);
|
||||
}
|
||||
} else {
|
||||
loadState = 'error';
|
||||
errorText = 'Mount container not available';
|
||||
}
|
||||
} catch (e) {
|
||||
loadState = 'error';
|
||||
errorText = 'Unexpected error: ' + (e.message || e);
|
||||
console.error('[PluginBundleHost] error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function getComponentList() {
|
||||
const reg = window.__VERSTAK_PLUGIN_REGISTRY__;
|
||||
if (!reg || !currentPluginId || !reg[currentPluginId]) return [];
|
||||
return Object.keys(reg[currentPluginId]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="plugin-bundle-host">
|
||||
{#if loadState === 'idle'}
|
||||
<div class="host-state idle">
|
||||
<p>Select a plugin view from the sidebar</p>
|
||||
</div>
|
||||
|
||||
{:else if loadState === 'loading'}
|
||||
<div class="host-state loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading plugin bundle...</p>
|
||||
</div>
|
||||
|
||||
{:else if loadState === 'error'}
|
||||
<div class="host-state error">
|
||||
<div class="error-icon">⚠️</div>
|
||||
<p class="error-title">Plugin View Error</p>
|
||||
<div class="error-details">
|
||||
<p><strong>Plugin:</strong> {currentPluginId || 'unknown'}</p>
|
||||
<p><strong>Component:</strong> {currentComponent || 'unknown'}</p>
|
||||
<p class="error-message">{errorText || 'Unknown error'}</p>
|
||||
{#if pluginInfo}
|
||||
<p class="error-meta">Frontend entry: {pluginInfo.entry || 'none'}</p>
|
||||
{/if}
|
||||
{#if getComponentList().length > 0}
|
||||
<p class="error-meta">Available components: {getComponentList().join(', ')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if loadState === 'loaded'}
|
||||
<div
|
||||
class="plugin-mount-container"
|
||||
bind:this={mountContainer}
|
||||
data-plugin-id={currentPluginId}
|
||||
data-component={currentComponent}
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.plugin-bundle-host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.host-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.host-state.idle {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.host-state.loading {
|
||||
color: #a0a0b8;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid #333;
|
||||
border-top-color: #4ecca3;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.host-state.error {
|
||||
color: #e94560;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-details {
|
||||
font-size: 0.85rem;
|
||||
color: #a0a0b8;
|
||||
max-width: 400px;
|
||||
text-align: left;
|
||||
background: #16213e;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #0f3460;
|
||||
}
|
||||
|
||||
.error-details p {
|
||||
margin: 0.3rem 0;
|
||||
}
|
||||
|
||||
.error-details strong {
|
||||
color: #e0e0f0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #e94560;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.5rem !important;
|
||||
padding: 0.5rem;
|
||||
background: rgba(233, 69, 96, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.error-meta {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
margin-top: 0.3rem !important;
|
||||
}
|
||||
|
||||
.plugin-mount-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
// VerstakPluginAPI is the restricted API passed to plugin frontend bundles.
|
||||
// Plugins do NOT get direct access to Wails bridge — only what's exposed here.
|
||||
// All methods are stubs or limited implementations.
|
||||
|
||||
(function() {
|
||||
// Store registered components per plugin
|
||||
window.__VERSTAK_PLUGIN_REGISTRY__ = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
|
||||
|
||||
// Original register function
|
||||
const origRegister = window.VerstakPluginRegister;
|
||||
if (origRegister) {
|
||||
// Already defined — don't override
|
||||
return;
|
||||
}
|
||||
|
||||
window.VerstakPluginRegister = function(pluginId, bundle) {
|
||||
if (!pluginId || !bundle || !bundle.components) {
|
||||
console.error('[VerstakPluginRegister] invalid registration:', pluginId);
|
||||
return;
|
||||
}
|
||||
console.log('[VerstakPluginRegister] registered:', pluginId, Object.keys(bundle.components));
|
||||
window.__VERSTAK_PLUGIN_REGISTRY__[pluginId] = bundle.components;
|
||||
};
|
||||
|
||||
// Create the restricted API object for a plugin host context
|
||||
window.VerstakPluginAPI = function(pluginId) {
|
||||
return {
|
||||
pluginId: pluginId,
|
||||
|
||||
capabilities: {
|
||||
has: function(capId) {
|
||||
// planned: query backend cap registry
|
||||
console.log('[plugin:' + pluginId + '] capabilities.has(' + capId + ') — stub');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
events: {
|
||||
publish: function(type, payload) {
|
||||
console.log('[plugin:' + pluginId + '] event publish:', type, payload);
|
||||
// planned: actual event bus bridge
|
||||
},
|
||||
subscribe: function(type, handler) {
|
||||
console.log('[plugin:' + pluginId + '] event subscribe:', type, '(stub)');
|
||||
// planned: actual event bus bridge
|
||||
}
|
||||
},
|
||||
|
||||
settings: {
|
||||
read: function(key) {
|
||||
console.log('[plugin:' + pluginId + '] settings.read(' + key + ') — stub');
|
||||
return null;
|
||||
},
|
||||
write: function(key, value) {
|
||||
console.log('[plugin:' + pluginId + '] settings.write(' + key + ',', value, ') — stub');
|
||||
// planned: backend storage namespace
|
||||
}
|
||||
},
|
||||
|
||||
commands: {
|
||||
execute: function(cmdId, args) {
|
||||
console.log('[plugin:' + pluginId + '] commands.execute(' + cmdId + ') — stub');
|
||||
// planned: command execution
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
})();
|
||||
@@ -1,7 +1,8 @@
|
||||
<script>
|
||||
import PluginCard from './PluginCard.svelte';
|
||||
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { GetPlugins, GetCapabilities, GetPermissions, GetContributions, ReloadPlugins, GetVaultStatus, GetVaultPluginState, EnablePlugin, DisablePlugin, ReadPluginSettings, WritePluginSettings } from '../../../wailsjs/go/api/App';
|
||||
import { GetPlugins, GetCapabilities, GetPermissions, GetContributions, ReloadPlugins, GetVaultStatus, GetVaultPluginState, EnablePlugin, DisablePlugin, ReadPluginSettings, WritePluginSettings, GetPluginFrontendInfo } from '../../../wailsjs/go/api/App';
|
||||
|
||||
let plugins = [];
|
||||
let capabilities = [];
|
||||
@@ -15,6 +16,7 @@
|
||||
let settingsData = {};
|
||||
let settingsPluginId = '';
|
||||
let settingsError = null;
|
||||
let settingsPluginInfo = null;
|
||||
let lastOpenedKey = '';
|
||||
|
||||
export let activeSettingsPluginId = '';
|
||||
@@ -28,12 +30,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
function openSettingsFromProps(pluginId, panelId) {
|
||||
async function openSettingsFromProps(pluginId, panelId) {
|
||||
const panel = (contributions.settingsPanels || []).find(sp => sp.pluginId === pluginId && (!panelId || sp.id === panelId));
|
||||
if (panel) {
|
||||
settingsPanel = panel;
|
||||
settingsPluginId = pluginId;
|
||||
settingsError = null;
|
||||
// Get plugin frontend info
|
||||
try {
|
||||
const info = await GetPluginFrontendInfo(pluginId);
|
||||
settingsPluginInfo = info;
|
||||
} catch { settingsPluginInfo = null; }
|
||||
ReadPluginSettings(pluginId).then(data => {
|
||||
settingsData = data || {};
|
||||
}).catch(() => { settingsData = {}; });
|
||||
@@ -245,31 +252,19 @@
|
||||
<div class="modal-overlay" on:click|self={closeSettings} on:keydown|self={(e) => e.key === 'Escape' && closeSettings()} role="presentation">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-label="Plugin Settings">
|
||||
<div class="modal-header">
|
||||
<h3>{settingsPanel.item.title}</h3>
|
||||
<h3>{settingsPanel.title}</h3>
|
||||
<button class="modal-close" on:click={closeSettings} type="button">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="settings-hint">Plugin: <code>{settingsPluginId}</code></p>
|
||||
<p class="settings-hint">Component: <code>{settingsPanel.item.component}</code></p>
|
||||
|
||||
{#if settingsPanel.item.id === 'verstak.platform-test.settings'}
|
||||
<div class="settings-form">
|
||||
<h4>Test Settings</h4>
|
||||
<div class="form-row">
|
||||
<label for="test-name">Test Name</label>
|
||||
<input id="test-name" type="text" bind:value={settingsData.testName} placeholder="Enter test name" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="test-interval">Test Interval (seconds)</label>
|
||||
<input id="test-interval" type="number" bind:value={settingsData.testInterval} min="1" max="300" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label><input type="checkbox" bind:checked={settingsData.autoRun} /> Auto-run on startup</label>
|
||||
</div>
|
||||
<button class="btn-save" on:click={() => saveSettings()} type="button">Save Settings</button>
|
||||
</div>
|
||||
{#if settingsPluginInfo && settingsPluginInfo.entry}
|
||||
<PluginBundleHost
|
||||
pluginId={settingsPluginId}
|
||||
componentId={settingsPanel.component || settingsPanel.id}
|
||||
/>
|
||||
{:else}
|
||||
<p class="placeholder">Settings component: {settingsPanel.item.component}</p>
|
||||
<p class="settings-hint">Component: <code>{settingsPanel.component}</code></p>
|
||||
<p class="placeholder">Settings panel frontend bundle not available</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import * as App from '../../../wailsjs/go/api/App';
|
||||
|
||||
@@ -27,11 +28,18 @@
|
||||
? plugins.find(p => p.manifest?.id === currentView.pluginId)
|
||||
: null;
|
||||
$: pluginStatus = currentPlugin ? currentPlugin.status : 'unknown';
|
||||
$: hasFrontend = currentPlugin?.manifest?.frontend?.entry != null;
|
||||
$: hostPluginId = currentView?.pluginId || activeViewPluginId;
|
||||
$: hostComponentId = currentView?.component || null;
|
||||
|
||||
// Reset render error when view changes
|
||||
$: if (activeView) {
|
||||
renderError = null;
|
||||
}
|
||||
|
||||
function onHostError(e) {
|
||||
renderError = e.detail?.message || 'Plugin view error';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#key `${activeViewPluginId}:${activeView}`}
|
||||
@@ -51,17 +59,27 @@
|
||||
<div class="view-header">
|
||||
<span class="view-icon">{currentView.icon || '📦'}</span>
|
||||
<h2>{currentView.title}</h2>
|
||||
{#if hasFrontend}
|
||||
<span class="frontend-badge">frontend bundle</span>
|
||||
{:else}
|
||||
<span class="no-frontend-badge">no frontend bundle</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="view-content">
|
||||
<div class="plugin-view-host" data-view-id={currentView.id} data-component={currentView.component}>
|
||||
{#if hasFrontend}
|
||||
<PluginBundleHost
|
||||
pluginId={hostPluginId}
|
||||
componentId={hostComponentId}
|
||||
/>
|
||||
{:else}
|
||||
<div class="placeholder">
|
||||
<p class="placeholder-label">Plugin View Host</p>
|
||||
<p class="placeholder-info"><span class="placeholder-key">Plugin:</span> <strong>{currentView.pluginId}</strong></p>
|
||||
<p class="placeholder-info"><span class="placeholder-key">View ID:</span> <code>{currentView.id}</code></p>
|
||||
<p class="placeholder-info"><span class="placeholder-key">Component:</span> <code>{currentView.component}</code></p>
|
||||
<p class="placeholder-badge">frontend bundle host not implemented yet</p>
|
||||
<p class="placeholder-badge">frontend bundle not available</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,14 +127,30 @@
|
||||
flex: 1;
|
||||
}
|
||||
.view-icon { font-size: 1.3rem; }
|
||||
.frontend-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: rgba(78, 204, 163, 0.15);
|
||||
color: #4ecca3;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.no-frontend-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: rgba(233, 69, 96, 0.1);
|
||||
color: #e94560;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.view-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
.plugin-view-host {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
}
|
||||
.placeholder {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
|
||||
Vendored
+4
@@ -27,6 +27,10 @@ export function GetCurrentWorkspaceNode():Promise<Record<string, any>>;
|
||||
|
||||
export function GetPermissions():Promise<Array<permissions.Entry>>;
|
||||
|
||||
export function GetPluginAssetContent(arg1:string,arg2:string):Promise<string|string>;
|
||||
|
||||
export function GetPluginFrontendInfo(arg1:string):Promise<Record<string, any>>;
|
||||
|
||||
export function GetPlugins():Promise<Array<plugin.Plugin>>;
|
||||
|
||||
export function GetVaultPluginState():Promise<Record<string, any>>;
|
||||
|
||||
@@ -46,6 +46,14 @@ export function GetPermissions() {
|
||||
return window['go']['api']['App']['GetPermissions']();
|
||||
}
|
||||
|
||||
export function GetPluginAssetContent(arg1, arg2) {
|
||||
return window['go']['api']['App']['GetPluginAssetContent'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function GetPluginFrontendInfo(arg1) {
|
||||
return window['go']['api']['App']['GetPluginFrontendInfo'](arg1);
|
||||
}
|
||||
|
||||
export function GetPlugins() {
|
||||
return window['go']['api']['App']['GetPlugins']();
|
||||
}
|
||||
|
||||
Regular → Executable
Regular → Executable
Reference in New Issue
Block a user