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
# 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)
-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 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)
+12 -6
View File
@@ -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}")
@@ -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
@@ -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:
+1 -1
View File
@@ -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
+5 -1
View File
@@ -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"]
@@ -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
+1 -1
View File
@@ -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
+4 -6
View File
@@ -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()
-2
View File
@@ -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
+6 -4
View File
@@ -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..."
}
}
+5 -1
View File
@@ -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:
@@ -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('/');
@@ -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,
);
@@ -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);
});
@@ -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
style={{
display: 'flex',
minWidth: '150px',
justifyContent: 'space-between',
alignItems: 'center',
<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);
}}
>
{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>
<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',
flexDirection: 'column',
justifyContent: 'center',
}}
onClick={() => Navigation.NavigateToExternalWeb(version.link)}
>
<FaInfo />
</DialogButton>
</Focusable>
</Field>
</li>
);
})}
+2 -2
View File
@@ -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);
})();
}, []);
+202 -117
View File
@@ -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,
},
});
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();
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 />,
});
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 }
+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 {
name: string;
version?: string;
+1 -4
View File
@@ -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;
+4 -4
View File
@@ -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());
}
+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 checkForUpdates = DeckyBackend.callable<[], VerInfo>('updater/check_for_updates');
DeckyBackend.addEventListener('frontend/finish_download', async () => {
DeckyBackend.addEventListener('updater/finish_download', async () => {
await doRestart();
});
+1 -23
View File
@@ -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 () => {