summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/release.yml2
-rw-r--r--README.md16
-rw-r--r--backend/decky_loader/browser.py6
-rw-r--r--backend/decky_loader/loader.py9
-rw-r--r--backend/decky_loader/locales/en-US.json21
-rw-r--r--backend/decky_loader/localplatform/localplatformlinux.py26
-rw-r--r--backend/decky_loader/plugin/plugin.py1
-rw-r--r--backend/decky_loader/plugin/sandboxed_plugin.py1
-rw-r--r--backend/decky_loader/utilities.py38
-rw-r--r--docs/images/download_button.svg183
-rw-r--r--frontend/package.json4
-rw-r--r--frontend/pnpm-lock.yaml20
-rw-r--r--frontend/src/components/DeckyState.tsx18
-rw-r--r--frontend/src/components/PluginView.tsx46
-rw-r--r--frontend/src/components/TitleView.tsx1
-rw-r--r--frontend/src/components/modals/MultiplePluginsInstallModal.tsx7
-rw-r--r--frontend/src/components/modals/PluginDisableModal.tsx39
-rw-r--r--frontend/src/components/modals/PluginInstallModal.tsx11
-rw-r--r--frontend/src/components/modals/PluginUninstallModal.tsx12
-rw-r--r--frontend/src/components/settings/pages/plugin_list/PluginListLabel.tsx19
-rw-r--r--frontend/src/components/settings/pages/plugin_list/index.tsx84
-rw-r--r--frontend/src/components/settings/pages/testing/index.tsx12
-rw-r--r--frontend/src/components/store/PluginCard.tsx19
-rw-r--r--frontend/src/components/store/Store.tsx3
-rw-r--r--frontend/src/index.ts10
-rw-r--r--frontend/src/plugin-loader.tsx104
-rw-r--r--frontend/src/plugin.ts4
-rw-r--r--frontend/src/store.tsx11
-rw-r--r--frontend/src/tabs-hook.tsx25
-rw-r--r--frontend/src/toaster.tsx1
30 files changed, 472 insertions, 281 deletions
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b8a0f724..ac1637e4 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -36,7 +36,7 @@ jobs:
uses: actions/checkout@v4
- name: Install semver-tool asdf
- uses: asdf-vm/actions/install@v3
+ uses: asdf-vm/actions/install@v4
with:
tool_versions: |
semver 3.4.0
diff --git a/README.md b/README.md
index 355c49de..87b8bd44 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
<br>
Decky Loader
<br>
- <a name="download button" href="https://github.com/SteamDeckHomebrew/decky-installer/releases/latest/download/decky_installer.desktop"><img src="./docs/images/download_button.svg" alt="Download decky" width="350px" style="padding-top: 15px;"></a>
+ <a name="download button" href="https://github.com/SteamDeckHomebrew/decky-installer/releases/latest/download/decky_installer.desktop"><img src="./docs/images/download_button.svg" alt="Download decky" width="150px" style="padding-top: 15px;"></a>
</h1>
<p align="center">
@@ -18,6 +18,15 @@
<!-- <img src="https://media.discordapp.net/attachments/966017112244125756/1012466063893610506/main.jpg" alt="Decky screenshot" width="80%">-->
</p>
+## 🩵 Backers and Sponsors
+
+[Become a backer or sponsor](https://opencollective.com/steamdeckhomebrew) to support our work! Contributing to our collective effort will help Decky Loader developers cover the costs of web servers, acquire new development hardware, and more.
+
+<!-- SPONSORS COMMENTED OUT UNTIL WE GET SOME SPONSORS TO AVOID BLANK SVG SPACE -->
+<a href="https://opencollective.com/steamdeckhomebrew"><img alt="Steam Deck Homebrew sponsors on Open Collective" src="https://opencollective.com/steamdeckhomebrew/sponsors.svg?button=true&avatarHeight=46&width=750"></a>
+
+<a href="https://opencollective.com/steamdeckhomebrew"><img alt="Steam Deck Homebrew backers on Open Collective" src="https://opencollective.com/steamdeckhomebrew/backers.svg?button=false&avatarHeight=46&width=750"></a>
+
## 📖 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://plugins.deckbrew.xyz/).
@@ -40,7 +49,9 @@ For more information about Decky Loader as well as documentation and development
- Sometimes Decky will disappear on SteamOS updates. This can easily be fixed by just re-running the installer and installing the stable branch again. If this doesn't work, try installing the prerelease instead. If that doesn't work, then [check the existing issues](https://github.com/SteamDeckHomebrew/decky-loader/issues) and if there isn't one then you can [file a new issue](https://github.com/SteamDeckHomebrew/decky-loader/issues/new?assignees=&labels=bug&template=bug_report.yml&title=%5BBUG%5D+%3Ctitle%3E).
## 💾 Installation
+
- This installation can be done without an admin/sudo password set.
+
1. Prepare a mouse and keyboard if possible.
- Keyboards and mice can be connected to the Steam Deck via USB-C or Bluetooth.
- Many Bluetooth keyboard and mouse apps are available for iOS and Android. (KDE connect is preinstalled on the steam deck)
@@ -54,7 +65,7 @@ For more information about Decky Loader as well as documentation and development
1. Either type your admin password or allow Decky to temporarily set your admin password to `Decky!` (this password will be removed after the installer finishes)
1. Choose the version of Decky Loader you want to install.
- **Latest Release**
- Intended for most users. This is the latest stable version of Decky Loader.
+ Intended for most users. This is the latest stable version of Decky Loader.
- **Latest Pre-Release**
Intended for plugin developers. Pre-releases are unlikely to be fully stable but contain the latest changes. For more information on plugin development, please consult [the wiki page](https://wiki.deckbrew.xyz/en/loader-dev/development).
1. Open the Return to Gaming Mode shortcut on your desktop.
@@ -68,6 +79,7 @@ We are sorry to see you go! If you are considering uninstalling because you are
1. Press the <img src="./docs/images/light/steam.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/steam.svg#gh-light-mode-only" height=16> button and open the Power menu.
1. Select "Switch to Desktop".
1. Run the installer file again, and select `uninstall decky loader`.
+
- There is also a fast uninstall for those who can use Konsole. Run `curl -L https://github.com/SteamDeckHomebrew/decky-installer/releases/latest/download/uninstall.sh | sh` and type your password when prompted.
## 🚀 Getting Started
diff --git a/backend/decky_loader/browser.py b/backend/decky_loader/browser.py
index 975a917a..fe8ae71a 100644
--- a/backend/decky_loader/browser.py
+++ b/backend/decky_loader/browser.py
@@ -150,6 +150,7 @@ class PluginBrowser:
# plugins_snapshot = self.plugins.copy()
# snapshot_string = pformat(plugins_snapshot)
# logger.debug("current plugins: %s", snapshot_string)
+
if name in self.plugins:
logger.debug("Plugin %s was found", name)
await self.plugins[name].stop(uninstall=True)
@@ -345,5 +346,10 @@ class PluginBrowser:
if name in plugin_order:
plugin_order.remove(name)
self.settings.setSetting("pluginOrder", plugin_order)
+
+ disabled_plugins: List[str] = self.settings.getSetting("disabled_plugins", [])
+ if name in disabled_plugins:
+ disabled_plugins.remove(name)
+ self.settings.setSetting("disabled_plugins", disabled_plugins)
logger.debug("Removed any settings for plugin %s", name)
diff --git a/backend/decky_loader/loader.py b/backend/decky_loader/loader.py
index e2e619f7..4574cd1d 100644
--- a/backend/decky_loader/loader.py
+++ b/backend/decky_loader/loader.py
@@ -78,6 +78,7 @@ class Loader:
self.live_reload = live_reload
self.reload_queue: ReloadQueue = Queue()
self.loop.create_task(self.handle_reloads())
+ self.context: PluginManager = server_instance
if live_reload:
self.observer = Observer()
@@ -130,7 +131,7 @@ class Loader:
async def get_plugins(self):
plugins = list(self.plugins.values())
- return [{"name": str(i), "version": i.version, "load_type": i.load_type} for i in plugins]
+ return [{"name": str(i), "version": i.version, "load_type": i.load_type, "disabled": i.disabled} for i in plugins]
async def handle_plugin_dist(self, request: web.Request):
plugin = self.plugins[request.match_info["plugin_name"]]
@@ -164,6 +165,10 @@ class Loader:
await self.ws.emit(f"loader/plugin_event", {"plugin": plugin.name, "event": event, "args": args})
plugin = PluginWrapper(file, plugin_directory, self.plugin_path, plugin_emitted_event)
+ if hasattr(self.context, "utilities") and plugin.name in await self.context.utilities.get_setting("disabled_plugins",[]):
+ plugin.disabled = True
+ self.plugins[plugin.name] = plugin
+ return
if plugin.name in self.plugins:
if not "debug" in plugin.flags and refresh:
self.logger.info(f"Plugin {plugin.name} is already loaded and has requested to not be re-loaded")
@@ -183,7 +188,7 @@ class Loader:
print_exc()
async def dispatch_plugin(self, name: str, version: str | None, load_type: int = PluginLoadType.ESMODULE_V1.value):
- await self.ws.emit("loader/import_plugin", name, version, load_type)
+ await self.ws.emit("loader/import_plugin", name, version, load_type, True, 15000)
async def import_plugins(self):
self.logger.info(f"import plugins from {self.plugin_path}")
diff --git a/backend/decky_loader/locales/en-US.json b/backend/decky_loader/locales/en-US.json
index 836f4878..1f87fe3b 100644
--- a/backend/decky_loader/locales/en-US.json
+++ b/backend/decky_loader/locales/en-US.json
@@ -102,6 +102,7 @@
},
"no_hash": "This plugin does not have a hash, you are installing it at your own risk.",
"not_installed": "(not installed)",
+ "disabled": "The plugin will be re-enabled after installation",
"overwrite": {
"button_idle": "Overwrite",
"button_processing": "Overwriting",
@@ -133,10 +134,13 @@
"uninstall": "Uninstall",
"update_all_one": "Update 1 plugin",
"update_all_other": "Update {{count}} plugins",
- "update_to": "Update to {{name}}"
+ "update_to": "Update to {{name}}",
+ "disable": "Disable",
+ "enable": "Enable"
},
"PluginListLabel": {
- "hidden": "Hidden from the quick access menu"
+ "hidden": "Hidden from the quick access menu",
+ "disabled": "Plugin disabled"
},
"PluginLoader": {
"decky_title": "Decky",
@@ -152,12 +156,23 @@
"desc": "Are you sure you want to uninstall {{name}}?",
"title": "Uninstall {{name}}"
},
+ "plugin_disable": {
+ "button": "Disable",
+ "desc": "Are you sure you want to disable {{name}}?",
+ "title": "Disable {{name}}",
+ "error": "Error disabling {{name}}"
+ },
+ "plugin_enable": {
+ "error": "Error enabling {{name}}"
+ },
"plugin_update_one": "Updates available for 1 plugin!",
"plugin_update_other": "Updates available for {{count}} plugins!"
},
"PluginView": {
"hidden_one": "1 plugin is hidden from this list",
- "hidden_other": "{{count}} plugins are hidden from this list"
+ "hidden_other": "{{count}} plugins are hidden from this list",
+ "disabled_one": "1 plugin is disabled",
+ "disabled_other": "{{count}} plugins are disabled"
},
"RemoteDebugging": {
"remote_cef": {
diff --git a/backend/decky_loader/localplatform/localplatformlinux.py b/backend/decky_loader/localplatform/localplatformlinux.py
index 1c8f2ace..21993e4c 100644
--- a/backend/decky_loader/localplatform/localplatformlinux.py
+++ b/backend/decky_loader/localplatform/localplatformlinux.py
@@ -116,28 +116,26 @@ def get_username() -> str:
return _get_user()
def setgid(user : UserType = UserType.HOST_USER):
- user_id = 0
-
- if user == UserType.HOST_USER:
- user_id = _get_user_group_id()
+ host_user_group_id, effective_user_group_id = _get_user_group_id(), _get_effective_user_group_id()
+ if host_user_group_id == effective_user_group_id:
+ pass
+ elif user == UserType.HOST_USER:
+ os.setgid(host_user_group_id)
elif user == UserType.EFFECTIVE_USER:
- pass # we already are
+ os.setgid(effective_user_group_id)
else:
raise Exception("Unknown user type")
-
- os.setgid(user_id)
def setuid(user : UserType = UserType.HOST_USER):
- user_id = 0
-
- if user == UserType.HOST_USER:
- user_id = _get_user_id()
+ host_user_id, effective_user_id = _get_user_id(), _get_effective_user_id()
+ if host_user_id == effective_user_id:
+ pass
+ elif user == UserType.HOST_USER:
+ os.setuid(host_user_id)
elif user == UserType.EFFECTIVE_USER:
- pass # we already are
+ os.setuid(effective_user_id)
else:
raise Exception("Unknown user type")
-
- os.setuid(user_id)
async def service_active(service_name : str) -> bool:
res, _, _ = await run(["systemctl", "is-active", service_name], stdout=DEVNULL, stderr=DEVNULL)
diff --git a/backend/decky_loader/plugin/plugin.py b/backend/decky_loader/plugin/plugin.py
index 61de4b1f..a7edaa45 100644
--- a/backend/decky_loader/plugin/plugin.py
+++ b/backend/decky_loader/plugin/plugin.py
@@ -41,6 +41,7 @@ class PluginWrapper:
self.author = json["author"]
self.flags = json["flags"]
self.api_version = json["api_version"] if "api_version" in json else 0
+ self.disabled = False
self.passive = not path.isfile(self.file)
diff --git a/backend/decky_loader/plugin/sandboxed_plugin.py b/backend/decky_loader/plugin/sandboxed_plugin.py
index 71e1d17b..20da747e 100644
--- a/backend/decky_loader/plugin/sandboxed_plugin.py
+++ b/backend/decky_loader/plugin/sandboxed_plugin.py
@@ -14,6 +14,7 @@ from ..localplatform.localsocket import LocalSocket
from ..localplatform.localplatform import setgid, setuid, get_username, get_home_path, ON_LINUX
from ..enums import UserType
from .. import helpers
+from .. import settings # pyright: ignore [reportUnusedImport]
from typing import List, TypeVar, Any
diff --git a/backend/decky_loader/utilities.py b/backend/decky_loader/utilities.py
index 69c69fe6..75593fd5 100644
--- a/backend/decky_loader/utilities.py
+++ b/backend/decky_loader/utilities.py
@@ -1,5 +1,5 @@
from __future__ import annotations
-from os import stat_result
+from os import path, stat_result
import uuid
from urllib.parse import unquote
from json.decoder import JSONDecodeError
@@ -8,7 +8,7 @@ import re
from traceback import format_exc
from stat import FILE_ATTRIBUTE_HIDDEN # pyright: ignore [reportAttributeAccessIssue, reportUnknownVariableType]
-from asyncio import StreamReader, StreamWriter, start_server, gather, open_connection
+from asyncio import StreamReader, StreamWriter, sleep, start_server, gather, open_connection
from aiohttp import ClientSession, hdrs
from aiohttp.web import Request, StreamResponse, Response, json_response, post
from typing import TYPE_CHECKING, Callable, Coroutine, Dict, Any, List, TypedDict
@@ -80,6 +80,8 @@ class Utilities:
context.ws.add_route("utilities/restart_webhelper", self.restart_webhelper)
context.ws.add_route("utilities/close_cef_socket", self.close_cef_socket)
context.ws.add_route("utilities/_call_legacy_utility", self._call_legacy_utility)
+ context.ws.add_route("utilities/enable_plugin", self.enable_plugin)
+ context.ws.add_route("utilities/disable_plugin", self.disable_plugin)
context.web_app.add_routes([
post("/methods/{method_name}", self._handle_legacy_server_method_call)
@@ -214,7 +216,7 @@ class Utilities:
async def http_request_legacy(self, method: str, url: str, extra_opts: Any = {}, timeout: int | None = None):
async with ClientSession() as web:
- res = await web.request(method, url, ssl=helpers.get_ssl_context(), timeout=timeout, **extra_opts)
+ res = await web.request(method, url, ssl=helpers.get_ssl_context(), timeout=timeout, **extra_opts) # type: ignore
text = await res.text()
return {
"status": res.status,
@@ -390,7 +392,6 @@ class Utilities:
"total": len(all),
}
-
# Based on https://stackoverflow.com/a/46422554/13174603
def start_rdt_proxy(self, ip: str, port: int):
async def pipe(reader: StreamReader, writer: StreamWriter):
@@ -474,3 +475,32 @@ class Utilities:
async def get_tab_id(self, name: str):
return (await get_tab(name)).id
+
+ async def disable_plugin(self, name: str):
+ disabled_plugins: List[str] = await self.get_setting("disabled_plugins", [])
+ if name not in disabled_plugins:
+ disabled_plugins.append(name)
+ await self.set_setting("disabled_plugins", disabled_plugins)
+
+ await self.context.plugin_loader.plugins[name].stop()
+ await self.context.ws.emit("loader/disable_plugin", name)
+
+ async def enable_plugin(self, name: str):
+ plugin_folder = self.context.plugin_browser.find_plugin_folder(name)
+ assert plugin_folder is not None
+ plugin_dir = path.join(self.context.plugin_browser.plugin_path, plugin_folder)
+
+ if name in self.context.plugin_loader.plugins:
+ plugin = self.context.plugin_loader.plugins[name]
+ if plugin.proc and plugin.proc.is_alive():
+ await plugin.stop()
+ self.context.plugin_loader.plugins.pop(name, None)
+ await sleep(1)
+
+ disabled_plugins: List[str] = await self.get_setting("disabled_plugins", [])
+
+ if name in disabled_plugins:
+ disabled_plugins.remove(name)
+ await self.set_setting("disabled_plugins", disabled_plugins)
+
+ await self.context.plugin_loader.import_plugin(path.join(plugin_dir, "main.py"), plugin_folder) \ No newline at end of file
diff --git a/docs/images/download_button.svg b/docs/images/download_button.svg
index 9f5d28b1..3a95878b 100644
--- a/docs/images/download_button.svg
+++ b/docs/images/download_button.svg
@@ -1,162 +1,27 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<!-- Created with Inkscape (http://www.inkscape.org/) -->
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 176.36 38">
+ <!-- Generator: Adobe Illustrator 29.2.1, SVG Export Plug-In . SVG Version: 2.1.0 Build 116) -->
+ <defs>
+ <style>
+ .st0 {
+ fill: #3fafa8;
+ }
-<svg
- width="81.700577mm"
- height="24.334814mm"
- viewBox="0 0 81.700577 24.334814"
- version="1.1"
- id="svg5"
- inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
- sodipodi:docname="download.svg"
- xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
- xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
- xmlns:xlink="http://www.w3.org/1999/xlink"
- xmlns="http://www.w3.org/2000/svg"
- xmlns:svg="http://www.w3.org/2000/svg">
- <sodipodi:namedview
- id="namedview7"
- pagecolor="#505050"
- bordercolor="#ffffff"
- borderopacity="1"
- inkscape:showpageshadow="0"
- inkscape:pageopacity="0"
- inkscape:pagecheckerboard="1"
- inkscape:deskcolor="#505050"
- inkscape:document-units="mm"
- showgrid="false"
- inkscape:zoom="3.659624"
- inkscape:cx="115.44902"
- inkscape:cy="59.295709"
- inkscape:window-width="1827"
- inkscape:window-height="1233"
- inkscape:window-x="69"
- inkscape:window-y="38"
- inkscape:window-maximized="0"
- inkscape:current-layer="layer1" />
- <defs
- id="defs2">
- <linearGradient
- inkscape:collect="always"
- id="linearGradient4494">
- <stop
- style="stop-color:#009fff;stop-opacity:1;"
- offset="0"
- id="stop4490" />
- <stop
- style="stop-color:#ff1965;stop-opacity:1;"
- offset="0.79417855"
- id="stop4498" />
- <stop
- style="stop-color:#b9b500;stop-opacity:1;"
- offset="1"
- id="stop4492" />
- </linearGradient>
- <linearGradient
- inkscape:collect="always"
- xlink:href="#linearGradient4494"
- id="linearGradient4496"
- x1="49.131042"
- y1="118.6573"
- x2="150.29259"
- y2="138.74957"
- gradientUnits="userSpaceOnUse"
- spreadMethod="pad"
- gradientTransform="matrix(1.0500324,0,0,1,-1.6155884,24.621921)" />
- <linearGradient
- inkscape:collect="always"
- xlink:href="#linearGradient4494"
- id="linearGradient13802"
- gradientUnits="userSpaceOnUse"
- gradientTransform="matrix(1.0500324,0,0,1,-1.6155884,24.621921)"
- x1="49.131042"
- y1="118.6573"
- x2="150.29259"
- y2="138.74957"
- spreadMethod="pad" />
+ .st1 {
+ fill: #fff;
+ }
+ </style>
</defs>
- <g
- inkscape:label="Layer 1"
- inkscape:groupmode="layer"
- id="layer1"
- transform="translate(-64.149712,-136.3326)">
- <rect
- style="mix-blend-mode:normal;fill:url(#linearGradient13802);fill-opacity:1;stroke:none;stroke-width:0.271121"
- id="rect111"
- width="81.700577"
- height="24.334814"
- x="64.149712"
- y="136.3326"
- ry="8.1781616" />
- <text
- xml:space="preserve"
- style="font-size:3.175px;fill:#000000;stroke:none;stroke-width:0.264583"
- x="66.364288"
- y="124.84658"
- id="text10382"><tspan
- sodipodi:role="line"
- id="tspan10380"
- style="stroke-width:0.264583"
- x="66.364288"
- y="124.84658" /></text>
- <text
- xml:space="preserve"
- style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:15.1694px;font-family:sans-serif;-inkscape-font-specification:sans-serif;white-space:pre;inline-size:82.6483;display:inline;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.264583"
- x="67.732498"
- y="126.05277"
- id="text10440"
- transform="translate(1.088576,28.135753)"><tspan
- x="67.732498"
- y="126.05277"
- id="tspan13872">Download</tspan></text>
- <rect
- style="mix-blend-mode:normal;fill:url(#linearGradient4496);fill-opacity:1;stroke:none;stroke-width:0.271121"
- id="rect13792"
- width="81.700577"
- height="24.334814"
- x="64.149712"
- y="136.3326"
- ry="8.1781616" />
- <text
- xml:space="preserve"
- style="font-size:3.175px;fill:#000000;stroke:none;stroke-width:0.264583"
- x="66.364288"
- y="124.84658"
- id="text13796"><tspan
- sodipodi:role="line"
- id="tspan13794"
- style="stroke-width:0.264583"
- x="66.364288"
- y="124.84658" /></text>
- <g
- aria-label="Download"
- transform="translate(1.088576,28.135753)"
- id="text13800"
- style="font-size:15.1694px;-inkscape-font-specification:sans-serif;white-space:pre;inline-size:82.6483;display:inline;fill:#ffffff;stroke-width:0.264583">
- <path
- d="m 77.880751,120.53111 q 0,2.74566 -1.501771,4.14125 -1.486601,1.38041 -4.156416,1.38041 h -3.01871 v -10.83095 h 3.337268 q 1.638295,0 2.836678,0.60678 1.198382,0.60677 1.850666,1.78999 0.652285,1.16804 0.652285,2.91252 z m -1.441093,0.0455 q 0,-2.16923 -1.077028,-3.17041 -1.061858,-1.01635 -3.01871,-1.01635 H 70.5691 v 8.49487 h 1.471432 q 4.399126,0 4.399126,-4.30811 z"
- id="path13828" />
- <path
- d="m 87.164417,121.9722 q 0,2.01753 -1.03152,3.1249 -1.01635,1.10737 -2.760831,1.10737 -1.077027,0 -1.926513,-0.48542 -0.834317,-0.50059 -1.319738,-1.4411 -0.485421,-0.95567 -0.485421,-2.30575 0,-2.01753 1.01635,-3.10972 1.01635,-1.0922 2.760831,-1.0922 1.107366,0 1.941683,0.50059 0.849486,0.48542 1.319738,1.42592 0.485421,0.92534 0.485421,2.27541 z m -6.143608,0 q 0,1.4411 0.561268,2.29058 0.576437,0.83432 1.820328,0.83432 1.228722,0 1.805159,-0.83432 0.576437,-0.84948 0.576437,-2.29058 0,-1.44109 -0.576437,-2.26024 -0.576437,-0.81914 -1.820328,-0.81914 -1.243891,0 -1.805159,0.81914 -0.561268,0.81915 -0.561268,2.26024 z"
- id="path13830" />
- <path
- d="m 94.218174,121.45644 q -0.197202,-0.62194 -0.348896,-1.21355 -0.136525,-0.60677 -0.212372,-0.9405 h -0.06068 q -0.06068,0.33373 -0.197203,0.9405 -0.136524,0.59161 -0.348896,1.22872 l -1.456262,4.56599 h -1.51694 l -2.229902,-8.1308 h 1.380415 l 1.122536,4.33845 q 0.166863,0.65229 0.318557,1.31974 0.151694,0.66745 0.212372,1.10737 h 0.06068 q 0.06068,-0.25788 0.136525,-0.63712 0.09102,-0.37923 0.197202,-0.78881 0.106186,-0.42474 0.212372,-0.75847 l 1.441093,-4.58116 h 1.456262 l 1.395585,4.58116 q 0.166864,0.51576 0.318558,1.12254 0.166863,0.60678 0.227541,1.04669 h 0.06068 q 0.04551,-0.37924 0.197202,-1.04669 0.166864,-0.66745 0.348897,-1.36525 l 1.137705,-4.33845 h 1.365246 l -2.260241,8.1308 h -1.562448 z"
- id="path13832" />
- <path
- d="m 104.8064,117.77028 q 1.45627,0 2.19957,0.71296 0.7433,0.69779 0.7433,2.27541 v 5.29412 h -1.31974 v -5.2031 q 0,-1.95685 -1.82033,-1.95685 -1.35007,0 -1.86583,0.75847 -0.51576,0.75847 -0.51576,2.18439 v 4.21709 h -1.33491 v -8.1308 h 1.07703 l 0.1972,1.10737 h 0.0759 q 0.3944,-0.63711 1.09219,-0.9405 0.69779,-0.31856 1.47143,-0.31856 z"
- id="path13834" />
- <path
- d="m 111.6023,126.05277 h -1.33491 v -11.52874 h 1.33491 z"
- id="path13836" />
- <path
- d="m 121.25003,121.9722 q 0,2.01753 -1.03152,3.1249 -1.01635,1.10737 -2.76084,1.10737 -1.07702,0 -1.92651,-0.48542 -0.83432,-0.50059 -1.31974,-1.4411 -0.48542,-0.95567 -0.48542,-2.30575 0,-2.01753 1.01635,-3.10972 1.01635,-1.0922 2.76083,-1.0922 1.10737,0 1.94169,0.50059 0.84948,0.48542 1.31973,1.42592 0.48543,0.92534 0.48543,2.27541 z m -6.14361,0 q 0,1.4411 0.56127,2.29058 0.57643,0.83432 1.82032,0.83432 1.22873,0 1.80516,-0.83432 0.57644,-0.84948 0.57644,-2.29058 0,-1.44109 -0.57644,-2.26024 -0.57643,-0.81914 -1.82033,-0.81914 -1.24389,0 -1.80515,0.81914 -0.56127,0.81915 -0.56127,2.26024 z"
- id="path13838" />
- <path
- d="m 126.43796,117.78545 q 1.4866,0 2.19956,0.65228 0.71296,0.65229 0.71296,2.07821 v 5.53683 h -0.97084 l -0.25788,-1.15287 h -0.0607 q -0.53093,0.66745 -1.12253,0.98601 -0.57644,0.31856 -1.60796,0.31856 -1.10737,0 -1.8355,-0.57644 -0.72813,-0.59161 -0.72813,-1.8355 0,-1.21355 0.95567,-1.86583 0.95567,-0.66746 2.94287,-0.72814 l 1.38041,-0.0455 v -0.48542 q 0,-1.01635 -0.43991,-1.41076 -0.43991,-0.3944 -1.24389,-0.3944 -0.63712,0 -1.21355,0.1972 -0.57644,0.18203 -1.07703,0.42474 l -0.40957,-1.00118 q 0.53092,-0.28822 1.25906,-0.48542 0.72813,-0.21237 1.51694,-0.21237 z m 0.3944,4.33845 q -1.51694,0.0607 -2.10855,0.48542 -0.57643,0.42474 -0.57643,1.19838 0,0.68262 0.40957,1.00118 0.42474,0.31856 1.07703,0.31856 1.03152,0 1.71414,-0.56127 0.68262,-0.57644 0.68262,-1.75965 v -0.72813 z"
- id="path13840" />
- <path
- d="m 134.7508,126.20447 q -1.51694,0 -2.42711,-1.04669 -0.91016,-1.06186 -0.91016,-3.15524 0,-2.09337 0.91016,-3.15523 0.92534,-1.07703 2.44228,-1.07703 0.9405,0 1.53211,0.3489 0.60677,0.34889 0.98601,0.84948 h 0.091 q -0.0152,-0.1972 -0.0607,-0.57643 -0.0303,-0.39441 -0.0303,-0.62195 v -3.24625 h 1.3349 v 11.52874 h -1.07702 l -0.19721,-1.09219 h -0.0607 q -0.36407,0.51576 -0.97084,0.87982 -0.60678,0.36407 -1.56245,0.36407 z m 0.21237,-1.10737 q 1.2894,0 1.80516,-0.69779 0.53093,-0.71296 0.53093,-2.13889 v -0.24271 q 0,-1.51694 -0.50059,-2.32092 -0.50059,-0.81914 -1.85067,-0.81914 -1.07703,0 -1.62313,0.86465 -0.53093,0.84949 -0.53093,2.29058 0,1.45626 0.53093,2.26024 0.5461,0.80398 1.6383,0.80398 z"
- id="path13842" />
- </g>
+ <rect class="st0" x="0" y="0" width="176.36" height="38" rx="19" ry="19"/>
+ <g>
+ <path class="st1" d="M59.4,26.66v-15.77h4.92c2.76,0,4.85.63,6.25,1.9,1.4,1.27,2.11,3.2,2.11,5.79s-.76,4.47-2.29,5.92c-1.53,1.45-3.58,2.17-6.17,2.17h-4.82ZM62.01,13.13v11.28h2.09c1.83,0,3.25-.5,4.28-1.49,1.03-.99,1.54-2.43,1.54-4.31s-.49-3.21-1.46-4.12c-.98-.91-2.41-1.37-4.31-1.37h-2.13Z"/>
+ <path class="st1" d="M80.12,26.92c-1.78,0-3.2-.52-4.25-1.57-1.05-1.05-1.57-2.46-1.57-4.23,0-1.8.56-3.24,1.67-4.34s2.54-1.64,4.31-1.64,3.17.54,4.2,1.62c1.03,1.08,1.54,2.47,1.54,4.18s-.54,3.18-1.62,4.31c-1.08,1.13-2.51,1.69-4.28,1.69ZM80.22,24.84c1.01,0,1.8-.35,2.35-1.04.56-.69.84-1.63.84-2.81s-.28-2.05-.84-2.74c-.56-.69-1.34-1.04-2.35-1.04s-1.81.36-2.41,1.07c-.6.71-.9,1.64-.9,2.78s.3,2.11.89,2.78,1.4,1.01,2.42,1.01Z"/>
+ <path class="st1" d="M103.61,15.4l-3.32,11.26h-2.67l-2.02-7.33c-.05-.19-.09-.34-.11-.45-.02-.11-.05-.25-.08-.43h-.05c-.03.18-.06.32-.09.43s-.07.25-.12.41l-2.19,7.36h-2.64l-3.31-11.26h2.6l2.01,7.71c.04.13.07.27.09.41.02.14.05.31.08.49h.07c.04-.19.07-.36.1-.5.03-.14.07-.29.12-.43l2.29-7.68h2.43l2.05,7.72c.02.09.05.21.08.36.03.15.07.33.1.54h.08c.04-.21.07-.36.09-.47.02-.11.06-.25.1-.43l1.95-7.72h2.39Z"/>
+ <path class="st1" d="M115.36,26.66h-2.55v-6.59c0-.93-.19-1.64-.56-2.13-.37-.49-.93-.73-1.66-.73-.8,0-1.45.29-1.95.86-.5.57-.75,1.29-.75,2.17v6.42h-2.56v-11.26h2.56v1.53h.04c.4-.57.91-1.01,1.55-1.33.63-.31,1.32-.47,2.06-.47,1.25,0,2.2.4,2.85,1.19.65.79.98,1.92.98,3.4v6.94Z"/>
+ <path class="st1" d="M118.22,26.66V9.98h2.56v16.67h-2.56Z"/>
+ <path class="st1" d="M128.95,26.92c-1.78,0-3.2-.52-4.25-1.57s-1.57-2.46-1.57-4.23c0-1.8.56-3.24,1.67-4.34,1.11-1.1,2.54-1.64,4.31-1.64s3.17.54,4.2,1.62c1.03,1.08,1.54,2.47,1.54,4.18s-.54,3.18-1.62,4.31c-1.08,1.13-2.51,1.69-4.28,1.69ZM129.05,24.84c1.01,0,1.8-.35,2.35-1.04.56-.69.84-1.63.84-2.81s-.28-2.05-.84-2.74c-.56-.69-1.34-1.04-2.35-1.04s-1.81.36-2.41,1.07c-.6.71-.9,1.64-.9,2.78s.3,2.11.89,2.78c.59.67,1.4,1.01,2.42,1.01Z"/>
+ <path class="st1" d="M144.71,26.66h-2.48v-1.4h-.04c-.4.54-.88.96-1.45,1.24-.57.28-1.21.42-1.91.42-1.04,0-1.89-.3-2.56-.89-.66-.59-1-1.37-1-2.33,0-1.03.33-1.86,1-2.49.66-.63,1.62-1.01,2.85-1.15l3.12-.35v-.54c0-.7-.19-1.22-.58-1.57s-.9-.52-1.53-.52-1.15.14-1.57.42c-.43.28-.78.68-1.06,1.2l-1.91-.98c.38-.76.98-1.38,1.8-1.86s1.8-.73,2.93-.73c1.42,0,2.51.37,3.26,1.12.75.74,1.13,1.82,1.13,3.24v7.17ZM142.25,22.08v-.62l-2.72.3c-.62.07-1.08.24-1.36.52-.29.28-.43.65-.43,1.12s.16.86.49,1.16c.33.3.75.45,1.27.45.82,0,1.49-.28,1.99-.83s.76-1.25.76-2.09Z"/>
+ <path class="st1" d="M155.4,25.1c-.41.6-.93,1.06-1.55,1.36-.62.31-1.33.46-2.12.46-1.51,0-2.7-.5-3.57-1.5-.87-1-1.3-2.38-1.3-4.13,0-1.89.49-3.39,1.46-4.5.97-1.11,2.27-1.66,3.89-1.66.7,0,1.34.14,1.91.42.57.28,1,.63,1.29,1.06h.04v-6.62h2.56v16.67h-2.56v-1.56h-.04ZM149.46,21.19c0,1.14.26,2.04.78,2.68.52.65,1.24.97,2.16.97s1.69-.32,2.24-.97c.56-.64.84-1.47.84-2.49v-1.29c0-.82-.27-1.51-.81-2.06s-1.24-.83-2.1-.83c-.96,0-1.72.34-2.28,1.03s-.84,1.67-.84,2.95Z"/>
</g>
-</svg>
+ <path class="st1" d="M29.96,6.28h3.98c.66,0,1.19.53,1.19,1.19v8.35h4.36c.88,0,1.33,1.07.7,1.69l-7.56,7.56c-.37.37-.98.37-1.36,0l-7.57-7.56c-.63-.63-.18-1.69.7-1.69h4.36V7.47c0-.66.53-1.19,1.19-1.19ZM44.67,24.96v5.57c0,.66-.53,1.19-1.19,1.19h-23.06c-.66,0-1.19-.53-1.19-1.19v-5.57c0-.66.53-1.19,1.19-1.19h7.29l2.44,2.44c1,1,2.61,1,3.61,0l2.44-2.44h7.29c.66,0,1.19.53,1.19,1.19ZM38.5,29.34c0-.55-.45-.99-.99-.99s-.99.45-.99.99.45.99.99.99.99-.45.99-.99ZM41.68,29.34c0-.55-.45-.99-.99-.99s-.99.45-.99.99.45.99.99.99.99-.45.99-.99Z"/>
+</svg> \ No newline at end of file
diff --git a/frontend/package.json b/frontend/package.json
index 12d9ce85..b0eb2f51 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -13,7 +13,7 @@
"localize": "i18next"
},
"devDependencies": {
- "@decky/api": "^1.1.1",
+ "@decky/api": "^1.1.3",
"@rollup/plugin-commonjs": "^26.0.1",
"@rollup/plugin-image": "^3.0.3",
"@rollup/plugin-json": "^6.1.0",
@@ -47,7 +47,7 @@
}
},
"dependencies": {
- "@decky/ui": "^4.11.0",
+ "@decky/ui": "^4.11.4",
"compare-versions": "^6.1.1",
"filesize": "^10.1.2",
"i18next": "^25.6.0",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 10da2f88..2b047aa8 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@decky/ui':
- specifier: ^4.11.0
- version: 4.11.0
+ specifier: ^4.11.4
+ version: 4.11.4
compare-versions:
specifier: ^6.1.1
version: 6.1.1
@@ -40,8 +40,8 @@ importers:
version: 4.0.0
devDependencies:
'@decky/api':
- specifier: ^1.1.1
- version: 1.1.1
+ specifier: ^1.1.3
+ version: 1.1.3
'@rollup/plugin-commonjs':
specifier: ^26.0.1
version: 26.0.1(rollup@4.22.4)
@@ -219,11 +219,11 @@ packages:
resolution: {integrity: sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==}
engines: {node: '>=6.9.0'}
- '@decky/api@1.1.1':
- resolution: {integrity: sha512-R5fkBRHBt5QIQY7Q0AlbVIhlIZ/nTzwBOoi8Rt4Go2fjFnoMKPInCJl6cPjXzimGwl2pyqKJgY6VnH6ar0XrHQ==}
+ '@decky/api@1.1.3':
+ resolution: {integrity: sha512-XsPCZxfxk5I1UtylIUN3qaWQI31siQbKfbLIskkI5innEatY1m4NQqBv/6hwPaO9mKMbdqYpnh5PSJDeMEOOBA==}
- '@decky/ui@4.11.0':
- resolution: {integrity: sha512-l9PstFC+S8FE8M2kIM78L8cYW4vzJ/ZD30II0huarHLcCsKM4Q+rbmEnbWjlJ1/KLmGXVRXBdAbyD4X/FzfxnQ==}
+ '@decky/ui@4.11.4':
+ resolution: {integrity: sha512-8rANkj5vkYTcT7VBBUzlBuowyBctU8gU5reWtsntmYdr7dGPLRqfgKDRqVH09HCd5plXyJKWDSpqiDsUHmKRJg==}
'@esbuild/aix-ppc64@0.20.2':
resolution: {integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==}
@@ -2309,9 +2309,9 @@ snapshots:
'@babel/helper-validator-identifier': 7.24.7
to-fast-properties: 2.0.0
- '@decky/api@1.1.1': {}
+ '@decky/api@1.1.3': {}
- '@decky/ui@4.11.0': {}
+ '@decky/ui@4.11.4': {}
'@esbuild/aix-ppc64@0.20.2':
optional: true
diff --git a/frontend/src/components/DeckyState.tsx b/frontend/src/components/DeckyState.tsx
index d2ac63ae..d1b558c1 100644
--- a/frontend/src/components/DeckyState.tsx
+++ b/frontend/src/components/DeckyState.tsx
@@ -1,12 +1,14 @@
import { FC, ReactNode, createContext, useContext, useEffect, useState } from 'react';
import { DEFAULT_NOTIFICATION_SETTINGS, NotificationSettings } from '../notification-service';
-import { Plugin } from '../plugin';
+import { DisabledPlugin, Plugin } from '../plugin';
import { PluginUpdateMapping } from '../store';
import { VerInfo } from '../updater';
interface PublicDeckyState {
plugins: Plugin[];
+ disabledPlugins: DisabledPlugin[];
+ installedPlugins: (Plugin | DisabledPlugin)[];
pluginOrder: string[];
frozenPlugins: string[];
hiddenPlugins: string[];
@@ -26,6 +28,8 @@ export interface UserInfo {
export class DeckyState {
private _plugins: Plugin[] = [];
+ private _disabledPlugins: DisabledPlugin[] = [];
+ private _installedPlugins: (Plugin | DisabledPlugin)[] = [];
private _pluginOrder: string[] = [];
private _frozenPlugins: string[] = [];
private _hiddenPlugins: string[] = [];
@@ -42,6 +46,8 @@ export class DeckyState {
publicState(): PublicDeckyState {
return {
plugins: this._plugins,
+ disabledPlugins: this._disabledPlugins,
+ installedPlugins: this._installedPlugins,
pluginOrder: this._pluginOrder,
frozenPlugins: this._frozenPlugins,
hiddenPlugins: this._hiddenPlugins,
@@ -62,6 +68,13 @@ export class DeckyState {
setPlugins(plugins: Plugin[]) {
this._plugins = plugins;
+ this._installedPlugins = [...plugins, ...this._disabledPlugins];
+ this.notifyUpdate();
+ }
+
+ setDisabledPlugins(disabledPlugins: DisabledPlugin[]) {
+ this._disabledPlugins = disabledPlugins;
+ this._installedPlugins = [...this._plugins, ...disabledPlugins];
this.notifyUpdate();
}
@@ -125,6 +138,7 @@ interface DeckyStateContext extends PublicDeckyState {
setIsLoaderUpdating(hasUpdate: boolean): void;
setActivePlugin(name: string): void;
setPluginOrder(pluginOrder: string[]): void;
+ setDisabledPlugins(disabled: DisabledPlugin[]): void;
closeActivePlugin(): void;
}
@@ -163,6 +177,7 @@ export const DeckyStateContextProvider: FC<Props> = ({ children, deckyState }) =
const setActivePlugin = deckyState.setActivePlugin.bind(deckyState);
const closeActivePlugin = deckyState.closeActivePlugin.bind(deckyState);
const setPluginOrder = deckyState.setPluginOrder.bind(deckyState);
+ const setDisabledPlugins = deckyState.setDisabledPlugins.bind(deckyState);
return (
<DeckyStateContext.Provider
@@ -173,6 +188,7 @@ export const DeckyStateContextProvider: FC<Props> = ({ children, deckyState }) =
setActivePlugin,
closeActivePlugin,
setPluginOrder,
+ setDisabledPlugins,
}}
>
{children}
diff --git a/frontend/src/components/PluginView.tsx b/frontend/src/components/PluginView.tsx
index 1d39972e..ffaa176a 100644
--- a/frontend/src/components/PluginView.tsx
+++ b/frontend/src/components/PluginView.tsx
@@ -1,7 +1,7 @@
import { ButtonItem, ErrorBoundary, Focusable, PanelSection, PanelSectionRow } from '@decky/ui';
import { FC, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
-import { FaEyeSlash } from 'react-icons/fa';
+import { FaBan, FaEyeSlash } from 'react-icons/fa';
import { useDeckyState } from './DeckyState';
import NotificationBadge from './NotificationBadge';
@@ -9,8 +9,16 @@ import { useQuickAccessVisible } from './QuickAccessVisibleState';
import TitleView from './TitleView';
const PluginView: FC = () => {
- const { plugins, hiddenPlugins, updates, activePlugin, pluginOrder, setActivePlugin, closeActivePlugin } =
- useDeckyState();
+ const {
+ plugins,
+ disabledPlugins,
+ hiddenPlugins,
+ updates,
+ activePlugin,
+ pluginOrder,
+ setActivePlugin,
+ closeActivePlugin,
+ } = useDeckyState();
const visible = useQuickAccessVisible();
const { t } = useTranslation();
@@ -21,7 +29,9 @@ const PluginView: FC = () => {
.sort((a, b) => pluginOrder.indexOf(a.name) - pluginOrder.indexOf(b.name))
.filter((p) => p.content)
.filter(({ name }) => !hiddenPlugins.includes(name));
- }, [plugins, pluginOrder]);
+ }, [plugins, pluginOrder, hiddenPlugins]);
+
+ const numberOfHidden = hiddenPlugins.filter((name) => !!plugins.find((p) => p.name === name)).length;
if (activePlugin) {
return (
@@ -53,12 +63,28 @@ const PluginView: FC = () => {
</ButtonItem>
</PanelSectionRow>
))}
- {hiddenPlugins.length > 0 && (
- <div style={{ display: 'flex', alignItems: 'center', gap: '10px', fontSize: '0.8rem', marginTop: '10px' }}>
- <FaEyeSlash />
- <div>{t('PluginView.hidden', { count: hiddenPlugins.length })}</div>
- </div>
- )}
+ <div
+ style={{
+ display: 'flex',
+ flexDirection: 'column',
+ position: 'absolute',
+ justifyContent: 'center',
+ padding: '5px 0px',
+ }}
+ >
+ {numberOfHidden > 0 && (
+ <div style={{ display: 'flex', alignItems: 'center', gap: '10px', fontSize: '0.8rem' }}>
+ <FaEyeSlash />
+ <div>{t('PluginView.hidden', { count: numberOfHidden })}</div>
+ </div>
+ )}
+ {disabledPlugins.length > 0 && (
+ <div style={{ display: 'flex', alignItems: 'center', gap: '10px', fontSize: '0.8rem' }}>
+ <FaBan />
+ <div>{t('PluginView.disabled', { count: disabledPlugins.length })}</div>
+ </div>
+ )}
+ </div>
</PanelSection>
</div>
</>
diff --git a/frontend/src/components/TitleView.tsx b/frontend/src/components/TitleView.tsx
index 0cb82b7f..cce779ff 100644
--- a/frontend/src/components/TitleView.tsx
+++ b/frontend/src/components/TitleView.tsx
@@ -8,7 +8,6 @@ import { useDeckyState } from './DeckyState';
const titleStyles: CSSProperties = {
display: 'flex',
- paddingTop: '3px',
paddingRight: '16px',
position: 'sticky',
top: '0px',
diff --git a/frontend/src/components/modals/MultiplePluginsInstallModal.tsx b/frontend/src/components/modals/MultiplePluginsInstallModal.tsx
index 9c86f3db..e5c1c647 100644
--- a/frontend/src/components/modals/MultiplePluginsInstallModal.tsx
+++ b/frontend/src/components/modals/MultiplePluginsInstallModal.tsx
@@ -3,10 +3,11 @@ import { FC, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FaCheck, FaDownload } from 'react-icons/fa';
-import { InstallType, InstallTypeTranslationMapping } from '../../plugin';
+import { DisabledPlugin, InstallType, InstallTypeTranslationMapping } from '../../plugin';
interface MultiplePluginsInstallModalProps {
requests: { name: string; version: string; hash: string; install_type: InstallType }[];
+ disabledPlugins: DisabledPlugin[];
onOK(): void | Promise<void>;
onCancel(): void | Promise<void>;
closeModal?(): void;
@@ -17,6 +18,7 @@ type TitleTranslationMapping = 'mixed' | (typeof InstallTypeTranslationMapping)[
const MultiplePluginsInstallModal: FC<MultiplePluginsInstallModalProps> = ({
requests,
+ disabledPlugins,
onOK,
onCancel,
closeModal,
@@ -116,10 +118,11 @@ const MultiplePluginsInstallModal: FC<MultiplePluginsInstallModalProps> = ({
version,
});
+ const disabled = disabledPlugins.some((p) => p.name === name);
return (
<li key={i} style={{ display: 'flex', flexDirection: 'column' }}>
<span>
- {description}{' '}
+ {disabled ? `${description} - ${t('PluginInstallModal.disabled')}` : description}{' '}
{(pluginsCompleted.includes(name) && <FaCheck />) || (name === pluginInProgress && <FaDownload />)}
</span>
{hash === 'False' && (
diff --git a/frontend/src/components/modals/PluginDisableModal.tsx b/frontend/src/components/modals/PluginDisableModal.tsx
new file mode 100644
index 00000000..16ddd4bf
--- /dev/null
+++ b/frontend/src/components/modals/PluginDisableModal.tsx
@@ -0,0 +1,39 @@
+import { ConfirmModal, Spinner } from '@decky/ui';
+import { FC, useState } from 'react';
+
+import { disablePlugin } from '../../plugin';
+
+interface PluginDisableModalProps {
+ name: string;
+ title: string;
+ buttonText: string;
+ description: string;
+ closeModal?(): void;
+}
+
+const PluginDisableModal: FC<PluginDisableModalProps> = ({ name, title, buttonText, description, closeModal }) => {
+ const [disabling, setDisabling] = useState<boolean>(false);
+ return (
+ <ConfirmModal
+ closeModal={closeModal}
+ onOK={async () => {
+ setDisabling(true);
+ await disablePlugin(name);
+ closeModal?.();
+ }}
+ bOKDisabled={disabling}
+ bCancelDisabled={disabling}
+ strTitle={
+ <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', width: '100%' }}>
+ {title}
+ {disabling && <Spinner width="24px" height="24px" style={{ marginLeft: 'auto' }} />}
+ </div>
+ }
+ strOKButtonText={buttonText}
+ >
+ {description}
+ </ConfirmModal>
+ );
+};
+
+export default PluginDisableModal;
diff --git a/frontend/src/components/modals/PluginInstallModal.tsx b/frontend/src/components/modals/PluginInstallModal.tsx
index 16419d91..0075fce5 100644
--- a/frontend/src/components/modals/PluginInstallModal.tsx
+++ b/frontend/src/components/modals/PluginInstallModal.tsx
@@ -9,6 +9,7 @@ interface PluginInstallModalProps {
version: string;
hash: string;
installType: InstallType;
+ disabled?: boolean;
onOK(): void;
onCancel(): void;
closeModal?(): void;
@@ -19,6 +20,7 @@ const PluginInstallModal: FC<PluginInstallModalProps> = ({
version,
hash,
installType,
+ disabled,
onOK,
onCancel,
closeModal,
@@ -45,6 +47,10 @@ const PluginInstallModal: FC<PluginInstallModalProps> = ({
}, []);
const installTypeTranslationKey = InstallTypeTranslationMapping[installType];
+ const description = t(`PluginInstallModal.${installTypeTranslationKey}.desc`, {
+ artifact: artifact,
+ version: version,
+ });
return (
<ConfirmModal
@@ -118,10 +124,7 @@ const PluginInstallModal: FC<PluginInstallModalProps> = ({
// t('PluginInstallModal.update.desc')
// t('PluginInstallModal.downgrade.desc')
// t('PluginInstallModal.overwrite.desc')
- t(`PluginInstallModal.${installTypeTranslationKey}.desc`, {
- artifact: artifact,
- version: version,
- })
+ disabled ? `${description} ${t('PluginInstallModal.disabled')}` : description
}
</div>
{hash == 'False' && <span style={{ color: 'red' }}>{t('PluginInstallModal.no_hash')}</span>}
diff --git a/frontend/src/components/modals/PluginUninstallModal.tsx b/frontend/src/components/modals/PluginUninstallModal.tsx
index be479859..37d3d789 100644
--- a/frontend/src/components/modals/PluginUninstallModal.tsx
+++ b/frontend/src/components/modals/PluginUninstallModal.tsx
@@ -2,8 +2,10 @@ import { ConfirmModal, Spinner } from '@decky/ui';
import { FC, useState } from 'react';
import { uninstallPlugin } from '../../plugin';
+import { DeckyState } from '../DeckyState';
interface PluginUninstallModalProps {
+ deckyState: DeckyState;
name: string;
title: string;
buttonText: string;
@@ -11,7 +13,14 @@ interface PluginUninstallModalProps {
closeModal?(): void;
}
-const PluginUninstallModal: FC<PluginUninstallModalProps> = ({ name, title, buttonText, description, closeModal }) => {
+const PluginUninstallModal: FC<PluginUninstallModalProps> = ({
+ name,
+ title,
+ buttonText,
+ description,
+ deckyState,
+ closeModal,
+}) => {
const [uninstalling, setUninstalling] = useState<boolean>(false);
return (
<ConfirmModal
@@ -19,6 +28,7 @@ const PluginUninstallModal: FC<PluginUninstallModalProps> = ({ name, title, butt
onOK={async () => {
setUninstalling(true);
await uninstallPlugin(name);
+ deckyState.setDisabledPlugins(deckyState.publicState().disabledPlugins.filter((d) => d.name !== name));
// uninstalling a plugin resets the hidden setting for it server-side
// we invalidate here so if you re-install it, you won't have an out-of-date hidden filter
await DeckyPluginLoader.frozenPluginsService.invalidate();
diff --git a/frontend/src/components/settings/pages/plugin_list/PluginListLabel.tsx b/frontend/src/components/settings/pages/plugin_list/PluginListLabel.tsx
index fec03e56..59171b39 100644
--- a/frontend/src/components/settings/pages/plugin_list/PluginListLabel.tsx
+++ b/frontend/src/components/settings/pages/plugin_list/PluginListLabel.tsx
@@ -1,15 +1,16 @@
import { FC } from 'react';
import { useTranslation } from 'react-i18next';
-import { FaEyeSlash, FaLock } from 'react-icons/fa';
+import { FaBan, FaEyeSlash, FaLock } from 'react-icons/fa';
interface PluginListLabelProps {
frozen: boolean;
hidden: boolean;
+ disabled: boolean;
name: string;
version?: string;
}
-const PluginListLabel: FC<PluginListLabelProps> = ({ name, frozen, hidden, version }) => {
+const PluginListLabel: FC<PluginListLabelProps> = ({ name, frozen, hidden, version, disabled }) => {
const { t } = useTranslation();
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
@@ -43,6 +44,20 @@ const PluginListLabel: FC<PluginListLabelProps> = ({ name, frozen, hidden, versi
{t('PluginListLabel.hidden')}
</div>
)}
+ {disabled && (
+ <div
+ style={{
+ fontSize: '0.8rem',
+ color: '#dcdedf',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '10px',
+ }}
+ >
+ <FaBan />
+ {t('PluginListLabel.disabled')}
+ </div>
+ )}
</div>
);
};
diff --git a/frontend/src/components/settings/pages/plugin_list/index.tsx b/frontend/src/components/settings/pages/plugin_list/index.tsx
index 9a7cb076..43d79709 100644
--- a/frontend/src/components/settings/pages/plugin_list/index.tsx
+++ b/frontend/src/components/settings/pages/plugin_list/index.tsx
@@ -2,9 +2,11 @@ import {
DialogBody,
DialogButton,
DialogControlsSection,
+ Focusable,
GamepadEvent,
Menu,
MenuItem,
+ NavEntryPositionPreferences,
ReorderableEntry,
ReorderableList,
showContextMenu,
@@ -13,7 +15,7 @@ import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FaDownload, FaEllipsisH, FaRecycle } from 'react-icons/fa';
-import { InstallType } from '../../../../plugin';
+import { InstallType, enablePlugin } from '../../../../plugin';
import {
StorePluginVersion,
getPluginList,
@@ -35,6 +37,7 @@ async function reinstallPlugin(pluginName: string, currentVersion?: string) {
type PluginTableData = PluginData & {
name: string;
+ disabled: boolean;
frozen: boolean;
onFreeze(): void;
onUnfreeze(): void;
@@ -54,22 +57,25 @@ function PluginInteractables(props: { entry: ReorderableEntry<PluginTableData> }
return null;
}
- const { name, update, version, onHide, onShow, hidden, onFreeze, onUnfreeze, frozen, isDeveloper } = props.entry.data;
+ const { name, update, version, onHide, onShow, hidden, onFreeze, onUnfreeze, frozen, isDeveloper, disabled } =
+ props.entry.data;
const showCtxMenu = (e: MouseEvent | GamepadEvent) => {
showContextMenu(
<Menu label={t('PluginListIndex.plugin_actions')}>
- <MenuItem
- onSelected={async () => {
- try {
- await reloadPluginBackend(name);
- } catch (err) {
- console.error('Error Reloading Plugin Backend', err);
- }
- }}
- >
- {t('PluginListIndex.reload')}
- </MenuItem>
+ {!disabled && (
+ <MenuItem
+ onSelected={async () => {
+ try {
+ await reloadPluginBackend(name);
+ } catch (err) {
+ console.error('Error Reloading Plugin Backend', err);
+ }
+ }}
+ >
+ {t('PluginListIndex.reload')}
+ </MenuItem>
+ )}
<MenuItem
onSelected={() =>
DeckyPluginLoader.uninstallPlugin(
@@ -82,11 +88,28 @@ function PluginInteractables(props: { entry: ReorderableEntry<PluginTableData> }
>
{t('PluginListIndex.uninstall')}
</MenuItem>
- {hidden ? (
- <MenuItem onSelected={onShow}>{t('PluginListIndex.show')}</MenuItem>
+ {disabled ? (
+ <MenuItem onSelected={() => enablePlugin(name)}>{t('PluginListIndex.enable')}</MenuItem>
) : (
- <MenuItem onSelected={onHide}>{t('PluginListIndex.hide')}</MenuItem>
+ <MenuItem
+ onSelected={() =>
+ DeckyPluginLoader.disablePlugin(
+ name,
+ t('PluginLoader.plugin_disable.title', { name }),
+ t('PluginLoader.plugin_disable.button'),
+ t('PluginLoader.plugin_disable.desc', { name }),
+ )
+ }
+ >
+ {t('PluginListIndex.disable')}
+ </MenuItem>
)}
+ {!disabled &&
+ (hidden ? (
+ <MenuItem onSelected={onShow}>{t('PluginListIndex.show')}</MenuItem>
+ ) : (
+ <MenuItem onSelected={onHide}>{t('PluginListIndex.hide')}</MenuItem>
+ ))}
{frozen ? (
<MenuItem onSelected={onUnfreeze}>{t('PluginListIndex.unfreeze')}</MenuItem>
) : (
@@ -98,7 +121,7 @@ function PluginInteractables(props: { entry: ReorderableEntry<PluginTableData> }
};
return (
- <>
+ <Focusable navEntryPreferPosition={NavEntryPositionPreferences.MAINTAIN_X} style={{ display: 'flex' }}>
{update ? (
<DialogButton
style={{ height: '40px', minWidth: '60px', marginRight: '10px' }}
@@ -137,7 +160,7 @@ function PluginInteractables(props: { entry: ReorderableEntry<PluginTableData> }
>
<FaEllipsisH />
</DialogButton>
- </>
+ </Focusable>
);
}
@@ -147,16 +170,18 @@ type PluginData = {
};
export default function PluginList({ isDeveloper }: { isDeveloper: boolean }) {
- const { plugins, updates, pluginOrder, setPluginOrder, frozenPlugins, hiddenPlugins } = useDeckyState();
+ const { installedPlugins, disabledPlugins, updates, pluginOrder, setPluginOrder, frozenPlugins, hiddenPlugins } =
+ useDeckyState();
+
const [_, setPluginOrderSetting] = useSetting<string[]>(
'pluginOrder',
- plugins.map((plugin) => plugin.name),
+ installedPlugins.map((plugin) => plugin.name),
);
const { t } = useTranslation();
useEffect(() => {
DeckyPluginLoader.checkPluginUpdates();
- }, []);
+ }, [installedPlugins, frozenPlugins]);
const [pluginEntries, setPluginEntries] = useState<ReorderableEntry<PluginTableData>[]>([]);
const hiddenPluginsService = DeckyPluginLoader.hiddenPluginsService;
@@ -164,15 +189,24 @@ export default function PluginList({ isDeveloper }: { isDeveloper: boolean }) {
useEffect(() => {
setPluginEntries(
- plugins.map(({ name, version }) => {
+ installedPlugins.map(({ name, version }) => {
const frozen = frozenPlugins.includes(name);
const hidden = hiddenPlugins.includes(name);
return {
- label: <PluginListLabel name={name} frozen={frozen} hidden={hidden} version={version} />,
+ label: (
+ <PluginListLabel
+ name={name}
+ frozen={frozen}
+ hidden={hidden}
+ version={version}
+ disabled={disabledPlugins.find((p) => p.name == name) !== undefined}
+ />
+ ),
position: pluginOrder.indexOf(name),
data: {
name,
+ disabled: disabledPlugins.some((disabledPlugin) => disabledPlugin.name === name),
frozen,
hidden,
isDeveloper,
@@ -186,9 +220,9 @@ export default function PluginList({ isDeveloper }: { isDeveloper: boolean }) {
};
}),
);
- }, [plugins, updates, hiddenPlugins]);
+ }, [installedPlugins, updates, hiddenPlugins, disabledPlugins]);
- if (plugins.length === 0) {
+ if (installedPlugins.length === 0) {
return (
<div>
<p>{t('PluginListIndex.no_plugin')}</p>
diff --git a/frontend/src/components/settings/pages/testing/index.tsx b/frontend/src/components/settings/pages/testing/index.tsx
index 8f02c207..0f8b5ebe 100644
--- a/frontend/src/components/settings/pages/testing/index.tsx
+++ b/frontend/src/components/settings/pages/testing/index.tsx
@@ -4,6 +4,7 @@ import {
DialogControlsSection,
Field,
Focusable,
+ NavEntryPositionPreferences,
Navigation,
ProgressBar,
SteamSpinner,
@@ -65,9 +66,9 @@ export default function TestingVersionList() {
if (testingVersions.length === 0) {
return (
- <div>
+ <DialogBody>
<p>No open PRs found</p>
- </div>
+ </DialogBody>
);
}
@@ -79,7 +80,7 @@ export default function TestingVersionList() {
<ul style={{ listStyleType: 'none', padding: '0' }}>
{testingVersions.map((version) => {
return (
- <li>
+ <li key={`${version.id}_${version.name}`}>
<Field
label={
<>
@@ -87,7 +88,10 @@ export default function TestingVersionList() {
</>
}
>
- <Focusable style={{ height: '40px', marginLeft: 'auto', display: 'flex' }}>
+ <Focusable
+ style={{ height: '40px', marginLeft: 'auto', display: 'flex' }}
+ navEntryPreferPosition={NavEntryPositionPreferences.MAINTAIN_X}
+ >
<DialogButton
style={{ height: '40px', minWidth: '60px', marginRight: '10px' }}
onClick={async () => {
diff --git a/frontend/src/components/store/PluginCard.tsx b/frontend/src/components/store/PluginCard.tsx
index f64abd09..a47207c9 100644
--- a/frontend/src/components/store/PluginCard.tsx
+++ b/frontend/src/components/store/PluginCard.tsx
@@ -1,15 +1,23 @@
-import { ButtonItem, Dropdown, Focusable, PanelSectionRow, SingleDropdownOption, SuspensefulImage } from '@decky/ui';
+import {
+ ButtonItem,
+ Dropdown,
+ Focusable,
+ NavEntryPositionPreferences,
+ PanelSectionRow,
+ SingleDropdownOption,
+ SuspensefulImage,
+} from '@decky/ui';
import { CSSProperties, FC, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FaArrowDown, FaArrowUp, FaCheck, FaDownload, FaRecycle } from 'react-icons/fa';
-import { InstallType, Plugin } from '../../plugin';
+import { DisabledPlugin, InstallType, Plugin } from '../../plugin';
import { StorePlugin, requestPluginInstall } from '../../store';
import ExternalLink from '../ExternalLink';
interface PluginCardProps {
storePlugin: StorePlugin;
- installedPlugin: Plugin | undefined;
+ installedPlugin: Plugin | DisabledPlugin | undefined;
}
const PluginCard: FC<PluginCardProps> = ({ storePlugin, installedPlugin }) => {
@@ -139,7 +147,10 @@ const PluginCard: FC<PluginCardProps> = ({ storePlugin, installedPlugin }) => {
</div>
<div className="deckyStoreCardButtonRow">
<PanelSectionRow>
- <Focusable style={{ display: 'flex', gap: '5px', padding: 0 }}>
+ <Focusable
+ style={{ display: 'flex', gap: '5px', padding: 0 }}
+ navEntryPreferPosition={NavEntryPositionPreferences.MAINTAIN_X}
+ >
<div
className="deckyStoreCardInstallContainer"
style={
diff --git a/frontend/src/components/store/Store.tsx b/frontend/src/components/store/Store.tsx
index 3209ba08..f1d50033 100644
--- a/frontend/src/components/store/Store.tsx
+++ b/frontend/src/components/store/Store.tsx
@@ -105,7 +105,7 @@ const BrowseTab: FC<{ setPluginCount: Dispatch<SetStateAction<number | null>> }>
})();
}, []);
- const { plugins: installedPlugins } = useDeckyState();
+ const { installedPlugins } = useDeckyState();
return (
<>
@@ -240,6 +240,7 @@ const BrowseTab: FC<{ setPluginCount: Dispatch<SetStateAction<number | null>> }>
})
.map((plugin: StorePlugin) => (
<PluginCard
+ key={`${plugin.id}_${plugin.name}`}
storePlugin={plugin}
installedPlugin={installedPlugins.find((installedPlugin) => installedPlugin.name === plugin.name)}
/>
diff --git a/frontend/src/index.ts b/frontend/src/index.ts
index 4f4ff4f7..7ef5087f 100644
--- a/frontend/src/index.ts
+++ b/frontend/src/index.ts
@@ -22,11 +22,13 @@
DFLWebpack.findModule((m) => m.createPortal && m.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE);
console.debug('[Decky:Boot] Setting up JSX internals...');
- const jsx = DFLWebpack.findModule((m) => m.jsx && Object.keys(m).length == 1)?.jsx;
- if (jsx) {
+ const jsxModule = DFLWebpack.findModule((m) => (m.jsx && m.jsxs) || (m.jsx && Object.keys(m).length == 1));
+ if (jsxModule.jsxs) {
+ window.SP_JSX = jsxModule;
+ } else {
window.SP_JSX = {
- jsx,
- jsxs: jsx,
+ jsx: jsxModule.jsx,
+ jsxs: jsxModule.jsx,
Fragment: window.SP_REACT.Fragment,
};
}
diff --git a/frontend/src/plugin-loader.tsx b/frontend/src/plugin-loader.tsx
index 2bdfcec1..fd4dc1c0 100644
--- a/frontend/src/plugin-loader.tsx
+++ b/frontend/src/plugin-loader.tsx
@@ -19,6 +19,7 @@ import { DeckyState, DeckyStateContextProvider, UserInfo, useDeckyState } from '
import { File, FileSelectionType } from './components/modals/filepicker';
import { deinitFilepickerPatches, initFilepickerPatches } from './components/modals/filepicker/patches';
import MultiplePluginsInstallModal from './components/modals/MultiplePluginsInstallModal';
+import PluginDisableModal from './components/modals/PluginDisableModal';
import PluginInstallModal from './components/modals/PluginInstallModal';
import PluginUninstallModal from './components/modals/PluginUninstallModal';
import NotificationBadge from './components/NotificationBadge';
@@ -30,7 +31,7 @@ import { FrozenPluginService } from './frozen-plugins-service';
import { HiddenPluginsService } from './hidden-plugins-service';
import Logger from './logger';
import { NotificationService } from './notification-service';
-import { InstallType, Plugin, PluginLoadType } from './plugin';
+import { DisabledPlugin, InstallType, Plugin, PluginLoadType } from './plugin';
import RouterHook from './router-hook';
import { deinitSteamFixes, initSteamFixes } from './steamfixes';
import { checkForPluginUpdates } from './store';
@@ -91,6 +92,7 @@ class PluginLoader extends Logger {
DeckyBackend.addEventListener('loader/notify_updates', this.notifyUpdates.bind(this));
DeckyBackend.addEventListener('loader/import_plugin', this.importPlugin.bind(this));
DeckyBackend.addEventListener('loader/unload_plugin', this.unloadPlugin.bind(this));
+ DeckyBackend.addEventListener('loader/disable_plugin', this.doDisablePlugin.bind(this));
DeckyBackend.addEventListener('loader/add_plugin_install_prompt', this.addPluginInstallPrompt.bind(this));
DeckyBackend.addEventListener(
'loader/add_multiple_plugins_install_prompt',
@@ -175,7 +177,7 @@ class PluginLoader extends Logger {
private getPluginsFromBackend = DeckyBackend.callable<
[],
- { name: string; version: string; load_type: PluginLoadType }[]
+ { name: string; version: string; load_type: PluginLoadType; disabled: boolean }[]
>('loader/get_plugins');
private restartWebhelper = DeckyBackend.callable<[], void>('utilities/restart_webhelper');
@@ -198,10 +200,16 @@ class PluginLoader extends Logger {
this.runCrashChecker();
const plugins = await this.getPluginsFromBackend();
const pluginLoadPromises = [];
+ const disabledPlugins: DisabledPlugin[] = [];
const loadStart = performance.now();
for (const plugin of plugins) {
- if (!this.hasPlugin(plugin.name))
- pluginLoadPromises.push(this.importPlugin(plugin.name, plugin.version, plugin.load_type, false));
+ if (plugin.disabled) {
+ disabledPlugins.push({ name: plugin.name, version: plugin.version });
+ this.deckyState.setDisabledPlugins(disabledPlugins);
+ } else {
+ if (!this.hasPlugin(plugin.name))
+ pluginLoadPromises.push(this.importPlugin(plugin.name, plugin.version, plugin.load_type, false));
+ }
}
await Promise.all(pluginLoadPromises);
const loadEnd = performance.now();
@@ -252,7 +260,9 @@ class PluginLoader extends Logger {
public async checkPluginUpdates() {
const frozenPlugins = this.deckyState.publicState().frozenPlugins;
- const updates = await checkForPluginUpdates(this.plugins.filter((p) => !frozenPlugins.includes(p.name)));
+ const updates = await checkForPluginUpdates(
+ this.deckyState.publicState().installedPlugins.filter((p) => !frozenPlugins.includes(p.name)),
+ );
this.deckyState.setUpdates(updates);
return updates;
}
@@ -290,6 +300,7 @@ class PluginLoader extends Logger {
version={version}
hash={hash}
installType={install_type}
+ disabled={this.deckyState.publicState().disabledPlugins.some((p) => p.name === artifact)}
onOK={() => DeckyBackend.call<[string]>('utilities/confirm_plugin_install', request_id)}
onCancel={() => DeckyBackend.call<[string]>('utilities/cancel_plugin_install', request_id)}
/>,
@@ -303,6 +314,7 @@ class PluginLoader extends Logger {
showModal(
<MultiplePluginsInstallModal
requests={requests}
+ disabledPlugins={this.deckyState.publicState().disabledPlugins}
onOK={() => DeckyBackend.call<[string]>('utilities/confirm_plugin_install', request_id)}
onCancel={() => DeckyBackend.call<[string]>('utilities/cancel_plugin_install', request_id)}
/>,
@@ -310,7 +322,19 @@ class PluginLoader extends Logger {
}
public uninstallPlugin(name: string, title: string, buttonText: string, description: string) {
- showModal(<PluginUninstallModal name={name} title={title} buttonText={buttonText} description={description} />);
+ showModal(
+ <PluginUninstallModal
+ name={name}
+ title={title}
+ buttonText={buttonText}
+ description={description}
+ deckyState={this.deckyState}
+ />,
+ );
+ }
+
+ public disablePlugin(name: string, title: string, buttonText: string, description: string) {
+ showModal(<PluginDisableModal name={name} title={title} buttonText={buttonText} description={description} />);
}
public hasPlugin(name: string) {
@@ -351,6 +375,19 @@ class PluginLoader extends Logger {
this.errorBoundaryHook.deinit();
}
+ public doDisablePlugin(name: string) {
+ const plugin = this.plugins.find((plugin) => plugin.name === name);
+ if (plugin == undefined) return;
+
+ plugin?.onDismount?.();
+ this.plugins = this.plugins.filter((p) => p !== plugin);
+ this.deckyState.setDisabledPlugins([
+ ...this.deckyState.publicState().disabledPlugins,
+ { name: plugin.name, version: plugin.version },
+ ]);
+ this.deckyState.setPlugins(this.plugins);
+ }
+
public unloadPlugin(name: string, skipStateUpdate: boolean = false) {
const plugin = this.plugins.find((plugin) => plugin.name === name);
plugin?.onDismount?.();
@@ -363,6 +400,7 @@ class PluginLoader extends Logger {
version?: string | undefined,
loadType: PluginLoadType = PluginLoadType.ESMODULE_V1,
useQueue: boolean = true,
+ timeoutMS?: number,
) {
if (useQueue && this.reloadLock) {
this.log('Reload currently in progress, adding to queue', name);
@@ -376,9 +414,11 @@ class PluginLoader extends Logger {
this.unloadPlugin(name, true);
const startTime = performance.now();
- await this.importReactPlugin(name, version, loadType);
+
+ await this.importReactPlugin(name, version, loadType, timeoutMS);
const endTime = performance.now();
+ this.deckyState.setDisabledPlugins(this.deckyState.publicState().disabledPlugins.filter((d) => d.name !== name));
this.deckyState.setPlugins(this.plugins);
this.log(`Loaded ${name} in ${endTime - startTime}ms`);
} catch (e) {
@@ -388,7 +428,7 @@ class PluginLoader extends Logger {
this.reloadLock = false;
const nextPlugin = this.pluginReloadQueue.shift();
if (nextPlugin) {
- this.importPlugin(nextPlugin.name, nextPlugin.version, loadType);
+ this.importPlugin(nextPlugin.name, nextPlugin.version, nextPlugin.loadType, true, timeoutMS);
}
}
}
@@ -398,12 +438,28 @@ class PluginLoader extends Logger {
name: string,
version?: string,
loadType: PluginLoadType = PluginLoadType.ESMODULE_V1,
+ timeoutMS?: number,
) {
let spExists = this.checkForSP();
+ const timeoutException = new Error(
+ `${name} failed to load within ${timeoutMS ? `${timeoutMS / 1000} second` : ''} time limit`,
+ );
+ let timeout: number | undefined;
+
try {
switch (loadType) {
case PluginLoadType.ESMODULE_V1:
- const plugin_exports = await import(`http://127.0.0.1:1337/plugins/${name}/dist/index.js?t=${Date.now()}`);
+ const importJS = () => import(`http://127.0.0.1:1337/plugins/${name}/dist/index.js?t=${Date.now()}`);
+
+ const promise =
+ timeoutMS === undefined
+ ? importJS()
+ : Promise.race([
+ importJS(),
+ new Promise((_, reject) => (timeout = setTimeout(() => reject(timeoutException), timeoutMS))),
+ ]);
+
+ const plugin_exports = await promise;
let plugin = plugin_exports.default();
this.plugins.push({
@@ -415,12 +471,26 @@ class PluginLoader extends Logger {
break;
case PluginLoadType.LEGACY_EVAL_IIFE:
- let res = await fetch(`http://127.0.0.1:1337/plugins/${name}/frontend_bundle`, {
- credentials: 'include',
- headers: {
- 'X-Decky-Auth': deckyAuthToken,
- },
- });
+ const fetchJS = async () => {
+ const controller = new AbortController();
+ const { signal } = controller;
+
+ if (timeoutMS !== undefined) timeout = setTimeout(() => controller.abort(), timeoutMS);
+
+ try {
+ return await fetch(`http://127.0.0.1:1337/plugins/${name}/frontend_bundle`, {
+ credentials: 'include',
+ headers: {
+ 'X-Decky-Auth': deckyAuthToken,
+ },
+ signal,
+ });
+ } catch (e: any) {
+ throw 'name' in e && e.name === 'AbortError' ? timeoutException : e;
+ }
+ };
+
+ let res = await fetchJS();
if (res.ok) {
let plugin_export: (serverAPI: any) => Plugin = await eval(
(await res.text()) + `\n//# sourceURL=decky://decky/legacy_plugin/${encodeURIComponent(name)}/index.js`,
@@ -439,6 +509,8 @@ class PluginLoader extends Logger {
throw new Error(`${name} has no defined loadType.`);
}
} catch (e) {
+ if (e === timeoutException) throw timeoutException;
+
this.error('Error loading plugin ' + name, e);
const TheError: FC<{}> = () => (
<PanelSection>
@@ -481,6 +553,8 @@ class PluginLoader extends Logger {
body: '' + e,
icon: <FaExclamationCircle />,
});
+ } finally {
+ if (timeout !== undefined) clearTimeout(timeout);
}
if (spExists && !this.checkForSP()) {
diff --git a/frontend/src/plugin.ts b/frontend/src/plugin.ts
index f2b99f71..746ef29e 100644
--- a/frontend/src/plugin.ts
+++ b/frontend/src/plugin.ts
@@ -15,6 +15,8 @@ export interface Plugin {
titleView?: JSX.Element;
}
+export type DisabledPlugin = Pick<Plugin, 'name' | 'version'>;
+
export enum InstallType {
INSTALL,
REINSTALL,
@@ -56,3 +58,5 @@ type installPluginsArgs = [
export let installPlugins = DeckyBackend.callable<installPluginsArgs>('utilities/install_plugins');
export let uninstallPlugin = DeckyBackend.callable<[name: string]>('utilities/uninstall_plugin');
+export let enablePlugin = DeckyBackend.callable<[name: string]>('utilities/enable_plugin');
+export let disablePlugin = DeckyBackend.callable<[name: string]>('utilities/disable_plugin');
diff --git a/frontend/src/store.tsx b/frontend/src/store.tsx
index dfd9b04b..33c384a5 100644
--- a/frontend/src/store.tsx
+++ b/frontend/src/store.tsx
@@ -1,6 +1,6 @@
-import { compare } from 'compare-versions';
+import { compare, validate } from 'compare-versions';
-import { InstallType, Plugin, installPlugin, installPlugins } from './plugin';
+import { DisabledPlugin, InstallType, Plugin, installPlugin, installPlugins } from './plugin';
import { getSetting, setSetting } from './utils/settings';
export enum Store {
@@ -113,18 +113,21 @@ export async function requestMultiplePluginInstalls(requests: PluginInstallReque
);
}
-export async function checkForPluginUpdates(plugins: Plugin[]): Promise<PluginUpdateMapping> {
+export async function checkForPluginUpdates(plugins: (Plugin | DisabledPlugin)[]): Promise<PluginUpdateMapping> {
const serverData = await getPluginList();
const updateMap = new Map<string, StorePluginVersion>();
for (let plugin of plugins) {
const remotePlugin = serverData?.find((x) => x.name == plugin.name);
//FIXME: Ugly hack since plugin.version might be null during evaluation,
//so this will set the older version possible
- const curVer = plugin.version ? plugin.version : '0.0';
+ const curVer = plugin.version ? plugin.version : '0.0.0';
+
if (
remotePlugin &&
remotePlugin.versions?.length > 0 &&
plugin.version != remotePlugin?.versions?.[0]?.name &&
+ validate(remotePlugin.versions?.[0]?.name) &&
+ validate(curVer) &&
compare(remotePlugin?.versions?.[0]?.name, curVer, '>')
) {
updateMap.set(plugin.name, remotePlugin.versions[0]);
diff --git a/frontend/src/tabs-hook.tsx b/frontend/src/tabs-hook.tsx
index a70c6580..5d9518fd 100644
--- a/frontend/src/tabs-hook.tsx
+++ b/frontend/src/tabs-hook.tsx
@@ -29,7 +29,8 @@ interface Tab {
class TabsHook extends Logger {
// private keys = 7;
tabs: Tab[] = [];
- private qamPatch?: Patch;
+ private qamBrowserViewPatch?: Patch;
+ private qamEmbeddedPatch?: Patch;
constructor() {
super('TabsHook');
@@ -40,11 +41,13 @@ class TabsHook extends Logger {
}
init() {
- // TODO patch the "embedded" renderer in this module too (seems to be for VR? unsure)
const qamModule = findModuleByExport((e) => e?.type?.toString?.()?.includes('QuickAccessMenuBrowserView'));
- const qamRenderer = Object.values(qamModule).find((e: any) =>
+ const qamBrowserViewRenderer = Object.values(qamModule).find((e: any) =>
e?.type?.toString?.()?.includes('QuickAccessMenuBrowserView'),
);
+ const qamEmbeddedRenderer = Object.values(qamModule).find((e: any) =>
+ e?.type?.toString?.()?.includes('QuickAccessMenuEmbedded'),
+ );
const patchHandler = createReactTreePatcher(
[(tree) => findInReactTree(tree, (node) => node?.props?.onFocusNavDeactivated)],
@@ -56,12 +59,21 @@ class TabsHook extends Logger {
'TabsHook',
);
- this.qamPatch = afterPatch(qamRenderer, 'type', patchHandler);
+ this.qamBrowserViewPatch = afterPatch(qamBrowserViewRenderer, 'type', patchHandler);
+ if (qamEmbeddedRenderer) this.qamEmbeddedPatch = afterPatch(qamEmbeddedRenderer, 'type', patchHandler);
// Patch already rendered qam
const root = getReactRoot(document.getElementById('root') as any);
- const qamNode = root && findInReactTree(root, (n: any) => n.elementType == qamRenderer); // need elementType, because type is actually mobx wrapper
+ const qamNode =
+ root &&
+ findInReactTree(
+ root,
+ (n: any) =>
+ n.elementType == qamBrowserViewRenderer ||
+ (qamEmbeddedRenderer != null && n.elementType == qamEmbeddedRenderer),
+ ); // need elementType, because type is actually mobx wrapper
if (qamNode) {
+ console.log('patching existing qam');
// Only affects this fiber node so we don't need to unpatch here
qamNode.type = qamNode.elementType.type;
if (qamNode?.alternate) {
@@ -71,7 +83,8 @@ class TabsHook extends Logger {
}
deinit() {
- this.qamPatch?.unpatch();
+ this.qamBrowserViewPatch?.unpatch();
+ this.qamEmbeddedPatch?.unpatch();
}
add(tab: Tab) {
diff --git a/frontend/src/toaster.tsx b/frontend/src/toaster.tsx
index 2305b870..99f8023e 100644
--- a/frontend/src/toaster.tsx
+++ b/frontend/src/toaster.tsx
@@ -81,6 +81,7 @@ class Toaster extends Logger {
const info = {
showToast: toast.showToast,
sound: toast.sound,
+ playSound: toast.playSound,
eFeature: 0,
toastDurationMS: toastData.nToastDurationMS,
bCritical: toast.critical,