mirror of
https://github.com/penpot/penpot.git
synced 2026-04-06 03:12:40 +02:00
* 🐛 Handle plugin errors gracefully without crashing the UI Plugin errors (like 'Set is not a constructor') were propagating to the global error handler and showing the exception page. This fix: - Uses a WeakMap to track plugin errors (works in SES hardened environment) - Wraps setTimeout/setInterval handlers to mark errors and re-throw them - Frontend global handler checks isPluginError and logs to console Plugin errors are now logged to console with 'Plugin Error' prefix but don't crash the main application or show the exception page. Signed-off-by: AI Agent <agent@penpot.app> * ✨ Improved handling of plugin errors on initialization * ✨ Fix test and linter --------- Signed-off-by: AI Agent <agent@penpot.app> Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
166 lines
3.9 KiB
TypeScript
166 lines
3.9 KiB
TypeScript
import type { Context, Theme } from '@penpot/plugin-types';
|
|
|
|
import { prepareUrl, loadManifestCode } from './parse-manifest.js';
|
|
import { Manifest } from './models/manifest.model.js';
|
|
import { PluginModalElement } from './modal/plugin-modal.js';
|
|
import { openUIApi } from './api/openUI.api.js';
|
|
import { OpenUIOptions } from './models/open-ui-options.model.js';
|
|
import { RegisterListener } from './models/plugin.model.js';
|
|
import { openUISchema } from './models/open-ui-options.schema.js';
|
|
|
|
export async function createPluginManager(
|
|
context: Context,
|
|
manifest: Manifest,
|
|
onCloseCallback: () => void,
|
|
onReloadModal: (code: string) => void,
|
|
) {
|
|
let code = await loadManifestCode(manifest);
|
|
|
|
let loaded = false;
|
|
let destroyed = false;
|
|
let modal: PluginModalElement | null = null;
|
|
let uiMessagesCallbacks: ((message: unknown) => void)[] = [];
|
|
const timeouts = new Set<ReturnType<typeof setTimeout>>();
|
|
const intervals = new Set<ReturnType<typeof setInterval>>();
|
|
|
|
const allowDownloads = !!manifest.permissions.find(
|
|
(s) => s === 'allow:downloads',
|
|
);
|
|
|
|
const themeChangeId = context.addListener('themechange', (theme: Theme) => {
|
|
modal?.setTheme(theme);
|
|
});
|
|
|
|
const listenerId: symbol = context.addListener('finish', () => {
|
|
closePlugin();
|
|
|
|
context?.removeListener(listenerId);
|
|
});
|
|
|
|
let listeners: symbol[] = [];
|
|
|
|
const removeAllEventListeners = () => {
|
|
destroyListener(themeChangeId);
|
|
|
|
listeners.forEach((id) => {
|
|
destroyListener(id);
|
|
});
|
|
|
|
uiMessagesCallbacks = [];
|
|
listeners = [];
|
|
};
|
|
|
|
const closePlugin = () => {
|
|
removeAllEventListeners();
|
|
|
|
timeouts.forEach(clearTimeout);
|
|
timeouts.clear();
|
|
|
|
intervals.forEach(clearInterval);
|
|
intervals.clear();
|
|
|
|
if (modal) {
|
|
modal.removeEventListener('close', closePlugin);
|
|
modal.remove();
|
|
modal = null;
|
|
}
|
|
|
|
destroyed = true;
|
|
|
|
onCloseCallback();
|
|
};
|
|
|
|
const onLoadModal = async () => {
|
|
if (!loaded) {
|
|
loaded = true;
|
|
return;
|
|
}
|
|
|
|
removeAllEventListeners();
|
|
|
|
code = await loadManifestCode(manifest);
|
|
|
|
onReloadModal(code);
|
|
};
|
|
|
|
const openModal = (name: string, url: string, options?: OpenUIOptions) => {
|
|
const theme = context.theme as Theme;
|
|
const modalUrl = prepareUrl(manifest, url, { theme });
|
|
|
|
if (modal?.getAttribute('iframe-src') === modalUrl) {
|
|
return;
|
|
}
|
|
|
|
modal = openUIApi(name, modalUrl, theme, options, allowDownloads);
|
|
|
|
modal.setTheme(theme);
|
|
|
|
modal.addEventListener('close', closePlugin, {
|
|
once: true,
|
|
});
|
|
|
|
modal.addEventListener('load', onLoadModal);
|
|
};
|
|
|
|
const registerMessageCallback = (callback: (message: unknown) => void) => {
|
|
uiMessagesCallbacks.push(callback);
|
|
};
|
|
|
|
const registerListener: RegisterListener = (type, callback, props) => {
|
|
const id = context.addListener(
|
|
type,
|
|
(...params) => {
|
|
// penpot has a debounce to run the events, so some events can be triggered after the plugin is closed
|
|
if (destroyed) {
|
|
return;
|
|
}
|
|
|
|
callback(...params);
|
|
},
|
|
props,
|
|
);
|
|
|
|
listeners.push(id);
|
|
|
|
return id;
|
|
};
|
|
|
|
const destroyListener = (listenerId: symbol) => {
|
|
context.removeListener(listenerId);
|
|
};
|
|
|
|
return {
|
|
close: closePlugin,
|
|
destroyListener,
|
|
openModal,
|
|
resizeModal: (width: number, height: number) => {
|
|
openUISchema.parse({ width, height });
|
|
|
|
if (modal) {
|
|
modal.resize(width, height);
|
|
}
|
|
},
|
|
getModal: () => modal,
|
|
registerListener,
|
|
registerMessageCallback,
|
|
sendMessage: (message: unknown) => {
|
|
uiMessagesCallbacks.forEach((callback) => callback(message));
|
|
},
|
|
get manifest() {
|
|
return manifest;
|
|
},
|
|
get context() {
|
|
return context;
|
|
},
|
|
get timeouts() {
|
|
return timeouts;
|
|
},
|
|
get intervals() {
|
|
return intervals;
|
|
},
|
|
get code() {
|
|
return code;
|
|
},
|
|
};
|
|
}
|