mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-06-17 00:37:49 +00:00
35e7c80835
* First iteration for internationalization of the loader * First iteration for internationalization of the loader * Cleanup node mess * Cleanup node mess pt2 * Additional touches * Latest decky changed merged into i18n and updated translation. * Styling fixes * Initial backend hosting implementation * Added correct url path of the loopback server. * Added correct url path of the loopback server. * Some better namespaced text. * Added whitelist for locales path. * Refactor languages and fix hooks logic bugs. * Small typo in language translation structure. * Working backend, automatically swtich languages with steam and language fixes. * Fix to languages * Key fixes * Additional language fixes. * Additional json changes * Final text revision and added a vscode tasks to automatically extract text from code. * Typo in the middleware * Remove unused imports * Cleanup whitespaces. * Import changes * Revert "Import changes" This reverts commit8e8231950f. * Update index.d.ts * Clean up unused imports * Delete pnpm-lock.yaml * Update rollup.config.js * Update PluginInstallModal.tsx * Update index.tsx * Update plugin-loader.tsx * Update plugin-loader.tsx * Revert "Delete pnpm-lock.yaml" This reverts commit3a39f36f21. * Additional strings reworks. * Fixes for issues coming from github merge. * Fixes for master * Styling fixes * Styling pt2 * Missed a few strings in master, * Styling fixes * Additional master merge fixes. * Final cleanup and adaptation to master. * Final empty language cleanup and few string added * Small changes to italian translation * Disabled translation on a few components inside plugin-loader for missing react hooks. * Fixed passing tag to translation. * Disable debug output for reducing console spam. * Return correct content type * Small italian language change * Added support for country code * Fixed missing translation for uninstall popup. * Fix class name shenanigans for toast notification * Update dependencies * Fixed github workflow to include the new locales folder * Update dependencies to latest version (unless it's React) and fixed the new small errors that cropped up * Missed a file name change * Updated dev dependencies to latest version * Missed a few dev dependencies * Revert "Update dependencies to latest version (unless it's React) and fixed the new small errors that cropped up" Messed up merge with a different main branch * Messed up deletion of rollup config. * Fix broken pnpm lock file * Missed a localized string during the merge * Fixed a parameter mistake in the uninstall text parameter * Fix pnpm random issues * Small italian language tweaks * Fix wrong parameter passed to the uninstall function call * Another fix on a wrong function parameter * Additional translation text on the store and branch selection channels * Changed the default type passed to map to being able to index the two arrays. * Reverted and reworked the last changes * Distinguish events in UI for installing vs reinstalling plugins * Additional fixes for reinstall prompt * Revert the use of intevalPlural since the parser doesn't seem to support that. * Missed a routing path in the backend * Small bugfixes * Small fixes * Correctly adding the parameter to the request headers. * Refactoring of the UI popup modal * Fix pnpm shenanigans * Final fixes for the install UI localization * Clean up unnedeed backend code * Small rework on text selection. * Cleaned up parser configuration * Removed extracttext dependency to pnpmsetup * Merged translation and cleaned up parser * Fixed JSON structure after manual merge. * Added translation to the file picker * Revert changes to PluginInstallModal * Reworked the text modal for the final time * Missed the proper linted text * Missed the backend change * Final branch cleanup * Fixed small translation bleeding Caused from the manual merge of _old.json files. * fix extra space in browser.py * fix extra newline in plugin-loader.tsx * Cleanup i18next-parser.config.mjs * Update plugin-loader.tsx * Cleanup language files * Better labeling of text * Fixed language typos in BranchSelect * Fixed language typos in StoreSelect * Cleanup plugin-loader.tsx from unused imports * Removed the path bypass since I'm using authentication from the frontend. * Reimplemented this component as a functional component. * Updated dependencies and lockfile * Removed static route from main.py Already handled in loader.py * Small italian coherency fixes * Fix small typography fixes on plugin name uninstall * Fixed italian typo on removal popup * Reenabled manual escaping value in i18next * Set to fallback to the default language if the string in the JSON file is empty. * Fixed pnpm wankery * Added a missed italian text translation string --------- Co-authored-by: AAGaming <aa@mail.catvibers.me>
398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
import { ConfirmModal, ModalRoot, Patch, QuickAccessTab, Router, showModal, sleep } from 'decky-frontend-lib';
|
|
import { FC, lazy } from 'react';
|
|
import { FaCog, FaExclamationCircle, FaPlug } from 'react-icons/fa';
|
|
|
|
import { DeckyState, DeckyStateContextProvider, useDeckyState } from './components/DeckyState';
|
|
import LegacyPlugin from './components/LegacyPlugin';
|
|
import { deinitFilepickerPatches, initFilepickerPatches } from './components/modals/filepicker/patches';
|
|
import PluginInstallModal from './components/modals/PluginInstallModal';
|
|
import NotificationBadge from './components/NotificationBadge';
|
|
import PluginView from './components/PluginView';
|
|
import WithSuspense from './components/WithSuspense';
|
|
import Logger from './logger';
|
|
import { Plugin } from './plugin';
|
|
import RouterHook from './router-hook';
|
|
import { deinitSteamFixes, initSteamFixes } from './steamfixes';
|
|
import { checkForUpdates } from './store';
|
|
import TabsHook from './tabs-hook';
|
|
import OldTabsHook from './tabs-hook.old';
|
|
import Toaster from './toaster';
|
|
import { VerInfo, callUpdaterMethod } from './updater';
|
|
import { getSetting } from './utils/settings';
|
|
|
|
const StorePage = lazy(() => import('./components/store/Store'));
|
|
const SettingsPage = lazy(() => import('./components/settings'));
|
|
|
|
const FilePicker = lazy(() => import('./components/modals/filepicker'));
|
|
|
|
class PluginLoader extends Logger {
|
|
private plugins: Plugin[] = [];
|
|
private tabsHook: TabsHook | OldTabsHook = document.title == 'SP' ? new OldTabsHook() : new TabsHook();
|
|
// private windowHook: WindowHook = new WindowHook();
|
|
private routerHook: RouterHook = new RouterHook();
|
|
public toaster: Toaster = new Toaster();
|
|
private deckyState: DeckyState = new DeckyState();
|
|
|
|
private reloadLock: boolean = false;
|
|
// stores a list of plugin names which requested to be reloaded
|
|
private pluginReloadQueue: { name: string; version?: string }[] = [];
|
|
|
|
private focusWorkaroundPatch?: Patch;
|
|
|
|
constructor() {
|
|
super(PluginLoader.name);
|
|
this.tabsHook.init();
|
|
this.log('Initialized');
|
|
|
|
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 />
|
|
</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.updateVersion();
|
|
}
|
|
|
|
public async updateVersion() {
|
|
const versionInfo = (await callUpdaterMethod('get_version')).result as VerInfo;
|
|
this.deckyState.setVersionInfo(versionInfo);
|
|
|
|
return versionInfo;
|
|
}
|
|
|
|
public async notifyUpdates() {
|
|
const versionInfo = await this.updateVersion();
|
|
if (versionInfo?.remote && versionInfo?.remote?.tag_name != versionInfo?.current) {
|
|
this.toaster.toast({
|
|
//title: t('PluginLoader.decky_title'),
|
|
title: 'Decky',
|
|
//body: t('PluginLoader.decky_update_available', { tag_name: versionInfo?.remote?.tag_name }),
|
|
body: `Update to ${versionInfo?.remote?.tag_name} available!`,
|
|
onClick: () => Router.Navigate('/decky/settings'),
|
|
});
|
|
this.deckyState.setHasLoaderUpdate(true);
|
|
}
|
|
await sleep(7000);
|
|
await this.notifyPluginUpdates();
|
|
}
|
|
|
|
public async checkPluginUpdates() {
|
|
const updates = await checkForUpdates(this.plugins);
|
|
this.deckyState.setUpdates(updates);
|
|
return updates;
|
|
}
|
|
|
|
public async notifyPluginUpdates() {
|
|
const updates = await this.checkPluginUpdates();
|
|
if (updates?.size > 0) {
|
|
this.toaster.toast({
|
|
//title: t('PluginLoader.decky_title'),
|
|
title: 'Decky',
|
|
//body: t('PluginLoader.plugin_update', { count: updates.size }),
|
|
body: `Updates available for ${updates.size} plugin${updates.size > 1 ? 's' : ''}!`,
|
|
onClick: () => Router.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={() => this.callServerMethod('confirm_plugin_install', { request_id })}
|
|
onCancel={() => this.callServerMethod('cancel_plugin_install', { request_id })}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
public uninstallPlugin(name: string, title: string, button_text: string, description: string) {
|
|
showModal(
|
|
<ConfirmModal
|
|
onOK={async () => {
|
|
await this.callServerMethod('uninstall_plugin', { name });
|
|
}}
|
|
onCancel={() => {
|
|
// do nothing
|
|
}}
|
|
strTitle={title}
|
|
strOKButtonText={button_text}
|
|
>
|
|
{description}
|
|
</ConfirmModal>,
|
|
);
|
|
}
|
|
|
|
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) => {
|
|
console.log(pluginOrder);
|
|
this.deckyState.setPluginOrder(pluginOrder);
|
|
});
|
|
}
|
|
|
|
public deinit() {
|
|
this.routerHook.removeRoute('/decky/store');
|
|
this.routerHook.removeRoute('/decky/settings');
|
|
deinitSteamFixes();
|
|
deinitFilepickerPatches();
|
|
this.focusWorkaroundPatch?.unpatch();
|
|
}
|
|
|
|
public unloadPlugin(name: string) {
|
|
console.log('Plugin List: ', this.plugins);
|
|
const plugin = this.plugins.find((plugin) => plugin.name === name || plugin.name === name.replace('$LEGACY_', ''));
|
|
plugin?.onDismount?.();
|
|
this.plugins = this.plugins.filter((p) => p !== plugin);
|
|
this.deckyState.setPlugins(this.plugins);
|
|
}
|
|
|
|
public async importPlugin(name: string, version?: string | undefined) {
|
|
if (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);
|
|
|
|
if (name.startsWith('$LEGACY_')) {
|
|
await this.importLegacyPlugin(name.replace('$LEGACY_', ''));
|
|
} else {
|
|
await this.importReactPlugin(name, version);
|
|
}
|
|
|
|
this.deckyState.setPlugins(this.plugins);
|
|
this.log(`Loaded ${name}`);
|
|
} catch (e) {
|
|
throw e;
|
|
} finally {
|
|
this.reloadLock = false;
|
|
const nextPlugin = this.pluginReloadQueue.shift();
|
|
if (nextPlugin) {
|
|
this.importPlugin(nextPlugin.name, nextPlugin.version);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async importReactPlugin(name: string, version?: string) {
|
|
let res = await fetch(`http://127.0.0.1:1337/plugins/${name}/frontend_bundle`, {
|
|
credentials: 'include',
|
|
headers: {
|
|
Authentication: window.deckyAuthToken,
|
|
},
|
|
});
|
|
if (res.ok) {
|
|
try {
|
|
let plugin_export = await eval(await res.text());
|
|
let plugin = plugin_export(this.createPluginAPI(name));
|
|
this.plugins.push({
|
|
...plugin,
|
|
name: name,
|
|
version: version,
|
|
});
|
|
} catch (e) {
|
|
//this.error(t('PluginLoader.plugin_load_error.message', { name: name }), e);
|
|
this.error('Error loading plugin ' + name, e);
|
|
/*const TheError: FC<{}> = () => (
|
|
<>
|
|
{t('PluginLoader.error')}:{' '}
|
|
<pre>
|
|
<code>{e instanceof Error ? e.stack : JSON.stringify(e)}</code>
|
|
</pre>
|
|
<>{t('PluginLoader.plugin_error_uninstall', { icon: "<FaCog style={{ display: 'inline' }} />" })}</>
|
|
</>
|
|
);*/
|
|
const TheError: FC<{}> = () => (
|
|
<>
|
|
Error:{' '}
|
|
<pre>
|
|
<code>{e instanceof Error ? e.stack : JSON.stringify(e)}</code>
|
|
</pre>
|
|
<>
|
|
Please go to <FaCog style={{ display: 'inline' }} /> in the Decky menu if you need to uninstall this
|
|
plugin.
|
|
</>
|
|
</>
|
|
);
|
|
this.plugins.push({
|
|
name: name,
|
|
version: version,
|
|
content: <TheError />,
|
|
icon: <FaExclamationCircle />,
|
|
});
|
|
this.toaster.toast({
|
|
//title: t('PluginLoader.plugin_load_error.toast', { name: name }),
|
|
title: 'Error loading ' + name,
|
|
body: '' + e,
|
|
icon: <FaExclamationCircle />,
|
|
});
|
|
}
|
|
} else throw new Error(`${name} frontend_bundle not OK`);
|
|
}
|
|
|
|
private async importLegacyPlugin(name: string) {
|
|
const url = `http://127.0.0.1:1337/plugins/load_main/${name}`;
|
|
this.plugins.push({
|
|
name: name,
|
|
icon: <FaPlug />,
|
|
content: <LegacyPlugin url={url} />,
|
|
});
|
|
}
|
|
|
|
async callServerMethod(methodName: string, args = {}) {
|
|
const response = await fetch(`http://127.0.0.1:1337/methods/${methodName}`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authentication: window.deckyAuthToken,
|
|
},
|
|
body: JSON.stringify(args),
|
|
});
|
|
|
|
return response.json();
|
|
}
|
|
|
|
openFilePicker(
|
|
startPath: string,
|
|
includeFiles?: boolean,
|
|
regex?: RegExp,
|
|
): 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}
|
|
regex={regex}
|
|
onSubmit={resolve}
|
|
closeModal={closeModal}
|
|
/>
|
|
</WithSuspense>
|
|
</ModalRoot>
|
|
);
|
|
showModal(<Content />);
|
|
});
|
|
}
|
|
|
|
createPluginAPI(pluginName: string) {
|
|
return {
|
|
routerHook: this.routerHook,
|
|
toaster: this.toaster,
|
|
callServerMethod: this.callServerMethod,
|
|
openFilePicker: this.openFilePicker,
|
|
async callPluginMethod(methodName: string, args = {}) {
|
|
const response = await fetch(`http://127.0.0.1:1337/plugins/${pluginName}/methods/${methodName}`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authentication: window.deckyAuthToken,
|
|
},
|
|
body: JSON.stringify({
|
|
args,
|
|
}),
|
|
});
|
|
|
|
return response.json();
|
|
},
|
|
fetchNoCors(url: string, request: any = {}) {
|
|
let args = { method: 'POST', headers: {} };
|
|
const req = { ...args, ...request, url, data: request.body };
|
|
req?.body && delete req.body;
|
|
return this.callServerMethod('http_request', req);
|
|
},
|
|
executeInTab(tab: string, runAsync: boolean, code: string) {
|
|
return this.callServerMethod('execute_in_tab', {
|
|
tab,
|
|
run_async: runAsync,
|
|
code,
|
|
});
|
|
},
|
|
injectCssIntoTab(tab: string, style: string) {
|
|
return this.callServerMethod('inject_css_into_tab', {
|
|
tab,
|
|
style,
|
|
});
|
|
},
|
|
removeCssFromTab(tab: string, cssId: any) {
|
|
return this.callServerMethod('remove_css_from_tab', {
|
|
tab,
|
|
css_id: cssId,
|
|
});
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
export default PluginLoader;
|