352 lines
9.4 KiB
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>
|