mirror of
https://github.com/penpot/penpot.git
synced 2026-10-30 14:30:37 +00:00
Unloading or replacing a plugin while its code was still being fetched could leave a late instance running, or block reopening a plugin that closed during startup. Each load now carries an AbortController. Unloading, a newer load of the same plugin or logout aborts it, and the plugin manager closes itself on abort, also while it fetches its code. createPlugin returns nothing for a plugin closed before its sandbox exists, and failed loads clear their pending state so the plugin can be loaded again.
207 lines
4.8 KiB
TypeScript
207 lines
4.8 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';
|
|
import { validateUIUrl } from './validate-url.js';
|
|
|
|
export async function createPluginManager(
|
|
context: Context,
|
|
manifest: Manifest,
|
|
onCloseCallback: () => void,
|
|
onReloadModal: (code: string) => void,
|
|
signal?: AbortSignal,
|
|
) {
|
|
let code = '';
|
|
|
|
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 allowClipboardRead = !!manifest.permissions.find(
|
|
(s) => s === 'clipboard:read',
|
|
);
|
|
|
|
const allowClipboardWrite = !!manifest.permissions.find(
|
|
(s) => s === 'clipboard:write',
|
|
);
|
|
|
|
const themeChangeId = context.addListener('themechange', (theme: Theme) => {
|
|
modal?.setTheme(theme);
|
|
});
|
|
|
|
const listenerId = context.addListener('finish', () => {
|
|
if (manifest.scope !== 'global') closePlugin();
|
|
});
|
|
const logoutId = context.addListener('logout', () => closePlugin());
|
|
|
|
let listeners: symbol[] = [];
|
|
|
|
const removeAllEventListeners = () => {
|
|
destroyListener(themeChangeId);
|
|
|
|
listeners.forEach((id) => {
|
|
destroyListener(id);
|
|
});
|
|
|
|
uiMessagesCallbacks = [];
|
|
listeners = [];
|
|
};
|
|
|
|
const closePlugin = () => {
|
|
if (destroyed) return;
|
|
destroyed = true;
|
|
signal?.removeEventListener('abort', closePlugin);
|
|
removeAllEventListeners();
|
|
destroyListener(listenerId);
|
|
destroyListener(logoutId);
|
|
|
|
timeouts.forEach(clearTimeout);
|
|
timeouts.clear();
|
|
|
|
intervals.forEach(clearInterval);
|
|
intervals.clear();
|
|
|
|
if (modal) {
|
|
modal.removeEventListener('close', closePlugin);
|
|
modal.remove();
|
|
modal = null;
|
|
}
|
|
|
|
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 });
|
|
validateUIUrl(modalUrl, manifest.host);
|
|
|
|
if (modal?.getAttribute('iframe-src') === modalUrl) {
|
|
return;
|
|
}
|
|
|
|
modal = openUIApi(
|
|
name,
|
|
modalUrl,
|
|
theme,
|
|
options,
|
|
allowDownloads,
|
|
allowClipboardRead,
|
|
allowClipboardWrite,
|
|
);
|
|
|
|
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);
|
|
};
|
|
|
|
signal?.addEventListener('abort', closePlugin, { once: true });
|
|
try {
|
|
if (signal?.aborted) {
|
|
closePlugin();
|
|
} else {
|
|
code = await loadManifestCode(manifest);
|
|
}
|
|
} catch (error) {
|
|
if (!destroyed) {
|
|
closePlugin();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
return {
|
|
get destroyed() {
|
|
return destroyed;
|
|
},
|
|
close: closePlugin,
|
|
destroyListener,
|
|
openModal,
|
|
resizeModal: (width: number, height: number) => {
|
|
openUISchema.parse({ width, height });
|
|
|
|
if (modal) {
|
|
modal.resize(width, height);
|
|
}
|
|
},
|
|
getModal: () => modal,
|
|
get iframeWindow(): Window | null {
|
|
return modal?.getIframeContentWindow() ?? null;
|
|
},
|
|
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;
|
|
},
|
|
};
|
|
}
|