more major websocket progress

This commit is contained in:
AAGaming
2024-02-21 01:08:25 -05:00
parent 61cf80f8a2
commit 6d2e9365c0
26 changed files with 358 additions and 240 deletions
+5 -13
View File
@@ -4,7 +4,7 @@ import json
# from pprint import pformat # from pprint import pformat
# Partial imports # Partial imports
from aiohttp import ClientSession from aiohttp import ClientSession, request
from asyncio import sleep from asyncio import sleep
from hashlib import sha256 from hashlib import sha256
from io import BytesIO from io import BytesIO
@@ -123,7 +123,6 @@ class PluginBrowser:
async def uninstall_plugin(self, name: str): async def uninstall_plugin(self, name: str):
if self.loader.watcher: if self.loader.watcher:
self.loader.watcher.disabled = True self.loader.watcher.disabled = True
tab = await get_gamepadui_tab()
plugin_folder = self.find_plugin_folder(name) plugin_folder = self.find_plugin_folder(name)
assert plugin_folder is not None assert plugin_folder is not None
plugin_dir = path.join(self.plugin_path, plugin_folder) plugin_dir = path.join(self.plugin_path, plugin_folder)
@@ -131,8 +130,7 @@ class PluginBrowser:
logger.info("uninstalling " + name) logger.info("uninstalling " + name)
logger.info(" at dir " + plugin_dir) logger.info(" at dir " + plugin_dir)
logger.debug("calling frontend unload for %s" % str(name)) logger.debug("calling frontend unload for %s" % str(name))
res = await tab.evaluate_js(f"DeckyPluginLoader.unloadPlugin('{name}')") await self.loader.ws.emit("loader/unload_plugin", name)
logger.debug("result of unload from UI: %s", res)
# plugins_snapshot = self.plugins.copy() # plugins_snapshot = self.plugins.copy()
# snapshot_string = pformat(plugins_snapshot) # snapshot_string = pformat(plugins_snapshot)
# logger.debug("current plugins: %s", snapshot_string) # 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): async def request_plugin_install(self, artifact: str, name: str, version: str, hash: str, install_type: PluginInstallType):
request_id = str(time()) request_id = str(time())
self.install_requests[request_id] = PluginInstallContext(artifact, name, version, hash) self.install_requests[request_id] = PluginInstallContext(artifact, name, version, hash)
tab = await get_gamepadui_tab()
await tab.open_websocket() await self.loader.ws.emit("loader/add_plugin_install_prompt", name, version, request_id, hash, install_type)
await tab.evaluate_js(f"DeckyPluginLoader.addPluginInstallPrompt('{name}', '{version}', '{request_id}', '{hash}', {install_type})")
async def request_multiple_plugin_installs(self, requests: List[PluginInstallRequest]): async def request_multiple_plugin_installs(self, requests: List[PluginInstallRequest]):
request_id = str(time()) request_id = str(time())
self.install_requests[request_id] = [PluginInstallContext(req['artifact'], req['name'], req['version'], req['hash']) for req in requests] 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 self.loader.ws.emit("loader/add_multiple_plugins_install_prompt", request_id, requests)
await tab.open_websocket()
await tab.evaluate_js(f"DeckyPluginLoader.addMultiplePluginsInstallPrompt('{request_id}', [{js_requests_parameter}])")
async def confirm_plugin_install(self, request_id: str): async def confirm_plugin_install(self, request_id: str):
requestOrRequests = self.install_requests.pop(request_id) requestOrRequests = self.install_requests.pop(request_id)
-6
View File
@@ -1,6 +0,0 @@
from enum import Enum
class UserType(Enum):
HOST_USER = 1
EFFECTIVE_USER = 2
ROOT = 3
+10
View File
@@ -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
+14 -2
View File
@@ -12,7 +12,7 @@ from aiohttp.web import Request, Response, middleware
from aiohttp.typedefs import Handler from aiohttp.typedefs import Handler
from aiohttp import ClientSession from aiohttp import ClientSession
from .localplatform import localplatform from .localplatform import localplatform
from .customtypes import UserType from .enums import UserType
from logging import getLogger from logging import getLogger
from packaging.version import Version from packaging.version import Version
@@ -23,6 +23,7 @@ csrf_token = str(uuid.uuid4())
ssl_ctx = ssl.create_default_context(cafile=certifi.where()) ssl_ctx = ssl.create_default_context(cafile=certifi.where())
assets_regex = re.compile("^/plugins/.*/assets/.*") assets_regex = re.compile("^/plugins/.*/assets/.*")
dist_regex = re.compile("^/plugins/.*/dist/.*")
frontend_regex = re.compile("^/frontend/.*") frontend_regex = re.compile("^/frontend/.*")
logger = getLogger("Main") logger = getLogger("Main")
@@ -34,7 +35,18 @@ def get_csrf_token():
@middleware @middleware
async def csrf_middleware(request: Request, handler: Handler): 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 await handler(request)
return Response(text='Forbidden', status=403) return Response(text='Forbidden', status=403)
+12 -6
View File
@@ -16,9 +16,9 @@ from typing import TYPE_CHECKING, List
if TYPE_CHECKING: if TYPE_CHECKING:
from .main import PluginManager from .main import PluginManager
from .injector import get_gamepadui_tab
from .plugin.plugin import PluginWrapper from .plugin.plugin import PluginWrapper
from .wsrouter import WSRouter from .wsrouter import WSRouter
from .enums import PluginLoadType
Plugins = dict[str, PluginWrapper] Plugins = dict[str, PluginWrapper]
ReloadQueue = Queue[Tuple[str, str, bool | None] | Tuple[str, str]] 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("/frontend/{path:.*}", self.handle_frontend_assets),
web.get("/locales/{path:.*}", self.handle_frontend_locales), web.get("/locales/{path:.*}", self.handle_frontend_locales),
web.get("/plugins/{plugin_name}/frontend_bundle", self.handle_frontend_bundle), 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), web.get("/plugins/{plugin_name}/assets/{path:.*}", self.handle_plugin_frontend_assets),
]) ])
@@ -126,7 +127,13 @@ class Loader:
async def get_plugins(self): async def get_plugins(self):
plugins = list(self.plugins.values()) 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): async def handle_plugin_frontend_assets(self, request: web.Request):
plugin = self.plugins[request.match_info["plugin_name"]] plugin = self.plugins[request.match_info["plugin_name"]]
@@ -145,7 +152,7 @@ class Loader:
async def plugin_emitted_event(event: str, data: Any): async def plugin_emitted_event(event: str, data: Any):
self.logger.debug(f"PLUGIN EMITTED EVENT: {str(event)} {data}") self.logger.debug(f"PLUGIN EMITTED EVENT: {str(event)} {data}")
event_data = PluginEvent(plugin_name=plugin.name, event=event, data=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) plugin = PluginWrapper(file, plugin_directory, self.plugin_path, plugin_emitted_event)
if plugin.name in self.plugins: if plugin.name in self.plugins:
@@ -166,9 +173,8 @@ class Loader:
self.logger.error(f"Could not load {file}. {e}") self.logger.error(f"Could not load {file}. {e}")
print_exc() print_exc()
async def dispatch_plugin(self, name: str, version: str | None): async def dispatch_plugin(self, name: str, version: str | None, load_type: int = PluginLoadType.ESMODULE_V1.value):
gpui_tab = await get_gamepadui_tab() await self.ws.emit("loader/import_plugin", name, version, load_type)
await gpui_tab.evaluate_js(f"window.importDeckyPlugin('{name}', '{version}')")
def import_plugins(self): def import_plugins(self):
self.logger.info(f"import plugins from {self.plugin_path}") self.logger.info(f"import plugins from {self.plugin_path}")
@@ -1,6 +1,6 @@
import os, pwd, grp, sys, logging import os, pwd, grp, sys, logging
from subprocess import call, run, DEVNULL, PIPE, STDOUT from subprocess import call, run, DEVNULL, PIPE, STDOUT
from ..customtypes import UserType from ..enums import UserType
logger = logging.getLogger("localplatform") logger = logging.getLogger("localplatform")
@@ -157,6 +157,7 @@ async def service_start(service_name : str) -> bool:
return res.returncode == 0 return res.returncode == 0
async def restart_webhelper() -> bool: async def restart_webhelper() -> bool:
logger.info("Restarting steamwebhelper")
res = run(["killall", "-s", "SIGTERM", "steamwebhelper"], stdout=DEVNULL, stderr=DEVNULL) res = run(["killall", "-s", "SIGTERM", "steamwebhelper"], stdout=DEVNULL, stderr=DEVNULL)
return res.returncode == 0 return res.returncode == 0
@@ -1,4 +1,4 @@
from ..customtypes import UserType from ..enums import UserType
import os, sys import os, sys
def chown(path : str, user : UserType = UserType.HOST_USER, recursive : bool = True) -> bool: def chown(path : str, user : UserType = UserType.HOST_USER, recursive : bool = True) -> bool:
+1 -1
View File
@@ -30,7 +30,7 @@ from .loader import Loader
from .settings import SettingsManager from .settings import SettingsManager
from .updater import Updater from .updater import Updater
from .utilities import Utilities from .utilities import Utilities
from .customtypes import UserType from .enums import UserType
from .wsrouter import WSRouter from .wsrouter import WSRouter
+5 -1
View File
@@ -4,9 +4,9 @@ from logging import getLogger
from os import path from os import path
from multiprocessing import Process from multiprocessing import Process
from .sandboxed_plugin import SandboxedPlugin from .sandboxed_plugin import SandboxedPlugin
from .messages import MethodCallRequest, SocketMessageType from .messages import MethodCallRequest, SocketMessageType
from ..enums import PluginLoadType
from ..localplatform.localsocket import LocalSocket from ..localplatform.localsocket import LocalSocket
from typing import Any, Callable, Coroutine, Dict, List from typing import Any, Callable, Coroutine, Dict, List
@@ -21,10 +21,14 @@ class PluginWrapper:
self.version = None 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")) 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")): 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")) package_json = load(open(path.join(plugin_path, plugin_directory, "package.json"), "r", encoding="utf-8"))
self.version = package_json["version"] 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.name = json["name"]
self.author = json["author"] self.author = json["author"]
@@ -11,7 +11,7 @@ from asyncio import (get_event_loop, new_event_loop,
from .messages import SocketResponseDict, SocketMessageType from .messages import SocketResponseDict, SocketMessageType
from ..localplatform.localsocket import LocalSocket from ..localplatform.localsocket import LocalSocket
from ..localplatform.localplatform import setgid, setuid, get_username, get_home_path from ..localplatform.localplatform import setgid, setuid, get_username, get_home_path
from ..customtypes import UserType from ..enums import UserType
from .. import helpers from .. import helpers
from typing import List, TypeVar, Type from typing import List, TypeVar, Type
+1 -1
View File
@@ -2,7 +2,7 @@ from json import dump, load
from os import mkdir, path, listdir, rename from os import mkdir, path, listdir, rename
from typing import Any, Dict from typing import Any, Dict
from .localplatform.localplatform import chown, folder_owner, get_chown_plugin_path 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 from .helpers import get_homebrew_path
+4 -6
View File
@@ -35,7 +35,6 @@ class TestingVersion(TypedDict):
link: str link: str
head_sha: str head_sha: str
class Updater: class Updater:
def __init__(self, context: PluginManager) -> None: def __init__(self, context: PluginManager) -> None:
self.context = context self.context = context
@@ -103,7 +102,7 @@ class Updater:
logger.debug("checking for updates") logger.debug("checking for updates")
selectedBranch = self.get_branch(self.context.settings) selectedBranch = self.get_branch(self.context.settings)
async with ClientSession() as web: 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() remoteVersions: List[RemoteVer] = await res.json()
if selectedBranch == 0: if selectedBranch == 0:
logger.debug("release type: release") logger.debug("release type: release")
@@ -126,8 +125,7 @@ class Updater:
logger.error("release type: NOT FOUND") logger.error("release type: NOT FOUND")
raise ValueError("no valid branch found") raise ValueError("no valid branch found")
logger.info("Updated remote version information") logger.info("Updated remote version information")
tab = await get_gamepadui_tab() await self.context.ws.emit("loader/notify_updates")
await tab.evaluate_js(f"window.DeckyPluginLoader.notifyUpdates()", False, True, False)
return await self.get_version_info() return await self.get_version_info()
async def version_reloader(self): async def version_reloader(self):
@@ -158,7 +156,7 @@ class Updater:
raw += len(c) raw += len(c)
new_progress = round((raw / total) * 100) new_progress = round((raw / total) * 100)
if progress != new_progress: 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 progress = new_progress
with open(path.join(getcwd(), ".loader.version"), "w", encoding="utf-8") as out: 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(f"Setting the executable flag with chcon returned {await process.wait()}")
logger.info("Updated loader installation.") 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 self.do_restart()
await tab.close_websocket() await tab.close_websocket()
-2
View File
@@ -93,9 +93,7 @@ class WSRouter:
async for msg in ws: async for msg in ws:
msg = cast(WSMessageExtra, msg) msg = cast(WSMessageExtra, msg)
self.logger.debug(msg)
if msg.type == WSMsgType.TEXT: if msg.type == WSMsgType.TEXT:
self.logger.debug(msg.data)
if msg.data == 'close': if msg.data == 'close':
# TODO DO NOT RELY ON THIS! # TODO DO NOT RELY ON THIS!
break break
+3 -1
View File
@@ -265,6 +265,8 @@
} }
}, },
"Testing": { "Testing": {
"download": "Download" "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..."
} }
} }
+5 -1
View File
@@ -7,7 +7,7 @@ settings:
dependencies: dependencies:
decky-frontend-lib: decky-frontend-lib:
specifier: 3.24.5 specifier: 3.24.5
version: link:../../lib version: 3.24.5
filesize: filesize:
specifier: ^10.0.7 specifier: ^10.0.7
version: 10.0.7 version: 10.0.7
@@ -1482,6 +1482,10 @@ packages:
dependencies: dependencies:
ms: 2.1.2 ms: 2.1.2
/decky-frontend-lib@3.24.5:
resolution: {integrity: sha512-eYlbKDOOcIBPI0b76Rqvlryq2ym/QNiry4xf2pFrXmBa1f95dflqbQAb2gTq9uHEa5gFmeV4lUcMPGJ3M14Xqw==}
dev: false
/decode-named-character-reference@1.0.2: /decode-named-character-reference@1.0.2:
resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==} resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==}
dependencies: dependencies:
@@ -1,6 +1,7 @@
import { Patch, findModuleChild, replacePatch, sleep } from 'decky-frontend-lib'; import { Patch, findModuleChild, replacePatch, sleep } from 'decky-frontend-lib';
import Logger from '../../../../logger'; import Logger from '../../../../logger';
import { FileSelectionType } from '..';
const logger = new Logger('LibraryPatch'); const logger = new Logger('LibraryPatch');
@@ -13,7 +14,12 @@ function rePatch() {
const details = window.appDetailsStore.GetAppDetails(appid); const details = window.appDetailsStore.GetAppDetails(appid);
logger.debug('game details', details); logger.debug('game details', details);
// strShortcutStartDir // 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); logger.debug('user selected', file);
window.SteamClient.Apps.SetShortcutExe(appid, JSON.stringify(file.path)); window.SteamClient.Apps.SetShortcutExe(appid, JSON.stringify(file.path));
const pathArr = file.path.split('/'); const pathArr = file.path.split('/');
@@ -28,7 +28,7 @@ const installFromZip = async () => {
logger.error('The default path has not been found!'); logger.error('The default path has not been found!');
return; 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) => { (val) => {
const url = `file://${val.path}`; const url = `file://${val.path}`;
console.log(`Installing plugin locally from ${url}`); 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() { export default function DeveloperSettings() {
const [enableValveInternal, setEnableValveInternal] = useSetting<boolean>('developer.valve_internal', false); const [enableValveInternal, setEnableValveInternal] = useSetting<boolean>('developer.valve_internal', false);
const [reactDevtoolsEnabled, setReactDevtoolsEnabled] = useSetting<boolean>('developer.rdt.enabled', false); const [reactDevtoolsEnabled, setReactDevtoolsEnabled] = useSetting<boolean>('developer.rdt.enabled', false);
@@ -85,7 +87,7 @@ export default function DeveloperSettings() {
<DialogButton <DialogButton
onClick={async () => { onClick={async () => {
try { try {
let tabId = await DeckyBackend.call<[name: string], string>('utilities/get_tab_id', 'SharedJSContext'); let tabId = await getTabID('SharedJSContext');
Navigation.NavigateToExternalWeb( Navigation.NavigateToExternalWeb(
'localhost:8080/devtools/inspector.html?ws=localhost:8080/devtools/page/' + tabId, 'localhost:8080/devtools/inspector.html?ws=localhost:8080/devtools/page/' + tabId,
); );
@@ -75,12 +75,12 @@ export default function UpdaterSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
useEffect(() => { useEffect(() => {
const a = DeckyBackend.addEventListener('frontend/update_download_percentage', (percentage) => { const a = DeckyBackend.addEventListener('updater/update_download_percentage', (percentage) => {
setUpdateProgress(percentage); setUpdateProgress(percentage);
setIsLoaderUpdating(true); setIsLoaderUpdating(true);
}); });
const b = DeckyBackend.addEventListener('frontend/finish_download', () => { const b = DeckyBackend.addEventListener('updater/finish_download', () => {
setUpdateProgress(0); setUpdateProgress(0);
setReloading(true); setReloading(true);
}); });
@@ -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 { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FaDownload, FaInfo } from 'react-icons/fa'; import { FaDownload, FaInfo } from 'react-icons/fa';
@@ -19,13 +27,23 @@ const downloadTestingVersion = DeckyBackend.callable<[pr_id: number, sha: string
export default function TestingVersionList() { export default function TestingVersionList() {
const { t } = useTranslation(); const { t } = useTranslation();
const [testingVersions, setTestingVersions] = useState<TestingVersion[]>([]); const [testingVersions, setTestingVersions] = useState<TestingVersion[]>([]);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
setTestingVersions(await getTestingVersions()); setTestingVersions(await getTestingVersions());
setLoading(false);
})(); })();
}, []); }, []);
if (loading) {
return (
<>
<SteamSpinner>{t('Testing.loading')}</SteamSpinner>
</>
);
}
if (testingVersions.length === 0) { if (testingVersions.length === 0) {
return ( return (
<div> <div>
@@ -37,13 +55,18 @@ export default function TestingVersionList() {
return ( return (
<DialogBody> <DialogBody>
<DialogControlsSection> <DialogControlsSection>
<h4>{t('Testing.header')}</h4>
<ul style={{ listStyleType: 'none', padding: '0' }}> <ul style={{ listStyleType: 'none', padding: '0' }}>
{testingVersions.map((version) => { {testingVersions.map((version) => {
return ( return (
<li style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', paddingBottom: '10px' }}> <li>
<span> <Field
label={
<>
{version.name} <span style={{ opacity: '50%' }}>{'#' + version.id}</span> {version.name} <span style={{ opacity: '50%' }}>{'#' + version.id}</span>
</span> </>
}
>
<Focusable style={{ height: '40px', marginLeft: 'auto', display: 'flex' }}> <Focusable style={{ height: '40px', marginLeft: 'auto', display: 'flex' }}>
<DialogButton <DialogButton
style={{ height: '40px', minWidth: '60px', marginRight: '10px' }} style={{ height: '40px', minWidth: '60px', marginRight: '10px' }}
@@ -79,6 +102,7 @@ export default function TestingVersionList() {
<FaInfo /> <FaInfo />
</DialogButton> </DialogButton>
</Focusable> </Focusable>
</Field>
</li> </li>
); );
})} })}
+2 -2
View File
@@ -89,7 +89,7 @@ const BrowseTab: FC<{ setPluginCount: Dispatch<SetStateAction<number | null>> }>
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const res = await getPluginList(selectedSort[0], selectedSort[1]); const res = await getPluginList(selectedSort[0], selectedSort[1]);
logger.log('got data!', res); logger.debug('got data!', res);
setPluginList(res); setPluginList(res);
setPluginCount(res.length); setPluginCount(res.length);
})(); })();
@@ -98,7 +98,7 @@ const BrowseTab: FC<{ setPluginCount: Dispatch<SetStateAction<number | null>> }>
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const storeRes = await getStore(); 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); setIsTesting(storeRes === Store.Testing);
})(); })();
}, []); }, []);
+138 -53
View File
@@ -2,7 +2,6 @@ import {
ModalRoot, ModalRoot,
PanelSection, PanelSection,
PanelSectionRow, PanelSectionRow,
Patch,
QuickAccessTab, QuickAccessTab,
Router, Router,
findSP, findSP,
@@ -26,7 +25,7 @@ import { FrozenPluginService } from './frozen-plugins-service';
import { HiddenPluginsService } from './hidden-plugins-service'; import { HiddenPluginsService } from './hidden-plugins-service';
import Logger from './logger'; import Logger from './logger';
import { NotificationService } from './notification-service'; import { NotificationService } from './notification-service';
import { InstallType, Plugin } from './plugin'; import { InstallType, Plugin, PluginLoadType } from './plugin';
import RouterHook from './router-hook'; import RouterHook from './router-hook';
import { deinitSteamFixes, initSteamFixes } from './steamfixes'; import { deinitSteamFixes, initSteamFixes } from './steamfixes';
import { checkForPluginUpdates } from './store'; import { checkForPluginUpdates } from './store';
@@ -41,6 +40,18 @@ const SettingsPage = lazy(() => import('./components/settings'));
const FilePicker = lazy(() => import('./components/modals/filepicker')); 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 { class PluginLoader extends Logger {
private plugins: Plugin[] = []; private plugins: Plugin[] = [];
private tabsHook: TabsHook = new TabsHook(); private tabsHook: TabsHook = new TabsHook();
@@ -55,11 +66,21 @@ class PluginLoader extends Logger {
private reloadLock: boolean = false; private reloadLock: boolean = false;
// stores a list of plugin names which requested to be reloaded // stores a list of plugin names which requested to be reloaded
private pluginReloadQueue: { name: string; version?: string }[] = []; private pluginReloadQueue: { name: string; version?: string }[] = [];
private apiKeys: Map<string, string> = new Map();
private focusWorkaroundPatch?: Patch;
constructor() { constructor() {
super(PluginLoader.name); 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(); this.tabsHook.init();
const TabBadge = () => { const TabBadge = () => {
@@ -108,7 +129,10 @@ class PluginLoader extends Logger {
.then(() => this.log('Initialized')); .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() { private async loadPlugins() {
// wait for SP window to exist before loading plugins // wait for SP window to exist before loading plugins
@@ -119,7 +143,8 @@ class PluginLoader extends Logger {
const pluginLoadPromises = []; const pluginLoadPromises = [];
const loadStart = performance.now(); const loadStart = performance.now();
for (const plugin of plugins) { 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); await Promise.all(pluginLoadPromises);
const loadEnd = performance.now(); const loadEnd = performance.now();
@@ -256,7 +281,6 @@ class PluginLoader extends Logger {
this.routerHook.removeRoute('/decky/settings'); this.routerHook.removeRoute('/decky/settings');
deinitSteamFixes(); deinitSteamFixes();
deinitFilepickerPatches(); deinitFilepickerPatches();
this.focusWorkaroundPatch?.unpatch();
} }
public unloadPlugin(name: string) { public unloadPlugin(name: string) {
@@ -266,7 +290,12 @@ class PluginLoader extends Logger {
this.deckyState.setPlugins(this.plugins); 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) { if (useQueue && this.reloadLock) {
this.log('Reload currently in progress, adding to queue', name); this.log('Reload currently in progress, adding to queue', name);
this.pluginReloadQueue.push({ name, version: version }); this.pluginReloadQueue.push({ name, version: version });
@@ -279,7 +308,7 @@ class PluginLoader extends Logger {
this.unloadPlugin(name); this.unloadPlugin(name);
const startTime = performance.now(); const startTime = performance.now();
await this.importReactPlugin(name, version); await this.importReactPlugin(name, version, loadType);
const endTime = performance.now(); const endTime = performance.now();
this.deckyState.setPlugins(this.plugins); this.deckyState.setPlugins(this.plugins);
@@ -297,32 +326,57 @@ class PluginLoader extends Logger {
} }
} }
private async importReactPlugin(name: string, version?: string) { 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`, { let res = await fetch(`http://127.0.0.1:1337/plugins/${name}/frontend_bundle`, {
credentials: 'include', credentials: 'include',
headers: { headers: {
Authentication: deckyAuthToken, Authentication: deckyAuthToken,
}, },
}); });
if (res.ok) { if (res.ok) {
try { let plugin_export: (serverAPI: any) => Plugin = await eval(await res.text());
let plugin_export = await eval(await res.text()); let plugin = plugin_export(this.createLegacyPluginAPI(name));
let plugin = plugin_export(this.createPluginAPI(name));
this.plugins.push({ this.plugins.push({
...plugin, ...plugin,
name: name, name: name,
version: version, version: version,
}); });
} else throw new Error(`${name} frontend_bundle not OK`);
break;
default:
throw new Error(`${name} has no defined loadType.`);
}
} catch (e) { } catch (e) {
this.error('Error loading plugin ' + name, e); this.error('Error loading plugin ' + name, e);
const TheError: FC<{}> = () => ( const TheError: FC<{}> = () => (
<PanelSection> <PanelSection>
<PanelSectionRow> <PanelSectionRow>
<div <div className={quickAccessMenuClasses.FriendsTitle} style={{ display: 'flex', justifyContent: 'center' }}>
className={quickAccessMenuClasses.FriendsTitle}
style={{ display: 'flex', justifyContent: 'center' }}
>
<TranslationHelper trans_class={TranslationClass.PLUGIN_LOADER} trans_text="error" /> <TranslationHelper trans_class={TranslationClass.PLUGIN_LOADER} trans_text="error" />
</div> </div>
</PanelSectionRow> </PanelSectionRow>
@@ -360,7 +414,6 @@ class PluginLoader extends Logger {
icon: <FaExclamationCircle />, icon: <FaExclamationCircle />,
}); });
} }
} else throw new Error(`${name} frontend_bundle not OK`);
} }
async callServerMethod(methodName: string, args = {}) { async callServerMethod(methodName: string, args = {}) {
@@ -374,20 +427,20 @@ class PluginLoader extends Logger {
); );
} }
openFilePicker( openFilePickerLegacy(
startPath: string, startPath: string,
selectFiles?: boolean, selectFiles?: boolean,
regex?: RegExp, regex?: RegExp,
): Promise<{ path: string; realpath: string }> { ): Promise<{ path: string; realpath: string }> {
this.warn('openFilePicker is deprecated and will be removed. Please migrate to openFilePickerV2'); this.warn('openFilePicker is deprecated and will be removed. Please migrate to openFilePickerV2');
if (selectFiles) { if (selectFiles) {
return this.openFilePickerV2(FileSelectionType.FILE, startPath, true, true, regex); return this.openFilePicker(FileSelectionType.FILE, startPath, true, true, regex);
} else { } else {
return this.openFilePickerV2(FileSelectionType.FOLDER, startPath, false, true, regex); return this.openFilePicker(FileSelectionType.FOLDER, startPath, false, true, regex);
} }
} }
openFilePickerV2( openFilePicker(
select: FileSelectionType, select: FileSelectionType,
startPath: string, startPath: string,
includeFiles?: boolean, includeFiles?: boolean,
@@ -428,36 +481,6 @@ 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);
},
},
routerHook: this.routerHook,
toaster: this.toaster,
// Legacy
callServerMethod: this.callServerMethod,
openFilePicker: this.openFilePicker,
openFilePickerV2: this.openFilePickerV2,
// Legacy
async callPluginMethod(methodName: string, args = {}) {
return DeckyBackend.call<[pluginName: string, methodName: string, kwargs: any], any>(
'loader/call_legacy_plugin_method',
pluginName,
methodName,
args,
);
},
/* TODO replace with the following flow (or similar) so we can reuse the JS Fetch API /* TODO replace with the following flow (or similar) so we can reuse the JS Fetch API
frontend --request URL only--> backend (ws method) frontend --request URL only--> backend (ws method)
backend --new temporary backend URL--> frontend (ws response) backend --new temporary backend URL--> frontend (ws response)
@@ -483,7 +506,69 @@ class PluginLoader extends Logger {
} catch (e) { } catch (e) {
return { success: false, result: e?.toString() }; 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.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.fetchNoCors,
executeInTab: DeckyBackend.callable< executeInTab: DeckyBackend.callable<
[tab: String, runAsync: Boolean, code: string], [tab: String, runAsync: Boolean, code: string],
{ success: boolean; result: any } { success: boolean; result: any }
+5
View File
@@ -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 { export interface Plugin {
name: string; name: string;
version?: string; version?: string;
+1 -4
View File
@@ -6,7 +6,6 @@ import PluginLoader from './plugin-loader';
declare global { declare global {
export var DeckyPluginLoader: PluginLoader; export var DeckyPluginLoader: PluginLoader;
export var importDeckyPlugin: Function;
export var deckyHasLoaded: boolean; export var deckyHasLoaded: boolean;
export var deckyHasConnectedRDT: boolean | undefined; export var deckyHasConnectedRDT: boolean | undefined;
export var deckyAuthToken: string; export var deckyAuthToken: string;
@@ -45,9 +44,7 @@ declare global {
window?.DeckyPluginLoader?.deinit(); window?.DeckyPluginLoader?.deinit();
window.DeckyPluginLoader = new PluginLoader(); window.DeckyPluginLoader = new PluginLoader();
DeckyPluginLoader.init(); DeckyPluginLoader.init();
window.importDeckyPlugin = function (name: string, version: string) { console.log(import.meta.url);
DeckyPluginLoader?.importPlugin(name, version);
};
})(); })();
export default i18n; export default i18n;
+4 -4
View File
@@ -1,5 +1,5 @@
import reloadFix from './reload'; // import reloadFix from './reload';
import restartFix from './restart'; // import restartFix from './restart';
let fixes: Function[] = []; let fixes: Function[] = [];
export function deinitSteamFixes() { export function deinitSteamFixes() {
@@ -7,6 +7,6 @@ export function deinitSteamFixes() {
} }
export async function initSteamFixes() { export async function initSteamFixes() {
fixes.push(await reloadFix()); // fixes.push(await reloadFix());
fixes.push(await restartFix()); // fixes.push(await restartFix());
} }
+1 -1
View File
@@ -28,6 +28,6 @@ export const doRestart = DeckyBackend.callable('updater/do_restart');
export const getVersionInfo = DeckyBackend.callable<[], VerInfo>('updater/get_version_info'); export const getVersionInfo = DeckyBackend.callable<[], VerInfo>('updater/get_version_info');
export const checkForUpdates = DeckyBackend.callable<[], VerInfo>('updater/check_for_updates'); export const checkForUpdates = DeckyBackend.callable<[], VerInfo>('updater/check_for_updates');
DeckyBackend.addEventListener('frontend/finish_download', async () => { DeckyBackend.addEventListener('updater/finish_download', async () => {
await doRestart(); await doRestart();
}); });
+1 -23
View File
@@ -50,7 +50,6 @@ interface PromiseResolver<T> {
} }
export class WSRouter extends Logger { export class WSRouter extends Logger {
routes: Map<string, (...args: any) => any> = new Map();
runningCalls: Map<number, PromiseResolver<any>> = new Map(); runningCalls: Map<number, PromiseResolver<any>> = new Map();
eventListeners: Map<string, Set<(...args: any) => any>> = new Map(); eventListeners: Map<string, Set<(...args: any) => any>> = new Map();
ws?: WebSocket; ws?: WebSocket;
@@ -92,14 +91,6 @@ export class WSRouter extends Logger {
this.ws?.send(JSON.stringify(data)); 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) { addEventListener(event: string, listener: (...args: any) => any) {
if (!this.eventListeners.has(event)) { if (!this.eventListeners.has(event)) {
this.eventListeners.set(event, new Set([listener])); this.eventListeners.set(event, new Set([listener]));
@@ -123,20 +114,6 @@ export class WSRouter extends Logger {
try { try {
const data = JSON.parse(msg.data) as Message; const data = JSON.parse(msg.data) as Message;
switch (data.type) { 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: case MessageType.REPLY:
if (this.runningCalls.has(data.id)) { if (this.runningCalls.has(data.id)) {
this.runningCalls.get(data.id)!.resolve(data.result); this.runningCalls.get(data.id)!.resolve(data.result);
@@ -154,6 +131,7 @@ export class WSRouter extends Logger {
break; break;
case MessageType.EVENT: case MessageType.EVENT:
this.debug(`Recieved event ${data.event} with args`, data.args);
if (this.eventListeners.has(data.event)) { if (this.eventListeners.has(data.event)) {
for (const listener of this.eventListeners.get(data.event)!) { for (const listener of this.eventListeners.get(data.event)!) {
(async () => { (async () => {