diff options
| author | AAGaming <aagaming@riseup.net> | 2024-02-21 01:08:25 -0500 |
|---|---|---|
| committer | AAGaming <aagaming@riseup.net> | 2024-02-21 01:08:25 -0500 |
| commit | 6d2e9365c0fb1bea804743245d79a5b97e3af108 (patch) | |
| tree | d34d09b7406d065edc79ff4a6c694f5caee923f0 | |
| parent | 61cf80f8a2d472e9cbc7d401c7cd24075d5bcf28 (diff) | |
| download | decky-loader-6d2e9365c0fb1bea804743245d79a5b97e3af108.tar.gz decky-loader-6d2e9365c0fb1bea804743245d79a5b97e3af108.zip | |
more major websocket progress
26 files changed, 357 insertions, 239 deletions
diff --git a/backend/decky_loader/browser.py b/backend/decky_loader/browser.py index def81011..cb573b13 100644 --- a/backend/decky_loader/browser.py +++ b/backend/decky_loader/browser.py @@ -4,7 +4,7 @@ import json # from pprint import pformat # Partial imports -from aiohttp import ClientSession +from aiohttp import ClientSession, request from asyncio import sleep from hashlib import sha256 from io import BytesIO @@ -123,7 +123,6 @@ class PluginBrowser: async def uninstall_plugin(self, name: str): if self.loader.watcher: self.loader.watcher.disabled = True - tab = await get_gamepadui_tab() plugin_folder = self.find_plugin_folder(name) assert plugin_folder is not None plugin_dir = path.join(self.plugin_path, plugin_folder) @@ -131,8 +130,7 @@ class PluginBrowser: logger.info("uninstalling " + name) logger.info(" at dir " + plugin_dir) logger.debug("calling frontend unload for %s" % str(name)) - res = await tab.evaluate_js(f"DeckyPluginLoader.unloadPlugin('{name}')") - logger.debug("result of unload from UI: %s", res) + await self.loader.ws.emit("loader/unload_plugin", name) # plugins_snapshot = self.plugins.copy() # snapshot_string = pformat(plugins_snapshot) # logger.debug("current plugins: %s", snapshot_string) @@ -258,20 +256,14 @@ class PluginBrowser: async def request_plugin_install(self, artifact: str, name: str, version: str, hash: str, install_type: PluginInstallType): request_id = str(time()) self.install_requests[request_id] = PluginInstallContext(artifact, name, version, hash) - tab = await get_gamepadui_tab() - await tab.open_websocket() - await tab.evaluate_js(f"DeckyPluginLoader.addPluginInstallPrompt('{name}', '{version}', '{request_id}', '{hash}', {install_type})") + + await self.loader.ws.emit("loader/add_plugin_install_prompt", name, version, request_id, hash, install_type) async def request_multiple_plugin_installs(self, requests: List[PluginInstallRequest]): request_id = str(time()) self.install_requests[request_id] = [PluginInstallContext(req['artifact'], req['name'], req['version'], req['hash']) for req in requests] - js_requests_parameter = ','.join([ - f"{{ name: '{req['name']}', version: '{req['version']}', hash: '{req['hash']}', install_type: {req['install_type']}}}" for req in requests - ]) - tab = await get_gamepadui_tab() - await tab.open_websocket() - await tab.evaluate_js(f"DeckyPluginLoader.addMultiplePluginsInstallPrompt('{request_id}', [{js_requests_parameter}])") + await self.loader.ws.emit("loader/add_multiple_plugins_install_prompt", request_id, requests) async def confirm_plugin_install(self, request_id: str): requestOrRequests = self.install_requests.pop(request_id) diff --git a/backend/decky_loader/customtypes.py b/backend/decky_loader/customtypes.py deleted file mode 100644 index 84ebc235..00000000 --- a/backend/decky_loader/customtypes.py +++ /dev/null @@ -1,6 +0,0 @@ -from enum import Enum - -class UserType(Enum): - HOST_USER = 1 - EFFECTIVE_USER = 2 - ROOT = 3
\ No newline at end of file diff --git a/backend/decky_loader/enums.py b/backend/decky_loader/enums.py new file mode 100644 index 00000000..e7fb4905 --- /dev/null +++ b/backend/decky_loader/enums.py @@ -0,0 +1,10 @@ +from enum import IntEnum + +class UserType(IntEnum): + HOST_USER = 1 + EFFECTIVE_USER = 2 + ROOT = 3 + +class PluginLoadType(IntEnum): + LEGACY_EVAL_IIFE = 0 # legacy, uses legacy serverAPI + ESMODULE_V1 = 1 # esmodule loading with modern @decky/backend apis
\ No newline at end of file diff --git a/backend/decky_loader/helpers.py b/backend/decky_loader/helpers.py index 2d5eb6dd..f4005cc5 100644 --- a/backend/decky_loader/helpers.py +++ b/backend/decky_loader/helpers.py @@ -12,7 +12,7 @@ from aiohttp.web import Request, Response, middleware from aiohttp.typedefs import Handler from aiohttp import ClientSession from .localplatform import localplatform -from .customtypes import UserType +from .enums import UserType from logging import getLogger from packaging.version import Version @@ -23,6 +23,7 @@ csrf_token = str(uuid.uuid4()) ssl_ctx = ssl.create_default_context(cafile=certifi.where()) assets_regex = re.compile("^/plugins/.*/assets/.*") +dist_regex = re.compile("^/plugins/.*/dist/.*") frontend_regex = re.compile("^/frontend/.*") logger = getLogger("Main") @@ -34,7 +35,18 @@ def get_csrf_token(): @middleware async def csrf_middleware(request: Request, handler: Handler): - if str(request.method) == "OPTIONS" or request.headers.get('Authentication') == csrf_token or str(request.rel_url) == "/auth/token" or str(request.rel_url).startswith("/plugins/load_main/") or str(request.rel_url).startswith("/static/") or str(request.rel_url).startswith("/steam_resource/") or str(request.rel_url).startswith("/frontend/") or str(request.rel_url.path) == "/ws" or assets_regex.match(str(request.rel_url)) or frontend_regex.match(str(request.rel_url)): + if str(request.method) == "OPTIONS" or \ + request.headers.get('Authentication') == csrf_token or \ + str(request.rel_url) == "/auth/token" or \ + str(request.rel_url).startswith("/plugins/load_main/") or \ + str(request.rel_url).startswith("/static/") or \ + str(request.rel_url).startswith("/steam_resource/") or \ + str(request.rel_url).startswith("/frontend/") or \ + str(request.rel_url.path) == "/ws" or \ + assets_regex.match(str(request.rel_url)) or \ + dist_regex.match(str(request.rel_url)) or \ + frontend_regex.match(str(request.rel_url)): + return await handler(request) return Response(text='Forbidden', status=403) diff --git a/backend/decky_loader/loader.py b/backend/decky_loader/loader.py index 550638a3..aad595e7 100644 --- a/backend/decky_loader/loader.py +++ b/backend/decky_loader/loader.py @@ -16,9 +16,9 @@ from typing import TYPE_CHECKING, List if TYPE_CHECKING: from .main import PluginManager -from .injector import get_gamepadui_tab from .plugin.plugin import PluginWrapper from .wsrouter import WSRouter +from .enums import PluginLoadType Plugins = dict[str, PluginWrapper] ReloadQueue = Queue[Tuple[str, str, bool | None] | Tuple[str, str]] @@ -96,6 +96,7 @@ class Loader: web.get("/frontend/{path:.*}", self.handle_frontend_assets), web.get("/locales/{path:.*}", self.handle_frontend_locales), web.get("/plugins/{plugin_name}/frontend_bundle", self.handle_frontend_bundle), + web.get("/plugins/{plugin_name}/dist/{path:.*}", self.handle_plugin_dist), web.get("/plugins/{plugin_name}/assets/{path:.*}", self.handle_plugin_frontend_assets), ]) @@ -126,7 +127,13 @@ class Loader: async def get_plugins(self): plugins = list(self.plugins.values()) - return [{"name": str(i), "version": i.version} for i in plugins] + return [{"name": str(i), "version": i.version, "load_type": i.load_type} for i in plugins] + + async def handle_plugin_dist(self, request: web.Request): + plugin = self.plugins[request.match_info["plugin_name"]] + file = path.join(self.plugin_path, plugin.plugin_directory, "dist", request.match_info["path"]) + + return web.FileResponse(file, headers={"Cache-Control": "no-cache"}) async def handle_plugin_frontend_assets(self, request: web.Request): plugin = self.plugins[request.match_info["plugin_name"]] @@ -145,7 +152,7 @@ class Loader: async def plugin_emitted_event(event: str, data: Any): self.logger.debug(f"PLUGIN EMITTED EVENT: {str(event)} {data}") event_data = PluginEvent(plugin_name=plugin.name, event=event, data=data) - await self.ws.emit("plugin_event", event_data) + await self.ws.emit("loader/plugin_event", event_data) plugin = PluginWrapper(file, plugin_directory, self.plugin_path, plugin_emitted_event) if plugin.name in self.plugins: @@ -166,9 +173,8 @@ class Loader: self.logger.error(f"Could not load {file}. {e}") print_exc() - async def dispatch_plugin(self, name: str, version: str | None): - gpui_tab = await get_gamepadui_tab() - await gpui_tab.evaluate_js(f"window.importDeckyPlugin('{name}', '{version}')") + async def dispatch_plugin(self, name: str, version: str | None, load_type: int = PluginLoadType.ESMODULE_V1.value): + await self.ws.emit("loader/import_plugin", name, version, load_type) def import_plugins(self): self.logger.info(f"import plugins from {self.plugin_path}") diff --git a/backend/decky_loader/localplatform/localplatformlinux.py b/backend/decky_loader/localplatform/localplatformlinux.py index 4eb112ee..2674e9bc 100644 --- a/backend/decky_loader/localplatform/localplatformlinux.py +++ b/backend/decky_loader/localplatform/localplatformlinux.py @@ -1,6 +1,6 @@ import os, pwd, grp, sys, logging from subprocess import call, run, DEVNULL, PIPE, STDOUT -from ..customtypes import UserType +from ..enums import UserType logger = logging.getLogger("localplatform") @@ -157,6 +157,7 @@ async def service_start(service_name : str) -> bool: return res.returncode == 0 async def restart_webhelper() -> bool: + logger.info("Restarting steamwebhelper") res = run(["killall", "-s", "SIGTERM", "steamwebhelper"], stdout=DEVNULL, stderr=DEVNULL) return res.returncode == 0 diff --git a/backend/decky_loader/localplatform/localplatformwin.py b/backend/decky_loader/localplatform/localplatformwin.py index f1a5be17..38e4b2b0 100644 --- a/backend/decky_loader/localplatform/localplatformwin.py +++ b/backend/decky_loader/localplatform/localplatformwin.py @@ -1,4 +1,4 @@ -from ..customtypes import UserType +from ..enums import UserType import os, sys def chown(path : str, user : UserType = UserType.HOST_USER, recursive : bool = True) -> bool: diff --git a/backend/decky_loader/main.py b/backend/decky_loader/main.py index 9095f711..64c76dc0 100644 --- a/backend/decky_loader/main.py +++ b/backend/decky_loader/main.py @@ -30,7 +30,7 @@ from .loader import Loader from .settings import SettingsManager from .updater import Updater from .utilities import Utilities -from .customtypes import UserType +from .enums import UserType from .wsrouter import WSRouter diff --git a/backend/decky_loader/plugin/plugin.py b/backend/decky_loader/plugin/plugin.py index 47d3d7b0..cad323f4 100644 --- a/backend/decky_loader/plugin/plugin.py +++ b/backend/decky_loader/plugin/plugin.py @@ -4,9 +4,9 @@ from logging import getLogger from os import path from multiprocessing import Process - from .sandboxed_plugin import SandboxedPlugin from .messages import MethodCallRequest, SocketMessageType +from ..enums import PluginLoadType from ..localplatform.localsocket import LocalSocket from typing import Any, Callable, Coroutine, Dict, List @@ -21,10 +21,14 @@ class PluginWrapper: self.version = None + self.load_type = PluginLoadType.LEGACY_EVAL_IIFE.value + json = load(open(path.join(plugin_path, plugin_directory, "plugin.json"), "r", encoding="utf-8")) if path.isfile(path.join(plugin_path, plugin_directory, "package.json")): package_json = load(open(path.join(plugin_path, plugin_directory, "package.json"), "r", encoding="utf-8")) self.version = package_json["version"] + if ("type" in package_json and package_json["type"] == "module"): + self.load_type = PluginLoadType.ESMODULE_V1.value self.name = json["name"] self.author = json["author"] diff --git a/backend/decky_loader/plugin/sandboxed_plugin.py b/backend/decky_loader/plugin/sandboxed_plugin.py index 3fd38e4f..b49dcf41 100644 --- a/backend/decky_loader/plugin/sandboxed_plugin.py +++ b/backend/decky_loader/plugin/sandboxed_plugin.py @@ -11,7 +11,7 @@ from asyncio import (get_event_loop, new_event_loop, from .messages import SocketResponseDict, SocketMessageType from ..localplatform.localsocket import LocalSocket from ..localplatform.localplatform import setgid, setuid, get_username, get_home_path -from ..customtypes import UserType +from ..enums import UserType from .. import helpers from typing import List, TypeVar, Type diff --git a/backend/decky_loader/settings.py b/backend/decky_loader/settings.py index c0f2b90c..b5f034aa 100644 --- a/backend/decky_loader/settings.py +++ b/backend/decky_loader/settings.py @@ -2,7 +2,7 @@ from json import dump, load from os import mkdir, path, listdir, rename from typing import Any, Dict from .localplatform.localplatform import chown, folder_owner, get_chown_plugin_path -from .customtypes import UserType +from .enums import UserType from .helpers import get_homebrew_path diff --git a/backend/decky_loader/updater.py b/backend/decky_loader/updater.py index a28f0c11..6355fcc7 100644 --- a/backend/decky_loader/updater.py +++ b/backend/decky_loader/updater.py @@ -35,7 +35,6 @@ class TestingVersion(TypedDict): link: str head_sha: str - class Updater: def __init__(self, context: PluginManager) -> None: self.context = context @@ -103,7 +102,7 @@ class Updater: logger.debug("checking for updates") selectedBranch = self.get_branch(self.context.settings) async with ClientSession() as web: - async with web.request("GET", "https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases", ssl=helpers.get_ssl_context()) as res: + async with web.request("GET", "https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases", headers={'X-GitHub-Api-Version': '2022-11-28'}, ssl=helpers.get_ssl_context()) as res: remoteVersions: List[RemoteVer] = await res.json() if selectedBranch == 0: logger.debug("release type: release") @@ -126,8 +125,7 @@ class Updater: logger.error("release type: NOT FOUND") raise ValueError("no valid branch found") logger.info("Updated remote version information") - tab = await get_gamepadui_tab() - await tab.evaluate_js(f"window.DeckyPluginLoader.notifyUpdates()", False, True, False) + await self.context.ws.emit("loader/notify_updates") return await self.get_version_info() async def version_reloader(self): @@ -158,7 +156,7 @@ class Updater: raw += len(c) new_progress = round((raw / total) * 100) if progress != new_progress: - self.context.loop.create_task(self.context.ws.emit("frontend/update_download_percentage", new_progress)) + self.context.loop.create_task(self.context.ws.emit("updater/update_download_percentage", new_progress)) progress = new_progress with open(path.join(getcwd(), ".loader.version"), "w", encoding="utf-8") as out: @@ -182,7 +180,7 @@ class Updater: logger.info(f"Setting the executable flag with chcon returned {await process.wait()}") logger.info("Updated loader installation.") - await self.context.ws.emit("frontend/finish_download") + await self.context.ws.emit("updater/finish_download") await self.do_restart() await tab.close_websocket() diff --git a/backend/decky_loader/wsrouter.py b/backend/decky_loader/wsrouter.py index 4874e967..918b74bc 100644 --- a/backend/decky_loader/wsrouter.py +++ b/backend/decky_loader/wsrouter.py @@ -93,9 +93,7 @@ class WSRouter: async for msg in ws: msg = cast(WSMessageExtra, msg) - self.logger.debug(msg) if msg.type == WSMsgType.TEXT: - self.logger.debug(msg.data) if msg.data == 'close': # TODO DO NOT RELY ON THIS! break diff --git a/backend/locales/en-US.json b/backend/locales/en-US.json index ca18f7da..fe544dea 100644 --- a/backend/locales/en-US.json +++ b/backend/locales/en-US.json @@ -263,8 +263,10 @@ "reloading": "Reloading", "updating": "Updating" } - }, - "Testing": { - "download": "Download" - } + }, + "Testing": { + "download": "Download", + "header": "The following versions of Decky Loader are built from open third-party Pull Requests. The Decky Loader team has not verified their functionality or security, and they may be outdated.", + "loading": "Loading open Pull Requests..." + } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 705f935b..ef1a7d6b 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -7,7 +7,7 @@ settings: dependencies: decky-frontend-lib: specifier: 3.24.5 - version: link:../../lib + version: 3.24.5 filesize: specifier: ^10.0.7 version: 10.0.7 @@ -1482,6 +1482,10 @@ packages: dependencies: ms: 2.1.2 + /decky-frontend-lib@3.24.5: + resolution: {integrity: sha512-eYlbKDOOcIBPI0b76Rqvlryq2ym/QNiry4xf2pFrXmBa1f95dflqbQAb2gTq9uHEa5gFmeV4lUcMPGJ3M14Xqw==} + dev: false + /decode-named-character-reference@1.0.2: resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==} dependencies: diff --git a/frontend/src/components/modals/filepicker/patches/library.ts b/frontend/src/components/modals/filepicker/patches/library.ts index 076e78f6..71eb9541 100644 --- a/frontend/src/components/modals/filepicker/patches/library.ts +++ b/frontend/src/components/modals/filepicker/patches/library.ts @@ -1,6 +1,7 @@ import { Patch, findModuleChild, replacePatch, sleep } from 'decky-frontend-lib'; import Logger from '../../../../logger'; +import { FileSelectionType } from '..'; const logger = new Logger('LibraryPatch'); @@ -13,7 +14,12 @@ function rePatch() { const details = window.appDetailsStore.GetAppDetails(appid); logger.debug('game details', details); // strShortcutStartDir - const file = await DeckyPluginLoader.openFilePicker(details?.strShortcutStartDir.replaceAll('"', '') || '/'); + const file = await DeckyPluginLoader.openFilePicker( + FileSelectionType.FILE, + details?.strShortcutStartDir.replaceAll('"', '') || '/', + true, + true, + ); logger.debug('user selected', file); window.SteamClient.Apps.SetShortcutExe(appid, JSON.stringify(file.path)); const pathArr = file.path.split('/'); diff --git a/frontend/src/components/settings/pages/developer/index.tsx b/frontend/src/components/settings/pages/developer/index.tsx index 9c8504e7..091e367e 100644 --- a/frontend/src/components/settings/pages/developer/index.tsx +++ b/frontend/src/components/settings/pages/developer/index.tsx @@ -28,7 +28,7 @@ const installFromZip = async () => { logger.error('The default path has not been found!'); return; } - DeckyPluginLoader.openFilePickerV2(FileSelectionType.FILE, path, true, true, undefined, ['zip'], false, false).then( + DeckyPluginLoader.openFilePicker(FileSelectionType.FILE, path, true, true, undefined, ['zip'], false, false).then( (val) => { const url = `file://${val.path}`; console.log(`Installing plugin locally from ${url}`); @@ -37,6 +37,8 @@ const installFromZip = async () => { ); }; +const getTabID = DeckyBackend.callable<[name: string], string>('utilities/get_tab_id'); + export default function DeveloperSettings() { const [enableValveInternal, setEnableValveInternal] = useSetting<boolean>('developer.valve_internal', false); const [reactDevtoolsEnabled, setReactDevtoolsEnabled] = useSetting<boolean>('developer.rdt.enabled', false); @@ -85,7 +87,7 @@ export default function DeveloperSettings() { <DialogButton onClick={async () => { try { - let tabId = await DeckyBackend.call<[name: string], string>('utilities/get_tab_id', 'SharedJSContext'); + let tabId = await getTabID('SharedJSContext'); Navigation.NavigateToExternalWeb( 'localhost:8080/devtools/inspector.html?ws=localhost:8080/devtools/page/' + tabId, ); diff --git a/frontend/src/components/settings/pages/general/Updater.tsx b/frontend/src/components/settings/pages/general/Updater.tsx index c563bbca..3c7e53f1 100644 --- a/frontend/src/components/settings/pages/general/Updater.tsx +++ b/frontend/src/components/settings/pages/general/Updater.tsx @@ -75,12 +75,12 @@ export default function UpdaterSettings() { const { t } = useTranslation(); useEffect(() => { - const a = DeckyBackend.addEventListener('frontend/update_download_percentage', (percentage) => { + const a = DeckyBackend.addEventListener('updater/update_download_percentage', (percentage) => { setUpdateProgress(percentage); setIsLoaderUpdating(true); }); - const b = DeckyBackend.addEventListener('frontend/finish_download', () => { + const b = DeckyBackend.addEventListener('updater/finish_download', () => { setUpdateProgress(0); setReloading(true); }); diff --git a/frontend/src/components/settings/pages/testing/index.tsx b/frontend/src/components/settings/pages/testing/index.tsx index 72267295..cdf51c71 100644 --- a/frontend/src/components/settings/pages/testing/index.tsx +++ b/frontend/src/components/settings/pages/testing/index.tsx @@ -1,4 +1,12 @@ -import { DialogBody, DialogButton, DialogControlsSection, Focusable, Navigation } from 'decky-frontend-lib'; +import { + DialogBody, + DialogButton, + DialogControlsSection, + Field, + Focusable, + Navigation, + SteamSpinner, +} from 'decky-frontend-lib'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { FaDownload, FaInfo } from 'react-icons/fa'; @@ -19,13 +27,23 @@ const downloadTestingVersion = DeckyBackend.callable<[pr_id: number, sha: string export default function TestingVersionList() { const { t } = useTranslation(); const [testingVersions, setTestingVersions] = useState<TestingVersion[]>([]); + const [loading, setLoading] = useState<boolean>(true); useEffect(() => { (async () => { setTestingVersions(await getTestingVersions()); + setLoading(false); })(); }, []); + if (loading) { + return ( + <> + <SteamSpinner>{t('Testing.loading')}</SteamSpinner> + </> + ); + } + if (testingVersions.length === 0) { return ( <div> @@ -37,48 +55,54 @@ export default function TestingVersionList() { return ( <DialogBody> <DialogControlsSection> + <h4>{t('Testing.header')}</h4> <ul style={{ listStyleType: 'none', padding: '0' }}> {testingVersions.map((version) => { return ( - <li style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', paddingBottom: '10px' }}> - <span> - {version.name} <span style={{ opacity: '50%' }}>{'#' + version.id}</span> - </span> - <Focusable style={{ height: '40px', marginLeft: 'auto', display: 'flex' }}> - <DialogButton - style={{ height: '40px', minWidth: '60px', marginRight: '10px' }} - onClick={() => { - downloadTestingVersion(version.id, version.head_sha); - setSetting('branch', UpdateBranch.Testing); - }} - > - <div + <li> + <Field + label={ + <> + {version.name} <span style={{ opacity: '50%' }}>{'#' + version.id}</span> + </> + } + > + <Focusable style={{ height: '40px', marginLeft: 'auto', display: 'flex' }}> + <DialogButton + style={{ height: '40px', minWidth: '60px', marginRight: '10px' }} + onClick={() => { + downloadTestingVersion(version.id, version.head_sha); + setSetting('branch', UpdateBranch.Testing); + }} + > + <div + style={{ + display: 'flex', + minWidth: '150px', + justifyContent: 'space-between', + alignItems: 'center', + }} + > + {t('Testing.download')} + <FaDownload style={{ paddingLeft: '1rem' }} /> + </div> + </DialogButton> + <DialogButton style={{ + height: '40px', + width: '40px', + padding: '10px 12px', + minWidth: '40px', display: 'flex', - minWidth: '150px', - justifyContent: 'space-between', - alignItems: 'center', + flexDirection: 'column', + justifyContent: 'center', }} + onClick={() => Navigation.NavigateToExternalWeb(version.link)} > - {t('Testing.download')} - <FaDownload style={{ paddingLeft: '1rem' }} /> - </div> - </DialogButton> - <DialogButton - style={{ - height: '40px', - width: '40px', - padding: '10px 12px', - minWidth: '40px', - display: 'flex', - flexDirection: 'column', - justifyContent: 'center', - }} - onClick={() => Navigation.NavigateToExternalWeb(version.link)} - > - <FaInfo /> - </DialogButton> - </Focusable> + <FaInfo /> + </DialogButton> + </Focusable> + </Field> </li> ); })} diff --git a/frontend/src/components/store/Store.tsx b/frontend/src/components/store/Store.tsx index e3d1b0f1..fdb871b0 100644 --- a/frontend/src/components/store/Store.tsx +++ b/frontend/src/components/store/Store.tsx @@ -89,7 +89,7 @@ const BrowseTab: FC<{ setPluginCount: Dispatch<SetStateAction<number | null>> }> useEffect(() => { (async () => { const res = await getPluginList(selectedSort[0], selectedSort[1]); - logger.log('got data!', res); + logger.debug('got data!', res); setPluginList(res); setPluginCount(res.length); })(); @@ -98,7 +98,7 @@ const BrowseTab: FC<{ setPluginCount: Dispatch<SetStateAction<number | null>> }> useEffect(() => { (async () => { const storeRes = await getStore(); - logger.log(`store is ${storeRes}, isTesting is ${storeRes === Store.Testing}`); + logger.debug(`store is ${storeRes}, isTesting is ${storeRes === Store.Testing}`); setIsTesting(storeRes === Store.Testing); })(); }, []); diff --git a/frontend/src/plugin-loader.tsx b/frontend/src/plugin-loader.tsx index 43073385..fd5762ea 100644 --- a/frontend/src/plugin-loader.tsx +++ b/frontend/src/plugin-loader.tsx @@ -2,7 +2,6 @@ import { ModalRoot, PanelSection, PanelSectionRow, - Patch, QuickAccessTab, Router, findSP, @@ -26,7 +25,7 @@ import { FrozenPluginService } from './frozen-plugins-service'; import { HiddenPluginsService } from './hidden-plugins-service'; import Logger from './logger'; import { NotificationService } from './notification-service'; -import { InstallType, Plugin } from './plugin'; +import { InstallType, Plugin, PluginLoadType } from './plugin'; import RouterHook from './router-hook'; import { deinitSteamFixes, initSteamFixes } from './steamfixes'; import { checkForPluginUpdates } from './store'; @@ -41,6 +40,18 @@ 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_deckyPluginBackendAPIInit?: { + connect: (version: number, key: string) => any; // Returns the backend API used above, no real point adding types to this. + }; + } +} + +const callPluginMethod = DeckyBackend.callable<[pluginName: string, method: string, ...args: any], any>( + 'loader/call_plugin_method', +); + class PluginLoader extends Logger { private plugins: Plugin[] = []; private tabsHook: TabsHook = new TabsHook(); @@ -55,11 +66,21 @@ class PluginLoader extends Logger { private reloadLock: boolean = false; // stores a list of plugin names which requested to be reloaded private pluginReloadQueue: { name: string; version?: string }[] = []; - - private focusWorkaroundPatch?: Patch; + private apiKeys: Map<string, string> = new Map(); constructor() { super(PluginLoader.name); + console.log(import.meta.url); + + 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), + ); + this.tabsHook.init(); const TabBadge = () => { @@ -108,7 +129,10 @@ class PluginLoader extends Logger { .then(() => this.log('Initialized')); } - private getPluginsFromBackend = DeckyBackend.callable<[], { name: string; version: string }[]>('loader/get_plugins'); + 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 @@ -119,7 +143,8 @@ class PluginLoader extends Logger { const pluginLoadPromises = []; const loadStart = performance.now(); for (const plugin of plugins) { - if (!this.hasPlugin(plugin.name)) pluginLoadPromises.push(this.importPlugin(plugin.name, plugin.version, false)); + 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(); @@ -256,7 +281,6 @@ class PluginLoader extends Logger { this.routerHook.removeRoute('/decky/settings'); deinitSteamFixes(); deinitFilepickerPatches(); - this.focusWorkaroundPatch?.unpatch(); } public unloadPlugin(name: string) { @@ -266,7 +290,12 @@ class PluginLoader extends Logger { this.deckyState.setPlugins(this.plugins); } - public async importPlugin(name: string, version?: string | undefined, useQueue: boolean = true) { + 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 }); @@ -279,7 +308,7 @@ class PluginLoader extends Logger { this.unloadPlugin(name); const startTime = performance.now(); - await this.importReactPlugin(name, version); + await this.importReactPlugin(name, version, loadType); const endTime = performance.now(); this.deckyState.setPlugins(this.plugins); @@ -297,70 +326,94 @@ class PluginLoader extends Logger { } } - 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: 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('Error loading plugin ' + name, e); - const TheError: FC<{}> = () => ( - <PanelSection> - <PanelSectionRow> - <div - className={quickAccessMenuClasses.FriendsTitle} - style={{ display: 'flex', justifyContent: 'center' }} - > - <TranslationHelper trans_class={TranslationClass.PLUGIN_LOADER} trans_text="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 - trans_class={TranslationClass.PLUGIN_LOADER} - trans_text="plugin_error_uninstall" - i18n_args={{ name: name }} - /> - </div> - </PanelSectionRow> - </PanelSection> - ); - this.plugins.push({ - name: name, - version: version, - content: <TheError />, - icon: <FaExclamationCircle />, - }); - this.toaster.toast({ - title: ( - <TranslationHelper - trans_class={TranslationClass.PLUGIN_LOADER} - trans_text="plugin_load_error.toast" - i18n_args={{ name: name }} - /> - ), - body: '' + e, - icon: <FaExclamationCircle />, - }); + private async importReactPlugin( + name: string, + version?: string, + loadType: PluginLoadType = PluginLoadType.ESMODULE_V1, + ) { + try { + switch (loadType) { + case PluginLoadType.ESMODULE_V1: + const uuid = this.initPluginBackendAPIConnection(name); + let plugin_export: () => Plugin; + try { + plugin_export = await import(`http://127.0.0.1:1337/plugins/${name}/dist/index.js#apiKey=${uuid}`); + } finally { + this.destroyPluginBackendAPIConnection(uuid); + } + let plugin = plugin_export(); + + 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: { + Authentication: deckyAuthToken, + }, + }); + if (res.ok) { + let plugin_export: (serverAPI: any) => Plugin = await eval(await res.text()); + 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.`); } - } else throw new Error(`${name} frontend_bundle not OK`); + } catch (e) { + this.error('Error loading plugin ' + name, e); + const TheError: FC<{}> = () => ( + <PanelSection> + <PanelSectionRow> + <div className={quickAccessMenuClasses.FriendsTitle} style={{ display: 'flex', justifyContent: 'center' }}> + <TranslationHelper trans_class={TranslationClass.PLUGIN_LOADER} trans_text="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 + trans_class={TranslationClass.PLUGIN_LOADER} + trans_text="plugin_error_uninstall" + i18n_args={{ name: name }} + /> + </div> + </PanelSectionRow> + </PanelSection> + ); + this.plugins.push({ + name: name, + version: version, + content: <TheError />, + icon: <FaExclamationCircle />, + }); + this.toaster.toast({ + title: ( + <TranslationHelper + trans_class={TranslationClass.PLUGIN_LOADER} + trans_text="plugin_load_error.toast" + i18n_args={{ name: name }} + /> + ), + body: '' + e, + icon: <FaExclamationCircle />, + }); + } } async callServerMethod(methodName: string, args = {}) { @@ -374,20 +427,20 @@ class PluginLoader extends Logger { ); } - openFilePicker( + 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.openFilePickerV2(FileSelectionType.FILE, startPath, true, true, regex); + return this.openFilePicker(FileSelectionType.FILE, startPath, true, true, regex); } else { - return this.openFilePickerV2(FileSelectionType.FOLDER, startPath, false, true, regex); + return this.openFilePicker(FileSelectionType.FOLDER, startPath, false, true, regex); } } - openFilePickerV2( + openFilePicker( select: FileSelectionType, startPath: string, includeFiles?: boolean, @@ -428,27 +481,84 @@ class PluginLoader extends Logger { }); } - createPluginAPI(pluginName: string) { - const pluginAPI = { - backend: { - call<Args extends any[] = any[], Return = void>(method: string, ...args: Args): Promise<Return> { - return DeckyBackend.call<[pluginName: string, method: string, ...args: Args], Return>( - 'loader/call_plugin_method', - pluginName, - method, - ...args, - ); - }, - callable<Args extends any[] = any[], Return = void>(method: string): (...args: Args) => Promise<Return> { - return (...args) => pluginAPI.backend.call<Args, Return>(method, ...args); - }, + /* TODO replace with the following flow (or similar) so we can reuse the JS Fetch API + frontend --request URL only--> backend (ws method) + backend --new temporary backend URL--> frontend (ws response) + frontend <--> backend <--> target URL (over http!) + */ + async fetchNoCors(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() }; + } + } + + destroyPluginBackendAPIConnection(uuid: string) { + if (this.apiKeys.delete(uuid)) { + this.debug(`backend api connection init data destroyed for ${uuid}`); + } + } + + 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_deckyPluginBackendAPIInit = { + connect: (version: number, key: string) => { + if (!this.apiKeys.has(key)) { + throw new Error(`Backend API key ${key} is invalid.`); + } + + const pluginName = this.apiKeys.get(key)!; + + if (version <= 0) { + this.destroyPluginBackendAPIConnection(key); + throw new Error(`UUID ${key} requested invalid backend api version ${version}.`); + } + + const backendAPI = { + call: (methodName: string, ...args: any) => { + return callPluginMethod(pluginName, methodName, ...args); + }, + callable: (methodName: string) => { + return (...args: any) => callPluginMethod(pluginName, methodName, ...args); + }, + }; + + this.destroyPluginBackendAPIConnection(key); + return backendAPI; }, + }; + } + + initPluginBackendAPIConnection(pluginName: string) { + const key = crypto.randomUUID(); + this.apiKeys.set(key, pluginName); + + return key; + } + + createLegacyPluginAPI(pluginName: string) { + const pluginAPI = { routerHook: this.routerHook, toaster: this.toaster, // Legacy callServerMethod: this.callServerMethod, - openFilePicker: this.openFilePicker, - openFilePickerV2: this.openFilePickerV2, + openFilePicker: this.openFilePickerLegacy, + openFilePickerV2: this.openFilePicker, // Legacy async callPluginMethod(methodName: string, args = {}) { return DeckyBackend.call<[pluginName: string, methodName: string, kwargs: any], any>( @@ -458,32 +568,7 @@ class PluginLoader extends Logger { args, ); }, - /* TODO replace with the following flow (or similar) so we can reuse the JS Fetch API - frontend --request URL only--> backend (ws method) - backend --new temporary backend URL--> frontend (ws response) - frontend <--> backend <--> target URL (over http!) - */ - async fetchNoCors(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() }; - } - }, + fetchNoCors: this.fetchNoCors, executeInTab: DeckyBackend.callable< [tab: String, runAsync: Boolean, code: string], { success: boolean; result: any } diff --git a/frontend/src/plugin.ts b/frontend/src/plugin.ts index d750a63e..92a0c625 100644 --- a/frontend/src/plugin.ts +++ b/frontend/src/plugin.ts @@ -1,3 +1,8 @@ +export enum PluginLoadType { + LEGACY_EVAL_IIFE = 0, // legacy, uses legacy serverAPI + ESMODULE_V1 = 1, // esmodule loading with modern @decky/backend apis +} + export interface Plugin { name: string; version?: string; diff --git a/frontend/src/start.tsx b/frontend/src/start.tsx index 0803f46e..a2837a1b 100644 --- a/frontend/src/start.tsx +++ b/frontend/src/start.tsx @@ -6,7 +6,6 @@ import PluginLoader from './plugin-loader'; declare global { export var DeckyPluginLoader: PluginLoader; - export var importDeckyPlugin: Function; export var deckyHasLoaded: boolean; export var deckyHasConnectedRDT: boolean | undefined; export var deckyAuthToken: string; @@ -45,9 +44,7 @@ declare global { window?.DeckyPluginLoader?.deinit(); window.DeckyPluginLoader = new PluginLoader(); DeckyPluginLoader.init(); - window.importDeckyPlugin = function (name: string, version: string) { - DeckyPluginLoader?.importPlugin(name, version); - }; + console.log(import.meta.url); })(); export default i18n; diff --git a/frontend/src/steamfixes/index.ts b/frontend/src/steamfixes/index.ts index fe0e3e05..45f07b2a 100644 --- a/frontend/src/steamfixes/index.ts +++ b/frontend/src/steamfixes/index.ts @@ -1,5 +1,5 @@ -import reloadFix from './reload'; -import restartFix from './restart'; +// import reloadFix from './reload'; +// import restartFix from './restart'; let fixes: Function[] = []; export function deinitSteamFixes() { @@ -7,6 +7,6 @@ export function deinitSteamFixes() { } export async function initSteamFixes() { - fixes.push(await reloadFix()); - fixes.push(await restartFix()); + // fixes.push(await reloadFix()); + // fixes.push(await restartFix()); } diff --git a/frontend/src/updater.ts b/frontend/src/updater.ts index edc9eeb3..c8e96521 100644 --- a/frontend/src/updater.ts +++ b/frontend/src/updater.ts @@ -28,6 +28,6 @@ export const doRestart = DeckyBackend.callable('updater/do_restart'); export const getVersionInfo = DeckyBackend.callable<[], VerInfo>('updater/get_version_info'); export const checkForUpdates = DeckyBackend.callable<[], VerInfo>('updater/check_for_updates'); -DeckyBackend.addEventListener('frontend/finish_download', async () => { +DeckyBackend.addEventListener('updater/finish_download', async () => { await doRestart(); }); diff --git a/frontend/src/wsrouter.ts b/frontend/src/wsrouter.ts index 778c03bf..37df4262 100644 --- a/frontend/src/wsrouter.ts +++ b/frontend/src/wsrouter.ts @@ -50,7 +50,6 @@ interface PromiseResolver<T> { } export class WSRouter extends Logger { - routes: Map<string, (...args: any) => any> = new Map(); runningCalls: Map<number, PromiseResolver<any>> = new Map(); eventListeners: Map<string, Set<(...args: any) => any>> = new Map(); ws?: WebSocket; @@ -92,14 +91,6 @@ export class WSRouter extends Logger { this.ws?.send(JSON.stringify(data)); } - addRoute(name: string, route: (...args: any) => any) { - this.routes.set(name, route); - } - - removeRoute(name: string) { - this.routes.delete(name); - } - addEventListener(event: string, listener: (...args: any) => any) { if (!this.eventListeners.has(event)) { this.eventListeners.set(event, new Set([listener])); @@ -123,20 +114,6 @@ export class WSRouter extends Logger { try { const data = JSON.parse(msg.data) as Message; switch (data.type) { - case MessageType.CALL: - if (this.routes.has(data.route)) { - try { - const res = await this.routes.get(data.route)!(...data.args); - this.write({ type: MessageType.REPLY, id: data.id, result: res }); - this.debug(`Started JS call ${data.route} ID ${data.id}`); - } catch (e) { - await this.write({ type: MessageType.ERROR, id: data.id, error: (e as Error)?.stack || e }); - } - } else { - await this.write({ type: MessageType.ERROR, id: data.id, error: `Route ${data.route} does not exist.` }); - } - break; - case MessageType.REPLY: if (this.runningCalls.has(data.id)) { this.runningCalls.get(data.id)!.resolve(data.result); @@ -154,6 +131,7 @@ export class WSRouter extends Logger { break; case MessageType.EVENT: + this.debug(`Recieved event ${data.event} with args`, data.args); if (this.eventListeners.has(data.event)) { for (const listener of this.eventListeners.get(data.event)!) { (async () => { |
