import { ModalRoot, PanelSection, PanelSectionRow, QuickAccessTab, Router, findSP, quickAccessMenuClasses, showModal, sleep, } from '@decky/ui'; import { FC, lazy } from 'react'; import { FaExclamationCircle, FaPlug } from 'react-icons/fa'; 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 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 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 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[] = []; private errorBoundaryHook: ErrorBoundaryHook = new ErrorBoundaryHook(); private tabsHook: TabsHook = new TabsHook(); private 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 = 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 }[] = []; constructor() { super(PluginLoader.name); this.errorBoundaryHook.init(); 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 0) || hasLoaderUpdate} />; }; this.tabsHook.add({ id: QuickAccessTab.Decky, title: null, content: ( ), icon: ( ), }); this.routerHook.addRoute('/decky/store', () => ( )); this.routerHook.addRoute('/decky/settings', () => { return ( ); }); 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() { // 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.toaster.toast({ title: , body: ( ), onClick: () => Router.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.toaster.toast({ title: , body: ( ), onClick: () => Router.Navigate('/decky/settings/plugins'), }); } } public addPluginInstallPrompt( artifact: string, version: string, request_id: string, hash: string, install_type: number, ) { showModal( 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( 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(); } 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('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 { 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<{}> = () => (
              {e instanceof Error ? e.stack : JSON.stringify(e)}
            
); this.plugins.push({ name: name, version: version, content: , icon: , }); this.toaster.toast({ title: ( ), body: '' + e, icon: , }); } } 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 { reject('User canceled'); closeModal?.(); }} > ); showModal(); }); } // 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 { const headers: { [name: string]: string } = { ...(init?.headers as { [name: string]: string }), 'X-Decky-Auth': deckyAuthToken, 'X-Decky-Fetch-URL': input, }; if (init?.excludedHeaders) { headers['X-Decky-Fetch-Excluded-Headers'] = init.excludedHeaders.join(', '); } return fetch('http://127.0.0.1:1337/fetch', { ...init, 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; } // this is terrible but a. we're going to redo this entire method anyway and b. it was already terrible 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/backend, 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 > 1) { throw new Error(`Plugin ${pluginName} requested unsupported backend 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, }; 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, openFilePicker: this.openFilePickerLegacy, openFilePickerV2: this.openFilePicker, // 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, 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;