mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-05 23:57:19 +08:00
feat: enhance plugin frame URL building and improve query handling for plugin styles and entry
This commit is contained in:
@@ -7,10 +7,22 @@ export interface PluginAppHostProps extends AppComponentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildPluginFrameUrl(params: Record<string, string>): string {
|
function buildPluginFrameUrl(params: Record<string, string>): 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()}`;
|
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。
|
* 使用 iframe 隔离渲染与样式,避免插件污染宿主 DOM/CSS。
|
||||||
@@ -19,6 +31,7 @@ function buildPluginFrameUrl(params: Record<string, string>): string {
|
|||||||
export const PluginAppHost: React.FC<PluginAppHostProps> = ({
|
export const PluginAppHost: React.FC<PluginAppHostProps> = ({
|
||||||
plugin,
|
plugin,
|
||||||
filePath,
|
filePath,
|
||||||
|
entry,
|
||||||
onRequestClose,
|
onRequestClose,
|
||||||
}) => {
|
}) => {
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
@@ -29,10 +42,13 @@ export const PluginAppHost: React.FC<PluginAppHostProps> = ({
|
|||||||
() =>
|
() =>
|
||||||
buildPluginFrameUrl({
|
buildPluginFrameUrl({
|
||||||
pluginKey: plugin.key,
|
pluginKey: plugin.key,
|
||||||
|
pluginVersion: plugin.version || '',
|
||||||
|
pluginStyles: JSON.stringify(getPluginStylePaths(plugin)),
|
||||||
mode: 'file',
|
mode: 'file',
|
||||||
filePath,
|
filePath,
|
||||||
|
entry: JSON.stringify(entry),
|
||||||
}),
|
}),
|
||||||
[plugin.key, filePath]
|
[plugin, filePath, entry]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -78,9 +94,11 @@ export const PluginAppOpenHost: React.FC<PluginAppOpenHostProps> = ({ plugin, on
|
|||||||
() =>
|
() =>
|
||||||
buildPluginFrameUrl({
|
buildPluginFrameUrl({
|
||||||
pluginKey: plugin.key,
|
pluginKey: plugin.key,
|
||||||
|
pluginVersion: plugin.version || '',
|
||||||
|
pluginStyles: JSON.stringify(getPluginStylePaths(plugin)),
|
||||||
mode: 'app',
|
mode: 'app',
|
||||||
}),
|
}),
|
||||||
[plugin.key]
|
[plugin]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
+153
-59
@@ -10,6 +10,15 @@ import { vfsApi, type VfsEntry } from './api/vfs';
|
|||||||
|
|
||||||
type FrameMode = 'file' | 'app';
|
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) {
|
function renderStatus(text: string, isError: boolean = false) {
|
||||||
const root = document.getElementById('root');
|
const root = document.getElementById('root');
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
@@ -44,12 +53,42 @@ function scheduleStatus(text: string, delayMs: number) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getQuery() {
|
function tryParseJson<T = unknown>(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 params = new URLSearchParams(window.location.search);
|
||||||
const pluginKey = (params.get('pluginKey') || '').trim();
|
const pluginKey = (params.get('pluginKey') || '').trim();
|
||||||
const mode = (params.get('mode') || 'file') as FrameMode;
|
const mode = (params.get('mode') || 'file') as FrameMode;
|
||||||
const filePath = (params.get('filePath') || '').trim();
|
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<unknown>(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<any>(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) {
|
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 {
|
function createHostApi(pluginKey: string): FoxelHostApi {
|
||||||
const showMessage: FoxelHostApi['showMessage'] = (type, content) => {
|
const showMessage: FoxelHostApi['showMessage'] = (type, content) => {
|
||||||
const antd = window.__FOXEL_EXTERNALS__?.antd;
|
const antd = window.__FOXEL_EXTERNALS__?.antd;
|
||||||
@@ -89,8 +173,7 @@ function createHostApi(pluginKey: string): FoxelHostApi {
|
|||||||
callApi: async <T = unknown>(path: string, options?: RequestInit & { json?: unknown }) =>
|
callApi: async <T = unknown>(path: string, options?: RequestInit & { json?: unknown }) =>
|
||||||
request<T>(path, options),
|
request<T>(path, options),
|
||||||
getTempLink: async (filePath: string) => {
|
getTempLink: async (filePath: string) => {
|
||||||
const token = await vfsApi.getTempLinkToken(filePath);
|
return await getTempLinkUrl(filePath);
|
||||||
return vfsApi.getTempPublicUrl(token.token);
|
|
||||||
},
|
},
|
||||||
getStreamUrl: (filePath: string) => vfsApi.streamUrl(filePath),
|
getStreamUrl: (filePath: string) => vfsApi.streamUrl(filePath),
|
||||||
};
|
};
|
||||||
@@ -110,27 +193,16 @@ function withVersion(url: string, version?: string | null): string {
|
|||||||
return u.pathname + u.search;
|
return u.pathname + u.search;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPluginStyles(pluginKey: string, plugin: PluginItem, version?: string | null) {
|
function injectPluginStyles(pluginKey: string, stylePaths: string[], version?: string | null) {
|
||||||
const stylePaths = getPluginStylePaths(plugin);
|
|
||||||
if (stylePaths.length === 0) return;
|
if (stylePaths.length === 0) return;
|
||||||
|
|
||||||
const tasks = stylePaths.map(
|
stylePaths.forEach((p) => {
|
||||||
(p) =>
|
const href = withVersion(`/api/plugins/${pluginKey}/assets/${p.replace(/^\/+/, '')}`, version);
|
||||||
new Promise<void>((resolve) => {
|
const link = document.createElement('link');
|
||||||
const href = withVersion(
|
link.rel = 'stylesheet';
|
||||||
`/api/plugins/${pluginKey}/assets/${p.replace(/^\/+/, '')}`,
|
link.href = href;
|
||||||
version
|
document.head.appendChild(link);
|
||||||
);
|
});
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPluginBundle(pluginKey: string, version?: string | null): Promise<RegisteredPlugin> {
|
async function loadPluginBundle(pluginKey: string, version?: string | null): Promise<RegisteredPlugin> {
|
||||||
@@ -164,24 +236,43 @@ async function loadPluginBundle(pluginKey: string, version?: string | null): Pro
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildFileContext(filePath: string) {
|
function isLikelyImage(pathOrName: string) {
|
||||||
const stat = (await vfsApi.stat(filePath)) as any;
|
return /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(pathOrName);
|
||||||
const name =
|
}
|
||||||
typeof stat?.name === 'string' && stat.name.trim().length > 0
|
|
||||||
? stat.name
|
|
||||||
: filePath.replace(/\\/g, '/').split('/').filter(Boolean).pop() || 'unknown';
|
|
||||||
|
|
||||||
const entry: VfsEntry = {
|
function preloadImage(url: string) {
|
||||||
name,
|
const img = new Image();
|
||||||
is_dir: Boolean(stat?.is_dir),
|
img.decoding = 'async';
|
||||||
size: Number(stat?.size || 0),
|
img.src = url;
|
||||||
mtime: Number(stat?.mtime || 0),
|
}
|
||||||
type: typeof stat?.type === 'string' ? stat.type : undefined,
|
|
||||||
has_thumbnail: Boolean(stat?.has_thumbnail),
|
|
||||||
};
|
|
||||||
|
|
||||||
const token = await vfsApi.getTempLinkToken(filePath);
|
async function buildFileContext(filePath: string, entryOverride: VfsEntry | null) {
|
||||||
const downloadUrl = vfsApi.getTempPublicUrl(token.token);
|
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);
|
const streamUrl = vfsApi.streamUrl(filePath);
|
||||||
|
|
||||||
return { entry, urls: { downloadUrl, streamUrl } };
|
return { entry, urls: { downloadUrl, streamUrl } };
|
||||||
@@ -190,7 +281,7 @@ async function buildFileContext(filePath: string) {
|
|||||||
async function main() {
|
async function main() {
|
||||||
initExternals();
|
initExternals();
|
||||||
|
|
||||||
const { pluginKey, mode, filePath } = getQuery();
|
const { pluginKey, mode, filePath, pluginVersion, pluginStyles, entry } = getQuery();
|
||||||
if (!pluginKey) {
|
if (!pluginKey) {
|
||||||
renderStatus('Missing pluginKey in query string', true);
|
renderStatus('Missing pluginKey in query string', true);
|
||||||
return;
|
return;
|
||||||
@@ -204,34 +295,34 @@ async function main() {
|
|||||||
|
|
||||||
const cancelLoading = scheduleStatus('Loading plugin...', 200);
|
const cancelLoading = scheduleStatus('Loading plugin...', 200);
|
||||||
|
|
||||||
let plugin: PluginItem;
|
const host = createHostApi(pluginKey);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
const pluginPromise = (async () => {
|
||||||
await loadPluginStyles(pluginKey, plugin, plugin.version);
|
if (pluginVersion && pluginStyles) {
|
||||||
} catch {
|
injectPluginStyles(pluginKey, pluginStyles, pluginVersion);
|
||||||
// ignore
|
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 registered: RegisteredPlugin;
|
||||||
|
let ctx: Awaited<ReturnType<typeof buildFileContext>> | null;
|
||||||
try {
|
try {
|
||||||
registered = await loadPluginBundle(pluginKey, plugin.version);
|
[registered, ctx] = await Promise.all([pluginPromise, ctxPromise]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
cancelLoading();
|
cancelLoading();
|
||||||
renderStatus(`Failed to load plugin bundle: ${msg}`, true);
|
renderStatus(`Failed to load plugin: ${msg}`, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelLoading();
|
cancelLoading();
|
||||||
const host = createHostApi(pluginKey);
|
|
||||||
|
|
||||||
let cleanup: (() => void) | null = null;
|
let cleanup: (() => void) | null = null;
|
||||||
const mountError = async () => {
|
const mountError = async () => {
|
||||||
@@ -251,8 +342,11 @@ async function main() {
|
|||||||
throw new Error('Missing filePath in query string');
|
throw new Error('Missing filePath in query string');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { entry, urls } = await buildFileContext(filePath);
|
if (!ctx) {
|
||||||
const ret = await registered.mount(root, { filePath, entry, urls, host });
|
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;
|
if (typeof ret === 'function') cleanup = ret;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
|||||||
Reference in New Issue
Block a user