diff --git a/web/src/apps/PluginHost/index.tsx b/web/src/apps/PluginHost/index.tsx index 41902f5..51e8342 100644 --- a/web/src/apps/PluginHost/index.tsx +++ b/web/src/apps/PluginHost/index.tsx @@ -7,10 +7,22 @@ export interface PluginAppHostProps extends AppComponentProps { } function buildPluginFrameUrl(params: Record): string { - const qs = new URLSearchParams(params); + const qs = new URLSearchParams(); + Object.entries(params).forEach(([k, v]) => { + if (typeof v !== 'string') return; + const value = v.trim(); + if (!value) return; + qs.set(k, value); + }); return `/plugin-frame.html?${qs.toString()}`; } +function getPluginStylePaths(plugin: PluginItem): string[] { + const styles = (plugin.manifest as any)?.frontend?.styles as unknown; + if (!Array.isArray(styles)) return []; + return styles.filter((s) => typeof s === 'string' && s.trim().length > 0); +} + /** * 插件宿主组件 - 文件打开模式 * 使用 iframe 隔离渲染与样式,避免插件污染宿主 DOM/CSS。 @@ -19,6 +31,7 @@ function buildPluginFrameUrl(params: Record): string { export const PluginAppHost: React.FC = ({ plugin, filePath, + entry, onRequestClose, }) => { const iframeRef = useRef(null); @@ -29,10 +42,13 @@ export const PluginAppHost: React.FC = ({ () => buildPluginFrameUrl({ pluginKey: plugin.key, + pluginVersion: plugin.version || '', + pluginStyles: JSON.stringify(getPluginStylePaths(plugin)), mode: 'file', filePath, + entry: JSON.stringify(entry), }), - [plugin.key, filePath] + [plugin, filePath, entry] ); useEffect(() => { @@ -78,9 +94,11 @@ export const PluginAppOpenHost: React.FC = ({ plugin, on () => buildPluginFrameUrl({ pluginKey: plugin.key, + pluginVersion: plugin.version || '', + pluginStyles: JSON.stringify(getPluginStylePaths(plugin)), mode: 'app', }), - [plugin.key] + [plugin] ); useEffect(() => { diff --git a/web/src/plugin-frame.ts b/web/src/plugin-frame.ts index 7638b33..f47c17c 100644 --- a/web/src/plugin-frame.ts +++ b/web/src/plugin-frame.ts @@ -10,6 +10,15 @@ import { vfsApi, type VfsEntry } from './api/vfs'; type FrameMode = 'file' | 'app'; +type FrameQuery = { + pluginKey: string; + mode: FrameMode; + filePath: string; + pluginVersion: string; + pluginStyles: string[] | null; + entry: VfsEntry | null; +}; + function renderStatus(text: string, isError: boolean = false) { const root = document.getElementById('root'); if (!root) return; @@ -44,12 +53,42 @@ function scheduleStatus(text: string, delayMs: number) { }; } -function getQuery() { +function tryParseJson(raw: string): T | null { + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +function getQuery(): FrameQuery { const params = new URLSearchParams(window.location.search); const pluginKey = (params.get('pluginKey') || '').trim(); const mode = (params.get('mode') || 'file') as FrameMode; const filePath = (params.get('filePath') || '').trim(); - return { pluginKey, mode, filePath }; + const pluginVersion = (params.get('pluginVersion') || '').trim(); + + const rawStyles = (params.get('pluginStyles') || '').trim(); + const parsedStyles = rawStyles ? tryParseJson(rawStyles) : null; + const pluginStyles = Array.isArray(parsedStyles) + ? parsedStyles.filter((s) => typeof s === 'string' && s.trim().length > 0) + : null; + + const rawEntry = (params.get('entry') || '').trim(); + const parsedEntry = rawEntry ? tryParseJson(rawEntry) : null; + const entry: VfsEntry | null = + parsedEntry && typeof parsedEntry === 'object' && typeof parsedEntry.name === 'string' + ? { + name: String(parsedEntry.name), + is_dir: Boolean(parsedEntry.is_dir), + size: Number(parsedEntry.size || 0), + mtime: Number(parsedEntry.mtime || 0), + type: typeof parsedEntry.type === 'string' ? parsedEntry.type : undefined, + has_thumbnail: Boolean(parsedEntry.has_thumbnail), + } + : null; + + return { pluginKey, mode, filePath, pluginVersion, pluginStyles, entry }; } function postToParent(data: any) { @@ -58,6 +97,51 @@ function postToParent(data: any) { } } +type TempLinkCache = { + url: string; + fetchedAt: number; + expiresIn: number; +}; + +const TEMP_LINK_CACHE_PREFIX = 'foxel:tempLink:'; +const TEMP_LINK_DEFAULT_EXPIRES_IN = 3600; + +function getTempLinkCacheKey(filePath: string) { + return `${TEMP_LINK_CACHE_PREFIX}${filePath}`; +} + +function readTempLinkCache(filePath: string): TempLinkCache | null { + try { + const raw = sessionStorage.getItem(getTempLinkCacheKey(filePath)); + if (!raw) return null; + const parsed = JSON.parse(raw) as TempLinkCache; + if (!parsed || typeof parsed.url !== 'string') return null; + if (!parsed.fetchedAt || !parsed.expiresIn) return null; + if (Date.now() - parsed.fetchedAt >= parsed.expiresIn * 1000 - 10_000) return null; + return parsed; + } catch { + return null; + } +} + +function writeTempLinkCache(filePath: string, item: TempLinkCache) { + try { + sessionStorage.setItem(getTempLinkCacheKey(filePath), JSON.stringify(item)); + } catch { + void 0; + } +} + +async function getTempLinkUrl(filePath: string, expiresIn: number = TEMP_LINK_DEFAULT_EXPIRES_IN) { + const cached = readTempLinkCache(filePath); + if (cached) return cached.url; + + const tokenData = await vfsApi.getTempLinkToken(filePath, expiresIn); + const url = typeof tokenData?.url === 'string' && tokenData.url.trim() ? tokenData.url : vfsApi.getTempPublicUrl(tokenData.token); + writeTempLinkCache(filePath, { url, fetchedAt: Date.now(), expiresIn }); + return url; +} + function createHostApi(pluginKey: string): FoxelHostApi { const showMessage: FoxelHostApi['showMessage'] = (type, content) => { const antd = window.__FOXEL_EXTERNALS__?.antd; @@ -89,8 +173,7 @@ function createHostApi(pluginKey: string): FoxelHostApi { callApi: async (path: string, options?: RequestInit & { json?: unknown }) => request(path, options), getTempLink: async (filePath: string) => { - const token = await vfsApi.getTempLinkToken(filePath); - return vfsApi.getTempPublicUrl(token.token); + return await getTempLinkUrl(filePath); }, getStreamUrl: (filePath: string) => vfsApi.streamUrl(filePath), }; @@ -110,27 +193,16 @@ function withVersion(url: string, version?: string | null): string { return u.pathname + u.search; } -async function loadPluginStyles(pluginKey: string, plugin: PluginItem, version?: string | null) { - const stylePaths = getPluginStylePaths(plugin); +function injectPluginStyles(pluginKey: string, stylePaths: string[], version?: string | null) { if (stylePaths.length === 0) return; - const tasks = stylePaths.map( - (p) => - new Promise((resolve) => { - const href = withVersion( - `/api/plugins/${pluginKey}/assets/${p.replace(/^\/+/, '')}`, - version - ); - const link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = href; - link.onload = () => resolve(); - link.onerror = () => resolve(); - document.head.appendChild(link); - }) - ); - - await Promise.all(tasks); + stylePaths.forEach((p) => { + const href = withVersion(`/api/plugins/${pluginKey}/assets/${p.replace(/^\/+/, '')}`, version); + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; + document.head.appendChild(link); + }); } async function loadPluginBundle(pluginKey: string, version?: string | null): Promise { @@ -164,24 +236,43 @@ async function loadPluginBundle(pluginKey: string, version?: string | null): Pro }); } -async function buildFileContext(filePath: string) { - const stat = (await vfsApi.stat(filePath)) as any; - const name = - typeof stat?.name === 'string' && stat.name.trim().length > 0 - ? stat.name - : filePath.replace(/\\/g, '/').split('/').filter(Boolean).pop() || 'unknown'; +function isLikelyImage(pathOrName: string) { + return /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(pathOrName); +} - const entry: VfsEntry = { - name, - is_dir: Boolean(stat?.is_dir), - size: Number(stat?.size || 0), - mtime: Number(stat?.mtime || 0), - type: typeof stat?.type === 'string' ? stat.type : undefined, - has_thumbnail: Boolean(stat?.has_thumbnail), - }; +function preloadImage(url: string) { + const img = new Image(); + img.decoding = 'async'; + img.src = url; +} - const token = await vfsApi.getTempLinkToken(filePath); - const downloadUrl = vfsApi.getTempPublicUrl(token.token); +async function buildFileContext(filePath: string, entryOverride: VfsEntry | null) { + const entryPromise = entryOverride + ? Promise.resolve(entryOverride) + : (async () => { + const stat = (await vfsApi.stat(filePath)) as any; + const name = + typeof stat?.name === 'string' && stat.name.trim().length > 0 + ? stat.name + : filePath.replace(/\\/g, '/').split('/').filter(Boolean).pop() || 'unknown'; + + const entry: VfsEntry = { + name, + is_dir: Boolean(stat?.is_dir), + size: Number(stat?.size || 0), + mtime: Number(stat?.mtime || 0), + type: typeof stat?.type === 'string' ? stat.type : undefined, + has_thumbnail: Boolean(stat?.has_thumbnail), + }; + return entry; + })(); + + const downloadUrlPromise = getTempLinkUrl(filePath); + if (isLikelyImage(filePath)) { + downloadUrlPromise.then(preloadImage).catch(() => void 0); + } + + const [entry, downloadUrl] = await Promise.all([entryPromise, downloadUrlPromise]); const streamUrl = vfsApi.streamUrl(filePath); return { entry, urls: { downloadUrl, streamUrl } }; @@ -190,7 +281,7 @@ async function buildFileContext(filePath: string) { async function main() { initExternals(); - const { pluginKey, mode, filePath } = getQuery(); + const { pluginKey, mode, filePath, pluginVersion, pluginStyles, entry } = getQuery(); if (!pluginKey) { renderStatus('Missing pluginKey in query string', true); return; @@ -204,34 +295,34 @@ async function main() { const cancelLoading = scheduleStatus('Loading plugin...', 200); - let plugin: PluginItem; - try { - plugin = await pluginsApi.get(pluginKey); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - cancelLoading(); - renderStatus(`Failed to load plugin info: ${msg}`, true); - return; - } + const host = createHostApi(pluginKey); - try { - await loadPluginStyles(pluginKey, plugin, plugin.version); - } catch { - // ignore - } + const pluginPromise = (async () => { + if (pluginVersion && pluginStyles) { + injectPluginStyles(pluginKey, pluginStyles, pluginVersion); + return await loadPluginBundle(pluginKey, pluginVersion); + } + + const plugin: PluginItem = await pluginsApi.get(pluginKey); + const resolvedVersion = plugin.version || ''; + injectPluginStyles(pluginKey, getPluginStylePaths(plugin), resolvedVersion); + return await loadPluginBundle(pluginKey, resolvedVersion); + })(); + + const ctxPromise = mode === 'file' ? buildFileContext(filePath, entry) : Promise.resolve(null); let registered: RegisteredPlugin; + let ctx: Awaited> | null; try { - registered = await loadPluginBundle(pluginKey, plugin.version); + [registered, ctx] = await Promise.all([pluginPromise, ctxPromise]); } catch (e) { const msg = e instanceof Error ? e.message : String(e); cancelLoading(); - renderStatus(`Failed to load plugin bundle: ${msg}`, true); + renderStatus(`Failed to load plugin: ${msg}`, true); return; } cancelLoading(); - const host = createHostApi(pluginKey); let cleanup: (() => void) | null = null; const mountError = async () => { @@ -251,8 +342,11 @@ async function main() { throw new Error('Missing filePath in query string'); } - const { entry, urls } = await buildFileContext(filePath); - const ret = await registered.mount(root, { filePath, entry, urls, host }); + if (!ctx) { + throw new Error('Missing file context'); + } + + const ret = await registered.mount(root, { filePath, entry: ctx.entry, urls: ctx.urls, host }); if (typeof ret === 'function') cleanup = ret; } catch (e) { const msg = e instanceof Error ? e.message : String(e);