mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-06-13 12:15:09 +03:00
706 lines
24 KiB
TypeScript
706 lines
24 KiB
TypeScript
import { ToastNotification } from '@decky/api';
|
|
import {
|
|
ModalRoot,
|
|
Navigation,
|
|
PanelSection,
|
|
PanelSectionRow,
|
|
QuickAccessTab,
|
|
findSP,
|
|
quickAccessMenuClasses,
|
|
showModal,
|
|
sleep,
|
|
} from '@decky/ui';
|
|
import { FC, lazy } from 'react';
|
|
import { FaDownload, FaExclamationCircle, FaPlug } from 'react-icons/fa';
|
|
|
|
import DeckyIcon from './components/DeckyIcon';
|
|
import { DeckyState, DeckyStateContextProvider, UserInfo, useDeckyState } from './components/DeckyState';
|
|
import { File, FileSelectionType } from './components/modals/filepicker';
|
|
import { deinitFilepickerPatches, initFilepickerPatches } from './components/modals/filepicker/patches';
|
|
import MultiplePluginsInstallModal from './components/modals/MultiplePluginsInstallModal';
|
|
import PluginInstallModal from './components/modals/PluginInstallModal';
|
|
import PluginUninstallModal from './components/modals/PluginUninstallModal';
|
|
import NotificationBadge from './components/NotificationBadge';
|
|
import PluginView from './components/PluginView';
|
|
import { useQuickAccessVisible } from './components/QuickAccessVisibleState';
|
|
import WithSuspense from './components/WithSuspense';
|
|
import ErrorBoundaryHook from './errorboundary-hook';
|
|
import { FrozenPluginService } from './frozen-plugins-service';
|
|
import { HiddenPluginsService } from './hidden-plugins-service';
|
|
import Logger from './logger';
|
|
import { NotificationService } from './notification-service';
|
|
import { InstallType, Plugin, PluginLoadType } from './plugin';
|
|
import RouterHook, { UIMode } from './router-hook';
|
|
import { deinitSteamFixes, initSteamFixes } from './steamfixes';
|
|
import { checkForPluginUpdates } from './store';
|
|
import TabsHook from './tabs-hook';
|
|
import Toaster from './toaster';
|
|
import { getVersionInfo } from './updater';
|
|
import { getSetting, setSetting } from './utils/settings';
|
|
import TranslationHelper, { TranslationClass } from './utils/TranslationHelper';
|
|
|
|
const StorePage = lazy(() => import('./components/store/Store'));
|
|
const SettingsPage = lazy(() => import('./components/settings'));
|
|
|
|
const FilePicker = lazy(() => import('./components/modals/filepicker'));
|
|
|
|
declare global {
|
|
interface Window {
|
|
__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit?: {
|
|
connect: (version: number, key: string) => any; // Returns the backend API used above, no real point adding types to this.
|
|
};
|
|
}
|
|
}
|
|
|
|
/** Map of event names to event listeners */
|
|
type listenerMap = Map<string, Set<(...args: any) => any>>;
|
|
|
|
interface DeckyRequestInit extends RequestInit {
|
|
excludedHeaders: string[];
|
|
}
|
|
|
|
const callPluginMethod = DeckyBackend.callable<[pluginName: string, method: string, ...args: any], any>(
|
|
'loader/call_plugin_method',
|
|
);
|
|
|
|
class PluginLoader extends Logger {
|
|
private plugins: Plugin[] = [];
|
|
public errorBoundaryHook: ErrorBoundaryHook = new ErrorBoundaryHook();
|
|
private tabsHook: TabsHook = new TabsHook();
|
|
public routerHook: RouterHook = new RouterHook();
|
|
public toaster: Toaster = new Toaster();
|
|
private deckyState: DeckyState = new DeckyState();
|
|
// stores a map of plugin names to all their event listeners
|
|
private pluginEventListeners: Map<string, listenerMap> = new Map();
|
|
|
|
public frozenPluginsService = new FrozenPluginService(this.deckyState);
|
|
public hiddenPluginsService = new HiddenPluginsService(this.deckyState);
|
|
public notificationService = new NotificationService(this.deckyState);
|
|
|
|
private reloadLock: boolean = false;
|
|
// stores a list of plugin names which requested to be reloaded
|
|
private pluginReloadQueue: { name: string; version?: string }[] = [];
|
|
|
|
private loaderUpdateToast?: ToastNotification;
|
|
private pluginUpdateToast?: ToastNotification;
|
|
|
|
constructor() {
|
|
super(PluginLoader.name);
|
|
|
|
DeckyBackend.addEventListener('loader/notify_updates', this.notifyUpdates.bind(this));
|
|
DeckyBackend.addEventListener('loader/import_plugin', this.importPlugin.bind(this));
|
|
DeckyBackend.addEventListener('loader/unload_plugin', this.unloadPlugin.bind(this));
|
|
DeckyBackend.addEventListener('loader/add_plugin_install_prompt', this.addPluginInstallPrompt.bind(this));
|
|
DeckyBackend.addEventListener(
|
|
'loader/add_multiple_plugins_install_prompt',
|
|
this.addMultiplePluginsInstallPrompt.bind(this),
|
|
);
|
|
DeckyBackend.addEventListener('updater/update_download_percentage', () => {
|
|
this.deckyState.setIsLoaderUpdating(true);
|
|
});
|
|
DeckyBackend.addEventListener(`loader/plugin_event`, this.pluginEventListener);
|
|
|
|
this.tabsHook.init();
|
|
|
|
const TabBadge = () => {
|
|
const { updates, hasLoaderUpdate } = useDeckyState();
|
|
return <NotificationBadge show={(updates && updates.size > 0) || hasLoaderUpdate} />;
|
|
};
|
|
|
|
this.tabsHook.add({
|
|
id: QuickAccessTab.Decky,
|
|
title: null,
|
|
content: (
|
|
<DeckyStateContextProvider deckyState={this.deckyState}>
|
|
<PluginView />
|
|
</DeckyStateContextProvider>
|
|
),
|
|
icon: (
|
|
<DeckyStateContextProvider deckyState={this.deckyState}>
|
|
<FaPlug />
|
|
<TabBadge />
|
|
<style>
|
|
{`
|
|
/* fixes random overscrolling in QAM */
|
|
.${quickAccessMenuClasses?.TabContentColumn} {
|
|
flex-grow: 1 !important;
|
|
margin-top: 0 !important;
|
|
margin-bottom: 0 !important;
|
|
justify-content: center !important;
|
|
}
|
|
.${quickAccessMenuClasses?.Tab} {
|
|
flex-grow: 1 !important;
|
|
height: unset !important;
|
|
--decky-qam-tab-max-height: 64px; /* make things a little easier for themers */
|
|
max-height: var(--decky-qam-tab-max-height) !important;
|
|
}
|
|
/* they broke the footer a while ago and forgot to update the styles LOL */
|
|
.${quickAccessMenuClasses?.Tabs}.${quickAccessMenuClasses.TabsWithFooter} {
|
|
margin-bottom: 0 !important;
|
|
padding-bottom: 0 !important;
|
|
}
|
|
`}
|
|
</style>
|
|
</DeckyStateContextProvider>
|
|
),
|
|
});
|
|
|
|
this.routerHook.addRoute('/decky/store', () => (
|
|
<WithSuspense route={true}>
|
|
<StorePage />
|
|
</WithSuspense>
|
|
));
|
|
this.routerHook.addRoute('/decky/settings', () => {
|
|
return (
|
|
<DeckyStateContextProvider deckyState={this.deckyState}>
|
|
<WithSuspense route={true}>
|
|
<SettingsPage />
|
|
</WithSuspense>
|
|
</DeckyStateContextProvider>
|
|
);
|
|
});
|
|
|
|
initSteamFixes();
|
|
|
|
initFilepickerPatches();
|
|
|
|
this.initPluginBackendAPI();
|
|
|
|
Promise.all([this.getUserInfo(), this.updateVersion()])
|
|
.then(() => this.loadPlugins())
|
|
.then(() => this.checkPluginUpdates())
|
|
.then(() => this.log('Initialized'));
|
|
}
|
|
|
|
private getPluginsFromBackend = DeckyBackend.callable<
|
|
[],
|
|
{ name: string; version: string; load_type: PluginLoadType }[]
|
|
>('loader/get_plugins');
|
|
|
|
private async loadPlugins() {
|
|
let registration: any;
|
|
const uiMode = await new Promise(
|
|
(r) =>
|
|
(registration = SteamClient.UI.RegisterForUIModeChanged((mode: UIMode) => {
|
|
r(mode);
|
|
registration.unregister();
|
|
})),
|
|
);
|
|
if (uiMode == UIMode.BigPicture) {
|
|
// wait for SP window to exist before loading plugins
|
|
while (!findSP()) {
|
|
await sleep(100);
|
|
}
|
|
}
|
|
const plugins = await this.getPluginsFromBackend();
|
|
const pluginLoadPromises = [];
|
|
const loadStart = performance.now();
|
|
for (const plugin of plugins) {
|
|
if (!this.hasPlugin(plugin.name))
|
|
pluginLoadPromises.push(this.importPlugin(plugin.name, plugin.version, plugin.load_type, false));
|
|
}
|
|
await Promise.all(pluginLoadPromises);
|
|
const loadEnd = performance.now();
|
|
this.log(`Loaded ${plugins.length} plugins in ${loadEnd - loadStart}ms`);
|
|
|
|
this.checkPluginUpdates();
|
|
}
|
|
|
|
public async getUserInfo() {
|
|
const userInfo = await DeckyBackend.call<[], UserInfo>('utilities/get_user_info');
|
|
setSetting('user_info.user_name', userInfo.username);
|
|
setSetting('user_info.user_home', userInfo.path);
|
|
}
|
|
|
|
public async updateVersion() {
|
|
const versionInfo = await getVersionInfo();
|
|
this.deckyState.setVersionInfo(versionInfo);
|
|
|
|
return versionInfo;
|
|
}
|
|
|
|
public async notifyUpdates() {
|
|
const versionInfo = await this.updateVersion();
|
|
if (versionInfo?.remote && versionInfo?.remote?.tag_name != versionInfo?.current) {
|
|
this.deckyState.setHasLoaderUpdate(true);
|
|
if (this.notificationService.shouldNotify('deckyUpdates')) {
|
|
this.loaderUpdateToast && this.loaderUpdateToast.dismiss();
|
|
await this.routerHook.waitForUnlock();
|
|
this.loaderUpdateToast = this.toaster.toast({
|
|
title: <TranslationHelper transClass={TranslationClass.PLUGIN_LOADER} transText="decky_title" />,
|
|
body: (
|
|
<TranslationHelper
|
|
transClass={TranslationClass.PLUGIN_LOADER}
|
|
transText="decky_update_available"
|
|
i18nArgs={{ tag_name: versionInfo?.remote?.tag_name }}
|
|
/>
|
|
),
|
|
logo: <DeckyIcon />,
|
|
icon: <FaDownload />,
|
|
onClick: () => Navigation.Navigate('/decky/settings'),
|
|
});
|
|
}
|
|
}
|
|
await sleep(7000);
|
|
await this.notifyPluginUpdates();
|
|
}
|
|
|
|
public async checkPluginUpdates() {
|
|
const frozenPlugins = this.deckyState.publicState().frozenPlugins;
|
|
|
|
const updates = await checkForPluginUpdates(this.plugins.filter((p) => !frozenPlugins.includes(p.name)));
|
|
this.deckyState.setUpdates(updates);
|
|
return updates;
|
|
}
|
|
|
|
public async notifyPluginUpdates() {
|
|
const updates = await this.checkPluginUpdates();
|
|
if (updates?.size > 0 && this.notificationService.shouldNotify('pluginUpdates')) {
|
|
this.pluginUpdateToast && this.pluginUpdateToast.dismiss();
|
|
this.pluginUpdateToast = this.toaster.toast({
|
|
title: <TranslationHelper transClass={TranslationClass.PLUGIN_LOADER} transText="decky_title" />,
|
|
body: (
|
|
<TranslationHelper
|
|
transClass={TranslationClass.PLUGIN_LOADER}
|
|
transText="plugin_update"
|
|
i18nArgs={{ count: updates.size }}
|
|
/>
|
|
),
|
|
logo: <DeckyIcon />,
|
|
icon: <FaDownload />,
|
|
onClick: () => Navigation.Navigate('/decky/settings/plugins'),
|
|
});
|
|
}
|
|
}
|
|
|
|
public addPluginInstallPrompt(
|
|
artifact: string,
|
|
version: string,
|
|
request_id: string,
|
|
hash: string,
|
|
install_type: number,
|
|
) {
|
|
showModal(
|
|
<PluginInstallModal
|
|
artifact={artifact}
|
|
version={version}
|
|
hash={hash}
|
|
installType={install_type}
|
|
onOK={() => DeckyBackend.call<[string]>('utilities/confirm_plugin_install', request_id)}
|
|
onCancel={() => DeckyBackend.call<[string]>('utilities/cancel_plugin_install', request_id)}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
public addMultiplePluginsInstallPrompt(
|
|
request_id: string,
|
|
requests: { name: string; version: string; hash: string; install_type: InstallType }[],
|
|
) {
|
|
showModal(
|
|
<MultiplePluginsInstallModal
|
|
requests={requests}
|
|
onOK={() => DeckyBackend.call<[string]>('utilities/confirm_plugin_install', request_id)}
|
|
onCancel={() => DeckyBackend.call<[string]>('utilities/cancel_plugin_install', request_id)}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
public uninstallPlugin(name: string, title: string, buttonText: string, description: string) {
|
|
showModal(<PluginUninstallModal name={name} title={title} buttonText={buttonText} description={description} />);
|
|
}
|
|
|
|
public hasPlugin(name: string) {
|
|
return Boolean(this.plugins.find((plugin) => plugin.name == name));
|
|
}
|
|
|
|
public dismountAll() {
|
|
for (const plugin of this.plugins) {
|
|
this.log(`Dismounting ${plugin.name}`);
|
|
plugin.onDismount?.();
|
|
}
|
|
}
|
|
|
|
public init() {
|
|
getSetting('developer.enabled', false).then((val) => {
|
|
if (val) import('./developer').then((developer) => developer.startup());
|
|
});
|
|
|
|
// Grab and set plugin order
|
|
getSetting<string[]>('pluginOrder', []).then((pluginOrder) => {
|
|
this.debug('pluginOrder: ', pluginOrder);
|
|
this.deckyState.setPluginOrder(pluginOrder);
|
|
});
|
|
|
|
this.frozenPluginsService.init();
|
|
this.hiddenPluginsService.init();
|
|
this.notificationService.init();
|
|
}
|
|
|
|
public deinit() {
|
|
this.routerHook.removeRoute('/decky/store');
|
|
this.routerHook.removeRoute('/decky/settings');
|
|
deinitSteamFixes();
|
|
deinitFilepickerPatches();
|
|
this.routerHook.deinit();
|
|
this.tabsHook.deinit();
|
|
this.toaster.deinit();
|
|
this.errorBoundaryHook.deinit();
|
|
}
|
|
|
|
public unloadPlugin(name: string) {
|
|
const plugin = this.plugins.find((plugin) => plugin.name === name);
|
|
plugin?.onDismount?.();
|
|
this.plugins = this.plugins.filter((p) => p !== plugin);
|
|
this.deckyState.setPlugins(this.plugins);
|
|
}
|
|
|
|
public async importPlugin(
|
|
name: string,
|
|
version?: string | undefined,
|
|
loadType: PluginLoadType = PluginLoadType.ESMODULE_V1,
|
|
useQueue: boolean = true,
|
|
) {
|
|
if (useQueue && this.reloadLock) {
|
|
this.log('Reload currently in progress, adding to queue', name);
|
|
this.pluginReloadQueue.push({ name, version: version });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (useQueue) this.reloadLock = true;
|
|
this.log(`Trying to load ${name}`);
|
|
|
|
this.unloadPlugin(name);
|
|
const startTime = performance.now();
|
|
await this.importReactPlugin(name, version, loadType);
|
|
const endTime = performance.now();
|
|
|
|
this.deckyState.setPlugins(this.plugins);
|
|
this.log(`Loaded ${name} in ${endTime - startTime}ms`);
|
|
} catch (e) {
|
|
throw e;
|
|
} finally {
|
|
if (useQueue) {
|
|
this.reloadLock = false;
|
|
const nextPlugin = this.pluginReloadQueue.shift();
|
|
if (nextPlugin) {
|
|
this.importPlugin(nextPlugin.name, nextPlugin.version);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async importReactPlugin(
|
|
name: string,
|
|
version?: string,
|
|
loadType: PluginLoadType = PluginLoadType.ESMODULE_V1,
|
|
) {
|
|
try {
|
|
switch (loadType) {
|
|
case PluginLoadType.ESMODULE_V1:
|
|
const plugin_exports = await import(`http://127.0.0.1:1337/plugins/${name}/dist/index.js`);
|
|
let plugin = plugin_exports.default();
|
|
|
|
this.plugins.push({
|
|
...plugin,
|
|
name: name,
|
|
version: version,
|
|
});
|
|
break;
|
|
|
|
case PluginLoadType.LEGACY_EVAL_IIFE:
|
|
let res = await fetch(`http://127.0.0.1:1337/plugins/${name}/frontend_bundle`, {
|
|
credentials: 'include',
|
|
headers: {
|
|
'X-Decky-Auth': deckyAuthToken,
|
|
},
|
|
});
|
|
if (res.ok) {
|
|
let plugin_export: (serverAPI: any) => Plugin = await eval(
|
|
(await res.text()) + `\n//# sourceURL=decky://decky/legacy_plugin/${encodeURIComponent(name)}/index.js`,
|
|
);
|
|
let plugin = plugin_export(this.createLegacyPluginAPI(name));
|
|
this.plugins.push({
|
|
...plugin,
|
|
name: name,
|
|
version: version,
|
|
});
|
|
} else throw new Error(`${name} frontend_bundle not OK`);
|
|
break;
|
|
|
|
default:
|
|
throw new Error(`${name} has no defined loadType.`);
|
|
}
|
|
} catch (e) {
|
|
this.error('Error loading plugin ' + name, e);
|
|
const TheError: FC<{}> = () => (
|
|
<PanelSection>
|
|
<PanelSectionRow>
|
|
<div className={quickAccessMenuClasses.FriendsTitle} style={{ display: 'flex', justifyContent: 'center' }}>
|
|
<TranslationHelper transClass={TranslationClass.PLUGIN_LOADER} transText="error" />
|
|
</div>
|
|
</PanelSectionRow>
|
|
<PanelSectionRow>
|
|
<pre style={{ overflowX: 'scroll' }}>
|
|
<code>{e instanceof Error ? e.stack : JSON.stringify(e)}</code>
|
|
</pre>
|
|
</PanelSectionRow>
|
|
<PanelSectionRow>
|
|
<div className={quickAccessMenuClasses.Text}>
|
|
<TranslationHelper
|
|
transClass={TranslationClass.PLUGIN_LOADER}
|
|
transText="plugin_error_uninstall"
|
|
i18nArgs={{ name: name }}
|
|
/>
|
|
</div>
|
|
</PanelSectionRow>
|
|
</PanelSection>
|
|
);
|
|
this.plugins.push({
|
|
name: name,
|
|
version: version,
|
|
content: <TheError />,
|
|
icon: <FaExclamationCircle />,
|
|
});
|
|
this.toaster.toast({
|
|
title: (
|
|
<TranslationHelper
|
|
transClass={TranslationClass.PLUGIN_LOADER}
|
|
transText="plugin_load_error.toast"
|
|
i18nArgs={{ name: name }}
|
|
/>
|
|
),
|
|
body: '' + e,
|
|
icon: <FaExclamationCircle />,
|
|
});
|
|
}
|
|
}
|
|
|
|
async callServerMethod(methodName: string, args = {}) {
|
|
this.warn(
|
|
`Calling ${methodName} via callServerMethod, which is deprecated and will be removed in a future release. Please switch to the backend API.`,
|
|
);
|
|
return await DeckyBackend.call<[methodName: string, kwargs: any], any>(
|
|
'utilities/_call_legacy_utility',
|
|
methodName,
|
|
args,
|
|
);
|
|
}
|
|
|
|
openFilePickerLegacy(
|
|
startPath: string,
|
|
selectFiles?: boolean,
|
|
regex?: RegExp,
|
|
): Promise<{ path: string; realpath: string }> {
|
|
this.warn('openFilePicker is deprecated and will be removed. Please migrate to openFilePickerV2');
|
|
if (selectFiles) {
|
|
return this.openFilePicker(FileSelectionType.FILE, startPath, true, true, regex);
|
|
} else {
|
|
return this.openFilePicker(FileSelectionType.FOLDER, startPath, false, true, regex);
|
|
}
|
|
}
|
|
|
|
openFilePicker(
|
|
select: FileSelectionType,
|
|
startPath: string,
|
|
includeFiles?: boolean,
|
|
includeFolders?: boolean,
|
|
filter?: RegExp | ((file: File) => boolean),
|
|
extensions?: string[],
|
|
showHiddenFiles?: boolean,
|
|
allowAllFiles?: boolean,
|
|
max?: number,
|
|
): Promise<{ path: string; realpath: string }> {
|
|
return new Promise((resolve, reject) => {
|
|
const Content = ({ closeModal }: { closeModal?: () => void }) => (
|
|
// Purposely outside of the FilePicker component as lazy-loaded ModalRoots don't focus correctly
|
|
<ModalRoot
|
|
onCancel={() => {
|
|
reject('User canceled');
|
|
closeModal?.();
|
|
}}
|
|
>
|
|
<WithSuspense>
|
|
<FilePicker
|
|
startPath={startPath}
|
|
includeFiles={includeFiles}
|
|
includeFolders={includeFolders}
|
|
filter={filter}
|
|
validFileExtensions={extensions}
|
|
allowAllFiles={allowAllFiles}
|
|
defaultHidden={showHiddenFiles}
|
|
onSubmit={resolve}
|
|
closeModal={closeModal}
|
|
fileSelType={select}
|
|
max={max}
|
|
/>
|
|
</WithSuspense>
|
|
</ModalRoot>
|
|
);
|
|
showModal(<Content />);
|
|
});
|
|
}
|
|
|
|
// Useful for audio/video streams
|
|
getExternalResourceURL(url: string) {
|
|
return `http://127.0.0.1:1337/fetch?auth=${deckyAuthToken}&fetch_url=${encodeURIComponent(url)}`;
|
|
}
|
|
|
|
// Same syntax as fetch but only supports the url-based syntax and an object for headers since it's the most common usage pattern
|
|
fetchNoCors(input: string, init?: DeckyRequestInit | undefined): Promise<Response> {
|
|
const { headers: initHeaders = {}, ...restOfInit } = init || {};
|
|
const getPrefixedHeaders = () => {
|
|
let prefixedInitHeaders: { [name: string]: any } = {};
|
|
for (const [key, value] of Object.entries(initHeaders)) {
|
|
prefixedInitHeaders[`X-Decky-Header-${key}`] = value;
|
|
}
|
|
return prefixedInitHeaders;
|
|
};
|
|
const headers: { [name: string]: string } = getPrefixedHeaders();
|
|
|
|
if (init?.excludedHeaders) {
|
|
headers['X-Decky-Fetch-Excluded-Headers'] = init.excludedHeaders.join(', ');
|
|
}
|
|
|
|
return fetch(this.getExternalResourceURL(input), {
|
|
...restOfInit,
|
|
credentials: 'include',
|
|
headers,
|
|
});
|
|
}
|
|
|
|
async legacyFetchNoCors(url: string, request: any = {}) {
|
|
let method: string;
|
|
const req = { headers: {}, ...request, data: request.body };
|
|
req?.body && delete req.body;
|
|
if (!request.method) {
|
|
method = 'POST';
|
|
} else {
|
|
method = request.method;
|
|
delete req.method;
|
|
}
|
|
try {
|
|
const ret = await DeckyBackend.call<
|
|
[method: string, url: string, extra_opts?: any],
|
|
{ status: number; headers: { [key: string]: string }; body: string }
|
|
>('utilities/http_request', method, url, req);
|
|
return { success: true, result: ret };
|
|
} catch (e) {
|
|
return { success: false, result: e?.toString() };
|
|
}
|
|
}
|
|
|
|
initPluginBackendAPI() {
|
|
// Things will break *very* badly if plugin code touches this outside of @decky/api, so lets make that clear.
|
|
window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit = {
|
|
connect: (version: number, pluginName: string) => {
|
|
if (version < 1 || version > 2) {
|
|
console.warn(`Plugin ${pluginName} requested unsupported api version ${version}.`);
|
|
}
|
|
|
|
const eventListeners: listenerMap = new Map();
|
|
this.pluginEventListeners.set(pluginName, eventListeners);
|
|
|
|
const backendAPI = {
|
|
call: (methodName: string, ...args: any) => {
|
|
return callPluginMethod(pluginName, methodName, ...args);
|
|
},
|
|
callable: (methodName: string) => {
|
|
return (...args: any) => callPluginMethod(pluginName, methodName, ...args);
|
|
},
|
|
addEventListener: (event: string, listener: (...args: any) => any) => {
|
|
if (!eventListeners.has(event)) {
|
|
eventListeners.set(event, new Set([listener]));
|
|
} else {
|
|
eventListeners.get(event)?.add(listener);
|
|
}
|
|
return listener;
|
|
},
|
|
removeEventListener: (event: string, listener: (...args: any) => any) => {
|
|
if (eventListeners.has(event)) {
|
|
const set = eventListeners.get(event);
|
|
set?.delete(listener);
|
|
}
|
|
},
|
|
openFilePicker: this.openFilePicker.bind(this),
|
|
executeInTab: DeckyBackend.callable<
|
|
[tab: String, runAsync: Boolean, code: string],
|
|
{ success: boolean; result: any }
|
|
>('utilities/execute_in_tab'),
|
|
fetchNoCors: this.fetchNoCors.bind(this),
|
|
getExternalResourceURL: this.getExternalResourceURL.bind(this),
|
|
injectCssIntoTab: DeckyBackend.callable<[tab: string, style: string], string>(
|
|
'utilities/inject_css_into_tab',
|
|
),
|
|
removeCssFromTab: DeckyBackend.callable<[tab: string, cssId: string]>('utilities/remove_css_from_tab'),
|
|
routerHook: this.routerHook,
|
|
toaster: this.toaster,
|
|
_version: 1,
|
|
} as any;
|
|
|
|
if (version >= 2) {
|
|
backendAPI._version = 2;
|
|
backendAPI.useQuickAccessVisible = useQuickAccessVisible;
|
|
}
|
|
|
|
this.debug(`${pluginName} connected to loader API.`);
|
|
return backendAPI;
|
|
},
|
|
};
|
|
}
|
|
|
|
pluginEventListener = (data: { plugin: string; event: string; args: any }) => {
|
|
const { plugin, event, args } = data;
|
|
this.debug(`Recieved plugin event ${event} for ${plugin} with args`, args);
|
|
if (!this.pluginEventListeners.has(plugin)) {
|
|
this.warn(`plugin ${plugin} does not have event listeners`);
|
|
return;
|
|
}
|
|
const eventListeners = this.pluginEventListeners.get(plugin)!;
|
|
if (eventListeners.has(event)) {
|
|
for (const listener of eventListeners.get(event)!) {
|
|
(async () => {
|
|
try {
|
|
await listener(...args);
|
|
} catch (e) {
|
|
this.error(`error in event ${event}`, e, listener);
|
|
}
|
|
})();
|
|
}
|
|
} else {
|
|
this.warn(`event ${event} has no listeners`);
|
|
}
|
|
};
|
|
|
|
createLegacyPluginAPI(pluginName: string) {
|
|
const pluginAPI = {
|
|
routerHook: this.routerHook,
|
|
toaster: this.toaster,
|
|
// Legacy
|
|
callServerMethod: this.callServerMethod.bind(this),
|
|
openFilePicker: this.openFilePickerLegacy.bind(this),
|
|
openFilePickerV2: this.openFilePicker.bind(this),
|
|
// Legacy
|
|
async callPluginMethod(methodName: string, args = {}) {
|
|
return DeckyBackend.call<[pluginName: string, methodName: string, kwargs: any], any>(
|
|
'loader/call_legacy_plugin_method',
|
|
pluginName,
|
|
methodName,
|
|
args,
|
|
);
|
|
},
|
|
fetchNoCors: this.legacyFetchNoCors.bind(this),
|
|
executeInTab: DeckyBackend.callable<
|
|
[tab: String, runAsync: Boolean, code: string],
|
|
{ success: boolean; result: any }
|
|
>('utilities/execute_in_tab'),
|
|
injectCssIntoTab: DeckyBackend.callable<[tab: string, style: string], string>('utilities/inject_css_into_tab'),
|
|
removeCssFromTab: DeckyBackend.callable<[tab: string, cssId: string]>('utilities/remove_css_from_tab'),
|
|
};
|
|
|
|
return pluginAPI;
|
|
}
|
|
}
|
|
|
|
export default PluginLoader;
|