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:
2026-06-17 17:39:50 +08:00
parent 1dff97a9c0
commit 05ef1449bc
13 changed files with 887 additions and 30 deletions
@@ -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>
+41 -7
View File
@@ -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;
+4
View File
@@ -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>>;
+8
View File
@@ -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']();
}
View File
Vendored Regular → Executable
View File
View File