verstak-desktop/frontend/src/lib/plugin-host/PluginBundleHost.svelte

352 lines
9.4 KiB
Svelte

<script>
import { onMount, onDestroy } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
import { createPluginAPI } from './VerstakPluginAPI.js';
export let pluginId = null;
export let componentId = null;
export let viewPluginId = null;
export let componentProps = {};
let loadState = 'idle'; // idle | loading | loaded | error
let pluginInfo = null;
let errorText = '';
let mountContainer = null;
let currentPluginId = null;
let currentComponent = null;
let currentAPI = null;
let currentPropsKey = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
$: activePluginId = pluginId || viewPluginId;
$: activeComponent = componentId;
$: propsKey = JSON.stringify(componentProps || {});
// React to changes — reload on view change
$: if (activePluginId && activeComponent) {
loadAndMount(activePluginId, activeComponent, propsKey);
} else if (!activePluginId) {
cleanup();
loadState = 'idle';
}
onDestroy(() => {
unsubscribeLocale?.();
cleanup();
});
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale);
});
function cleanup() {
if (currentAPI && typeof currentAPI.dispose === 'function') {
try {
currentAPI.dispose();
} catch (e) {
console.error('[PluginBundleHost] API dispose error:', e);
}
}
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;
currentAPI = null;
currentPropsKey = '';
}
function unpackBackendResult(result) {
if (Array.isArray(result) && result.length === 2 && (typeof result[1] === 'string' || result[1] == null)) {
return { value: result[0], error: result[1] || '' };
}
return { value: result, error: '' };
}
async function loadAndMount(pId, compId, nextPropsKey) {
// If same plugin+component and already mounted, skip
if (currentPluginId === pId && currentComponent === compId && currentPropsKey === nextPropsKey && loadState === 'loaded') {
return;
}
// Cleanup previous
cleanup();
loadState = 'loading';
errorText = '';
currentPluginId = pId;
currentComponent = compId;
currentPropsKey = nextPropsKey;
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'
? tr('bundle.noFrontend')
: tr('bundle.notFound');
return;
}
try {
await i18n.loadPlugin(pId, info.localization);
} catch (catalogError) {
console.warn(`[PluginBundleHost] localization unavailable for ${pId}:`, catalogError);
}
// 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 assetResult = unpackBackendResult(await App.GetPluginAssetContent(pId, info.entry));
const content = assetResult.value;
if (assetResult.error || !content) {
loadState = 'error';
errorText = tr('bundle.loadFailed', { error: assetResult.error || tr('bundle.emptyContent') });
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 = tr('bundle.executionError', { error: e.message });
console.error('[PluginBundleHost] bundle exec error:', e);
return;
}
// Verify registration happened
if (!window.__VERSTAK_PLUGIN_REGISTRY__[pId]) {
loadState = 'error';
errorText = tr('bundle.registrationMissing');
return;
}
}
// Find the component
const components = window.__VERSTAK_PLUGIN_REGISTRY__[pId];
const comp = components[compId];
if (!comp || !comp.mount) {
loadState = 'error';
errorText = tr('bundle.componentMissing', {
component: compId,
available: Object.keys(components).join(', ') || tr('common.none'),
});
return;
}
// Create API
const api = createPluginAPI(pId);
currentAPI = api;
// 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, Object.assign({ componentId: compId }, componentProps || {}), api);
loadState = 'loaded';
errorText = '';
} catch (e) {
loadState = 'error';
errorText = tr('bundle.mountError', { error: e.message });
console.error('[PluginBundleHost] mount error:', e);
}
} else {
loadState = 'error';
errorText = tr('bundle.mountUnavailable');
}
} catch (e) {
loadState = 'error';
errorText = tr('bundle.unexpectedError', { 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>{tr('pluginView.select')}</p>
</div>
{:else if loadState === 'error'}
<div class="host-state error">
<Icon name="warning" size={24} class="error-icon" />
<p class="error-title">{tr('pluginView.error')}</p>
<div class="error-details">
<p><strong>{tr('common.plugin')}:</strong> {currentPluginId || tr('common.unknown')}</p>
<p><strong>{tr('common.component')}:</strong> {currentComponent || tr('common.unknown')}</p>
<p class="error-message">{errorText || tr('bundle.unknownError')}</p>
{#if pluginInfo}
<p class="error-meta">{tr('bundle.frontendEntry')}: {pluginInfo.entry || tr('common.none')}</p>
{/if}
{#if getComponentList().length > 0}
<p class="error-meta">{tr('bundle.availableComponents')}: {getComponentList().join(', ')}</p>
{/if}
</div>
</div>
{:else}
{#if loadState === 'loading'}
<div class="host-state loading">
<div class="spinner"></div>
<p>{tr('bundle.loading')}</p>
</div>
{/if}
<div
class="plugin-mount-container"
class:mount-hidden={loadState !== 'loaded'}
bind:this={mountContainer}
data-plugin-id={currentPluginId}
data-component={currentComponent}
style="flex:1;min-width:0;min-height:0;height:100%;display:flex;flex-direction:column;position:relative;"
></div>
{/if}
</div>
<style>
.plugin-bundle-host {
flex: 1;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.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;
}
.host-state.error :global(.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;
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
position: relative;
}
.plugin-mount-container.mount-hidden {
height: 0;
min-height: 0;
overflow: hidden;
visibility: hidden;
}
</style>