mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-07-10 22:51:59 +00:00
Compare commits
54 Commits
v2.2.4-pre1
...
v2.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 478fe32527 | |||
| 50764600c8 | |||
| aec7063139 | |||
| c9ee98e0c0 | |||
| 093b064a4e | |||
| 2955681975 | |||
| de42639726 | |||
| 17742e947a | |||
| 898271b33d | |||
| b44896524f | |||
| db7bb236d8 | |||
| 5e3de747d3 | |||
| d389b403b5 | |||
| bace5143d2 | |||
| f5fc205384 | |||
| 4d30339c34 | |||
| 5996a3f88b | |||
| 1b635c74b1 | |||
| a9bd5079de | |||
| c1fabe5b35 | |||
| ed82f51bb7 | |||
| df1524e15f | |||
| 2edd910df3 | |||
| 1cd69097ad | |||
| 84c3b039c3 | |||
| 2e6b3834da | |||
| 6749c78ed7 | |||
| 4ad15568cd | |||
| 58849b3002 | |||
| 6346da6fe5 | |||
| af51a29055 | |||
| c546a818f1 | |||
| 0226bd2bf8 | |||
| 7b16b623c8 | |||
| 6e3c05072c | |||
| 9b405e4bdc | |||
| 8007dd4dac | |||
| 91d4e5dfc3 | |||
| c885ee600d | |||
| 739b57e100 | |||
| 87a7361dc7 | |||
| acdea6da44 | |||
| f23ea5b841 | |||
| d51cd4605c | |||
| 7d73c7aa79 | |||
| fd187a6710 | |||
| 43ef9e65ea | |||
| 9233ee58c6 | |||
| fd59456f8b | |||
| 9b241101dd | |||
| bebe9428a6 | |||
| 7445f066ed | |||
| 6e48aefce8 | |||
| 0bc0a0dadb |
+18
-16
@@ -42,29 +42,33 @@ jobs:
|
|||||||
- name: Checkout 🧰
|
- name: Checkout 🧰
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Set up NodeJS 17 💎
|
- name: Set up NodeJS 18 💎
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 17
|
node-version: 18
|
||||||
|
|
||||||
- name: Set up Python 3.10 🐍
|
- name: Set up Python 3.10.2 🐍
|
||||||
uses: actions/setup-python@v3
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
python-version: "3.10.2"
|
||||||
|
|
||||||
- name: Install Python dependencies ⬇️
|
- name: Install Python dependencies ⬇️
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install pyinstaller
|
pip install pyinstaller==5.5
|
||||||
[ -f requirements.txt ] && pip install -r requirements.txt
|
[ -f requirements.txt ] && pip install -r requirements.txt
|
||||||
|
|
||||||
- name: Install NodeJS dependencies ⬇️
|
- name: Install JS dependencies ⬇️
|
||||||
|
working-directory: ./frontend
|
||||||
run: |
|
run: |
|
||||||
cd frontend
|
npm i -g pnpm
|
||||||
npm i
|
pnpm i --frozen-lockfile
|
||||||
npm run build
|
|
||||||
|
|
||||||
- name: Build 🛠️
|
- name: Build JS Frontend 🛠️
|
||||||
|
working-directory: ./frontend
|
||||||
|
run: pnpm run build
|
||||||
|
|
||||||
|
- name: Build Python Backend 🛠️
|
||||||
run: pyinstaller --noconfirm --onefile --name "PluginLoader" --add-data ./backend/static:/static --add-data ./backend/legacy:/legacy ./backend/*.py
|
run: pyinstaller --noconfirm --onefile --name "PluginLoader" --add-data ./backend/static:/static --add-data ./backend/legacy:/legacy ./backend/*.py
|
||||||
|
|
||||||
- name: Upload package artifact ⬆️
|
- name: Upload package artifact ⬆️
|
||||||
@@ -142,7 +146,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
echo "vOUT: v$OUT"
|
echo "vOUT: v$OUT"
|
||||||
echo ::set-output name=tag_name::v$OUT
|
echo tag_name=v$OUT >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Push tag 📤
|
- name: Push tag 📤
|
||||||
uses: rickstaa/action-create-tag@v1.3.2
|
uses: rickstaa/action-create-tag@v1.3.2
|
||||||
@@ -224,14 +228,12 @@ jobs:
|
|||||||
else
|
else
|
||||||
printf "type not selected, defaulting to new pre-release only\n"
|
printf "type not selected, defaulting to new pre-release only\n"
|
||||||
printf "bumping prerel\n"
|
printf "bumping prerel\n"
|
||||||
OUT="$VERSION-pre"
|
OUT=$(semver bump prerel "$VERSION")
|
||||||
printf "OUT: ${OUT}\n"
|
|
||||||
OUT=$(semver bump prerel "$OUT")
|
|
||||||
printf "OUT: ${OUT}\n"
|
printf "OUT: ${OUT}\n"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
printf "vOUT: v${OUT}\n"
|
printf "vOUT: v${OUT}\n"
|
||||||
echo ::set-output name=tag_name::v$OUT
|
echo tag_name=v$OUT >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Push tag 📤
|
- name: Push tag 📤
|
||||||
uses: rickstaa/action-create-tag@v1.3.2
|
uses: rickstaa/action-create-tag@v1.3.2
|
||||||
|
|||||||
Vendored
+34
-7
@@ -14,7 +14,9 @@
|
|||||||
"label": "localrun",
|
"label": "localrun",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"group": "none",
|
"group": "none",
|
||||||
"dependsOn" : ["buildall"],
|
"dependsOn": [
|
||||||
|
"buildall"
|
||||||
|
],
|
||||||
"detail": "Check for local runs, create a plugins folder",
|
"detail": "Check for local runs, create a plugins folder",
|
||||||
"command": "mkdir -p plugins",
|
"command": "mkdir -p plugins",
|
||||||
"problemMatcher": []
|
"problemMatcher": []
|
||||||
@@ -48,6 +50,16 @@
|
|||||||
"command": "cd frontend && pnpm i",
|
"command": "cd frontend && pnpm i",
|
||||||
"problemMatcher": []
|
"problemMatcher": []
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"script": "watch",
|
||||||
|
"type": "npm",
|
||||||
|
"path": "frontend",
|
||||||
|
"group": "build",
|
||||||
|
"problemMatcher": [],
|
||||||
|
"label": "watchfrontend",
|
||||||
|
"detail": "rollup -c -w",
|
||||||
|
"isBackground": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "buildfrontend",
|
"label": "buildfrontend",
|
||||||
"type": "npm",
|
"type": "npm",
|
||||||
@@ -55,8 +67,7 @@
|
|||||||
"detail": "rollup -c",
|
"detail": "rollup -c",
|
||||||
"script": "build",
|
"script": "build",
|
||||||
"path": "frontend",
|
"path": "frontend",
|
||||||
"problemMatcher": [],
|
"problemMatcher": []
|
||||||
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "buildall",
|
"label": "buildall",
|
||||||
@@ -95,7 +106,9 @@
|
|||||||
"detail": "Run indev PluginLoader on Deck",
|
"detail": "Run indev PluginLoader on Deck",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"group": "none",
|
"group": "none",
|
||||||
"dependsOn" : ["checkforsettings"],
|
"dependsOn": [
|
||||||
|
"checkforsettings"
|
||||||
|
],
|
||||||
"command": "ssh deck@${config:deckip} -p ${config:deckport} ${config:deckkey} 'export PLUGIN_PATH=${config:deckdir}/homebrew/dev/plugins; export CHOWN_PLUGIN_PATH=0; export LOG_LEVEL=DEBUG; cd ${config:deckdir}/homebrew/services; echo '${config:deckpass}' | sudo -SE python3 ${config:deckdir}/homebrew/dev/pluginloader/backend/main.py'",
|
"command": "ssh deck@${config:deckip} -p ${config:deckport} ${config:deckkey} 'export PLUGIN_PATH=${config:deckdir}/homebrew/dev/plugins; export CHOWN_PLUGIN_PATH=0; export LOG_LEVEL=DEBUG; cd ${config:deckdir}/homebrew/services; echo '${config:deckpass}' | sudo -SE python3 ${config:deckdir}/homebrew/dev/pluginloader/backend/main.py'",
|
||||||
"problemMatcher": []
|
"problemMatcher": []
|
||||||
},
|
},
|
||||||
@@ -108,6 +121,20 @@
|
|||||||
"problemMatcher": []
|
"problemMatcher": []
|
||||||
},
|
},
|
||||||
// ALL-IN-ONES
|
// ALL-IN-ONES
|
||||||
|
{
|
||||||
|
"label": "deployandrun",
|
||||||
|
"detail": "Deploy and run, skipping JS build. Useful when combined with npm:watch",
|
||||||
|
"dependsOrder": "sequence",
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": true
|
||||||
|
},
|
||||||
|
"dependsOn": [
|
||||||
|
"deploy",
|
||||||
|
"runpydeck"
|
||||||
|
],
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "updateremote",
|
"label": "updateremote",
|
||||||
"detail": "Build and deploy",
|
"detail": "Build and deploy",
|
||||||
@@ -115,7 +142,7 @@
|
|||||||
"group": "none",
|
"group": "none",
|
||||||
"dependsOn": [
|
"dependsOn": [
|
||||||
"buildall",
|
"buildall",
|
||||||
"deploy",
|
"deploy"
|
||||||
],
|
],
|
||||||
"problemMatcher": []
|
"problemMatcher": []
|
||||||
},
|
},
|
||||||
@@ -128,7 +155,7 @@
|
|||||||
"isDefault": true
|
"isDefault": true
|
||||||
},
|
},
|
||||||
"dependsOn": [
|
"dependsOn": [
|
||||||
"buildall",
|
"buildfrontend",
|
||||||
"deploy",
|
"deploy",
|
||||||
"runpydeck"
|
"runpydeck"
|
||||||
],
|
],
|
||||||
@@ -152,4 +179,4 @@
|
|||||||
"problemMatcher": []
|
"problemMatcher": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
## 📖 About
|
## 📖 About
|
||||||
|
|
||||||
Decky Loader is a homebrew plugin launcher for the Steam Deck. It can be used to [stylize your menus](https://github.com/suchmememanyskill/SDH-CssLoader), [change system sounds](https://github.com/EMERALD0874/SDH-AudioLoader), [adjust your screen saturation](https://github.com/libvibrant/vibrantDeck), [change additional system settings](https://github.com/NGnius/PowerTools), and [more](https://beta.deckbrew.xyz/).
|
Decky Loader is a homebrew plugin launcher for the Steam Deck. It can be used to [stylize your menus](https://github.com/suchmememanyskill/SDH-CssLoader), [change system sounds](https://github.com/EMERALD0874/SDH-AudioLoader), [adjust your screen saturation](https://github.com/libvibrant/vibrantDeck), [change additional system settings](https://github.com/NGnius/PowerTools), and [more](https://plugins.deckbrew.xyz/).
|
||||||
|
|
||||||
For more information about Decky Loader as well as documentation and development tools, please visit [our wiki](https://deckbrew.xyz).
|
For more information about Decky Loader as well as documentation and development tools, please visit [our wiki](https://deckbrew.xyz).
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ For more information about Decky Loader as well as documentation and development
|
|||||||
|
|
||||||
- Crankshaft is incompatible with Decky Loader. If you are using Crankshaft, please uninstall it before installing Decky Loader.
|
- Crankshaft is incompatible with Decky Loader. If you are using Crankshaft, please uninstall it before installing Decky Loader.
|
||||||
- Syncthing may use port 8080 on Steam Deck, which Decky Loader needs to function. If you are using Syncthing as a service, please change its port to something else.
|
- Syncthing may use port 8080 on Steam Deck, which Decky Loader needs to function. If you are using Syncthing as a service, please change its port to something else.
|
||||||
- If you are using any software that uses port 1337, please change its port to something else or uninstall it.
|
- If you are using any software that uses port 1337 or 8080, please change its port to something else or uninstall it.
|
||||||
|
|
||||||
## 💾 Installation
|
## 💾 Installation
|
||||||
|
|
||||||
|
|||||||
+16
-8
@@ -16,7 +16,7 @@ from zipfile import ZipFile
|
|||||||
|
|
||||||
# Local modules
|
# Local modules
|
||||||
from helpers import get_ssl_context, get_user, get_user_group, download_remote_binary_to_path
|
from helpers import get_ssl_context, get_user, get_user_group, download_remote_binary_to_path
|
||||||
from injector import get_tab, inject_to_tab
|
from injector import get_gamepadui_tab
|
||||||
|
|
||||||
logger = getLogger("Browser")
|
logger = getLogger("Browser")
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ class PluginBrowser:
|
|||||||
if access(packageJsonPath, R_OK):
|
if access(packageJsonPath, R_OK):
|
||||||
with open(packageJsonPath, 'r') as f:
|
with open(packageJsonPath, 'r') as f:
|
||||||
packageJson = json.load(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.
|
# create bin directory if needed.
|
||||||
rc=call(["chmod", "-R", "777", pluginBasePath])
|
rc=call(["chmod", "-R", "777", pluginBasePath])
|
||||||
if access(pluginBasePath, W_OK):
|
if access(pluginBasePath, W_OK):
|
||||||
@@ -97,14 +97,14 @@ class PluginBrowser:
|
|||||||
plugin = json.load(f)
|
plugin = json.load(f)
|
||||||
|
|
||||||
if plugin['name'] == name:
|
if plugin['name'] == name:
|
||||||
return path.join(self.plugin_path, folder)
|
return str(path.join(self.plugin_path, folder))
|
||||||
except:
|
except:
|
||||||
logger.debug(f"skipping {folder}")
|
logger.debug(f"skipping {folder}")
|
||||||
|
|
||||||
async def uninstall_plugin(self, name):
|
async def uninstall_plugin(self, name):
|
||||||
if self.loader.watcher:
|
if self.loader.watcher:
|
||||||
self.loader.watcher.disabled = True
|
self.loader.watcher.disabled = True
|
||||||
tab = await get_tab("SP")
|
tab = await get_gamepadui_tab()
|
||||||
try:
|
try:
|
||||||
logger.info("uninstalling " + name)
|
logger.info("uninstalling " + name)
|
||||||
logger.info(" at dir " + self.find_plugin_folder(name))
|
logger.info(" at dir " + self.find_plugin_folder(name))
|
||||||
@@ -124,12 +124,15 @@ class PluginBrowser:
|
|||||||
self.loader.watcher.disabled = False
|
self.loader.watcher.disabled = False
|
||||||
|
|
||||||
async def _install(self, artifact, name, version, hash):
|
async def _install(self, artifact, name, version, hash):
|
||||||
|
isInstalled = False
|
||||||
if self.loader.watcher:
|
if self.loader.watcher:
|
||||||
self.loader.watcher.disabled = True
|
self.loader.watcher.disabled = True
|
||||||
try:
|
try:
|
||||||
await self.uninstall_plugin(name)
|
pluginFolderPath = self.find_plugin_folder(name)
|
||||||
|
if pluginFolderPath:
|
||||||
|
isInstalled = True
|
||||||
except:
|
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})")
|
logger.info(f"Installing {name} (Version: {version})")
|
||||||
async with ClientSession() as client:
|
async with ClientSession() as client:
|
||||||
logger.debug(f"Fetching {artifact}")
|
logger.debug(f"Fetching {artifact}")
|
||||||
@@ -139,6 +142,12 @@ class PluginBrowser:
|
|||||||
data = await res.read()
|
data = await res.read()
|
||||||
logger.debug(f"Read {len(data)} bytes")
|
logger.debug(f"Read {len(data)} bytes")
|
||||||
res_zip = BytesIO(data)
|
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...")
|
logger.debug("Unzipping...")
|
||||||
ret = self._unzip_to_plugin_dir(res_zip, name, hash)
|
ret = self._unzip_to_plugin_dir(res_zip, name, hash)
|
||||||
if ret:
|
if ret:
|
||||||
@@ -151,7 +160,6 @@ class PluginBrowser:
|
|||||||
self.loader.plugins.pop(name, None)
|
self.loader.plugins.pop(name, None)
|
||||||
await sleep(1)
|
await sleep(1)
|
||||||
self.loader.import_plugin(path.join(plugin_dir, "main.py"), plugin_dir)
|
self.loader.import_plugin(path.join(plugin_dir, "main.py"), plugin_dir)
|
||||||
# await inject_to_tab("SP", "window.syncDeckyPlugins()")
|
|
||||||
else:
|
else:
|
||||||
logger.fatal(f"Failed Downloading Remote Binaries")
|
logger.fatal(f"Failed Downloading Remote Binaries")
|
||||||
else:
|
else:
|
||||||
@@ -164,7 +172,7 @@ class PluginBrowser:
|
|||||||
async def request_plugin_install(self, artifact, name, version, hash):
|
async def request_plugin_install(self, artifact, name, version, hash):
|
||||||
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_tab("SP")
|
tab = await get_gamepadui_tab()
|
||||||
await tab.open_websocket()
|
await tab.open_websocket()
|
||||||
await tab.evaluate_js(f"DeckyPluginLoader.addPluginInstallPrompt('{name}', '{version}', '{request_id}', '{hash}')")
|
await tab.evaluate_js(f"DeckyPluginLoader.addPluginInstallPrompt('{name}', '{version}', '{request_id}', '{hash}')")
|
||||||
|
|
||||||
|
|||||||
+11
-11
@@ -94,18 +94,18 @@ async def download_remote_binary_to_path(url, binHash, path) -> bool:
|
|||||||
if os.access(os.path.dirname(path), os.W_OK):
|
if os.access(os.path.dirname(path), os.W_OK):
|
||||||
async with ClientSession() as client:
|
async with ClientSession() as client:
|
||||||
res = await client.get(url, ssl=get_ssl_context())
|
res = await client.get(url, ssl=get_ssl_context())
|
||||||
if res.status == 200:
|
if res.status == 200:
|
||||||
data = BytesIO(await res.read())
|
data = BytesIO(await res.read())
|
||||||
remoteHash = sha256(data.getbuffer()).hexdigest()
|
remoteHash = sha256(data.getbuffer()).hexdigest()
|
||||||
if binHash == remoteHash:
|
if binHash == remoteHash:
|
||||||
data.seek(0)
|
data.seek(0)
|
||||||
with open(path, 'wb') as f:
|
with open(path, 'wb') as f:
|
||||||
f.write(data.getbuffer())
|
f.write(data.getbuffer())
|
||||||
rv = True
|
rv = True
|
||||||
else:
|
|
||||||
raise Exception(f"Fatal Error: Hash Mismatch for remote binary {path}@{url}")
|
|
||||||
else:
|
else:
|
||||||
rv = False
|
raise Exception(f"Fatal Error: Hash Mismatch for remote binary {path}@{url}")
|
||||||
|
else:
|
||||||
|
rv = False
|
||||||
except:
|
except:
|
||||||
rv = False
|
rv = False
|
||||||
|
|
||||||
|
|||||||
+352
-62
@@ -1,20 +1,27 @@
|
|||||||
#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 asyncio import sleep
|
||||||
from logging import debug, getLogger
|
from logging import getLogger
|
||||||
from traceback import format_exc
|
from traceback import format_exc
|
||||||
|
from typing import List
|
||||||
|
|
||||||
from aiohttp import ClientSession
|
from aiohttp import ClientSession, WSMsgType
|
||||||
from aiohttp.client_exceptions import ClientConnectorError
|
from aiohttp.client_exceptions import ClientConnectorError, ClientOSError
|
||||||
|
from asyncio.exceptions import TimeoutError
|
||||||
|
import uuid
|
||||||
|
|
||||||
BASE_ADDRESS = "http://localhost:8080"
|
BASE_ADDRESS = "http://localhost:8080"
|
||||||
|
|
||||||
logger = getLogger("Injector")
|
logger = getLogger("Injector")
|
||||||
|
|
||||||
|
|
||||||
class Tab:
|
class Tab:
|
||||||
|
cmd_id = 0
|
||||||
|
|
||||||
def __init__(self, res) -> None:
|
def __init__(self, res) -> None:
|
||||||
self.title = res["title"]
|
self.title = res["title"]
|
||||||
self.id = res["id"]
|
self.id = res["id"]
|
||||||
|
self.url = res["url"]
|
||||||
self.ws_url = res["webSocketDebuggerUrl"]
|
self.ws_url = res["webSocketDebuggerUrl"]
|
||||||
|
|
||||||
self.websocket = None
|
self.websocket = None
|
||||||
@@ -24,34 +31,318 @@ class Tab:
|
|||||||
self.client = ClientSession()
|
self.client = ClientSession()
|
||||||
self.websocket = await self.client.ws_connect(self.ws_url)
|
self.websocket = await self.client.ws_connect(self.ws_url)
|
||||||
|
|
||||||
|
async def close_websocket(self):
|
||||||
|
await self.websocket.close()
|
||||||
|
await self.client.close()
|
||||||
|
|
||||||
async def listen_for_message(self):
|
async def listen_for_message(self):
|
||||||
async for message in self.websocket:
|
async for message in self.websocket:
|
||||||
yield message
|
data = message.json()
|
||||||
|
yield data
|
||||||
|
logger.warn(f"The Tab {self.title} socket has been disconnected while listening for messages.")
|
||||||
|
await self.close_websocket()
|
||||||
|
|
||||||
async def _send_devtools_cmd(self, dc, receive=True):
|
async def _send_devtools_cmd(self, dc, receive=True):
|
||||||
if self.websocket:
|
if self.websocket:
|
||||||
|
self.cmd_id += 1
|
||||||
|
dc["id"] = self.cmd_id
|
||||||
await self.websocket.send_json(dc)
|
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")
|
raise RuntimeError("Websocket not opened")
|
||||||
|
|
||||||
async def evaluate_js(self, js, run_async=False, manage_socket=True, get_result=True):
|
async def evaluate_js(self, js, run_async=False, manage_socket=True, get_result=True):
|
||||||
if manage_socket:
|
try:
|
||||||
await self.open_websocket()
|
if manage_socket:
|
||||||
|
await self.open_websocket()
|
||||||
|
|
||||||
res = await self._send_devtools_cmd({
|
res = await self._send_devtools_cmd({
|
||||||
"id": 1,
|
"method": "Runtime.evaluate",
|
||||||
"method": "Runtime.evaluate",
|
"params": {
|
||||||
"params": {
|
"expression": js,
|
||||||
"expression": js,
|
"userGesture": True,
|
||||||
"userGesture": True,
|
"awaitPromise": run_async
|
||||||
"awaitPromise": run_async
|
}
|
||||||
}
|
}, get_result)
|
||||||
}, get_result)
|
|
||||||
|
|
||||||
if manage_socket:
|
finally:
|
||||||
await self.client.close()
|
if manage_socket:
|
||||||
|
await self.close_websocket()
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
async def has_global_var(self, var_name, manage_socket=True):
|
||||||
|
res = await self.evaluate_js(f"window['{var_name}'] !== null && window['{var_name}'] !== undefined", False, manage_socket)
|
||||||
|
|
||||||
|
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"]
|
||||||
|
|
||||||
|
async def close(self, manage_socket=True):
|
||||||
|
try:
|
||||||
|
if manage_socket:
|
||||||
|
await self.open_websocket()
|
||||||
|
|
||||||
|
res = await self._send_devtools_cmd({
|
||||||
|
"method": "Page.close",
|
||||||
|
}, False)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if manage_socket:
|
||||||
|
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 refresh(self):
|
||||||
|
try:
|
||||||
|
if manage_socket:
|
||||||
|
await self.open_websocket()
|
||||||
|
|
||||||
|
await self._send_devtools_cmd({
|
||||||
|
"method": "Page.reload",
|
||||||
|
}, False)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if manage_socket:
|
||||||
|
await self.close_websocket()
|
||||||
|
|
||||||
|
return
|
||||||
|
async def reload_and_evaluate(self, js, manage_socket=True):
|
||||||
|
"""
|
||||||
|
Reloads the current tab, with JS to run on load via debugger
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
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)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
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`)
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
if manage_socket:
|
||||||
|
await self.open_websocket()
|
||||||
|
|
||||||
|
res = await self._send_devtools_cmd({
|
||||||
|
"method": "Page.removeScriptToEvaluateOnNewDocument",
|
||||||
|
"params": {
|
||||||
|
"identifier": script_id
|
||||||
|
}
|
||||||
|
}, False)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if manage_socket:
|
||||||
|
await self.close_websocket()
|
||||||
|
|
||||||
|
async def has_element(self, element_name, manage_socket=True):
|
||||||
|
res = await self.evaluate_js(f"document.getElementById('{element_name}') != null", False, manage_socket)
|
||||||
|
|
||||||
|
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"]
|
||||||
|
|
||||||
|
async def inject_css(self, style, manage_socket=True):
|
||||||
|
try:
|
||||||
|
css_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
result = await self.evaluate_js(
|
||||||
|
f"""
|
||||||
|
(function() {{
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = "{css_id}";
|
||||||
|
document.head.append(style);
|
||||||
|
style.textContent = `{style}`;
|
||||||
|
}})()
|
||||||
|
""", False, manage_socket)
|
||||||
|
|
||||||
|
if "exceptionDetails" in result["result"]:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"result": result["result"]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"result": css_id
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"result": e
|
||||||
|
}
|
||||||
|
|
||||||
|
async def remove_css(self, css_id, manage_socket=True):
|
||||||
|
try:
|
||||||
|
result = await self.evaluate_js(
|
||||||
|
f"""
|
||||||
|
(function() {{
|
||||||
|
let style = document.getElementById("{css_id}");
|
||||||
|
|
||||||
|
if (style.nodeName.toLowerCase() == 'style')
|
||||||
|
style.parentNode.removeChild(style);
|
||||||
|
}})()
|
||||||
|
""", False, manage_socket)
|
||||||
|
|
||||||
|
if "exceptionDetails" in result["result"]:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"result": result
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"result": e
|
||||||
|
}
|
||||||
|
|
||||||
async def get_steam_resource(self, url):
|
async def get_steam_resource(self, url):
|
||||||
res = await self.evaluate_js(f'(async function test() {{ return await (await fetch("{url}")).text() }})()', True)
|
res = await self.evaluate_js(f'(async function test() {{ return await (await fetch("{url}")).text() }})()', True)
|
||||||
return res["result"]["result"]["value"]
|
return res["result"]["result"]["value"]
|
||||||
@@ -59,59 +350,58 @@ class Tab:
|
|||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
async def get_tabs():
|
|
||||||
async with ClientSession() as web:
|
|
||||||
res = {}
|
|
||||||
|
|
||||||
while True:
|
async def get_tabs() -> List[Tab]:
|
||||||
try:
|
res = {}
|
||||||
res = await web.get(f"{BASE_ADDRESS}/json")
|
|
||||||
except ClientConnectorError:
|
na = False
|
||||||
logger.debug("ClientConnectorError excepted.")
|
while True:
|
||||||
|
try:
|
||||||
|
async with ClientSession() as web:
|
||||||
|
res = await web.get(f"{BASE_ADDRESS}/json", timeout=3)
|
||||||
|
except ClientConnectorError:
|
||||||
|
if not na:
|
||||||
logger.debug("Steam isn't available yet. Wait for a moment...")
|
logger.debug("Steam isn't available yet. Wait for a moment...")
|
||||||
logger.error(format_exc())
|
na = True
|
||||||
await sleep(5)
|
await sleep(5)
|
||||||
else:
|
except ClientOSError:
|
||||||
break
|
logger.warn(f"The request to {BASE_ADDRESS}/json was reset")
|
||||||
|
await sleep(1)
|
||||||
if res.status == 200:
|
except TimeoutError:
|
||||||
r = await res.json()
|
logger.warn(f"The request to {BASE_ADDRESS}/json timed out")
|
||||||
return [Tab(i) for i in r]
|
await sleep(1)
|
||||||
else:
|
else:
|
||||||
raise Exception(f"/json did not return 200. {await res.text()}")
|
break
|
||||||
|
|
||||||
async def get_tab(tab_name):
|
if res.status == 200:
|
||||||
|
r = await res.json()
|
||||||
|
return [Tab(i) for i in r]
|
||||||
|
else:
|
||||||
|
raise Exception(f"/json did not return 200. {await res.text()}")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tab(tab_name) -> Tab:
|
||||||
tabs = await get_tabs()
|
tabs = await get_tabs()
|
||||||
tab = next((i for i in tabs if i.title == tab_name), None)
|
tab = next((i for i in tabs if i.title == tab_name), None)
|
||||||
if not tab:
|
if not tab:
|
||||||
raise ValueError(f"Tab {tab_name} not found")
|
raise ValueError(f"Tab {tab_name} not found")
|
||||||
return tab
|
return tab
|
||||||
|
|
||||||
|
async def get_tab_lambda(test) -> Tab:
|
||||||
|
tabs = await get_tabs()
|
||||||
|
tab = next((i for i in tabs if test(i)), None)
|
||||||
|
if not tab:
|
||||||
|
raise ValueError(f"Tab not found by lambda")
|
||||||
|
return tab
|
||||||
|
|
||||||
|
async def get_gamepadui_tab() -> Tab:
|
||||||
|
tabs = await get_tabs()
|
||||||
|
tab = next((i for i in tabs if ("https://steamloopback.host/routes/" in i.url and (i.title == "Steam" or i.title == "SP"))), None)
|
||||||
|
if not tab:
|
||||||
|
raise ValueError(f"GamepadUI Tab not found")
|
||||||
|
return tab
|
||||||
|
|
||||||
async def inject_to_tab(tab_name, js, run_async=False):
|
async def inject_to_tab(tab_name, js, run_async=False):
|
||||||
tab = await get_tab(tab_name)
|
tab = await get_tab(tab_name)
|
||||||
|
|
||||||
return await tab.evaluate_js(js, run_async)
|
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)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
res = await tab.evaluate_js(f"window['{var_name}'] !== null && window['{var_name}'] !== undefined", 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"]
|
|
||||||
|
|
||||||
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"]
|
|
||||||
+8
-6
@@ -15,7 +15,7 @@ try:
|
|||||||
except UnsupportedLibc:
|
except UnsupportedLibc:
|
||||||
from watchdog.observers.fsevents import FSEventsObserver as Observer
|
from watchdog.observers.fsevents import FSEventsObserver as Observer
|
||||||
|
|
||||||
from injector import get_tab, inject_to_tab
|
from injector import get_tab, get_gamepadui_tab
|
||||||
from plugin import PluginWrapper
|
from plugin import PluginWrapper
|
||||||
|
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ class Loader:
|
|||||||
with open(path.join(self.plugin_path, plugin.plugin_directory, "dist/index.js"), 'r') as bundle:
|
with open(path.join(self.plugin_path, plugin.plugin_directory, "dist/index.js"), 'r') as bundle:
|
||||||
return web.Response(text=bundle.read(), content_type="application/javascript")
|
return web.Response(text=bundle.read(), content_type="application/javascript")
|
||||||
|
|
||||||
def import_plugin(self, file, plugin_directory, refresh=False):
|
def import_plugin(self, file, plugin_directory, refresh=False, batch=False):
|
||||||
try:
|
try:
|
||||||
plugin = PluginWrapper(file, plugin_directory, self.plugin_path)
|
plugin = PluginWrapper(file, plugin_directory, self.plugin_path)
|
||||||
if plugin.name in self.plugins:
|
if plugin.name in self.plugins:
|
||||||
@@ -135,13 +135,15 @@ class Loader:
|
|||||||
self.logger.info(f"Plugin {plugin.name} is passive")
|
self.logger.info(f"Plugin {plugin.name} is passive")
|
||||||
self.plugins[plugin.name] = plugin.start()
|
self.plugins[plugin.name] = plugin.start()
|
||||||
self.logger.info(f"Loaded {plugin.name}")
|
self.logger.info(f"Loaded {plugin.name}")
|
||||||
self.loop.create_task(self.dispatch_plugin(plugin.name if not plugin.legacy else "$LEGACY_" + plugin.name, plugin.version))
|
if not batch:
|
||||||
|
self.loop.create_task(self.dispatch_plugin(plugin.name if not plugin.legacy else "$LEGACY_" + plugin.name, plugin.version))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
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, version):
|
async def dispatch_plugin(self, name, version):
|
||||||
await inject_to_tab("SP", f"window.importDeckyPlugin('{name}', '{version}')")
|
gpui_tab = await get_gamepadui_tab()
|
||||||
|
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}")
|
||||||
@@ -149,7 +151,7 @@ class Loader:
|
|||||||
directories = [i for i in listdir(self.plugin_path) if path.isdir(path.join(self.plugin_path, i)) and path.isfile(path.join(self.plugin_path, i, "plugin.json"))]
|
directories = [i for i in listdir(self.plugin_path) if path.isdir(path.join(self.plugin_path, i)) and path.isfile(path.join(self.plugin_path, i, "plugin.json"))]
|
||||||
for directory in directories:
|
for directory in directories:
|
||||||
self.logger.info(f"found plugin: {directory}")
|
self.logger.info(f"found plugin: {directory}")
|
||||||
self.import_plugin(path.join(self.plugin_path, directory, "main.py"), directory)
|
self.import_plugin(path.join(self.plugin_path, directory, "main.py"), directory, False, True)
|
||||||
|
|
||||||
async def handle_reloads(self):
|
async def handle_reloads(self):
|
||||||
while True:
|
while True:
|
||||||
@@ -206,7 +208,7 @@ class Loader:
|
|||||||
return web.Response(text=ret)
|
return web.Response(text=ret)
|
||||||
|
|
||||||
async def get_steam_resource(self, request):
|
async def get_steam_resource(self, request):
|
||||||
tab = await get_tab("QuickAccess")
|
tab = await get_tab("SP")
|
||||||
try:
|
try:
|
||||||
return web.Response(text=await tab.get_steam_resource(f"https://steamloopback.host/{request.match_info['path']}"), content_type="text/html")
|
return web.Response(text=await tab.get_steam_resource(f"https://steamloopback.host/{request.match_info['path']}"), content_type="text/html")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+83
-33
@@ -4,15 +4,15 @@ from subprocess import call
|
|||||||
if hasattr(sys, '_MEIPASS'):
|
if hasattr(sys, '_MEIPASS'):
|
||||||
call(['chmod', '-R', '755', sys._MEIPASS])
|
call(['chmod', '-R', '755', sys._MEIPASS])
|
||||||
# Full imports
|
# Full imports
|
||||||
from asyncio import get_event_loop, sleep
|
from asyncio import new_event_loop, set_event_loop, sleep
|
||||||
from json import dumps, loads
|
from json import dumps, loads
|
||||||
from logging import DEBUG, INFO, basicConfig, getLogger
|
from logging import DEBUG, INFO, basicConfig, getLogger
|
||||||
from os import getenv, chmod
|
from os import getenv, chmod, path
|
||||||
from traceback import format_exc
|
from traceback import format_exc
|
||||||
|
|
||||||
import aiohttp_cors
|
import aiohttp_cors
|
||||||
# Partial imports
|
# Partial imports
|
||||||
from aiohttp import ClientSession
|
from aiohttp import client_exceptions, WSMsgType
|
||||||
from aiohttp.web import Application, Response, get, run_app, static
|
from aiohttp.web import Application, Response, get, run_app, static
|
||||||
from aiohttp_jinja2 import setup as jinja_setup
|
from aiohttp_jinja2 import setup as jinja_setup
|
||||||
|
|
||||||
@@ -21,8 +21,8 @@ from browser import PluginBrowser
|
|||||||
from helpers import (REMOTE_DEBUGGER_UNIT, csrf_middleware, get_csrf_token,
|
from helpers import (REMOTE_DEBUGGER_UNIT, csrf_middleware, get_csrf_token,
|
||||||
get_home_path, get_homebrew_path, get_user,
|
get_home_path, get_homebrew_path, get_user,
|
||||||
get_user_group, set_user, set_user_group,
|
get_user_group, set_user, set_user_group,
|
||||||
stop_systemd_unit)
|
stop_systemd_unit, start_systemd_unit)
|
||||||
from injector import inject_to_tab, tab_has_global_var
|
from injector import get_gamepadui_tab, Tab, get_tabs
|
||||||
from loader import Loader
|
from loader import Loader
|
||||||
from settings import SettingsManager
|
from settings import SettingsManager
|
||||||
from updater import Updater
|
from updater import Updater
|
||||||
@@ -56,15 +56,15 @@ basicConfig(
|
|||||||
|
|
||||||
logger = getLogger("Main")
|
logger = getLogger("Main")
|
||||||
|
|
||||||
async def chown_plugin_dir(_):
|
async def chown_plugin_dir():
|
||||||
code_chown = call(["chown", "-R", USER+":"+GROUP, CONFIG["plugin_path"]])
|
code_chown = call(["chown", "-R", USER+":"+GROUP, CONFIG["plugin_path"]])
|
||||||
code_chmod = call(["chmod", "-R", "555", CONFIG["plugin_path"]])
|
code_chmod = call(["chmod", "-R", "555", CONFIG["plugin_path"]])
|
||||||
if code_chown != 0 or code_chmod != 0:
|
if code_chown != 0 or code_chmod != 0:
|
||||||
logger.error(f"chown/chmod exited with a non-zero exit code (chown: {code_chown}, chmod: {code_chmod})")
|
logger.error(f"chown/chmod exited with a non-zero exit code (chown: {code_chown}, chmod: {code_chmod})")
|
||||||
|
|
||||||
class PluginManager:
|
class PluginManager:
|
||||||
def __init__(self) -> None:
|
def __init__(self, loop) -> None:
|
||||||
self.loop = get_event_loop()
|
self.loop = loop
|
||||||
self.web_app = Application()
|
self.web_app = Application()
|
||||||
self.web_app.middlewares.append(csrf_middleware)
|
self.web_app.middlewares.append(csrf_middleware)
|
||||||
self.cors = aiohttp_cors.setup(self.web_app, defaults={
|
self.cors = aiohttp_cors.setup(self.web_app, defaults={
|
||||||
@@ -81,12 +81,19 @@ class PluginManager:
|
|||||||
self.updater = Updater(self)
|
self.updater = Updater(self)
|
||||||
|
|
||||||
jinja_setup(self.web_app)
|
jinja_setup(self.web_app)
|
||||||
if CONFIG["chown_plugin_path"] == True:
|
|
||||||
self.web_app.on_startup.append(chown_plugin_dir)
|
async def startup(_):
|
||||||
self.loop.create_task(self.loader_reinjector())
|
if self.settings.getSetting("cef_forward", False):
|
||||||
self.loop.create_task(self.load_plugins())
|
self.loop.create_task(start_systemd_unit(REMOTE_DEBUGGER_UNIT))
|
||||||
if not self.settings.getSetting("cef_forward", False):
|
else:
|
||||||
self.loop.create_task(stop_systemd_unit(REMOTE_DEBUGGER_UNIT))
|
self.loop.create_task(stop_systemd_unit(REMOTE_DEBUGGER_UNIT))
|
||||||
|
if CONFIG["chown_plugin_path"] == True:
|
||||||
|
chown_plugin_dir()
|
||||||
|
self.loop.create_task(self.loader_reinjector())
|
||||||
|
self.loop.create_task(self.load_plugins())
|
||||||
|
|
||||||
|
self.web_app.on_startup.append(startup)
|
||||||
|
|
||||||
self.loop.set_exception_handler(self.exception_handler)
|
self.loop.set_exception_handler(self.exception_handler)
|
||||||
self.web_app.add_routes([get("/auth/token", self.get_auth_token)])
|
self.web_app.add_routes([get("/auth/token", self.get_auth_token)])
|
||||||
|
|
||||||
@@ -103,32 +110,73 @@ class PluginManager:
|
|||||||
async def get_auth_token(self, request):
|
async def get_auth_token(self, request):
|
||||||
return Response(text=get_csrf_token())
|
return Response(text=get_csrf_token())
|
||||||
|
|
||||||
async def wait_for_server(self):
|
|
||||||
async with ClientSession() as web:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await web.get(f"http://{CONFIG['server_host']}:{CONFIG['server_port']}")
|
|
||||||
return
|
|
||||||
except Exception as e:
|
|
||||||
await sleep(0.1)
|
|
||||||
|
|
||||||
async def load_plugins(self):
|
async def load_plugins(self):
|
||||||
await self.wait_for_server()
|
# await self.wait_for_server()
|
||||||
|
logger.debug("Loading plugins")
|
||||||
self.plugin_loader.import_plugins()
|
self.plugin_loader.import_plugins()
|
||||||
# await inject_to_tab("SP", "window.syncDeckyPlugins();")
|
# await inject_to_tab("SP", "window.syncDeckyPlugins();")
|
||||||
|
|
||||||
async def loader_reinjector(self):
|
async def loader_reinjector(self):
|
||||||
await sleep(2)
|
|
||||||
await self.inject_javascript()
|
|
||||||
while True:
|
while True:
|
||||||
await sleep(5)
|
tab = None
|
||||||
if not await tab_has_global_var("SP", "deckyHasLoaded"):
|
nf = False
|
||||||
logger.info("Plugin loader isn't present in Steam anymore, reinjecting...")
|
dc = False
|
||||||
await self.inject_javascript()
|
while not tab:
|
||||||
|
try:
|
||||||
|
tab = await get_gamepadui_tab()
|
||||||
|
except client_exceptions.ClientConnectorError or client_exceptions.ServerDisconnectedError:
|
||||||
|
if not dc:
|
||||||
|
logger.debug("Couldn't connect to debugger, waiting...")
|
||||||
|
dc = True
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
if not nf:
|
||||||
|
logger.debug("Couldn't find GamepadUI tab, waiting...")
|
||||||
|
nf = True
|
||||||
|
pass
|
||||||
|
if not tab:
|
||||||
|
await sleep(5)
|
||||||
|
await tab.open_websocket()
|
||||||
|
await tab.enable()
|
||||||
|
await self.inject_javascript(tab, True)
|
||||||
|
try:
|
||||||
|
async for msg in tab.listen_for_message():
|
||||||
|
# this gets spammed a lot
|
||||||
|
if msg.get("method", None) != "Page.navigatedWithinDocument":
|
||||||
|
logger.debug("Page event: " + str(msg.get("method", None)))
|
||||||
|
if msg.get("method", None) == "Page.domContentEventFired":
|
||||||
|
if not await tab.has_global_var("deckyHasLoaded", False):
|
||||||
|
await self.inject_javascript(tab)
|
||||||
|
if msg.get("method", None) == "Inspector.detached":
|
||||||
|
logger.info("CEF has requested that we detach.")
|
||||||
|
await tab.close_websocket()
|
||||||
|
break
|
||||||
|
# If this is a forceful disconnect the loop will just stop without any failure message. In this case, injector.py will handle this for us so we don't need to close the socket.
|
||||||
|
# This is because of https://github.com/aio-libs/aiohttp/blob/3ee7091b40a1bc58a8d7846e7878a77640e96996/aiohttp/client_ws.py#L321
|
||||||
|
logger.info("CEF has disconnected...")
|
||||||
|
# At this point the loop starts again and we connect to the freshly started Steam client once it is ready.
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Exception while reading page events " + format_exc())
|
||||||
|
await tab.close_websocket()
|
||||||
|
pass
|
||||||
|
# while True:
|
||||||
|
# await sleep(5)
|
||||||
|
# if not await tab.has_global_var("deckyHasLoaded", False):
|
||||||
|
# logger.info("Plugin loader isn't present in Steam anymore, reinjecting...")
|
||||||
|
# await self.inject_javascript(tab)
|
||||||
|
|
||||||
async def inject_javascript(self, request=None):
|
async def inject_javascript(self, tab: Tab, first=False, request=None):
|
||||||
|
logger.info("Loading Decky frontend!")
|
||||||
try:
|
try:
|
||||||
await inject_to_tab("SP", "try{if (window.deckyHasLoaded) location.reload();window.deckyHasLoaded = true;(async()=>{while(!window.SP_REACT){await new Promise(r => setTimeout(r, 10))};await import('http://localhost:1337/frontend/index.js')})();}catch(e){console.error(e)}", True)
|
# if first:
|
||||||
|
# if await tab.has_global_var("deckyHasLoaded", False):
|
||||||
|
# tabs = await get_tabs()
|
||||||
|
# for t in tabs:
|
||||||
|
# if t.title != "Steam" and t.title != "SP":
|
||||||
|
# logger.debug("Closing tab: " + getattr(t, "title", "Untitled"))
|
||||||
|
# await t.close()
|
||||||
|
# await sleep(0.5)
|
||||||
|
await tab.evaluate_js("try{if (window.deckyHasLoaded){setTimeout(() => SteamClient.User.StartRestart(), 100)}else{window.deckyHasLoaded = true;(async()=>{try{while(!window.SP_REACT){await new Promise(r => setTimeout(r, 10))};await import('http://localhost:1337/frontend/index.js')}catch(e){console.error(e)};})();}}catch(e){console.error(e)}", False, False, False)
|
||||||
except:
|
except:
|
||||||
logger.info("Failed to inject JavaScript into tab\n" + format_exc())
|
logger.info("Failed to inject JavaScript into tab\n" + format_exc())
|
||||||
pass
|
pass
|
||||||
@@ -137,4 +185,6 @@ class PluginManager:
|
|||||||
return run_app(self.web_app, host=CONFIG["server_host"], port=CONFIG["server_port"], loop=self.loop, access_log=None)
|
return run_app(self.web_app, host=CONFIG["server_host"], port=CONFIG["server_port"], loop=self.loop, access_log=None)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
PluginManager().run()
|
loop = new_event_loop()
|
||||||
|
set_event_loop(loop)
|
||||||
|
PluginManager(loop).run()
|
||||||
|
|||||||
@@ -71,6 +71,15 @@ class PluginWrapper:
|
|||||||
self.log.error("Failed to start " + self.name + "!\n" + format_exc())
|
self.log.error("Failed to start " + self.name + "!\n" + format_exc())
|
||||||
exit(0)
|
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):
|
async def _setup_socket(self):
|
||||||
self.socket = await start_unix_server(self._listen_for_method_call, path=self.socket_addr, limit=BUFFER_LIMIT)
|
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
|
break
|
||||||
data = loads(line.decode("utf-8"))
|
data = loads(line.decode("utf-8"))
|
||||||
if "stop" in data:
|
if "stop" in data:
|
||||||
|
await self._unload()
|
||||||
get_event_loop().stop()
|
get_event_loop().stop()
|
||||||
while get_event_loop().is_running():
|
while get_event_loop().is_running():
|
||||||
await sleep(0)
|
await sleep(0)
|
||||||
|
|||||||
+69
-28
@@ -1,3 +1,5 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
from asyncio import sleep
|
from asyncio import sleep
|
||||||
from ensurepip import version
|
from ensurepip import version
|
||||||
@@ -9,7 +11,7 @@ from subprocess import call
|
|||||||
from aiohttp import ClientSession, web
|
from aiohttp import ClientSession, web
|
||||||
|
|
||||||
import helpers
|
import helpers
|
||||||
from injector import get_tab, inject_to_tab
|
from injector import get_gamepadui_tab, inject_to_tab
|
||||||
from settings import SettingsManager
|
from settings import SettingsManager
|
||||||
|
|
||||||
logger = getLogger("Updater")
|
logger = getLogger("Updater")
|
||||||
@@ -79,6 +81,20 @@ class Updater:
|
|||||||
async def _get_branch(self, manager: SettingsManager):
|
async def _get_branch(self, manager: SettingsManager):
|
||||||
return self.get_branch(manager)
|
return self.get_branch(manager)
|
||||||
|
|
||||||
|
# retrieve relevant service file's url for each branch
|
||||||
|
def get_service_url(self):
|
||||||
|
logger.debug("Getting service URL")
|
||||||
|
branch = self.get_branch(self.context.settings)
|
||||||
|
match branch:
|
||||||
|
case 0:
|
||||||
|
url = "https://raw.githubusercontent.com/SteamDeckHomebrew/decky-loader/main/dist/plugin_loader-release.service"
|
||||||
|
case 1 | 2:
|
||||||
|
url = "https://raw.githubusercontent.com/SteamDeckHomebrew/decky-loader/main/dist/plugin_loader-prerelease.service"
|
||||||
|
case _:
|
||||||
|
logger.error("You have an invalid branch set... Defaulting to prerelease service, please send the logs to the devs!")
|
||||||
|
url = "https://raw.githubusercontent.com/SteamDeckHomebrew/decky-loader/main/dist/plugin_loader-prerelease.service"
|
||||||
|
return str(url)
|
||||||
|
|
||||||
async def get_version(self):
|
async def get_version(self):
|
||||||
if self.localVer:
|
if self.localVer:
|
||||||
return {
|
return {
|
||||||
@@ -96,25 +112,20 @@ class Updater:
|
|||||||
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", ssl=helpers.get_ssl_context()) as res:
|
||||||
remoteVersions = await res.json()
|
remoteVersions = await res.json()
|
||||||
self.allRemoteVers = remoteVersions
|
self.allRemoteVers = remoteVersions
|
||||||
logger.debug("determining release type to find, branch is %i" % selectedBranch)
|
logger.debug("determining release type to find, branch is %i" % selectedBranch)
|
||||||
if selectedBranch == 0:
|
if selectedBranch == 0:
|
||||||
logger.debug("release type: release")
|
logger.debug("release type: release")
|
||||||
self.remoteVer = next(filter(lambda ver: ver["tag_name"].startswith("v") and not ver["prerelease"] and ver["tag_name"], remoteVersions), None)
|
self.remoteVer = next(filter(lambda ver: ver["tag_name"].startswith("v") and not ver["prerelease"] and ver["tag_name"], remoteVersions), None)
|
||||||
elif selectedBranch == 1:
|
elif selectedBranch == 1:
|
||||||
logger.debug("release type: pre-release")
|
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)
|
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:
|
else:
|
||||||
# logger.debug("release type: nightly")
|
logger.error("release type: NOT FOUND")
|
||||||
# self.remoteVer = next(filter(lambda ver: ver["prerelease"] and ver["tag_name"].startswith("v") and ver["tag_name"].find("nightly"), remoteVersions), None)
|
raise ValueError("no valid branch found")
|
||||||
else:
|
logger.info("Updated remote version information")
|
||||||
logger.error("release type: NOT FOUND")
|
tab = await get_gamepadui_tab()
|
||||||
raise ValueError("no valid branch found")
|
await tab.evaluate_js(f"window.DeckyPluginLoader.notifyUpdates()", False, True, False)
|
||||||
# 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)
|
|
||||||
return await self.get_version()
|
return await self.get_version()
|
||||||
|
|
||||||
async def version_reloader(self):
|
async def version_reloader(self):
|
||||||
@@ -127,14 +138,44 @@ class Updater:
|
|||||||
await sleep(60 * 60 * 6) # 6 hours
|
await sleep(60 * 60 * 6) # 6 hours
|
||||||
|
|
||||||
async def do_update(self):
|
async def do_update(self):
|
||||||
|
logger.debug("Starting update.")
|
||||||
version = self.remoteVer["tag_name"]
|
version = self.remoteVer["tag_name"]
|
||||||
download_url = self.remoteVer["assets"][0]["browser_download_url"]
|
download_url = self.remoteVer["assets"][0]["browser_download_url"]
|
||||||
|
service_url = self.get_service_url()
|
||||||
|
logger.debug("Retrieved service URL")
|
||||||
|
|
||||||
tab = await get_tab("SP")
|
tab = await get_gamepadui_tab()
|
||||||
await tab.open_websocket()
|
await tab.open_websocket()
|
||||||
async with ClientSession() as web:
|
async with ClientSession() as web:
|
||||||
|
logger.debug("Downloading systemd service")
|
||||||
|
# download the relevant systemd service depending upon branch
|
||||||
|
async with web.request("GET", service_url, ssl=helpers.get_ssl_context(), allow_redirects=True) as res:
|
||||||
|
logger.debug("Downloading service file")
|
||||||
|
data = await res.content.read()
|
||||||
|
logger.debug(str(data))
|
||||||
|
service_file_path = path.join(getcwd(), "plugin_loader.service")
|
||||||
|
try:
|
||||||
|
with open(path.join(getcwd(), "plugin_loader.service"), "wb") as out:
|
||||||
|
out.write(data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error at %s", exc_info=e)
|
||||||
|
with open(path.join(getcwd(), "plugin_loader.service"), 'r') as service_file:
|
||||||
|
service_data = service_file.read()
|
||||||
|
service_data = service_data.replace("${HOMEBREW_FOLDER}", "/home/"+helpers.get_user()+"/homebrew")
|
||||||
|
with open(path.join(getcwd(), "plugin_loader.service"), 'w') as service_file:
|
||||||
|
service_file.write(service_data)
|
||||||
|
|
||||||
|
logger.debug("Saved service file")
|
||||||
|
logger.debug("Copying service file over current file.")
|
||||||
|
shutil.copy(service_file_path, "/etc/systemd/system/plugin_loader.service")
|
||||||
|
if not os.path.exists(path.join(getcwd(), ".systemd")):
|
||||||
|
os.mkdir(path.join(getcwd(), ".systemd"))
|
||||||
|
shutil.move(service_file_path, path.join(getcwd(), ".systemd")+"/plugin_loader.service")
|
||||||
|
|
||||||
|
logger.debug("Downloading binary")
|
||||||
async with web.request("GET", download_url, ssl=helpers.get_ssl_context(), allow_redirects=True) as res:
|
async with web.request("GET", download_url, ssl=helpers.get_ssl_context(), allow_redirects=True) as res:
|
||||||
total = int(res.headers.get('content-length', 0))
|
total = int(res.headers.get('content-length', 0))
|
||||||
|
# we need to not delete the binary until we have downloaded the new binary!
|
||||||
try:
|
try:
|
||||||
remove(path.join(getcwd(), "PluginLoader"))
|
remove(path.join(getcwd(), "PluginLoader"))
|
||||||
except:
|
except:
|
||||||
@@ -150,14 +191,14 @@ class Updater:
|
|||||||
self.context.loop.create_task(tab.evaluate_js(f"window.DeckyUpdater.updateProgress({new_progress})", False, False, False))
|
self.context.loop.create_task(tab.evaluate_js(f"window.DeckyUpdater.updateProgress({new_progress})", False, False, False))
|
||||||
progress = new_progress
|
progress = new_progress
|
||||||
|
|
||||||
with open(path.join(getcwd(), ".loader.version"), "w") as out:
|
with open(path.join(getcwd(), ".loader.version"), "w") as out:
|
||||||
out.write(version)
|
out.write(version)
|
||||||
|
|
||||||
call(['chmod', '+x', path.join(getcwd(), "PluginLoader")])
|
call(['chmod', '+x', path.join(getcwd(), "PluginLoader")])
|
||||||
|
logger.info("Updated loader installation.")
|
||||||
logger.info("Updated loader installation.")
|
await tab.evaluate_js("window.DeckyUpdater.finish()", False, False)
|
||||||
await tab.evaluate_js("window.DeckyUpdater.finish()", False, False)
|
await self.do_restart()
|
||||||
await tab.client.close()
|
await tab.close_websocket()
|
||||||
|
|
||||||
async def do_restart(self):
|
async def do_restart(self):
|
||||||
call(["systemctl", "daemon-reload"])
|
call(["systemctl", "daemon-reload"])
|
||||||
|
|||||||
+77
-8
@@ -1,10 +1,13 @@
|
|||||||
import uuid
|
import uuid
|
||||||
import os
|
import os
|
||||||
from json.decoder import JSONDecodeError
|
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 aiohttp import ClientSession, web
|
||||||
|
|
||||||
from injector import inject_to_tab
|
from logging import getLogger
|
||||||
|
from injector import inject_to_tab, get_gamepadui_tab
|
||||||
import helpers
|
import helpers
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
@@ -26,9 +29,17 @@ class Utilities:
|
|||||||
"disallow_remote_debugging": self.disallow_remote_debugging,
|
"disallow_remote_debugging": self.disallow_remote_debugging,
|
||||||
"set_setting": self.set_setting,
|
"set_setting": self.set_setting,
|
||||||
"get_setting": self.get_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:
|
if context:
|
||||||
context.web_app.add_routes([
|
context.web_app.add_routes([
|
||||||
web.post("/methods/{method_name}", self._handle_server_method_call)
|
web.post("/methods/{method_name}", self._handle_server_method_call)
|
||||||
@@ -69,12 +80,12 @@ class Utilities:
|
|||||||
|
|
||||||
async def http_request(self, method="", url="", **kwargs):
|
async def http_request(self, method="", url="", **kwargs):
|
||||||
async with ClientSession() as web:
|
async with ClientSession() as web:
|
||||||
async with web.request(method, url, ssl=helpers.get_ssl_context(), **kwargs) as res:
|
res = await web.request(method, url, ssl=helpers.get_ssl_context(), **kwargs)
|
||||||
return {
|
return {
|
||||||
"status": res.status,
|
"status": res.status,
|
||||||
"headers": dict(res.headers),
|
"headers": dict(res.headers),
|
||||||
"body": await res.text()
|
"body": await res.text()
|
||||||
}
|
}
|
||||||
|
|
||||||
async def ping(self, **kwargs):
|
async def ping(self, **kwargs):
|
||||||
return "pong"
|
return "pong"
|
||||||
@@ -194,3 +205,61 @@ class Utilities:
|
|||||||
"realpath": os.path.realpath(path),
|
"realpath": os.path.realpath(path),
|
||||||
"files": files
|
"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:
|
||||||
|
res = await web.request("GET", "http://" + ip + ":8097", ssl=helpers.get_ssl_context())
|
||||||
|
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_gamepadui_tab()
|
||||||
|
# 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_gamepadui_tab()
|
||||||
|
self.rdt_script_id = None
|
||||||
|
await tab.evaluate_js("SteamClient.User.StartRestart();", False, True, False)
|
||||||
|
self.logger.info("React DevTools disabled")
|
||||||
|
|||||||
Vendored
-50
@@ -1,50 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
[ "$UID" -eq 0 ] || exec sudo "$0" "$@"
|
|
||||||
|
|
||||||
echo "Installing Steam Deck Plugin Loader nightly..."
|
|
||||||
|
|
||||||
USER_DIR="$(getent passwd $SUDO_USER | cut -d: -f6)"
|
|
||||||
HOMEBREW_FOLDER="${USER_DIR}/homebrew"
|
|
||||||
|
|
||||||
# Create folder structure
|
|
||||||
rm -rf ${HOMEBREW_FOLDER}/services
|
|
||||||
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services"
|
|
||||||
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins"
|
|
||||||
|
|
||||||
# Download latest nightly build and install it
|
|
||||||
rm -rf /tmp/plugin_loader
|
|
||||||
mkdir -p /tmp/plugin_loader
|
|
||||||
curl -L https://nightly.link/SteamDeckHomebrew/PluginLoader/workflows/build/main/Plugin%20Loader.zip --output /tmp/plugin_loader/PluginLoader.zip
|
|
||||||
unzip /tmp/plugin_loader/PluginLoader.zip -d /tmp/plugin_loader
|
|
||||||
cp /tmp/plugin_loader/PluginLoader ${HOMEBREW_FOLDER}/services/PluginLoader
|
|
||||||
rm -rf /tmp/plugin_loader
|
|
||||||
chmod +x ${HOMEBREW_FOLDER}/services/PluginLoader
|
|
||||||
|
|
||||||
systemctl --user stop plugin_loader 2> /dev/null
|
|
||||||
systemctl --user disable plugin_loader 2> /dev/null
|
|
||||||
rm -f ${USER_DIR}/.config/systemd/user/plugin_loader.service
|
|
||||||
|
|
||||||
systemctl stop plugin_loader 2> /dev/null
|
|
||||||
systemctl disable plugin_loader 2> /dev/null
|
|
||||||
rm -f /etc/systemd/system/plugin_loader.service
|
|
||||||
|
|
||||||
cat > /etc/systemd/system/plugin_loader.service <<- EOM
|
|
||||||
[Unit]
|
|
||||||
Description=SteamDeck Plugin Loader
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=root
|
|
||||||
Restart=always
|
|
||||||
|
|
||||||
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
|
|
||||||
WorkingDirectory=${HOMEBREW_FOLDER}/services
|
|
||||||
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOM
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl start plugin_loader
|
|
||||||
systemctl enable plugin_loader
|
|
||||||
Vendored
+24
-4
@@ -7,8 +7,8 @@ echo "Installing Steam Deck Plugin Loader pre-release..."
|
|||||||
USER_DIR="$(getent passwd $SUDO_USER | cut -d: -f6)"
|
USER_DIR="$(getent passwd $SUDO_USER | cut -d: -f6)"
|
||||||
HOMEBREW_FOLDER="${USER_DIR}/homebrew"
|
HOMEBREW_FOLDER="${USER_DIR}/homebrew"
|
||||||
|
|
||||||
# # Create folder structure
|
# Create folder structure
|
||||||
rm -rf ${HOMEBREW_FOLDER}/services
|
rm -rf "${HOMEBREW_FOLDER}/services"
|
||||||
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services"
|
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services"
|
||||||
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins"
|
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins"
|
||||||
|
|
||||||
@@ -26,10 +26,14 @@ systemctl --user disable plugin_loader 2> /dev/null
|
|||||||
|
|
||||||
systemctl stop plugin_loader 2> /dev/null
|
systemctl stop plugin_loader 2> /dev/null
|
||||||
systemctl disable plugin_loader 2> /dev/null
|
systemctl disable plugin_loader 2> /dev/null
|
||||||
rm -f /etc/systemd/system/plugin_loader.service
|
|
||||||
cat > /etc/systemd/system/plugin_loader.service <<- EOM
|
curl -L https://raw.githubusercontent.com/SteamDeckHomebrew/decky-loader/main/dist/plugin_loader-prerelease.service --output ${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service
|
||||||
|
|
||||||
|
cat > "${HOMEBREW_FOLDER}/services/plugin_loader-backup.service" <<- EOM
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=SteamDeck Plugin Loader
|
Description=SteamDeck Plugin Loader
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=root
|
User=root
|
||||||
@@ -41,6 +45,22 @@ Environment=LOG_LEVEL=DEBUG
|
|||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOM
|
EOM
|
||||||
|
|
||||||
|
if [[ -f "${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service" ]]; then
|
||||||
|
printf "Grabbed latest prerelease service.\n"
|
||||||
|
sed -i -e "s|\${HOMEBREW_FOLDER}|${HOMEBREW_FOLDER}|" "${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service"
|
||||||
|
cp -f "${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service" "/etc/systemd/system/plugin_loader.service"
|
||||||
|
else
|
||||||
|
printf "Could not curl latest prerelease systemd service, using built-in service as a backup!\n"
|
||||||
|
rm -f "/etc/systemd/system/plugin_loader.service"
|
||||||
|
cp "${HOMEBREW_FOLDER}/services/plugin_loader-backup.service" "/etc/systemd/system/plugin_loader.service"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p ${HOMEBREW_FOLDER}/services/.systemd
|
||||||
|
cp ${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service ${HOMEBREW_FOLDER}/services/.systemd/plugin_loader-prerelease.service
|
||||||
|
cp ${HOMEBREW_FOLDER}/services/plugin_loader-backup.service ${HOMEBREW_FOLDER}/services/.systemd/plugin_loader-backup.service
|
||||||
|
rm ${HOMEBREW_FOLDER}/services/plugin_loader-backup.service ${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service
|
||||||
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl start plugin_loader
|
systemctl start plugin_loader
|
||||||
systemctl enable plugin_loader
|
systemctl enable plugin_loader
|
||||||
|
|||||||
Vendored
+23
-2
@@ -26,10 +26,14 @@ systemctl --user disable plugin_loader 2> /dev/null
|
|||||||
|
|
||||||
systemctl stop plugin_loader 2> /dev/null
|
systemctl stop plugin_loader 2> /dev/null
|
||||||
systemctl disable plugin_loader 2> /dev/null
|
systemctl disable plugin_loader 2> /dev/null
|
||||||
rm -f "/etc/systemd/system/plugin_loader.service"
|
|
||||||
cat > "/etc/systemd/system/plugin_loader.service" <<- EOM
|
curl -L https://raw.githubusercontent.com/SteamDeckHomebrew/decky-loader/main/dist/plugin_loader-release.service --output ${HOMEBREW_FOLDER}/services/plugin_loader-release.service
|
||||||
|
|
||||||
|
cat > "${HOMEBREW_FOLDER}/services/plugin_loader-backup.service" <<- EOM
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=SteamDeck Plugin Loader
|
Description=SteamDeck Plugin Loader
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=root
|
User=root
|
||||||
@@ -37,9 +41,26 @@ Restart=always
|
|||||||
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
|
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
|
||||||
WorkingDirectory=${HOMEBREW_FOLDER}/services
|
WorkingDirectory=${HOMEBREW_FOLDER}/services
|
||||||
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
|
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
|
||||||
|
Environment=LOG_LEVEL=INFO
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOM
|
EOM
|
||||||
|
|
||||||
|
if [[ -f "${HOMEBREW_FOLDER}/services/plugin_loader-release.service" ]]; then
|
||||||
|
printf "Grabbed latest release service.\n"
|
||||||
|
sed -i -e "s|\${HOMEBREW_FOLDER}|${HOMEBREW_FOLDER}|" "${HOMEBREW_FOLDER}/services/plugin_loader-release.service"
|
||||||
|
cp -f "${HOMEBREW_FOLDER}/services/plugin_loader-release.service" "/etc/systemd/system/plugin_loader.service"
|
||||||
|
else
|
||||||
|
printf "Could not curl latest release systemd service, using built-in service as a backup!\n"
|
||||||
|
rm -f "/etc/systemd/system/plugin_loader.service"
|
||||||
|
cp "${HOMEBREW_FOLDER}/services/plugin_loader-backup.service" "/etc/systemd/system/plugin_loader.service"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p ${HOMEBREW_FOLDER}/services/.systemd
|
||||||
|
cp ${HOMEBREW_FOLDER}/services/plugin_loader-release.service ${HOMEBREW_FOLDER}/services/.systemd/plugin_loader-release.service
|
||||||
|
cp ${HOMEBREW_FOLDER}/services/plugin_loader-backup.service ${HOMEBREW_FOLDER}/services/.systemd/plugin_loader-backup.service
|
||||||
|
rm ${HOMEBREW_FOLDER}/services/plugin_loader-backup.service ${HOMEBREW_FOLDER}/services/plugin_loader-release.service
|
||||||
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl start plugin_loader
|
systemctl start plugin_loader
|
||||||
systemctl enable plugin_loader
|
systemctl enable plugin_loader
|
||||||
|
|||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=SteamDeck Plugin Loader
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
Restart=always
|
||||||
|
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
|
||||||
|
WorkingDirectory=${HOMEBREW_FOLDER}/services
|
||||||
|
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
|
||||||
|
Environment=LOG_LEVEL=DEBUG
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=SteamDeck Plugin Loader
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
Restart=always
|
||||||
|
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
|
||||||
|
WorkingDirectory=${HOMEBREW_FOLDER}/services
|
||||||
|
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
|
||||||
|
Environment=LOG_LEVEL=INFO
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"decky-frontend-lib": "^3.1.3",
|
"decky-frontend-lib": "^3.7.14",
|
||||||
"react-file-icon": "^1.2.0",
|
"react-file-icon": "^1.2.0",
|
||||||
"react-icons": "^4.4.0",
|
"react-icons": "^4.4.0",
|
||||||
"react-markdown": "^8.0.3",
|
"react-markdown": "^8.0.3",
|
||||||
|
|||||||
Generated
+4
-10
@@ -10,7 +10,7 @@ specifiers:
|
|||||||
'@types/react-file-icon': ^1.0.1
|
'@types/react-file-icon': ^1.0.1
|
||||||
'@types/react-router': 5.1.18
|
'@types/react-router': 5.1.18
|
||||||
'@types/webpack': ^5.28.0
|
'@types/webpack': ^5.28.0
|
||||||
decky-frontend-lib: ^3.1.3
|
decky-frontend-lib: ^3.7.14
|
||||||
husky: ^8.0.1
|
husky: ^8.0.1
|
||||||
import-sort-style-module: ^6.0.0
|
import-sort-style-module: ^6.0.0
|
||||||
inquirer: ^8.2.4
|
inquirer: ^8.2.4
|
||||||
@@ -30,7 +30,7 @@ specifiers:
|
|||||||
typescript: ^4.7.4
|
typescript: ^4.7.4
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
decky-frontend-lib: 3.1.3
|
decky-frontend-lib: 3.7.14
|
||||||
react-file-icon: 1.2.0_wcqkhtmu7mswc6yz4uyexck3ty
|
react-file-icon: 1.2.0_wcqkhtmu7mswc6yz4uyexck3ty
|
||||||
react-icons: 4.4.0_react@16.14.0
|
react-icons: 4.4.0_react@16.14.0
|
||||||
react-markdown: 8.0.3_vshvapmxg47tngu7tvrsqpq55u
|
react-markdown: 8.0.3_vshvapmxg47tngu7tvrsqpq55u
|
||||||
@@ -944,10 +944,8 @@ packages:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.2
|
ms: 2.1.2
|
||||||
|
|
||||||
/decky-frontend-lib/3.1.3:
|
/decky-frontend-lib/3.7.14:
|
||||||
resolution: {integrity: sha512-X6DGi90VdXnyoQi8Q4jEYhvBiNQualMNwXWKwgFitdor2ktNj5xp3a4uIi1ijbnS/vdW2AGKjraTfOMXtHUNAg==}
|
resolution: {integrity: sha512-ShAoP3VqiwkJYukDBHsOF9fk7wYw0VaKpHw6j9WdzLxwZwBcg0J7QBNIFYP3nfA0UgEwSJVEg/22kONiplipmA==}
|
||||||
dependencies:
|
|
||||||
minimist: 1.2.6
|
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/decode-named-character-reference/1.0.2:
|
/decode-named-character-reference/1.0.2:
|
||||||
@@ -1944,10 +1942,6 @@ packages:
|
|||||||
brace-expansion: 1.1.11
|
brace-expansion: 1.1.11
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/minimist/1.2.6:
|
|
||||||
resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/mri/1.2.0:
|
/mri/1.2.0:
|
||||||
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import externalGlobals from "rollup-plugin-external-globals";
|
|||||||
import del from 'rollup-plugin-delete'
|
import del from 'rollup-plugin-delete'
|
||||||
import replace from '@rollup/plugin-replace';
|
import replace from '@rollup/plugin-replace';
|
||||||
import typescript from '@rollup/plugin-typescript';
|
import typescript from '@rollup/plugin-typescript';
|
||||||
import { defineConfig } from 'rollup';
|
import { defineConfig, handleWarning } from 'rollup';
|
||||||
|
|
||||||
|
const hiddenWarnings = [
|
||||||
|
"THIS_IS_UNDEFINED",
|
||||||
|
"EVAL"
|
||||||
|
];
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
input: 'src/index.tsx',
|
input: 'src/index.tsx',
|
||||||
@@ -35,5 +40,9 @@ export default defineConfig({
|
|||||||
chunkFileNames: (chunkInfo) => {
|
chunkFileNames: (chunkInfo) => {
|
||||||
return 'chunk-[hash].js'
|
return 'chunk-[hash].js'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
onwarn: function ( message ) {
|
||||||
|
if (hiddenWarnings.some(warning => message.code === warning)) return;
|
||||||
|
handleWarning(message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { FC, createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface PublicDeckyGlobalComponentsState {
|
||||||
|
components: Map<string, FC>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeckyGlobalComponentsState {
|
||||||
|
// TODO a set would be better
|
||||||
|
private _components = new Map<string, FC>();
|
||||||
|
|
||||||
|
public eventBus = new EventTarget();
|
||||||
|
|
||||||
|
publicState(): PublicDeckyGlobalComponentsState {
|
||||||
|
return { components: this._components };
|
||||||
|
}
|
||||||
|
|
||||||
|
addComponent(path: string, component: FC) {
|
||||||
|
this._components.set(path, component);
|
||||||
|
this.notifyUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeComponent(path: string) {
|
||||||
|
this._components.delete(path);
|
||||||
|
this.notifyUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyUpdate() {
|
||||||
|
this.eventBus.dispatchEvent(new Event('update'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeckyGlobalComponentsContext extends PublicDeckyGlobalComponentsState {
|
||||||
|
addComponent(path: string, component: FC): void;
|
||||||
|
removeComponent(path: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeckyGlobalComponentsContext = createContext<DeckyGlobalComponentsContext>(null as any);
|
||||||
|
|
||||||
|
export const useDeckyGlobalComponentsState = () => useContext(DeckyGlobalComponentsContext);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
deckyGlobalComponentsState: DeckyGlobalComponentsState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeckyGlobalComponentsStateContextProvider: FC<Props> = ({
|
||||||
|
children,
|
||||||
|
deckyGlobalComponentsState: deckyGlobalComponentsState,
|
||||||
|
}) => {
|
||||||
|
const [publicDeckyGlobalComponentsState, setPublicDeckyGlobalComponentsState] =
|
||||||
|
useState<PublicDeckyGlobalComponentsState>({
|
||||||
|
...deckyGlobalComponentsState.publicState(),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onUpdate() {
|
||||||
|
setPublicDeckyGlobalComponentsState({ ...deckyGlobalComponentsState.publicState() });
|
||||||
|
}
|
||||||
|
|
||||||
|
deckyGlobalComponentsState.eventBus.addEventListener('update', onUpdate);
|
||||||
|
|
||||||
|
return () => deckyGlobalComponentsState.eventBus.removeEventListener('update', onUpdate);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addComponent = deckyGlobalComponentsState.addComponent.bind(deckyGlobalComponentsState);
|
||||||
|
const removeComponent = deckyGlobalComponentsState.removeComponent.bind(deckyGlobalComponentsState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DeckyGlobalComponentsContext.Provider
|
||||||
|
value={{ ...publicDeckyGlobalComponentsState, addComponent, removeComponent }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DeckyGlobalComponentsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { ToastData, joinClassNames } from 'decky-frontend-lib';
|
||||||
|
import { FC, useEffect, useState } from 'react';
|
||||||
|
import { ReactElement } from 'react-markdown/lib/react-markdown';
|
||||||
|
|
||||||
|
import { useDeckyToasterState } from './DeckyToasterState';
|
||||||
|
import Toast, { toastClasses } from './Toast';
|
||||||
|
|
||||||
|
interface DeckyToasterProps {}
|
||||||
|
|
||||||
|
interface RenderedToast {
|
||||||
|
component: ReactElement;
|
||||||
|
data: ToastData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeckyToaster: FC<DeckyToasterProps> = () => {
|
||||||
|
const { toasts, removeToast } = useDeckyToasterState();
|
||||||
|
const [renderedToast, setRenderedToast] = useState<RenderedToast | null>(null);
|
||||||
|
console.log(toasts);
|
||||||
|
if (toasts.size > 0) {
|
||||||
|
const [activeToast] = toasts;
|
||||||
|
if (!renderedToast || activeToast != renderedToast.data) {
|
||||||
|
// TODO play toast sound
|
||||||
|
console.log('rendering toast', activeToast);
|
||||||
|
setRenderedToast({ component: <Toast key={Math.random()} toast={activeToast} />, data: activeToast });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (renderedToast) setRenderedToast(null);
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
// not actually node but TS is shit
|
||||||
|
let interval: NodeJS.Timer | null;
|
||||||
|
if (renderedToast) {
|
||||||
|
interval = setTimeout(() => {
|
||||||
|
interval = null;
|
||||||
|
console.log('clear toast', renderedToast.data);
|
||||||
|
removeToast(renderedToast.data);
|
||||||
|
}, (renderedToast.data.duration || 5e3) + 1000);
|
||||||
|
console.log('set int', interval);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (interval) {
|
||||||
|
console.log('clearing int', interval);
|
||||||
|
clearTimeout(interval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [renderedToast]);
|
||||||
|
return (
|
||||||
|
<div className={joinClassNames('deckyToaster', toastClasses.ToastPlaceholder)}>
|
||||||
|
{renderedToast && renderedToast.component}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeckyToaster;
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { ToastData } from 'decky-frontend-lib';
|
||||||
|
import { FC, createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface PublicDeckyToasterState {
|
||||||
|
toasts: Set<ToastData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeckyToasterState {
|
||||||
|
// TODO a set would be better
|
||||||
|
private _toasts: Set<ToastData> = new Set();
|
||||||
|
|
||||||
|
public eventBus = new EventTarget();
|
||||||
|
|
||||||
|
publicState(): PublicDeckyToasterState {
|
||||||
|
return { toasts: this._toasts };
|
||||||
|
}
|
||||||
|
|
||||||
|
addToast(toast: ToastData) {
|
||||||
|
this._toasts.add(toast);
|
||||||
|
this.notifyUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeToast(toast: ToastData) {
|
||||||
|
this._toasts.delete(toast);
|
||||||
|
this.notifyUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyUpdate() {
|
||||||
|
this.eventBus.dispatchEvent(new Event('update'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeckyToasterContext extends PublicDeckyToasterState {
|
||||||
|
addToast(toast: ToastData): void;
|
||||||
|
removeToast(toast: ToastData): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeckyToasterContext = createContext<DeckyToasterContext>(null as any);
|
||||||
|
|
||||||
|
export const useDeckyToasterState = () => useContext(DeckyToasterContext);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
deckyToasterState: DeckyToasterState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeckyToasterStateContextProvider: FC<Props> = ({ children, deckyToasterState }) => {
|
||||||
|
const [publicDeckyToasterState, setPublicDeckyToasterState] = useState<PublicDeckyToasterState>({
|
||||||
|
...deckyToasterState.publicState(),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onUpdate() {
|
||||||
|
setPublicDeckyToasterState({ ...deckyToasterState.publicState() });
|
||||||
|
}
|
||||||
|
|
||||||
|
deckyToasterState.eventBus.addEventListener('update', onUpdate);
|
||||||
|
|
||||||
|
return () => deckyToasterState.eventBus.removeEventListener('update', onUpdate);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addToast = deckyToasterState.addToast.bind(deckyToasterState);
|
||||||
|
const removeToast = deckyToasterState.removeToast.bind(deckyToasterState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DeckyToasterContext.Provider value={{ ...publicDeckyToasterState, addToast, removeToast }}>
|
||||||
|
{children}
|
||||||
|
</DeckyToasterContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Focusable } from 'decky-frontend-lib';
|
import { Focusable, Router } from 'decky-frontend-lib';
|
||||||
import { FunctionComponent, useRef } from 'react';
|
import { FunctionComponent, useRef } from 'react';
|
||||||
import ReactMarkdown, { Options as ReactMarkdownOptions } from 'react-markdown';
|
import ReactMarkdown, { Options as ReactMarkdownOptions } from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
@@ -21,8 +21,8 @@ const Markdown: FunctionComponent<MarkdownProps> = (props) => {
|
|||||||
<Focusable
|
<Focusable
|
||||||
onActivate={() => {}}
|
onActivate={() => {}}
|
||||||
onOKButton={() => {
|
onOKButton={() => {
|
||||||
aRef?.current?.click();
|
|
||||||
props.onDismiss?.();
|
props.onDismiss?.();
|
||||||
|
Router.NavigateToExternalWeb(aRef.current!.href);
|
||||||
}}
|
}}
|
||||||
style={{ display: 'inline' }}
|
style={{ display: 'inline' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ButtonItem,
|
ButtonItem,
|
||||||
|
Focusable,
|
||||||
PanelSection,
|
PanelSection,
|
||||||
PanelSectionRow,
|
PanelSectionRow,
|
||||||
joinClassNames,
|
joinClassNames,
|
||||||
@@ -10,38 +11,47 @@ import { VFC } from 'react';
|
|||||||
|
|
||||||
import { useDeckyState } from './DeckyState';
|
import { useDeckyState } from './DeckyState';
|
||||||
import NotificationBadge from './NotificationBadge';
|
import NotificationBadge from './NotificationBadge';
|
||||||
|
import { useQuickAccessVisible } from './QuickAccessVisibleState';
|
||||||
|
import TitleView from './TitleView';
|
||||||
|
|
||||||
const PluginView: VFC = () => {
|
const PluginView: VFC = () => {
|
||||||
const { plugins, updates, activePlugin, setActivePlugin } = useDeckyState();
|
const { plugins, updates, activePlugin, setActivePlugin, closeActivePlugin } = useDeckyState();
|
||||||
|
const visible = useQuickAccessVisible();
|
||||||
|
|
||||||
if (activePlugin) {
|
if (activePlugin) {
|
||||||
return (
|
return (
|
||||||
<div
|
<Focusable onCancelButton={closeActivePlugin}>
|
||||||
className={joinClassNames(staticClasses.TabGroupPanel, scrollClasses.ScrollPanel, scrollClasses.ScrollY)}
|
<TitleView />
|
||||||
style={{ height: '100%' }}
|
<div
|
||||||
>
|
className={joinClassNames(staticClasses.TabGroupPanel, scrollClasses.ScrollPanel, scrollClasses.ScrollY)}
|
||||||
{activePlugin.content}
|
style={{ height: '100%' }}
|
||||||
</div>
|
>
|
||||||
|
{(visible || activePlugin.alwaysRender) && activePlugin.content}
|
||||||
|
</div>
|
||||||
|
</Focusable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={joinClassNames(staticClasses.TabGroupPanel, scrollClasses.ScrollPanel, scrollClasses.ScrollY)}>
|
<>
|
||||||
<PanelSection>
|
<TitleView />
|
||||||
{plugins
|
<div className={joinClassNames(staticClasses.TabGroupPanel, scrollClasses.ScrollPanel, scrollClasses.ScrollY)}>
|
||||||
.filter((p) => p.content)
|
<PanelSection>
|
||||||
.map(({ name, icon }) => (
|
{plugins
|
||||||
<PanelSectionRow key={name}>
|
.filter((p) => p.content)
|
||||||
<ButtonItem layout="below" onClick={() => setActivePlugin(name)}>
|
.map(({ name, icon }) => (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<PanelSectionRow key={name}>
|
||||||
{icon}
|
<ButtonItem layout="below" onClick={() => setActivePlugin(name)}>
|
||||||
<div>{name}</div>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<NotificationBadge show={updates?.has(name)} style={{ top: '-5px', right: '-5px' }} />
|
{icon}
|
||||||
</div>
|
<div>{name}</div>
|
||||||
</ButtonItem>
|
<NotificationBadge show={updates?.has(name)} style={{ top: '-5px', right: '-5px' }} />
|
||||||
</PanelSectionRow>
|
</div>
|
||||||
))}
|
</ButtonItem>
|
||||||
</PanelSection>
|
</PanelSectionRow>
|
||||||
</div>
|
))}
|
||||||
|
</PanelSection>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { FC, createContext, useContext, useState } from 'react';
|
||||||
|
|
||||||
|
const QuickAccessVisibleState = createContext<boolean>(true);
|
||||||
|
|
||||||
|
export const useQuickAccessVisible = () => useContext(QuickAccessVisibleState);
|
||||||
|
|
||||||
|
export const QuickAccessVisibleStateProvider: FC<{ initial: boolean; setter: ((val: boolean) => {}[]) | never[] }> = ({
|
||||||
|
children,
|
||||||
|
initial,
|
||||||
|
setter,
|
||||||
|
}) => {
|
||||||
|
const [visible, setVisible] = useState<boolean>(initial);
|
||||||
|
const [prev, setPrev] = useState<boolean>(initial);
|
||||||
|
// hack to use an array as a "pointer" to pass the setter up the tree
|
||||||
|
setter[0] = setVisible;
|
||||||
|
if (initial != prev) {
|
||||||
|
setPrev(initial);
|
||||||
|
setVisible(initial);
|
||||||
|
}
|
||||||
|
return <QuickAccessVisibleState.Provider value={visible}>{children}</QuickAccessVisibleState.Provider>;
|
||||||
|
};
|
||||||
@@ -2,13 +2,10 @@ import { ToastData, findModule, joinClassNames } from 'decky-frontend-lib';
|
|||||||
import { FunctionComponent } from 'react';
|
import { FunctionComponent } from 'react';
|
||||||
|
|
||||||
interface ToastProps {
|
interface ToastProps {
|
||||||
toast: {
|
toast: ToastData;
|
||||||
data: ToastData;
|
|
||||||
nToastDurationMS: number;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toastClasses = findModule((mod) => {
|
export const toastClasses = findModule((mod) => {
|
||||||
if (typeof mod !== 'object') return false;
|
if (typeof mod !== 'object') return false;
|
||||||
|
|
||||||
if (mod.ToastPlaceholder) {
|
if (mod.ToastPlaceholder) {
|
||||||
@@ -31,21 +28,17 @@ const templateClasses = findModule((mod) => {
|
|||||||
const Toast: FunctionComponent<ToastProps> = ({ toast }) => {
|
const Toast: FunctionComponent<ToastProps> = ({ toast }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ '--toast-duration': `${toast.nToastDurationMS}ms` } as React.CSSProperties}
|
style={{ '--toast-duration': `${toast.duration}ms` } as React.CSSProperties}
|
||||||
className={toastClasses.toastEnter}
|
onClick={toast.onClick}
|
||||||
|
className={joinClassNames(templateClasses.ShortTemplate, toast.className || '')}
|
||||||
>
|
>
|
||||||
<div
|
{toast.logo && <div className={templateClasses.StandardLogoDimensions}>{toast.logo}</div>}
|
||||||
onClick={toast.data.onClick}
|
<div className={joinClassNames(templateClasses.Content, toast.contentClassName || '')}>
|
||||||
className={joinClassNames(templateClasses.ShortTemplate, toast.data.className || '')}
|
<div className={templateClasses.Header}>
|
||||||
>
|
{toast.icon && <div className={templateClasses.Icon}>{toast.icon}</div>}
|
||||||
{toast.data.logo && <div className={templateClasses.StandardLogoDimensions}>{toast.data.logo}</div>}
|
<div className={templateClasses.Title}>{toast.title}</div>
|
||||||
<div className={joinClassNames(templateClasses.Content, toast.data.contentClassName || '')}>
|
|
||||||
<div className={templateClasses.Header}>
|
|
||||||
{toast.data.icon && <div className={templateClasses.Icon}>{toast.data.icon}</div>}
|
|
||||||
<div className={templateClasses.Title}>{toast.data.title}</div>
|
|
||||||
</div>
|
|
||||||
<div className={templateClasses.Body}>{toast.data.body}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className={templateClasses.Body}>{toast.body}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,25 +1,39 @@
|
|||||||
import { SidebarNavigation } from 'decky-frontend-lib';
|
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 GeneralSettings from './pages/general';
|
||||||
import PluginList from './pages/plugin_list';
|
import PluginList from './pages/plugin_list';
|
||||||
|
|
||||||
|
const DeveloperSettings = lazy(() => import('./pages/developer'));
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
return (
|
const [isDeveloper, setIsDeveloper] = useSetting<boolean>('developer.enabled', false);
|
||||||
<SidebarNavigation
|
|
||||||
title="Decky Settings"
|
const pages = [
|
||||||
showTitle
|
{
|
||||||
pages={[
|
title: 'General',
|
||||||
{
|
content: <GeneralSettings isDeveloper={isDeveloper} setIsDeveloper={setIsDeveloper} />,
|
||||||
title: 'General',
|
route: '/decky/settings/general',
|
||||||
content: <GeneralSettings />,
|
},
|
||||||
route: '/decky/settings/general',
|
{
|
||||||
},
|
title: 'Plugins',
|
||||||
{
|
content: <PluginList />,
|
||||||
title: 'Plugins',
|
route: '/decky/settings/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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ const logger = new Logger('BranchSelect');
|
|||||||
enum UpdateBranch {
|
enum UpdateBranch {
|
||||||
Stable,
|
Stable,
|
||||||
Prerelease,
|
Prerelease,
|
||||||
// Nightly,
|
// Testing,
|
||||||
}
|
}
|
||||||
|
|
||||||
const BranchSelect: FunctionComponent<{}> = () => {
|
const BranchSelect: FunctionComponent<{}> = () => {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Dropdown, Field, TextField } from 'decky-frontend-lib';
|
||||||
|
import { FunctionComponent } from 'react';
|
||||||
|
import { FaShapes } from 'react-icons/fa';
|
||||||
|
|
||||||
|
import Logger from '../../../../logger';
|
||||||
|
import { Store } from '../../../../store';
|
||||||
|
import { useSetting } from '../../../../utils/hooks/useSetting';
|
||||||
|
|
||||||
|
const logger = new Logger('StoreSelect');
|
||||||
|
|
||||||
|
const StoreSelect: FunctionComponent<{}> = () => {
|
||||||
|
const [selectedStore, setSelectedStore] = useSetting<Store>('store', Store.Default);
|
||||||
|
const [selectedStoreURL, setSelectedStoreURL] = useSetting<string | null>('store-url', null);
|
||||||
|
|
||||||
|
// Returns numerical values from 0 to 2 (with current branch setup as of 8/28/22)
|
||||||
|
// 0 being Default, 1 being Testing and 2 being Custom
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Field label="Store Channel">
|
||||||
|
<Dropdown
|
||||||
|
rgOptions={Object.values(Store)
|
||||||
|
.filter((store) => typeof store == 'string')
|
||||||
|
.map((store) => ({
|
||||||
|
label: store,
|
||||||
|
data: Store[store],
|
||||||
|
}))}
|
||||||
|
selectedOption={selectedStore}
|
||||||
|
onChange={async (newVal) => {
|
||||||
|
await setSelectedStore(newVal.data);
|
||||||
|
logger.log('switching stores!');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{selectedStore == Store.Custom && (
|
||||||
|
<Field
|
||||||
|
label="Custom Store"
|
||||||
|
indentLevel={1}
|
||||||
|
description={
|
||||||
|
<TextField
|
||||||
|
label={'URL'}
|
||||||
|
value={selectedStoreURL || undefined}
|
||||||
|
onChange={(e) => setSelectedStoreURL(e?.target.value || null)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
icon={<FaShapes style={{ display: 'block' }} />}
|
||||||
|
></Field>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StoreSelect;
|
||||||
@@ -14,6 +14,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { FaArrowDown } from 'react-icons/fa';
|
import { FaArrowDown } from 'react-icons/fa';
|
||||||
|
|
||||||
import { VerInfo, callUpdaterMethod, finishUpdate } from '../../../../updater';
|
import { VerInfo, callUpdaterMethod, finishUpdate } from '../../../../updater';
|
||||||
|
import { findSP } from '../../../../utils/windows';
|
||||||
import { useDeckyState } from '../../../DeckyState';
|
import { useDeckyState } from '../../../DeckyState';
|
||||||
import InlinePatchNotes from '../../../patchnotes/InlinePatchNotes';
|
import InlinePatchNotes from '../../../patchnotes/InlinePatchNotes';
|
||||||
import WithSuspense from '../../../WithSuspense';
|
import WithSuspense from '../../../WithSuspense';
|
||||||
@@ -21,6 +22,7 @@ import WithSuspense from '../../../WithSuspense';
|
|||||||
const MarkdownRenderer = lazy(() => import('../../../Markdown'));
|
const MarkdownRenderer = lazy(() => import('../../../Markdown'));
|
||||||
|
|
||||||
function PatchNotesModal({ versionInfo, closeModal }: { versionInfo: VerInfo | null; closeModal?: () => {} }) {
|
function PatchNotesModal({ versionInfo, closeModal }: { versionInfo: VerInfo | null; closeModal?: () => {} }) {
|
||||||
|
const SP = findSP();
|
||||||
return (
|
return (
|
||||||
<Focusable onCancelButton={closeModal}>
|
<Focusable onCancelButton={closeModal}>
|
||||||
<FocusRing>
|
<FocusRing>
|
||||||
@@ -50,12 +52,13 @@ function PatchNotesModal({ versionInfo, closeModal }: { versionInfo: VerInfo | n
|
|||||||
)}
|
)}
|
||||||
fnGetId={(id) => id}
|
fnGetId={(id) => id}
|
||||||
nNumItems={versionInfo?.all?.length}
|
nNumItems={versionInfo?.all?.length}
|
||||||
nHeight={window.innerHeight - 40}
|
nHeight={SP.innerHeight - 40}
|
||||||
nItemHeight={window.innerHeight - 40}
|
nItemHeight={SP.innerHeight - 40}
|
||||||
nItemMarginX={0}
|
nItemMarginX={0}
|
||||||
initialColumn={0}
|
initialColumn={0}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
fnGetColumnWidth={() => window.innerWidth}
|
fnGetColumnWidth={() => SP.innerWidth}
|
||||||
|
name="Decky Updates"
|
||||||
/>
|
/>
|
||||||
</FocusRing>
|
</FocusRing>
|
||||||
</Focusable>
|
</Focusable>
|
||||||
|
|||||||
@@ -1,29 +1,40 @@
|
|||||||
import { DialogButton, Field, TextField } from 'decky-frontend-lib';
|
import { DialogButton, Field, TextField, Toggle } from 'decky-frontend-lib';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FaShapes } from 'react-icons/fa';
|
import { FaShapes, FaTools } from 'react-icons/fa';
|
||||||
|
|
||||||
import { installFromURL } from '../../../../store';
|
import { installFromURL } from '../../../../store';
|
||||||
import BranchSelect from './BranchSelect';
|
import BranchSelect from './BranchSelect';
|
||||||
import RemoteDebuggingSettings from './RemoteDebugging';
|
import RemoteDebuggingSettings from './RemoteDebugging';
|
||||||
|
import StoreSelect from './StoreSelect';
|
||||||
import UpdaterSettings from './Updater';
|
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 [pluginURL, setPluginURL] = useState('');
|
||||||
// const [checked, setChecked] = useState(false); // store these in some kind of State instead
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* <Field
|
|
||||||
label="A Toggle with an icon"
|
|
||||||
icon={<FaShapes style={{ display: 'block' }} />}
|
|
||||||
>
|
|
||||||
<Toggle
|
|
||||||
value={checked}
|
|
||||||
onChange={(e) => setChecked(e)}
|
|
||||||
/>
|
|
||||||
</Field> */}
|
|
||||||
<UpdaterSettings />
|
<UpdaterSettings />
|
||||||
<BranchSelect />
|
<BranchSelect />
|
||||||
|
<StoreSelect />
|
||||||
<RemoteDebuggingSettings />
|
<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
|
<Field
|
||||||
label="Manual plugin install"
|
label="Manual plugin install"
|
||||||
description={<TextField label={'URL'} value={pluginURL} onChange={(e) => setPluginURL(e?.target.value)} />}
|
description={<TextField label={'URL'} value={pluginURL} onChange={(e) => setPluginURL(e?.target.value)} />}
|
||||||
|
|||||||
@@ -11,17 +11,10 @@ import {
|
|||||||
} from 'decky-frontend-lib';
|
} from 'decky-frontend-lib';
|
||||||
import { FC, useRef, useState } from 'react';
|
import { FC, useRef, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import { StorePlugin, StorePluginVersion, requestPluginInstall } from '../../store';
|
||||||
LegacyStorePlugin,
|
|
||||||
StorePlugin,
|
|
||||||
StorePluginVersion,
|
|
||||||
isLegacyPlugin,
|
|
||||||
requestLegacyPluginInstall,
|
|
||||||
requestPluginInstall,
|
|
||||||
} from '../../store';
|
|
||||||
|
|
||||||
interface PluginCardProps {
|
interface PluginCardProps {
|
||||||
plugin: StorePlugin | LegacyStorePlugin;
|
plugin: StorePlugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
|
const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
|
||||||
@@ -63,22 +56,13 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="deckyStoreCardHeader" style={{ display: 'flex', alignItems: 'center' }}>
|
<div className="deckyStoreCardHeader" style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<a
|
<div
|
||||||
style={{ fontSize: '18pt', padding: '10px' }}
|
style={{ fontSize: '18pt', padding: '10px' }}
|
||||||
className={joinClassNames(staticClasses.Text)}
|
className={joinClassNames(staticClasses.Text)}
|
||||||
// onClick={() => Router.NavigateToExternalWeb('https://github.com/' + plugin.artifact)}
|
// onClick={() => Router.NavigateToExternalWeb('https://github.com/' + plugin.artifact)}
|
||||||
>
|
>
|
||||||
{isLegacyPlugin(plugin) ? (
|
{plugin.name}
|
||||||
<div className="deckyStoreCardNameContainer">
|
</div>
|
||||||
<span className="deckyStoreCardLegacyRepoOwner" style={{ color: 'grey' }}>
|
|
||||||
{plugin.artifact.split('/')[0]}/
|
|
||||||
</span>
|
|
||||||
{plugin.artifact.split('/')[1]}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
plugin.name
|
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -94,17 +78,7 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
|
|||||||
width: 'auto',
|
width: 'auto',
|
||||||
height: '160px',
|
height: '160px',
|
||||||
}}
|
}}
|
||||||
src={
|
src={plugin.image_url}
|
||||||
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`
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -152,19 +126,6 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
|
|||||||
{tag == 'root' ? 'Requires root' : tag}
|
{tag == 'root' ? 'Requires root' : tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{isLegacyPlugin(plugin) && (
|
|
||||||
<span
|
|
||||||
className="deckyStoreCardTag deckyStoreCardLegacyTag"
|
|
||||||
style={{
|
|
||||||
color: '#232120',
|
|
||||||
padding: '5px',
|
|
||||||
borderRadius: '5px',
|
|
||||||
background: '#EDE841',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
legacy
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,11 +155,7 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
|
|||||||
<DialogButton
|
<DialogButton
|
||||||
className="deckyStoreCardInstallButton"
|
className="deckyStoreCardInstallButton"
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
onClick={() =>
|
onClick={() => requestPluginInstall(plugin.name, plugin.versions[selectedOption])}
|
||||||
isLegacyPlugin(plugin)
|
|
||||||
? requestLegacyPluginInstall(plugin, Object.keys(plugin.versions)[selectedOption])
|
|
||||||
: requestPluginInstall(plugin.name, plugin.versions[selectedOption])
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Install
|
Install
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
@@ -211,15 +168,10 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
|
|||||||
>
|
>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
rgOptions={
|
rgOptions={
|
||||||
(isLegacyPlugin(plugin)
|
plugin.versions.map((version: StorePluginVersion, index) => ({
|
||||||
? Object.keys(plugin.versions).map((v, k) => ({
|
data: index,
|
||||||
data: k,
|
label: version.name,
|
||||||
label: v,
|
})) as SingleDropdownOption[]
|
||||||
}))
|
|
||||||
: plugin.versions.map((version: StorePluginVersion, index) => ({
|
|
||||||
data: index,
|
|
||||||
label: version.name,
|
|
||||||
}))) as SingleDropdownOption[]
|
|
||||||
}
|
}
|
||||||
strDefaultLabel={'Select a version'}
|
strDefaultLabel={'Select a version'}
|
||||||
selectedOption={selectedOption}
|
selectedOption={selectedOption}
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ import { SteamSpinner } from 'decky-frontend-lib';
|
|||||||
import { FC, useEffect, useState } from 'react';
|
import { FC, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import Logger from '../../logger';
|
import Logger from '../../logger';
|
||||||
import { LegacyStorePlugin, StorePlugin, getLegacyPluginList, getPluginList } from '../../store';
|
import { StorePlugin, getPluginList } from '../../store';
|
||||||
import PluginCard from './PluginCard';
|
import PluginCard from './PluginCard';
|
||||||
|
|
||||||
const logger = new Logger('FilePicker');
|
const logger = new Logger('FilePicker');
|
||||||
|
|
||||||
const StorePage: FC<{}> = () => {
|
const StorePage: FC<{}> = () => {
|
||||||
const [data, setData] = useState<StorePlugin[] | null>(null);
|
const [data, setData] = useState<StorePlugin[] | null>(null);
|
||||||
const [legacyData, setLegacyData] = useState<LegacyStorePlugin[] | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -17,11 +16,6 @@ const StorePage: FC<{}> = () => {
|
|||||||
logger.log('got data!', res);
|
logger.log('got data!', res);
|
||||||
setData(res);
|
setData(res);
|
||||||
})();
|
})();
|
||||||
(async () => {
|
|
||||||
const res = await getLegacyPluginList();
|
|
||||||
logger.log('got legacy data!', res);
|
|
||||||
setLegacyData(res);
|
|
||||||
})();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -49,11 +43,6 @@ const StorePage: FC<{}> = () => {
|
|||||||
{data.map((plugin: StorePlugin) => (
|
{data.map((plugin: StorePlugin) => (
|
||||||
<PluginCard plugin={plugin} />
|
<PluginCard plugin={plugin} />
|
||||||
))}
|
))}
|
||||||
{!legacyData ? (
|
|
||||||
<SteamSpinner />
|
|
||||||
) : (
|
|
||||||
legacyData.map((plugin: LegacyStorePlugin) => <PluginCard plugin={plugin} />)
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import {
|
||||||
|
ReactRouter,
|
||||||
|
Router,
|
||||||
|
fakeRenderComponent,
|
||||||
|
findInReactTree,
|
||||||
|
findInTree,
|
||||||
|
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,
|
||||||
|
ReactUtils: {
|
||||||
|
fakeRenderComponent,
|
||||||
|
findInReactTree,
|
||||||
|
findInTree,
|
||||||
|
},
|
||||||
|
Router,
|
||||||
|
ReactRouter,
|
||||||
|
classes: {
|
||||||
|
scrollClasses,
|
||||||
|
staticClasses,
|
||||||
|
playSectionClasses,
|
||||||
|
scrollPanelClasses,
|
||||||
|
updaterFieldClasses,
|
||||||
|
gamepadDialogClasses,
|
||||||
|
gamepadSliderClasses,
|
||||||
|
quickAccessMenuClasses,
|
||||||
|
quickAccessControlsClasses,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+3
-25
@@ -1,6 +1,3 @@
|
|||||||
import { ButtonItem, CommonUIModule, webpackCache } from 'decky-frontend-lib';
|
|
||||||
import { forwardRef } from 'react';
|
|
||||||
|
|
||||||
import PluginLoader from './plugin-loader';
|
import PluginLoader from './plugin-loader';
|
||||||
import { DeckyUpdater } from './updater';
|
import { DeckyUpdater } from './updater';
|
||||||
|
|
||||||
@@ -11,32 +8,12 @@ declare global {
|
|||||||
importDeckyPlugin: Function;
|
importDeckyPlugin: Function;
|
||||||
syncDeckyPlugins: Function;
|
syncDeckyPlugins: Function;
|
||||||
deckyHasLoaded: boolean;
|
deckyHasLoaded: boolean;
|
||||||
|
deckyHasConnectedRDT?: boolean;
|
||||||
deckyAuthToken: string;
|
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 () => {
|
(async () => {
|
||||||
window.deckyAuthToken = await fetch('http://127.0.0.1:1337/auth/token').then((r) => r.text());
|
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?.deinit();
|
||||||
|
|
||||||
window.DeckyPluginLoader = new PluginLoader();
|
window.DeckyPluginLoader = new PluginLoader();
|
||||||
|
window.DeckyPluginLoader.init();
|
||||||
window.importDeckyPlugin = function (name: string, version: string) {
|
window.importDeckyPlugin = function (name: string, version: string) {
|
||||||
window.DeckyPluginLoader?.importPlugin(name, version);
|
window.DeckyPluginLoader?.importPlugin(name, version);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,15 +4,12 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
QuickAccessTab,
|
QuickAccessTab,
|
||||||
Router,
|
Router,
|
||||||
callOriginal,
|
|
||||||
findModuleChild,
|
|
||||||
replacePatch,
|
|
||||||
showModal,
|
showModal,
|
||||||
sleep,
|
sleep,
|
||||||
staticClasses,
|
staticClasses,
|
||||||
} from 'decky-frontend-lib';
|
} from 'decky-frontend-lib';
|
||||||
import { lazy } from 'react';
|
import { FC, lazy } from 'react';
|
||||||
import { FaPlug } from 'react-icons/fa';
|
import { FaCog, FaExclamationCircle, FaPlug } from 'react-icons/fa';
|
||||||
|
|
||||||
import { DeckyState, DeckyStateContextProvider, useDeckyState } from './components/DeckyState';
|
import { DeckyState, DeckyStateContextProvider, useDeckyState } from './components/DeckyState';
|
||||||
import LegacyPlugin from './components/LegacyPlugin';
|
import LegacyPlugin from './components/LegacyPlugin';
|
||||||
@@ -20,15 +17,16 @@ import { deinitFilepickerPatches, initFilepickerPatches } from './components/mod
|
|||||||
import PluginInstallModal from './components/modals/PluginInstallModal';
|
import PluginInstallModal from './components/modals/PluginInstallModal';
|
||||||
import NotificationBadge from './components/NotificationBadge';
|
import NotificationBadge from './components/NotificationBadge';
|
||||||
import PluginView from './components/PluginView';
|
import PluginView from './components/PluginView';
|
||||||
import TitleView from './components/TitleView';
|
|
||||||
import WithSuspense from './components/WithSuspense';
|
import WithSuspense from './components/WithSuspense';
|
||||||
import Logger from './logger';
|
import Logger from './logger';
|
||||||
import { Plugin } from './plugin';
|
import { Plugin } from './plugin';
|
||||||
import RouterHook from './router-hook';
|
import RouterHook from './router-hook';
|
||||||
import { checkForUpdates } from './store';
|
import { checkForUpdates } from './store';
|
||||||
import TabsHook from './tabs-hook';
|
import TabsHook from './tabs-hook';
|
||||||
|
import OldTabsHook from './tabs-hook.old';
|
||||||
import Toaster from './toaster';
|
import Toaster from './toaster';
|
||||||
import { VerInfo, callUpdaterMethod } from './updater';
|
import { VerInfo, callUpdaterMethod } from './updater';
|
||||||
|
import { getSetting } from './utils/settings';
|
||||||
|
|
||||||
const StorePage = lazy(() => import('./components/store/Store'));
|
const StorePage = lazy(() => import('./components/store/Store'));
|
||||||
const SettingsPage = lazy(() => import('./components/settings'));
|
const SettingsPage = lazy(() => import('./components/settings'));
|
||||||
@@ -41,10 +39,10 @@ declare global {
|
|||||||
|
|
||||||
class PluginLoader extends Logger {
|
class PluginLoader extends Logger {
|
||||||
private plugins: Plugin[] = [];
|
private plugins: Plugin[] = [];
|
||||||
private tabsHook: TabsHook = new TabsHook();
|
private tabsHook: TabsHook | OldTabsHook = document.title == 'SP' ? new OldTabsHook() : new TabsHook();
|
||||||
// private windowHook: WindowHook = new WindowHook();
|
// private windowHook: WindowHook = new WindowHook();
|
||||||
private routerHook: RouterHook = new RouterHook();
|
private routerHook: RouterHook = new RouterHook();
|
||||||
private toaster: Toaster = new Toaster();
|
public toaster: Toaster = new Toaster();
|
||||||
private deckyState: DeckyState = new DeckyState();
|
private deckyState: DeckyState = new DeckyState();
|
||||||
|
|
||||||
private reloadLock: boolean = false;
|
private reloadLock: boolean = false;
|
||||||
@@ -55,6 +53,7 @@ class PluginLoader extends Logger {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(PluginLoader.name);
|
super(PluginLoader.name);
|
||||||
|
this.tabsHook.init();
|
||||||
this.log('Initialized');
|
this.log('Initialized');
|
||||||
|
|
||||||
const TabBadge = () => {
|
const TabBadge = () => {
|
||||||
@@ -67,7 +66,6 @@ class PluginLoader extends Logger {
|
|||||||
title: null,
|
title: null,
|
||||||
content: (
|
content: (
|
||||||
<DeckyStateContextProvider deckyState={this.deckyState}>
|
<DeckyStateContextProvider deckyState={this.deckyState}>
|
||||||
<TitleView />
|
|
||||||
<PluginView />
|
<PluginView />
|
||||||
</DeckyStateContextProvider>
|
</DeckyStateContextProvider>
|
||||||
),
|
),
|
||||||
@@ -97,38 +95,6 @@ class PluginLoader extends Logger {
|
|||||||
initFilepickerPatches();
|
initFilepickerPatches();
|
||||||
|
|
||||||
this.updateVersion();
|
this.updateVersion();
|
||||||
|
|
||||||
const self = this;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// TODO remove all of this once Valve fixes the bug
|
|
||||||
const focusManager = findModuleChild((m) => {
|
|
||||||
if (typeof m !== 'object') return false;
|
|
||||||
for (let prop in m) {
|
|
||||||
if (m[prop]?.prototype?.TakeFocus) return m[prop];
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.focusWorkaroundPatch = replacePatch(focusManager.prototype, 'TakeFocus', function () {
|
|
||||||
// @ts-ignore
|
|
||||||
const classList = this.m_node?.m_element.classList;
|
|
||||||
if (
|
|
||||||
// @ts-ignore
|
|
||||||
(this.m_node?.m_element && classList.contains(staticClasses.TabGroupPanel)) ||
|
|
||||||
classList.contains('FriendsListTab') ||
|
|
||||||
classList.contains('FriendsTabList') ||
|
|
||||||
classList.contains('FriendsListAndChatsSteamDeck')
|
|
||||||
) {
|
|
||||||
self.debug('Intercepted friends re-focus');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return callOriginal;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
this.error('Friends focus patch failed', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateVersion() {
|
public async updateVersion() {
|
||||||
@@ -209,6 +175,12 @@ class PluginLoader extends Logger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
getSetting('developer.enabled', false).then((val) => {
|
||||||
|
if (val) import('./developer').then((developer) => developer.startup());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public deinit() {
|
public deinit() {
|
||||||
this.routerHook.removeRoute('/decky/store');
|
this.routerHook.removeRoute('/decky/store');
|
||||||
this.routerHook.removeRoute('/decky/settings');
|
this.routerHook.removeRoute('/decky/settings');
|
||||||
@@ -263,13 +235,36 @@ class PluginLoader extends Logger {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
let plugin_export = await eval(await res.text());
|
try {
|
||||||
let plugin = plugin_export(this.createPluginAPI(name));
|
let plugin_export = await eval(await res.text());
|
||||||
this.plugins.push({
|
let plugin = plugin_export(this.createPluginAPI(name));
|
||||||
...plugin,
|
this.plugins.push({
|
||||||
name: name,
|
...plugin,
|
||||||
version: version,
|
name: name,
|
||||||
});
|
version: version,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
this.error('Error loading plugin ' + name, e);
|
||||||
|
const TheError: FC<{}> = () => (
|
||||||
|
<>
|
||||||
|
Error:{' '}
|
||||||
|
<pre>
|
||||||
|
<code>{e instanceof Error ? e.stack : JSON.stringify(e)}</code>
|
||||||
|
</pre>
|
||||||
|
<>
|
||||||
|
Please go to <FaCog style={{ display: 'inline' }} /> in the Decky menu if you need to uninstall this
|
||||||
|
plugin.
|
||||||
|
</>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
this.plugins.push({
|
||||||
|
name: name,
|
||||||
|
version: version,
|
||||||
|
content: <TheError />,
|
||||||
|
icon: <FaExclamationCircle />,
|
||||||
|
});
|
||||||
|
this.toaster.toast({ title: 'Error loading ' + name, body: '' + e, icon: <FaExclamationCircle /> });
|
||||||
|
}
|
||||||
} else throw new Error(`${name} frontend_bundle not OK`);
|
} else throw new Error(`${name} frontend_bundle not OK`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export interface Plugin {
|
|||||||
icon: JSX.Element;
|
icon: JSX.Element;
|
||||||
content?: JSX.Element;
|
content?: JSX.Element;
|
||||||
onDismount?(): void;
|
onDismount?(): void;
|
||||||
|
alwaysRender?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { Patch, afterPatch, findModuleChild } from 'decky-frontend-lib';
|
import { Patch, afterPatch, findModuleChild } from 'decky-frontend-lib';
|
||||||
import { ReactElement, ReactNode, cloneElement, createElement, memo } from 'react';
|
import { FC, ReactElement, ReactNode, cloneElement, createElement, memo } from 'react';
|
||||||
import type { Route } from 'react-router';
|
import type { Route } from 'react-router';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DeckyGlobalComponentsState,
|
||||||
|
DeckyGlobalComponentsStateContextProvider,
|
||||||
|
useDeckyGlobalComponentsState,
|
||||||
|
} from './components/DeckyGlobalComponentsState';
|
||||||
import {
|
import {
|
||||||
DeckyRouterState,
|
DeckyRouterState,
|
||||||
DeckyRouterStateContextProvider,
|
DeckyRouterStateContextProvider,
|
||||||
@@ -22,8 +27,10 @@ class RouterHook extends Logger {
|
|||||||
private memoizedRouter: any;
|
private memoizedRouter: any;
|
||||||
private gamepadWrapper: any;
|
private gamepadWrapper: any;
|
||||||
private routerState: DeckyRouterState = new DeckyRouterState();
|
private routerState: DeckyRouterState = new DeckyRouterState();
|
||||||
|
private globalComponentsState: DeckyGlobalComponentsState = new DeckyGlobalComponentsState();
|
||||||
private wrapperPatch: Patch;
|
private wrapperPatch: Patch;
|
||||||
private routerPatch?: Patch;
|
private routerPatch?: Patch;
|
||||||
|
public routes?: any[];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('RouterHook');
|
super('RouterHook');
|
||||||
@@ -42,24 +49,28 @@ class RouterHook extends Logger {
|
|||||||
|
|
||||||
let Route: new () => Route;
|
let Route: new () => Route;
|
||||||
// Used to store the new replicated routes we create to allow routes to be unpatched.
|
// Used to store the new replicated routes we create to allow routes to be unpatched.
|
||||||
let toReplace = new Map<string, ReactNode>();
|
const processList = (
|
||||||
const DeckyWrapper = ({ children }: { children: ReactElement }) => {
|
routeList: any[],
|
||||||
const { routes, routePatches } = useDeckyRouterState();
|
routes: Map<string, RouterEntry> | null,
|
||||||
|
routePatches: Map<string, Set<RoutePatch>>,
|
||||||
const routeList = children.props.children[0].props.children;
|
save: boolean,
|
||||||
|
) => {
|
||||||
|
this.debug('Route list: ', routeList);
|
||||||
|
if (save) this.routes = routeList;
|
||||||
let routerIndex = routeList.length;
|
let routerIndex = routeList.length;
|
||||||
if (!routeList[routerIndex - 1]?.length || routeList[routerIndex - 1]?.length !== routes.size) {
|
if (routes) {
|
||||||
if (routeList[routerIndex - 1]?.length && routeList[routerIndex - 1].length !== routes.size) routerIndex--;
|
if (!routeList[routerIndex - 1]?.length || routeList[routerIndex - 1]?.length !== routes.size) {
|
||||||
const newRouterArray: ReactElement[] = [];
|
if (routeList[routerIndex - 1]?.length && routeList[routerIndex - 1].length !== routes.size) routerIndex--;
|
||||||
routes.forEach(({ component, props }, path) => {
|
const newRouterArray: ReactElement[] = [];
|
||||||
newRouterArray.push(
|
routes.forEach(({ component, props }, path) => {
|
||||||
<Route path={path} {...props}>
|
newRouterArray.push(
|
||||||
{createElement(component)}
|
<Route path={path} {...props}>
|
||||||
</Route>,
|
{createElement(component)}
|
||||||
);
|
</Route>,
|
||||||
});
|
);
|
||||||
routeList[routerIndex] = newRouterArray;
|
});
|
||||||
|
routeList[routerIndex] = newRouterArray;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
routeList.forEach((route: Route, index: number) => {
|
routeList.forEach((route: Route, index: number) => {
|
||||||
const replaced = toReplace.get(route?.props?.path as string);
|
const replaced = toReplace.get(route?.props?.path as string);
|
||||||
@@ -85,19 +96,40 @@ class RouterHook extends Logger {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
let toReplace = new Map<string, ReactNode>();
|
||||||
|
const DeckyWrapper = ({ children }: { children: ReactElement }) => {
|
||||||
|
const { routes, routePatches } = useDeckyRouterState();
|
||||||
|
const mainRouteList = children.props.children[0].props.children;
|
||||||
|
const ingameRouteList = children.props.children[1].props.children; // /appoverlay and /apprunning
|
||||||
|
processList(mainRouteList, routes, routePatches, true);
|
||||||
|
processList(ingameRouteList, null, routePatches, false);
|
||||||
|
|
||||||
this.debug('Rerendered routes list');
|
this.debug('Rerendered routes list');
|
||||||
return children;
|
return children;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let renderedComponents: ReactElement[] = [];
|
||||||
|
|
||||||
|
const DeckyGlobalComponentsWrapper = () => {
|
||||||
|
const { components } = useDeckyGlobalComponentsState();
|
||||||
|
if (renderedComponents.length != components.size) {
|
||||||
|
this.debug('Rerendering global components');
|
||||||
|
renderedComponents = Array.from(components.values()).map((GComponent) => <GComponent />);
|
||||||
|
}
|
||||||
|
return <>{renderedComponents}</>;
|
||||||
|
};
|
||||||
|
|
||||||
this.wrapperPatch = afterPatch(this.gamepadWrapper, 'render', (_: any, ret: any) => {
|
this.wrapperPatch = afterPatch(this.gamepadWrapper, 'render', (_: any, ret: any) => {
|
||||||
if (ret?.props?.children?.props?.children?.length == 5) {
|
if (ret?.props?.children?.props?.children?.length == 5 || ret?.props?.children?.props?.children?.length == 4) {
|
||||||
|
const idx = ret?.props?.children?.props?.children?.length == 4 ? 1 : 2;
|
||||||
if (
|
if (
|
||||||
ret.props.children.props.children[2]?.props?.children?.[0]?.type?.type
|
ret.props.children.props.children[idx]?.props?.children?.[0]?.type?.type
|
||||||
?.toString()
|
?.toString()
|
||||||
?.includes('GamepadUI.Settings.Root()')
|
?.includes('GamepadUI.Settings.Root()')
|
||||||
) {
|
) {
|
||||||
if (!this.router) {
|
if (!this.router) {
|
||||||
this.router = ret.props.children.props.children[2]?.props?.children?.[0]?.type;
|
this.router = ret.props.children.props.children[idx]?.props?.children?.[0]?.type;
|
||||||
this.routerPatch = afterPatch(this.router, 'type', (_: any, ret: any) => {
|
this.routerPatch = afterPatch(this.router, 'type', (_: any, ret: any) => {
|
||||||
if (!Route)
|
if (!Route)
|
||||||
Route = ret.props.children[0].props.children.find((x: any) => x.props.path == '/createaccount').type;
|
Route = ret.props.children[0].props.children.find((x: any) => x.props.path == '/createaccount').type;
|
||||||
@@ -111,7 +143,12 @@ class RouterHook extends Logger {
|
|||||||
this.memoizedRouter = memo(this.router.type);
|
this.memoizedRouter = memo(this.router.type);
|
||||||
this.memoizedRouter.isDeckyRouter = true;
|
this.memoizedRouter.isDeckyRouter = true;
|
||||||
}
|
}
|
||||||
ret.props.children.props.children[2].props.children[0].type = this.memoizedRouter;
|
ret.props.children.props.children.push(
|
||||||
|
<DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}>
|
||||||
|
<DeckyGlobalComponentsWrapper />
|
||||||
|
</DeckyGlobalComponentsStateContextProvider>,
|
||||||
|
);
|
||||||
|
ret.props.children.props.children[idx].props.children[0].type = this.memoizedRouter;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ret;
|
return ret;
|
||||||
@@ -126,6 +163,14 @@ class RouterHook extends Logger {
|
|||||||
return this.routerState.addPatch(path, patch);
|
return this.routerState.addPatch(path, patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addGlobalComponent(name: string, component: FC) {
|
||||||
|
this.globalComponentsState.addComponent(name, component);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeGlobalComponent(name: string) {
|
||||||
|
this.globalComponentsState.removeComponent(name);
|
||||||
|
}
|
||||||
|
|
||||||
removePatch(path: string, patch: RoutePatch) {
|
removePatch(path: string, patch: RoutePatch) {
|
||||||
this.routerState.removePatch(path, patch);
|
this.routerState.removePatch(path, patch);
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-51
@@ -1,6 +1,11 @@
|
|||||||
import { ConfirmModal, showModal, staticClasses } from 'decky-frontend-lib';
|
|
||||||
|
|
||||||
import { Plugin } from './plugin';
|
import { Plugin } from './plugin';
|
||||||
|
import { getSetting, setSetting } from './utils/settings';
|
||||||
|
|
||||||
|
export enum Store {
|
||||||
|
Default,
|
||||||
|
Testing,
|
||||||
|
Custom,
|
||||||
|
}
|
||||||
|
|
||||||
export interface StorePluginVersion {
|
export interface StorePluginVersion {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -14,31 +19,49 @@ export interface StorePlugin {
|
|||||||
author: string;
|
author: string;
|
||||||
description: string;
|
description: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
}
|
image_url: string;
|
||||||
|
|
||||||
export interface LegacyStorePlugin {
|
|
||||||
artifact: string;
|
|
||||||
versions: {
|
|
||||||
[version: string]: string;
|
|
||||||
};
|
|
||||||
author: string;
|
|
||||||
description: string;
|
|
||||||
tags: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// name: version
|
// name: version
|
||||||
export type PluginUpdateMapping = Map<string, StorePluginVersion>;
|
export type PluginUpdateMapping = Map<string, StorePluginVersion>;
|
||||||
|
|
||||||
export function getPluginList(): Promise<StorePlugin[]> {
|
export async function getPluginList(): Promise<StorePlugin[]> {
|
||||||
return fetch('https://beta.deckbrew.xyz/plugins', {
|
let version = await window.DeckyPluginLoader.updateVersion();
|
||||||
method: 'GET',
|
let store = await getSetting<Store>('store', Store.Default);
|
||||||
}).then((r) => r.json());
|
let customURL = await getSetting<string>('store-url', 'https://plugins.deckbrew.xyz/plugins');
|
||||||
}
|
let storeURL;
|
||||||
|
if (!store) {
|
||||||
export function getLegacyPluginList(): Promise<LegacyStorePlugin[]> {
|
console.log('Could not get a default store, using Default.');
|
||||||
return fetch('https://plugins.deckbrew.xyz/get_plugins', {
|
await setSetting('store-url', Store.Default);
|
||||||
method: 'GET',
|
return fetch('https://plugins.deckbrew.xyz/plugins', {
|
||||||
}).then((r) => r.json());
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'X-Decky-Version': version.current,
|
||||||
|
},
|
||||||
|
}).then((r) => r.json());
|
||||||
|
} else {
|
||||||
|
switch (+store) {
|
||||||
|
case Store.Default:
|
||||||
|
storeURL = 'https://plugins.deckbrew.xyz/plugins';
|
||||||
|
break;
|
||||||
|
case Store.Testing:
|
||||||
|
storeURL = 'https://testing.deckbrew.xyz/plugins';
|
||||||
|
break;
|
||||||
|
case Store.Custom:
|
||||||
|
storeURL = customURL;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error('Somehow you ended up without a standard URL, using the default URL.');
|
||||||
|
storeURL = 'https://plugins.deckbrew.xyz/plugins';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return fetch(storeURL, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'X-Decky-Version': version.current,
|
||||||
|
},
|
||||||
|
}).then((r) => r.json());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function installFromURL(url: string) {
|
export async function installFromURL(url: string) {
|
||||||
@@ -49,31 +72,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) {
|
export async function requestPluginInstall(plugin: string, selectedVer: StorePluginVersion) {
|
||||||
await window.DeckyPluginLoader.callServerMethod('install_plugin', {
|
await window.DeckyPluginLoader.callServerMethod('install_plugin', {
|
||||||
name: plugin,
|
name: plugin,
|
||||||
@@ -94,7 +92,3 @@ export async function checkForUpdates(plugins: Plugin[]): Promise<PluginUpdateMa
|
|||||||
}
|
}
|
||||||
return updateMap;
|
return updateMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLegacyPlugin(plugin: LegacyStorePlugin | StorePlugin): plugin is LegacyStorePlugin {
|
|
||||||
return 'artifact' in plugin;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { Patch, QuickAccessTab, afterPatch, sleep } from 'decky-frontend-lib';
|
// TabsHook for versions before the Desktop merge
|
||||||
|
import { Patch, afterPatch, sleep } from 'decky-frontend-lib';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import Logger from './logger';
|
import NewTabsHook from './tabs-hook';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
|
||||||
__TABS_HOOK_INSTANCE: any;
|
|
||||||
}
|
|
||||||
interface Array<T> {
|
interface Array<T> {
|
||||||
__filter: any;
|
__filter: any;
|
||||||
}
|
}
|
||||||
@@ -17,16 +15,8 @@ const isTabsArray = (tabs: any) => {
|
|||||||
return length >= 7 && tabs[length - 1]?.tab;
|
return length >= 7 && tabs[length - 1]?.tab;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Tab {
|
class TabsHook extends NewTabsHook {
|
||||||
id: QuickAccessTab | number;
|
|
||||||
title: any;
|
|
||||||
content: any;
|
|
||||||
icon: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
class TabsHook extends Logger {
|
|
||||||
// private keys = 7;
|
// private keys = 7;
|
||||||
tabs: Tab[] = [];
|
|
||||||
private quickAccess: any;
|
private quickAccess: any;
|
||||||
private tabRenderer: any;
|
private tabRenderer: any;
|
||||||
private memoizedQuickAccess: any;
|
private memoizedQuickAccess: any;
|
||||||
@@ -38,12 +28,12 @@ class TabsHook extends Logger {
|
|||||||
private cNodePatch?: Patch;
|
private cNodePatch?: Patch;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('TabsHook');
|
super();
|
||||||
|
|
||||||
this.log('Initialized');
|
this.log('Initialized stable TabsHook');
|
||||||
window.__TABS_HOOK_INSTANCE?.deinit?.();
|
}
|
||||||
window.__TABS_HOOK_INSTANCE = this;
|
|
||||||
|
|
||||||
|
init() {
|
||||||
const self = this;
|
const self = this;
|
||||||
const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
|
const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
|
||||||
let scrollRoot: any;
|
let scrollRoot: any;
|
||||||
@@ -83,17 +73,17 @@ class TabsHook extends Logger {
|
|||||||
if (ret) {
|
if (ret) {
|
||||||
if (!newQATabRenderer) {
|
if (!newQATabRenderer) {
|
||||||
this.tabRenderer = ret.props.children[1].children.type;
|
this.tabRenderer = ret.props.children[1].children.type;
|
||||||
newQATabRenderer = (...args: any) => {
|
newQATabRenderer = (...qamArgs: any[]) => {
|
||||||
const oFilter = Array.prototype.filter;
|
const oFilter = Array.prototype.filter;
|
||||||
Array.prototype.filter = function (...args: any[]) {
|
Array.prototype.filter = function (...args: any[]) {
|
||||||
if (isTabsArray(this)) {
|
if (isTabsArray(this)) {
|
||||||
self.render(this);
|
self.render(this, qamArgs[0].visible);
|
||||||
}
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
return oFilter.call(this, ...args);
|
return oFilter.call(this, ...args);
|
||||||
};
|
};
|
||||||
// TODO remove array hack entirely and use this instead const tabs = ret.props.children.props.children[0].props.children[1].props.children[0].props.children[0].props.tabs
|
// TODO remove array hack entirely and use this instead const tabs = ret.props.children.props.children[0].props.children[1].props.children[0].props.children[0].props.tabs
|
||||||
const ret = this.tabRenderer(...args);
|
const ret = this.tabRenderer(...qamArgs);
|
||||||
Array.prototype.filter = oFilter;
|
Array.prototype.filter = oFilter;
|
||||||
return ret;
|
return ret;
|
||||||
};
|
};
|
||||||
@@ -124,27 +114,6 @@ class TabsHook extends Logger {
|
|||||||
if (this.rendererTree) this.rendererTree.type = this.tabRenderer;
|
if (this.rendererTree) this.rendererTree.type = this.tabRenderer;
|
||||||
if (this.cNode) this.cNode.stateNode.forceUpdate();
|
if (this.cNode) this.cNode.stateNode.forceUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
add(tab: Tab) {
|
|
||||||
this.debug('Adding tab', tab.id, 'to render array');
|
|
||||||
this.tabs.push(tab);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeById(id: number) {
|
|
||||||
this.debug('Removing tab', id);
|
|
||||||
this.tabs = this.tabs.filter((tab) => tab.id !== id);
|
|
||||||
}
|
|
||||||
|
|
||||||
render(existingTabs: any[]) {
|
|
||||||
for (const { title, icon, content, id } of this.tabs) {
|
|
||||||
existingTabs.push({
|
|
||||||
key: id,
|
|
||||||
title,
|
|
||||||
tab: icon,
|
|
||||||
panel: content,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TabsHook;
|
export default TabsHook;
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// TabsHook for versions after the Desktop merge
|
||||||
|
import { Patch, QuickAccessTab, afterPatch, findInReactTree, sleep } from 'decky-frontend-lib';
|
||||||
|
|
||||||
|
import { QuickAccessVisibleStateProvider } from './components/QuickAccessVisibleState';
|
||||||
|
import Logger from './logger';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__TABS_HOOK_INSTANCE: any;
|
||||||
|
securitystore: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Tab {
|
||||||
|
id: QuickAccessTab | number;
|
||||||
|
title: any;
|
||||||
|
content: any;
|
||||||
|
icon: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TabsHook extends Logger {
|
||||||
|
// private keys = 7;
|
||||||
|
tabs: Tab[] = [];
|
||||||
|
private qAMRoot?: any;
|
||||||
|
private qamPatch?: Patch;
|
||||||
|
private unsubscribeSecurity?: () => void;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('TabsHook');
|
||||||
|
|
||||||
|
this.log('Initialized');
|
||||||
|
window.__TABS_HOOK_INSTANCE?.deinit?.();
|
||||||
|
window.__TABS_HOOK_INSTANCE = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
|
||||||
|
let qAMRoot: any;
|
||||||
|
const findQAMRoot = (currentNode: any, iters: number): any => {
|
||||||
|
if (iters >= 55) {
|
||||||
|
// currently 45
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof currentNode?.memoizedProps?.visible == 'boolean' &&
|
||||||
|
currentNode?.type?.toString()?.includes('QuickAccessMenuBrowserView')
|
||||||
|
) {
|
||||||
|
this.log(`QAM root was found in ${iters} recursion cycles`);
|
||||||
|
return currentNode;
|
||||||
|
}
|
||||||
|
if (currentNode.child) {
|
||||||
|
let node = findQAMRoot(currentNode.child, iters + 1);
|
||||||
|
if (node !== null) return node;
|
||||||
|
}
|
||||||
|
if (currentNode.sibling) {
|
||||||
|
let node = findQAMRoot(currentNode.sibling, iters + 1);
|
||||||
|
if (node !== null) return node;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
(async () => {
|
||||||
|
qAMRoot = findQAMRoot(tree, 0);
|
||||||
|
while (!qAMRoot) {
|
||||||
|
this.error(
|
||||||
|
'Failed to find QAM root node, reattempting in 5 seconds. A developer may need to increase the recursion limit.',
|
||||||
|
);
|
||||||
|
await sleep(5000);
|
||||||
|
qAMRoot = findQAMRoot(tree, 0);
|
||||||
|
}
|
||||||
|
this.qAMRoot = qAMRoot;
|
||||||
|
let patchedInnerQAM: any;
|
||||||
|
this.qamPatch = afterPatch(qAMRoot.return, 'type', (_: any, ret: any) => {
|
||||||
|
try {
|
||||||
|
if (!qAMRoot?.child) {
|
||||||
|
qAMRoot = findQAMRoot(tree, 0);
|
||||||
|
this.qAMRoot = qAMRoot;
|
||||||
|
}
|
||||||
|
if (qAMRoot?.child && !qAMRoot?.child?.type?.decky) {
|
||||||
|
afterPatch(qAMRoot.child, 'type', (_: any, ret: any) => {
|
||||||
|
try {
|
||||||
|
const qamTabsRenderer = findInReactTree(ret, (x) => x?.props?.onFocusNavDeactivated);
|
||||||
|
if (patchedInnerQAM) {
|
||||||
|
qamTabsRenderer.type = patchedInnerQAM;
|
||||||
|
} else {
|
||||||
|
afterPatch(qamTabsRenderer, 'type', (innerArgs: any, ret: any) => {
|
||||||
|
const tabs = findInReactTree(ret, (x) => x?.props?.tabs);
|
||||||
|
this.render(tabs.props.tabs, innerArgs[0].visible);
|
||||||
|
return ret;
|
||||||
|
});
|
||||||
|
patchedInnerQAM = qamTabsRenderer.type;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.error('Error patching QAM inner', e);
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
});
|
||||||
|
qAMRoot.child.type.decky = true;
|
||||||
|
qAMRoot.child.alternate.type = qAMRoot.child.type;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.error('Error patching QAM', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (qAMRoot.return.alternate) {
|
||||||
|
qAMRoot.return.alternate.type = qAMRoot.return.type;
|
||||||
|
}
|
||||||
|
this.log('Finished initial injection');
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit() {
|
||||||
|
this.qamPatch?.unpatch();
|
||||||
|
this.qAMRoot.return.alternate.type = this.qAMRoot.return.type;
|
||||||
|
this.unsubscribeSecurity?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
add(tab: Tab) {
|
||||||
|
this.debug('Adding tab', tab.id, 'to render array');
|
||||||
|
this.tabs.push(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeById(id: number) {
|
||||||
|
this.debug('Removing tab', id);
|
||||||
|
this.tabs = this.tabs.filter((tab) => tab.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(existingTabs: any[], visible: boolean) {
|
||||||
|
let deckyTabAmount = existingTabs.reduce((prev: any, cur: any) => (cur.decky ? prev + 1 : prev), 0);
|
||||||
|
if (deckyTabAmount == this.tabs.length) {
|
||||||
|
for (let tab of existingTabs) {
|
||||||
|
if (tab?.decky) tab.panel.props.setter[0](visible);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const { title, icon, content, id } of this.tabs) {
|
||||||
|
existingTabs.push({
|
||||||
|
key: id,
|
||||||
|
title,
|
||||||
|
tab: icon,
|
||||||
|
decky: true,
|
||||||
|
panel: (
|
||||||
|
<QuickAccessVisibleStateProvider initial={visible} setter={[]}>
|
||||||
|
{content}
|
||||||
|
</QuickAccessVisibleStateProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TabsHook;
|
||||||
+107
-39
@@ -1,4 +1,4 @@
|
|||||||
import { Patch, ToastData, afterPatch, findInReactTree, findModuleChild, sleep } from 'decky-frontend-lib';
|
import { Patch, ToastData, afterPatch, findInReactTree, sleep } from 'decky-frontend-lib';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
import Toast from './components/Toast';
|
import Toast from './components/Toast';
|
||||||
@@ -12,13 +12,18 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Toaster extends Logger {
|
class Toaster extends Logger {
|
||||||
private instanceRetPatch?: Patch;
|
// private routerHook: RouterHook;
|
||||||
|
// private toasterState: DeckyToasterState = new DeckyToasterState();
|
||||||
private node: any;
|
private node: any;
|
||||||
|
private rNode: any;
|
||||||
private settingsModule: any;
|
private settingsModule: any;
|
||||||
private ready: boolean = false;
|
private finishStartup?: () => void;
|
||||||
|
private ready: Promise<void> = new Promise((res) => (this.finishStartup = res));
|
||||||
|
private toasterPatch?: Patch;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('Toaster');
|
super('Toaster');
|
||||||
|
// this.routerHook = routerHook;
|
||||||
|
|
||||||
window.__TOASTER_INSTANCE?.deinit?.();
|
window.__TOASTER_INSTANCE?.deinit?.();
|
||||||
window.__TOASTER_INSTANCE = this;
|
window.__TOASTER_INSTANCE = this;
|
||||||
@@ -26,55 +31,116 @@ class Toaster extends Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
|
// this.routerHook.addGlobalComponent('DeckyToaster', () => (
|
||||||
|
// <DeckyToasterStateContextProvider deckyToasterState={this.toasterState}>
|
||||||
|
// <DeckyToaster />
|
||||||
|
// </DeckyToasterStateContextProvider>
|
||||||
|
// ));
|
||||||
let instance: any;
|
let instance: any;
|
||||||
|
const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
|
||||||
while (true) {
|
const findToasterRoot = (currentNode: any, iters: number): any => {
|
||||||
instance = findInReactTree(
|
if (iters >= 50) {
|
||||||
(document.getElementById('root') as any)._reactRootContainer._internalRoot.current,
|
// currently 40
|
||||||
(x) => x?.memoizedProps?.className?.startsWith?.('toastmanager_ToastPlaceholder'),
|
return null;
|
||||||
|
}
|
||||||
|
if (currentNode?.memoizedProps?.className?.startsWith?.('toastmanager_ToastPlaceholder')) {
|
||||||
|
this.log(`Toaster root was found in ${iters} recursion cycles`);
|
||||||
|
return currentNode;
|
||||||
|
}
|
||||||
|
if (currentNode.sibling) {
|
||||||
|
let node = findToasterRoot(currentNode.sibling, iters + 1);
|
||||||
|
if (node !== null) return node;
|
||||||
|
}
|
||||||
|
if (currentNode.child) {
|
||||||
|
let node = findToasterRoot(currentNode.child, iters + 1);
|
||||||
|
if (node !== null) return node;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
instance = findToasterRoot(tree, 0);
|
||||||
|
while (!instance) {
|
||||||
|
this.error(
|
||||||
|
'Failed to find Toaster root node, reattempting in 5 seconds. A developer may need to increase the recursion limit.',
|
||||||
);
|
);
|
||||||
if (instance) break;
|
await sleep(5000);
|
||||||
this.debug('finding instance');
|
instance = findToasterRoot(tree, 0);
|
||||||
await sleep(2000);
|
|
||||||
}
|
}
|
||||||
|
this.node = instance.return;
|
||||||
this.node = instance.sibling.child;
|
this.rNode = this.node.return;
|
||||||
let toast: any;
|
let toast: any;
|
||||||
let renderedToast: ReactNode = null;
|
let renderedToast: ReactNode = null;
|
||||||
afterPatch(this.node, 'type', (args: any[], ret: any) => {
|
let innerPatched: any;
|
||||||
const currentToast = args[0].notification;
|
const repatch = () => {
|
||||||
if (currentToast?.decky) {
|
if (this.node && !this.node.type.decky) {
|
||||||
if (currentToast !== toast) {
|
this.toasterPatch = afterPatch(this.node, 'type', (_: any, ret: any) => {
|
||||||
toast = currentToast;
|
const inner = findInReactTree(ret.props.children, (x) => x?.props?.onDismiss);
|
||||||
renderedToast = <Toast toast={toast} />;
|
if (innerPatched) {
|
||||||
}
|
inner.type = innerPatched;
|
||||||
ret.props.children = renderedToast;
|
} else {
|
||||||
} else {
|
afterPatch(inner, 'type', (innerArgs: any, ret: any) => {
|
||||||
toast = null;
|
const currentToast = innerArgs[0]?.notification;
|
||||||
renderedToast = null;
|
if (currentToast?.decky) {
|
||||||
|
if (currentToast == toast) {
|
||||||
|
ret.props.children = renderedToast;
|
||||||
|
} else {
|
||||||
|
toast = currentToast;
|
||||||
|
renderedToast = <Toast toast={toast.data} />;
|
||||||
|
ret.props.children = renderedToast;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast = null;
|
||||||
|
renderedToast = null;
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
});
|
||||||
|
innerPatched = inner.type;
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
});
|
||||||
|
this.node.type.decky = true;
|
||||||
|
this.node.alternate.type = this.node.type;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
const oRender = this.rNode.stateNode.__proto__.render;
|
||||||
|
let int: NodeJS.Timer | undefined;
|
||||||
|
this.rNode.stateNode.render = (...args: any[]) => {
|
||||||
|
const ret = oRender.call(this.rNode.stateNode, ...args);
|
||||||
|
if (ret && !this?.node?.return?.return) {
|
||||||
|
clearInterval(int);
|
||||||
|
int = setInterval(() => {
|
||||||
|
const n = findToasterRoot(tree, 0);
|
||||||
|
if (n?.return) {
|
||||||
|
clearInterval(int);
|
||||||
|
this.node = n.return;
|
||||||
|
this.rNode = this.node.return;
|
||||||
|
repatch();
|
||||||
|
} else {
|
||||||
|
this.error('Failed to re-grab Toaster node, trying again...');
|
||||||
|
}
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
repatch();
|
||||||
return ret;
|
return ret;
|
||||||
});
|
};
|
||||||
this.settingsModule = findModuleChild((m) => {
|
|
||||||
if (typeof m !== 'object') return undefined;
|
this.rNode.stateNode.shouldComponentUpdate = () => true;
|
||||||
for (let prop in m) {
|
this.rNode.stateNode.forceUpdate();
|
||||||
if (typeof m[prop]?.settings && m[prop]?.communityPreferences) return m[prop];
|
delete this.rNode.stateNode.shouldComponentUpdate;
|
||||||
}
|
|
||||||
});
|
|
||||||
this.log('Initialized');
|
this.log('Initialized');
|
||||||
this.ready = true;
|
this.finishStartup?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
async toast(toast: ToastData) {
|
async toast(toast: ToastData) {
|
||||||
while (!this.ready) {
|
// toast.duration = toast.duration || 5e3;
|
||||||
await sleep(100);
|
// this.toasterState.addToast(toast);
|
||||||
}
|
await this.ready;
|
||||||
const settings = this.settingsModule?.settings;
|
const settings = this.settingsModule?.settings;
|
||||||
let toastData = {
|
let toastData = {
|
||||||
nNotificationID: window.NotificationStore.m_nNextTestNotificationID++,
|
nNotificationID: window.NotificationStore.m_nNextTestNotificationID++,
|
||||||
rtCreated: Date.now(),
|
rtCreated: Date.now(),
|
||||||
eType: 15,
|
eType: 15,
|
||||||
nToastDurationMS: toast.duration || 5e3,
|
nToastDurationMS: toast.duration || (toast.duration = 5e3),
|
||||||
data: toast,
|
data: toast,
|
||||||
decky: true,
|
decky: true,
|
||||||
};
|
};
|
||||||
@@ -90,9 +156,11 @@ class Toaster extends Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deinit() {
|
deinit() {
|
||||||
this.instanceRetPatch?.unpatch();
|
this.toasterPatch?.unpatch();
|
||||||
this.node && delete this.node.stateNode.render;
|
this.node.alternate.type = this.node.type;
|
||||||
this.node && this.node.stateNode.forceUpdate();
|
delete this.rNode.stateNode.render;
|
||||||
|
this.ready = new Promise((res) => (this.finishStartup = res));
|
||||||
|
// this.routerHook.removeGlobalComponent('DeckyToaster');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export enum Branches {
|
export enum Branches {
|
||||||
Release,
|
Release,
|
||||||
Prerelease,
|
Prerelease,
|
||||||
Nightly,
|
// Testing,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeckyUpdater {
|
export interface DeckyUpdater {
|
||||||
|
|||||||
@@ -1,25 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
interface GetSettingArgs<T> {
|
import { getSetting, setSetting } from '../settings';
|
||||||
key: string;
|
|
||||||
default: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SetSettingArgs<T> {
|
export function useSetting<T>(key: string, def: T): [value: T, setValue: (value: T) => Promise<void>] {
|
||||||
key: string;
|
|
||||||
value: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSetting<T>(key: string, def: T): [value: T | null, setValue: (value: T) => Promise<void>] {
|
|
||||||
const [value, setValue] = useState(def);
|
const [value, setValue] = useState(def);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const res = (await window.DeckyPluginLoader.callServerMethod('get_setting', {
|
const res = await getSetting<T>(key, def);
|
||||||
key,
|
setValue(res);
|
||||||
default: def,
|
|
||||||
} as GetSettingArgs<T>)) as { result: T };
|
|
||||||
setValue(res.result);
|
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -27,10 +16,7 @@ export function useSetting<T>(key: string, def: T): [value: T | null, setValue:
|
|||||||
value,
|
value,
|
||||||
async (val: T) => {
|
async (val: T) => {
|
||||||
setValue(val);
|
setValue(val);
|
||||||
await window.DeckyPluginLoader.callServerMethod('set_setting', {
|
await setSetting(key, val);
|
||||||
key,
|
|
||||||
value: val,
|
|
||||||
} as SetSettingArgs<T>);
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function findSP(): Window {
|
||||||
|
// old (SP as host)
|
||||||
|
if (document.title == 'SP') return window;
|
||||||
|
// new (SP as popup)
|
||||||
|
return FocusNavController.m_ActiveContext.m_rgGamepadNavigationTrees.find((x: any) => x.m_ID == 'root_1_').Root
|
||||||
|
.Element.ownerDocument.defaultView;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user