Compare commits

...

9 Commits

Author SHA1 Message Date
TrainDoctor 6749c78ed7 During update, download updates first before removing old plugin files (#223)
* Remove old nightly support and unused logging

* Removed legacy code + added logic to account for offline update attempts

* Update backend/browser.py

Co-authored-by: AAGaming <aa@mail.catvibers.me>

* Update backend/browser.py

Co-authored-by: AAGaming <aa@mail.catvibers.me>

* Update frontend/src/toaster.tsx

Co-authored-by: AAGaming <aa@mail.catvibers.me>

* Use str instead of String (I was tired okay...)

* Remove false logic

* look for plugins not having remote_binary in pkg

Co-authored-by: AAGaming <aa@mail.catvibers.me>
2022-10-23 13:41:12 -07:00
TrainDoctor 4ad15568cd Merge drop-legacy to main. 2022-10-23 13:04:00 -07:00
TrainDoctor 58849b3002 Reduce amount of actions taken when using updateandrun 2022-10-22 19:11:00 -07:00
TrainDoctor 6346da6fe5 Actually utilize the unload function 2022-10-22 18:44:44 -07:00
TrainDoctor af51a29055 Added unload hook for plugins. 2022-10-22 18:36:49 -07:00
TrainDoctor c546a818f1 Send version when asking for plugin list 2022-10-22 16:52:48 -07:00
TrainDoctor 0226bd2bf8 Update build.yml 2022-10-22 13:02:23 -07:00
TrainDoctor 7b16b623c8 Fix toaster initialization 2022-10-16 15:52:21 -07:00
AAGaming 6e3c05072c Developer menu (#211)
* add settings utils to use settings outside of components

* initial implementation of developer menu

*  Add support for addScriptToEvaluateOnNewDocument

* React DevTools support

* increase chance of RDT successfully injecting

* Rewrite toaster hook to not re-create the window

* remove friends focus workaround because it's fixed

* Expose various DFL utilities as DFL in dev mode

* try to fix text field focuss

* move focusable to outside field

* add onTouchEnd and onClick to focusable

* Update pnpm-lock.yaml

Co-authored-by: FinalDoom <7464170-FinalDoom@users.noreply.gitlab.com>
Co-authored-by: TrainDoctor <traindoctor@protonmail.com>
2022-10-15 20:46:42 -07:00
20 changed files with 572 additions and 220 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
node-version: 18
- name: Set up Python 3.10 🐍
uses: actions/setup-python@v3
uses: actions/setup-python@v4
with:
python-version: "3.10"
+1 -1
View File
@@ -128,7 +128,7 @@
"isDefault": true
},
"dependsOn": [
"buildall",
"buildfrontend",
"deploy",
"runpydeck"
],
+13 -5
View File
@@ -57,7 +57,7 @@ class PluginBrowser:
if access(packageJsonPath, R_OK):
with open(packageJsonPath, 'r') as f:
packageJson = json.load(f)
if len(packageJson["remote_binary"]) > 0:
if "remote_binary" in packageJson and len(packageJson["remote_binary"]) > 0:
# create bin directory if needed.
rc=call(["chmod", "-R", "777", pluginBasePath])
if access(pluginBasePath, W_OK):
@@ -97,7 +97,7 @@ class PluginBrowser:
plugin = json.load(f)
if plugin['name'] == name:
return path.join(self.plugin_path, folder)
return str(path.join(self.plugin_path, folder))
except:
logger.debug(f"skipping {folder}")
@@ -124,12 +124,15 @@ class PluginBrowser:
self.loader.watcher.disabled = False
async def _install(self, artifact, name, version, hash):
isInstalled = False
if self.loader.watcher:
self.loader.watcher.disabled = True
try:
await self.uninstall_plugin(name)
pluginFolderPath = self.find_plugin_folder(name)
if pluginFolderPath:
isInstalled = True
except:
logger.error(f"Plugin {name} not installed, skipping uninstallation")
logger.error(f"Failed to determine if {name} is already installed, continuing anyway.")
logger.info(f"Installing {name} (Version: {version})")
async with ClientSession() as client:
logger.debug(f"Fetching {artifact}")
@@ -139,6 +142,12 @@ class PluginBrowser:
data = await res.read()
logger.debug(f"Read {len(data)} bytes")
res_zip = BytesIO(data)
if isInstalled:
try:
logger.debug("Uninstalling existing plugin...")
await self.uninstall_plugin(name)
except:
logger.error(f"Plugin {name} could not be uninstalled.")
logger.debug("Unzipping...")
ret = self._unzip_to_plugin_dir(res_zip, name, hash)
if ret:
@@ -151,7 +160,6 @@ class PluginBrowser:
self.loader.plugins.pop(name, None)
await sleep(1)
self.loader.import_plugin(path.join(plugin_dir, "main.py"), plugin_dir)
# await inject_to_tab("SP", "window.syncDeckyPlugins()")
else:
logger.fatal(f"Failed Downloading Remote Binaries")
else:
+188 -8
View File
@@ -1,7 +1,7 @@
#Injector code from https://github.com/SteamDeckHomebrew/steamdeck-ui-inject. More info on how it works there.
# Injector code from https://github.com/SteamDeckHomebrew/steamdeck-ui-inject. More info on how it works there.
from asyncio import sleep
from logging import debug, getLogger
from logging import getLogger
from traceback import format_exc
from aiohttp import ClientSession
@@ -11,7 +11,10 @@ BASE_ADDRESS = "http://localhost:8080"
logger = getLogger("Injector")
class Tab:
cmd_id = 0
def __init__(self, res) -> None:
self.title = res["title"]
self.id = res["id"]
@@ -24,14 +27,24 @@ class Tab:
self.client = ClientSession()
self.websocket = await self.client.ws_connect(self.ws_url)
async def close_websocket(self):
await self.client.close()
async def listen_for_message(self):
async for message in self.websocket:
yield message
data = message.json()
yield data
async def _send_devtools_cmd(self, dc, receive=True):
if self.websocket:
self.cmd_id += 1
dc["id"] = self.cmd_id
await self.websocket.send_json(dc)
return (await self.websocket.receive_json()) if receive else None
if receive:
async for msg in self.listen_for_message():
if "id" in msg and msg["id"] == dc["id"]:
return msg
return None
raise RuntimeError("Websocket not opened")
async def evaluate_js(self, js, run_async=False, manage_socket=True, get_result=True):
@@ -39,7 +52,6 @@ class Tab:
await self.open_websocket()
res = await self._send_devtools_cmd({
"id": 1,
"method": "Runtime.evaluate",
"params": {
"expression": js,
@@ -49,9 +61,172 @@ class Tab:
}, get_result)
if manage_socket:
await self.client.close()
await self.close_websocket()
return res
async def enable(self):
"""
Enables page domain notifications.
"""
await self._send_devtools_cmd({
"method": "Page.enable",
}, False)
async def disable(self):
"""
Disables page domain notifications.
"""
await self._send_devtools_cmd({
"method": "Page.disable",
}, False)
async def reload_and_evaluate(self, js, manage_socket=True):
"""
Reloads the current tab, with JS to run on load via debugger
"""
if manage_socket:
await self.open_websocket()
await self._send_devtools_cmd({
"method": "Debugger.enable"
}, True)
await self._send_devtools_cmd({
"method": "Runtime.evaluate",
"params": {
"expression": "location.reload();",
"userGesture": True,
"awaitPromise": False
}
}, False)
breakpoint_res = await self._send_devtools_cmd({
"method": "Debugger.setInstrumentationBreakpoint",
"params": {
"instrumentation": "beforeScriptExecution"
}
}, True)
logger.info(breakpoint_res)
# Page finishes loading when breakpoint hits
for x in range(20):
# this works around 1/5 of the time, so just send it 8 times.
# the js accounts for being injected multiple times allowing only one instance to run at a time anyway
await self._send_devtools_cmd({
"method": "Runtime.evaluate",
"params": {
"expression": js,
"userGesture": True,
"awaitPromise": False
}
}, False)
await self._send_devtools_cmd({
"method": "Debugger.removeBreakpoint",
"params": {
"breakpointId": breakpoint_res["result"]["breakpointId"]
}
}, False)
for x in range(4):
await self._send_devtools_cmd({
"method": "Debugger.resume"
}, False)
await self._send_devtools_cmd({
"method": "Debugger.disable"
}, True)
if manage_socket:
await self.close_websocket()
return
async def add_script_to_evaluate_on_new_document(self, js, add_dom_wrapper=True, manage_socket=True, get_result=True):
"""
How the underlying call functions is not particularly clear from the devtools docs, so stealing puppeteer's description:
Adds a function which would be invoked in one of the following scenarios:
* whenever the page is navigated
* whenever the child frame is attached or navigated. In this case, the
function is invoked in the context of the newly attached frame.
The function is invoked after the document was created but before any of
its scripts were run. This is useful to amend the JavaScript environment,
e.g. to seed `Math.random`.
Parameters
----------
js : str
The script to evaluate on new document
add_dom_wrapper : bool
True to wrap the script in a wait for the 'DOMContentLoaded' event.
DOM will usually not exist when this execution happens,
so it is necessary to delay til DOM is loaded if you are modifying it
manage_socket : bool
True to have this function handle opening/closing the websocket for this tab
get_result : bool
True to wait for the result of this call
Returns
-------
int or None
The identifier of the script added, used to remove it later.
(see remove_script_to_evaluate_on_new_document below)
None is returned if `get_result` is False
"""
wrappedjs = """
function scriptFunc() {
{js}
}
if (document.readyState === 'loading') {
addEventListener('DOMContentLoaded', () => {
scriptFunc();
});
} else {
scriptFunc();
}
""".format(js=js) if add_dom_wrapper else js
if manage_socket:
await self.open_websocket()
res = await self._send_devtools_cmd({
"method": "Page.addScriptToEvaluateOnNewDocument",
"params": {
"source": wrappedjs
}
}, get_result)
if manage_socket:
await self.close_websocket()
return res
async def remove_script_to_evaluate_on_new_document(self, script_id, manage_socket=True):
"""
Removes a script from a page that was added with `add_script_to_evaluate_on_new_document`
Parameters
----------
script_id : int
The identifier of the script to remove (returned from `add_script_to_evaluate_on_new_document`)
"""
if manage_socket:
await self.open_websocket()
res = await self._send_devtools_cmd({
"method": "Page.removeScriptToEvaluateOnNewDocument",
"params": {
"identifier": script_id
}
}, False)
if manage_socket:
await self.close_websocket()
async def get_steam_resource(self, url):
res = await self.evaluate_js(f'(async function test() {{ return await (await fetch("{url}")).text() }})()', True)
return res["result"]["result"]["value"]
@@ -59,6 +234,7 @@ class Tab:
def __repr__(self):
return self.title
async def get_tabs():
async with ClientSession() as web:
res = {}
@@ -80,6 +256,7 @@ async def get_tabs():
else:
raise Exception(f"/json did not return 200. {await res.text()}")
async def get_tab(tab_name):
tabs = await get_tabs()
tab = next((i for i in tabs if i.title == tab_name), None)
@@ -87,11 +264,13 @@ async def get_tab(tab_name):
raise ValueError(f"Tab {tab_name} not found")
return tab
async def inject_to_tab(tab_name, js, run_async=False):
tab = await get_tab(tab_name)
return await tab.evaluate_js(js, run_async)
async def tab_has_global_var(tab_name, var_name):
try:
tab = await get_tab(tab_name)
@@ -104,14 +283,15 @@ async def tab_has_global_var(tab_name, var_name):
return res["result"]["result"]["value"]
async def tab_has_element(tab_name, element_name):
try:
tab = await get_tab(tab_name)
except ValueError:
return False
res = await tab.evaluate_js(f"document.getElementById('{element_name}') != null", False)
if not "result" in res or not "result" in res["result"] or not "value" in res["result"]["result"]:
return False
return res["result"]["result"]["value"]
return res["result"]["result"]["value"]
+10
View File
@@ -71,6 +71,15 @@ class PluginWrapper:
self.log.error("Failed to start " + self.name + "!\n" + format_exc())
exit(0)
async def _unload(self):
try:
self.log.info("Attempting to unload " + self.name + "\n")
if hasattr(self.Plugin, "_unload"):
await self.Plugin._unload(self.Plugin)
except:
self.log.error("Failed to unload " + self.name + "!\n" + format_exc())
exit(0)
async def _setup_socket(self):
self.socket = await start_unix_server(self._listen_for_method_call, path=self.socket_addr, limit=BUFFER_LIMIT)
@@ -90,6 +99,7 @@ class PluginWrapper:
break
data = loads(line.decode("utf-8"))
if "stop" in data:
await self._unload()
get_event_loop().stop()
while get_event_loop().is_running():
await sleep(0)
-5
View File
@@ -104,14 +104,9 @@ class Updater:
elif selectedBranch == 1:
logger.debug("release type: pre-release")
self.remoteVer = next(filter(lambda ver: ver["prerelease"] and ver["tag_name"].startswith("v") and ver["tag_name"].find("-pre"), remoteVersions), None)
# elif selectedBranch == 2:
# logger.debug("release type: nightly")
# self.remoteVer = next(filter(lambda ver: ver["prerelease"] and ver["tag_name"].startswith("v") and ver["tag_name"].find("nightly"), remoteVersions), None)
else:
logger.error("release type: NOT FOUND")
raise ValueError("no valid branch found")
# doesn't make it to this line below or farther
# logger.debug("Remote Version: %s" % self.remoteVer.find("name"))
logger.info("Updated remote version information")
tab = await get_tab("SP")
await tab.evaluate_js(f"window.DeckyPluginLoader.notifyUpdates()", False, True, False)
+71 -2
View File
@@ -1,10 +1,13 @@
import uuid
import os
from json.decoder import JSONDecodeError
from traceback import format_exc
from asyncio import sleep, start_server, gather, open_connection
from aiohttp import ClientSession, web
from injector import inject_to_tab
from logging import getLogger
from injector import inject_to_tab, get_tab
import helpers
import subprocess
@@ -26,9 +29,17 @@ class Utilities:
"disallow_remote_debugging": self.disallow_remote_debugging,
"set_setting": self.set_setting,
"get_setting": self.get_setting,
"filepicker_ls": self.filepicker_ls
"filepicker_ls": self.filepicker_ls,
"disable_rdt": self.disable_rdt,
"enable_rdt": self.enable_rdt
}
self.logger = getLogger("Utilities")
self.rdt_proxy_server = None
self.rdt_script_id = None
self.rdt_proxy_task = None
if context:
context.web_app.add_routes([
web.post("/methods/{method_name}", self._handle_server_method_call)
@@ -194,3 +205,61 @@ class Utilities:
"realpath": os.path.realpath(path),
"files": files
}
# Based on https://stackoverflow.com/a/46422554/13174603
def start_rdt_proxy(self, ip, port):
async def pipe(reader, writer):
try:
while not reader.at_eof():
writer.write(await reader.read(2048))
finally:
writer.close()
async def handle_client(local_reader, local_writer):
try:
remote_reader, remote_writer = await open_connection(
ip, port)
pipe1 = pipe(local_reader, remote_writer)
pipe2 = pipe(remote_reader, local_writer)
await gather(pipe1, pipe2)
finally:
local_writer.close()
self.rdt_proxy_server = start_server(handle_client, "127.0.0.1", port)
self.rdt_proxy_task = self.context.loop.create_task(self.rdt_proxy_server)
def stop_rdt_proxy(self):
if self.rdt_proxy_server:
self.rdt_proxy_server.close()
self.rdt_proxy_task.cancel()
async def enable_rdt(self):
# TODO un-hardcode port
try:
self.stop_rdt_proxy()
ip = self.context.settings.getSetting("developer.rdt.ip", None)
if ip != None:
self.logger.info("Connecting to React DevTools at " + ip)
async with ClientSession() as web:
async with web.request("GET", "http://" + ip + ":8097", ssl=helpers.get_ssl_context()) as res:
if res.status != 200:
self.logger.error("Failed to connect to React DevTools at " + ip)
return False
self.start_rdt_proxy(ip, 8097)
script = "if(!window.deckyHasConnectedRDT){window.deckyHasConnectedRDT=true;\n" + await res.text() + "\n}"
self.logger.info("Connected to React DevTools, loading script")
tab = await get_tab("SP")
# RDT needs to load before React itself to work.
result = await tab.reload_and_evaluate(script)
self.logger.info(result)
except Exception:
self.logger.error("Failed to connect to React DevTools")
self.logger.error(format_exc())
async def disable_rdt(self):
self.logger.info("Disabling React DevTools")
tab = await get_tab("SP")
self.rdt_script_id = None
await tab.evaluate_js("location.reload();", False, True, False)
self.logger.info("React DevTools disabled")
+1 -1
View File
@@ -32,7 +32,7 @@ const Toast: FunctionComponent<ToastProps> = ({ toast }) => {
return (
<div
style={{ '--toast-duration': `${toast.nToastDurationMS}ms` } as React.CSSProperties}
className={joinClassNames(toastClasses.ToastPopup, toastClasses.toastEnter)}
className={toastClasses.toastEnter}
>
<div
onClick={toast.data.onClick}
+32 -18
View File
@@ -1,25 +1,39 @@
import { SidebarNavigation } from 'decky-frontend-lib';
import { lazy } from 'react';
import { useSetting } from '../../utils/hooks/useSetting';
import WithSuspense from '../WithSuspense';
import GeneralSettings from './pages/general';
import PluginList from './pages/plugin_list';
const DeveloperSettings = lazy(() => import('./pages/developer'));
export default function SettingsPage() {
return (
<SidebarNavigation
title="Decky Settings"
showTitle
pages={[
{
title: 'General',
content: <GeneralSettings />,
route: '/decky/settings/general',
},
{
title: 'Plugins',
content: <PluginList />,
route: '/decky/settings/plugins',
},
]}
/>
);
const [isDeveloper, setIsDeveloper] = useSetting<boolean>('developer.enabled', false);
const pages = [
{
title: 'General',
content: <GeneralSettings isDeveloper={isDeveloper} setIsDeveloper={setIsDeveloper} />,
route: '/decky/settings/general',
},
{
title: 'Plugins',
content: <PluginList />,
route: '/decky/settings/plugins',
},
];
if (isDeveloper)
pages.push({
title: 'Developer',
content: (
<WithSuspense>
<DeveloperSettings />
</WithSuspense>
),
route: '/decky/settings/developer',
});
return <SidebarNavigation title="Decky Settings" showTitle pages={pages} />;
}
@@ -0,0 +1,84 @@
import { Field, Focusable, TextField, Toggle } from 'decky-frontend-lib';
import { useRef } from 'react';
import { FaReact, FaSteamSymbol } from 'react-icons/fa';
import { setShouldConnectToReactDevTools, setShowValveInternal } from '../../../../developer';
import { useSetting } from '../../../../utils/hooks/useSetting';
export default function DeveloperSettings() {
const [enableValveInternal, setEnableValveInternal] = useSetting<boolean>('developer.valve_internal', false);
const [reactDevtoolsEnabled, setReactDevtoolsEnabled] = useSetting<boolean>('developer.rdt.enabled', false);
const [reactDevtoolsIP, setReactDevtoolsIP] = useSetting<string>('developer.rdt.ip', '');
const textRef = useRef<HTMLDivElement>(null);
return (
<>
<Field
label="Enable Valve Internal"
description={
<span style={{ whiteSpace: 'pre-line' }}>
Enables the Valve internal developer menu.{' '}
<span style={{ color: 'red' }}>Do not touch anything in this menu unless you know what it does.</span>
</span>
}
icon={<FaSteamSymbol style={{ display: 'block' }} />}
>
<Toggle
value={enableValveInternal}
onChange={(toggleValue) => {
setEnableValveInternal(toggleValue);
setShowValveInternal(toggleValue);
}}
/>
</Field>{' '}
<Focusable
onTouchEnd={
reactDevtoolsIP == ''
? () => {
(textRef.current?.childNodes[0] as HTMLInputElement)?.focus();
}
: undefined
}
onClick={
reactDevtoolsIP == ''
? () => {
(textRef.current?.childNodes[0] as HTMLInputElement)?.focus();
}
: undefined
}
onOKButton={
reactDevtoolsIP == ''
? () => {
(textRef.current?.childNodes[0] as HTMLInputElement)?.focus();
}
: undefined
}
>
<Field
label="Enable React DevTools"
description={
<>
<span style={{ whiteSpace: 'pre-line' }}>
Enables connection to a computer running React DevTools. Changing this setting will reload Steam. Set
the IP address before enabling.
</span>
<div ref={textRef}>
<TextField label={'IP'} value={reactDevtoolsIP} onChange={(e) => setReactDevtoolsIP(e?.target.value)} />
</div>
</>
}
icon={<FaReact style={{ display: 'block' }} />}
>
<Toggle
value={reactDevtoolsEnabled}
disabled={reactDevtoolsIP == ''}
onChange={(toggleValue) => {
setReactDevtoolsEnabled(toggleValue);
setShouldConnectToReactDevTools(toggleValue);
}}
/>
</Field>
</Focusable>
</>
);
}
@@ -1,29 +1,37 @@
import { DialogButton, Field, TextField } from 'decky-frontend-lib';
import { DialogButton, Field, TextField, Toggle } from 'decky-frontend-lib';
import { useState } from 'react';
import { FaShapes } from 'react-icons/fa';
import { FaShapes, FaTools } from 'react-icons/fa';
import { installFromURL } from '../../../../store';
import BranchSelect from './BranchSelect';
import RemoteDebuggingSettings from './RemoteDebugging';
import UpdaterSettings from './Updater';
export default function GeneralSettings() {
export default function GeneralSettings({
isDeveloper,
setIsDeveloper,
}: {
isDeveloper: boolean;
setIsDeveloper: (val: boolean) => void;
}) {
const [pluginURL, setPluginURL] = useState('');
// const [checked, setChecked] = useState(false); // store these in some kind of State instead
return (
<div>
{/* <Field
label="A Toggle with an icon"
icon={<FaShapes style={{ display: 'block' }} />}
>
<Toggle
value={checked}
onChange={(e) => setChecked(e)}
/>
</Field> */}
<UpdaterSettings />
<BranchSelect />
<RemoteDebuggingSettings />
<Field
label="Developer mode"
description={<span style={{ whiteSpace: 'pre-line' }}>Enables Decky's developer settings.</span>}
icon={<FaTools style={{ display: 'block' }} />}
>
<Toggle
value={isDeveloper}
onChange={(toggleValue) => {
setIsDeveloper(toggleValue);
}}
/>
</Field>
<Field
label="Manual plugin install"
description={<TextField label={'URL'} value={pluginURL} onChange={(e) => setPluginURL(e?.target.value)} />}
+14 -59
View File
@@ -11,17 +11,10 @@ import {
} from 'decky-frontend-lib';
import { FC, useRef, useState } from 'react';
import {
LegacyStorePlugin,
StorePlugin,
StorePluginVersion,
isLegacyPlugin,
requestLegacyPluginInstall,
requestPluginInstall,
} from '../../store';
import { StorePlugin, StorePluginVersion, requestPluginInstall } from '../../store';
interface PluginCardProps {
plugin: StorePlugin | LegacyStorePlugin;
plugin: StorePlugin;
}
const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
@@ -63,22 +56,13 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
}}
>
<div className="deckyStoreCardHeader" style={{ display: 'flex', alignItems: 'center' }}>
<a
<div
style={{ fontSize: '18pt', padding: '10px' }}
className={joinClassNames(staticClasses.Text)}
// onClick={() => Router.NavigateToExternalWeb('https://github.com/' + plugin.artifact)}
>
{isLegacyPlugin(plugin) ? (
<div className="deckyStoreCardNameContainer">
<span className="deckyStoreCardLegacyRepoOwner" style={{ color: 'grey' }}>
{plugin.artifact.split('/')[0]}/
</span>
{plugin.artifact.split('/')[1]}
</div>
) : (
plugin.name
)}
</a>
{plugin.name}
</div>
</div>
<div
style={{
@@ -94,17 +78,10 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
width: 'auto',
height: '160px',
}}
src={
isLegacyPlugin(plugin)
? `https://cdn.tzatzikiweeb.moe/file/steam-deck-homebrew/artifact_images/${plugin.artifact.replace(
'/',
'_',
)}.png`
: `https://cdn.tzatzikiweeb.moe/file/steam-deck-homebrew/artifact_images/${plugin.name.replace(
'/',
'_',
)}.png`
}
src={`https://cdn.tzatzikiweeb.moe/file/steam-deck-homebrew/artifact_images/${plugin.name.replace(
'/',
'_',
)}.png`}
/>
<div
style={{
@@ -152,19 +129,6 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
{tag == 'root' ? 'Requires root' : tag}
</span>
))}
{isLegacyPlugin(plugin) && (
<span
className="deckyStoreCardTag deckyStoreCardLegacyTag"
style={{
color: '#232120',
padding: '5px',
borderRadius: '5px',
background: '#EDE841',
}}
>
legacy
</span>
)}
</p>
</div>
</div>
@@ -194,11 +158,7 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
<DialogButton
className="deckyStoreCardInstallButton"
ref={buttonRef}
onClick={() =>
isLegacyPlugin(plugin)
? requestLegacyPluginInstall(plugin, Object.keys(plugin.versions)[selectedOption])
: requestPluginInstall(plugin.name, plugin.versions[selectedOption])
}
onClick={() => requestPluginInstall(plugin.name, plugin.versions[selectedOption])}
>
Install
</DialogButton>
@@ -211,15 +171,10 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
>
<Dropdown
rgOptions={
(isLegacyPlugin(plugin)
? Object.keys(plugin.versions).map((v, k) => ({
data: k,
label: v,
}))
: plugin.versions.map((version: StorePluginVersion, index) => ({
data: index,
label: version.name,
}))) as SingleDropdownOption[]
plugin.versions.map((version: StorePluginVersion, index) => ({
data: index,
label: version.name,
})) as SingleDropdownOption[]
}
strDefaultLabel={'Select a version'}
selectedOption={selectedOption}
+1 -12
View File
@@ -2,14 +2,13 @@ import { SteamSpinner } from 'decky-frontend-lib';
import { FC, useEffect, useState } from 'react';
import Logger from '../../logger';
import { LegacyStorePlugin, StorePlugin, getLegacyPluginList, getPluginList } from '../../store';
import { StorePlugin, getPluginList } from '../../store';
import PluginCard from './PluginCard';
const logger = new Logger('FilePicker');
const StorePage: FC<{}> = () => {
const [data, setData] = useState<StorePlugin[] | null>(null);
const [legacyData, setLegacyData] = useState<LegacyStorePlugin[] | null>(null);
useEffect(() => {
(async () => {
@@ -17,11 +16,6 @@ const StorePage: FC<{}> = () => {
logger.log('got data!', res);
setData(res);
})();
(async () => {
const res = await getLegacyPluginList();
logger.log('got legacy data!', res);
setLegacyData(res);
})();
}, []);
return (
@@ -49,11 +43,6 @@ const StorePage: FC<{}> = () => {
{data.map((plugin: StorePlugin) => (
<PluginCard plugin={plugin} />
))}
{!legacyData ? (
<SteamSpinner />
) : (
legacyData.map((plugin: LegacyStorePlugin) => <PluginCard plugin={plugin} />)
)}
</div>
)}
</div>
+88
View File
@@ -0,0 +1,88 @@
import {
ReactRouter,
Router,
findModule,
findModuleChild,
gamepadDialogClasses,
gamepadSliderClasses,
playSectionClasses,
quickAccessControlsClasses,
quickAccessMenuClasses,
scrollClasses,
scrollPanelClasses,
sleep,
staticClasses,
updaterFieldClasses,
} from 'decky-frontend-lib';
import { FaReact } from 'react-icons/fa';
import Logger from './logger';
import { getSetting } from './utils/settings';
const logger = new Logger('DeveloperMode');
let removeSettingsObserver: () => void = () => {};
export function setShowValveInternal(show: boolean) {
const settingsMod = findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (typeof m[prop]?.settings?.bIsValveEmail !== 'undefined') return m[prop];
}
});
if (show) {
removeSettingsObserver = settingsMod[
Object.getOwnPropertySymbols(settingsMod).find((x) => x.toString() == 'Symbol(mobx administration)') as any
].observe((e: any) => {
e.newValue.bIsValveEmail = true;
});
settingsMod.m_Settings.bIsValveEmail = true;
logger.log('Enabled Valve Internal menu');
} else {
removeSettingsObserver();
settingsMod.m_Settings.bIsValveEmail = false;
logger.log('Disabled Valve Internal menu');
}
}
export async function setShouldConnectToReactDevTools(enable: boolean) {
window.DeckyPluginLoader.toaster.toast({
title: (enable ? 'Enabling' : 'Disabling') + ' React DevTools',
body: 'Reloading in 5 seconds',
icon: <FaReact />,
});
await sleep(5000);
return enable
? window.DeckyPluginLoader.callServerMethod('enable_rdt')
: window.DeckyPluginLoader.callServerMethod('disable_rdt');
}
export async function startup() {
const isValveInternalEnabled = await getSetting('developer.valve_internal', false);
const isRDTEnabled = await getSetting('developer.rdt.enabled', false);
if (isValveInternalEnabled) setShowValveInternal(isValveInternalEnabled);
if ((isRDTEnabled && !window.deckyHasConnectedRDT) || (!isRDTEnabled && window.deckyHasConnectedRDT))
setShouldConnectToReactDevTools(isRDTEnabled);
logger.log('Exposing decky-frontend-lib APIs as DFL');
window.DFL = {
findModuleChild,
findModule,
Router,
ReactRouter,
classes: {
scrollClasses,
staticClasses,
playSectionClasses,
scrollPanelClasses,
updaterFieldClasses,
gamepadDialogClasses,
gamepadSliderClasses,
quickAccessMenuClasses,
quickAccessControlsClasses,
},
};
}
+3 -25
View File
@@ -1,6 +1,3 @@
import { ButtonItem, CommonUIModule, webpackCache } from 'decky-frontend-lib';
import { forwardRef } from 'react';
import PluginLoader from './plugin-loader';
import { DeckyUpdater } from './updater';
@@ -11,32 +8,12 @@ declare global {
importDeckyPlugin: Function;
syncDeckyPlugins: Function;
deckyHasLoaded: boolean;
deckyHasConnectedRDT?: boolean;
deckyAuthToken: string;
webpackJsonp: any;
DFL?: any;
}
}
// HACK to fix plugins using webpack v4 push
const v4Cache = {};
for (let m of Object.keys(webpackCache)) {
v4Cache[m] = { exports: webpackCache[m] };
}
if (!window.webpackJsonp || window.webpackJsonp.deckyShimmed) {
window.webpackJsonp = {
deckyShimmed: true,
push: (mod: any): any => {
if (mod[1].get_require) return { c: v4Cache };
},
};
CommonUIModule.__deckyButtonItemShim = forwardRef((props: any, ref: any) => {
// tricks the old filter into working
const dummy = `childrenContainerWidth:"min"`;
return <ButtonItem ref={ref} _shim={dummy} {...props} />;
});
}
(async () => {
window.deckyAuthToken = await fetch('http://127.0.0.1:1337/auth/token').then((r) => r.text());
@@ -44,6 +21,7 @@ if (!window.webpackJsonp || window.webpackJsonp.deckyShimmed) {
window.DeckyPluginLoader?.deinit();
window.DeckyPluginLoader = new PluginLoader();
window.DeckyPluginLoader.init();
window.importDeckyPlugin = function (name: string, version: string) {
window.DeckyPluginLoader?.importPlugin(name, version);
};
+8 -1
View File
@@ -25,6 +25,7 @@ import { checkForUpdates } from './store';
import TabsHook from './tabs-hook';
import Toaster from './toaster';
import { VerInfo, callUpdaterMethod } from './updater';
import { getSetting } from './utils/settings';
const StorePage = lazy(() => import('./components/store/Store'));
const SettingsPage = lazy(() => import('./components/settings'));
@@ -40,7 +41,7 @@ class PluginLoader extends Logger {
private tabsHook: TabsHook = new TabsHook();
// private windowHook: WindowHook = new WindowHook();
private routerHook: RouterHook = new RouterHook();
private toaster: Toaster = new Toaster();
public toaster: Toaster = new Toaster();
private deckyState: DeckyState = new DeckyState();
private reloadLock: boolean = false;
@@ -172,6 +173,12 @@ class PluginLoader extends Logger {
}
}
public init() {
getSetting('developer.enabled', false).then((val) => {
if (val) import('./developer').then((developer) => developer.startup());
});
}
public deinit() {
this.routerHook.removeRoute('/decky/store');
this.routerHook.removeRoute('/decky/settings');
+7 -49
View File
@@ -1,5 +1,3 @@
import { ConfirmModal, showModal, staticClasses } from 'decky-frontend-lib';
import { Plugin } from './plugin';
export interface StorePluginVersion {
@@ -14,30 +12,19 @@ export interface StorePlugin {
author: string;
description: string;
tags: string[];
}
export interface LegacyStorePlugin {
artifact: string;
versions: {
[version: string]: string;
};
author: string;
description: string;
tags: string[];
image_url: string;
}
// name: version
export type PluginUpdateMapping = Map<string, StorePluginVersion>;
export function getPluginList(): Promise<StorePlugin[]> {
return fetch('https://beta.deckbrew.xyz/plugins', {
method: 'GET',
}).then((r) => r.json());
}
export function getLegacyPluginList(): Promise<LegacyStorePlugin[]> {
return fetch('https://plugins.deckbrew.xyz/get_plugins', {
export async function getPluginList(): Promise<StorePlugin[]> {
let version = await window.DeckyPluginLoader.updateVersion();
return fetch('https://plugins.deckbrew.xyz/plugins', {
method: 'GET',
headers: {
'X-Decky-Version': version.current,
},
}).then((r) => r.json());
}
@@ -49,31 +36,6 @@ export async function installFromURL(url: string) {
});
}
export function requestLegacyPluginInstall(plugin: LegacyStorePlugin, selectedVer: string) {
showModal(
<ConfirmModal
onOK={() => {
window.DeckyPluginLoader.callServerMethod('install_plugin', {
name: plugin.artifact,
artifact: `https://github.com/${plugin.artifact}/archive/refs/tags/${selectedVer}.zip`,
version: selectedVer,
hash: plugin.versions[selectedVer],
});
}}
onCancel={() => {
// do nothing
}}
>
<div className={staticClasses.Title} style={{ flexDirection: 'column', boxShadow: 'unset' }}>
Using legacy plugins
</div>
You are currently installing a <b>legacy</b> plugin. Legacy plugins are no longer supported and may have issues.
Legacy plugins do not support gamepad input. To interact with a legacy plugin, you will need to use the
touchscreen.
</ConfirmModal>,
);
}
export async function requestPluginInstall(plugin: string, selectedVer: StorePluginVersion) {
await window.DeckyPluginLoader.callServerMethod('install_plugin', {
name: plugin,
@@ -94,7 +56,3 @@ export async function checkForUpdates(plugins: Plugin[]): Promise<PluginUpdateMa
}
return updateMap;
}
export function isLegacyPlugin(plugin: LegacyStorePlugin | StorePlugin): plugin is LegacyStorePlugin {
return 'artifact' in plugin;
}
-1
View File
@@ -69,7 +69,6 @@ class Toaster extends Logger {
}
return ret;
};
this.node.stateNode.forceUpdate();
this.settingsModule = findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
+5 -19
View File
@@ -1,25 +1,14 @@
import { useEffect, useState } from 'react';
interface GetSettingArgs<T> {
key: string;
default: T;
}
import { getSetting, setSetting } from '../settings';
interface SetSettingArgs<T> {
key: string;
value: T;
}
export function useSetting<T>(key: string, def: T): [value: T | null, setValue: (value: T) => Promise<void>] {
export function useSetting<T>(key: string, def: T): [value: T, setValue: (value: T) => Promise<void>] {
const [value, setValue] = useState(def);
useEffect(() => {
(async () => {
const res = (await window.DeckyPluginLoader.callServerMethod('get_setting', {
key,
default: def,
} as GetSettingArgs<T>)) as { result: T };
setValue(res.result);
const res = await getSetting<T>(key, def);
setValue(res);
})();
}, []);
@@ -27,10 +16,7 @@ export function useSetting<T>(key: string, def: T): [value: T | null, setValue:
value,
async (val: T) => {
setValue(val);
await window.DeckyPluginLoader.callServerMethod('set_setting', {
key,
value: val,
} as SetSettingArgs<T>);
await setSetting(key, val);
},
];
}
+24
View File
@@ -0,0 +1,24 @@
interface GetSettingArgs<T> {
key: string;
default: T;
}
interface SetSettingArgs<T> {
key: string;
value: T;
}
export async function getSetting<T>(key: string, def: T): Promise<T> {
const res = (await window.DeckyPluginLoader.callServerMethod('get_setting', {
key,
default: def,
} as GetSettingArgs<T>)) as { result: T };
return res.result;
}
export async function setSetting<T>(key: string, value: T): Promise<void> {
await window.DeckyPluginLoader.callServerMethod('set_setting', {
key,
value,
} as SetSettingArgs<T>);
}