Compare commits

...

17 Commits

Author SHA1 Message Date
marios 968c8b113c working plugin disable, not tested extensively
Builder Win / Build PluginLoader for Win (push) Has been cancelled
Builder / Build PluginLoader (push) Has been cancelled
Push Updated Plugin Stub to Template / copy-stub (push) Has been cancelled
Lint / Run linters (push) Has been cancelled
Type Check / Run type checkers (push) Has been cancelled
2025-10-07 02:40:41 +03:00
marios 6fac72112c Merge branch 'feat-disable-plugins' into marios8543/plugin-disable 2025-10-07 01:09:47 +03:00
marios 6640279ab3 add frontend 2025-10-07 01:08:39 +03:00
Jesse Bofill d2b6999520 fix mistakes 2025-10-06 15:39:59 -06:00
marios 6fba20239b Feat disable plugins (#810)
* implement base frontend changes necessary for plugin disabling

* implement frontend diisable functions/ modal

---------

Co-authored-by: Jesse Bofill <jesse_bofill@yahoo.com>
2025-10-06 23:54:49 +03:00
marios c3c0e87c6f plugin disable boilerplate / untested 2025-10-06 23:38:14 +03:00
Jesse Bofill 1ae6519209 implement frontend diisable functions/ modal 2025-10-06 14:29:39 -06:00
Jesse Bofill 65f1eb052d implement base frontend changes necessary for plugin disabling 2025-10-06 13:50:02 -06:00
AAGaming 86b5567d4e dfl bump to fix DialogHeader component (#800)
Builder Win / Build PluginLoader for Win (push) Has been cancelled
Builder / Build PluginLoader (push) Has been cancelled
Push Updated Plugin Stub to Template / copy-stub (push) Has been cancelled
Lint / Run linters (push) Has been cancelled
Type Check / Run type checkers (push) Has been cancelled
2025-08-20 15:46:17 -04:00
AAGaming 8f41eb93ef Merge commit from fork
Builder Win / Build PluginLoader for Win (push) Has been cancelled
Builder / Build PluginLoader (push) Has been cancelled
Push Updated Plugin Stub to Template / copy-stub (push) Has been cancelled
Lint / Run linters (push) Has been cancelled
Type Check / Run type checkers (push) Has been cancelled
* fix incorrect permissions on plugin directories

* chown plugin dirs too

* fix the stupid

* cleanup useless comments
2025-07-28 20:58:59 -04:00
AAGaming 670ae7d8a7 OOPS
Builder Win / Build PluginLoader for Win (push) Has been cancelled
Builder / Build PluginLoader (push) Has been cancelled
Push Updated Plugin Stub to Template / copy-stub (push) Has been cancelled
Lint / Run linters (push) Has been cancelled
Type Check / Run type checkers (push) Has been cancelled
2025-07-13 02:42:16 -04:00
AAGaming 01ec1663bc Merge branch 'main' of github.com:SteamDeckHomebrew/decky-loader 2025-07-13 02:37:34 -04:00
AAGaming b840d75cb8 certified pnpm moment 2025-07-13 02:37:03 -04:00
AAGaming e22ba20d13 Fix ModalRoot component on beta (#792) 2025-07-13 02:33:22 -04:00
AAGaming bff410d98b chore(readme): update to reflect dumb new pnpm change 2025-07-13 02:26:51 -04:00
AAGaming 7188db9877 feat(dev): make deckdebug.sh configurable
so i can use it on windows :3
2025-07-13 02:20:42 -04:00
AAGaming 02e8640ad4 bump @decky/ui to fix ConfirmModal component (#789)
Builder Win / Build PluginLoader for Win (push) Has been cancelled
Builder / Build PluginLoader (push) Has been cancelled
Push Updated Plugin Stub to Template / copy-stub (push) Has been cancelled
Lint / Run linters (push) Has been cancelled
Type Check / Run type checkers (push) Has been cancelled
Co-Authored-By: shadow <81448108+shdwmtr@users.noreply.github.com>
2025-07-08 23:30:47 -04:00
31 changed files with 297 additions and 107 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
working-directory: ./frontend
run: |
npm i -g pnpm
pnpm i --frozen-lockfile
pnpm i --frozen-lockfile --dangerously-allow-all-builds
- name: Build JS Frontend 🛠️
working-directory: ./frontend
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
working-directory: ./frontend
run: |
npm i -g pnpm
pnpm i --frozen-lockfile
pnpm i --frozen-lockfile --dangerously-allow-all-builds
- name: Build JS Frontend 🛠️
working-directory: ./frontend
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
working-directory: frontend
run: |
npm i -g pnpm
pnpm i --frozen-lockfile
pnpm i --frozen-lockfile --dangerously-allow-all-builds
- name: Run prettier (TypeScript)
working-directory: frontend
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
working-directory: frontend
run: |
npm i -g pnpm
pnpm i --frozen-lockfile
pnpm i --frozen-lockfile --dangerously-allow-all-builds
- name: Run pyright (Python)
uses: jakebailey/pyright-action@v1
+2 -2
View File
@@ -1,8 +1,8 @@
{
"deckip" : "0.0.0.0",
"deckip" : "192.168.1.69",
"deckport" : "22",
"deckuser" : "deck",
"deckpass" : "ssap",
"deckpass" : "deck",
"deckkey" : "-i ${env:HOME}/.ssh/id_rsa",
"deckdir" : "/home/deck"
}
+1 -1
View File
@@ -95,7 +95,7 @@ Please consult [the wiki page regarding development](https://wiki.deckbrew.xyz/e
1. In your clone of the repository, run these commands.
```bash
cd frontend
pnpm i
pnpm i # NOTE: you may need to approve esbuild's build script with pnpm approve-builds
pnpm run build
```
1. If you are modifying the UI, these commands will need to be run before deploying the changes to your Steam Deck.
+25 -10
View File
@@ -18,9 +18,10 @@ from enum import IntEnum
from typing import Dict, List, TypedDict
# Local modules
from .localplatform.localplatform import chown, chmod
from .localplatform.localplatform import chown, chmod, get_chown_plugin_path
from .loader import Loader, Plugins
from .helpers import get_ssl_context, download_remote_binary_to_path
from .enums import UserType
from .settings import SettingsManager
logger = getLogger("Browser")
@@ -60,13 +61,6 @@ class PluginBrowser:
return False
zip_file = ZipFile(zip)
zip_file.extractall(self.plugin_path)
plugin_folder = self.find_plugin_folder(name)
assert plugin_folder is not None
plugin_dir = path.join(self.plugin_path, plugin_folder)
if not chown(plugin_dir) or not chmod(plugin_dir, 555):
logger.error(f"chown/chmod exited with a non-zero exit code")
return False
return True
async def _download_remote_binaries_for_plugin_with_name(self, pluginBasePath: str):
@@ -101,8 +95,6 @@ class PluginBrowser:
rv = False
raise Exception(f"Error Downloading Remote Binary {binName}@{binURL} with hash {binHash} to {path.join(pluginBinPath, binName)}")
chown(self.plugin_path)
chmod(pluginBasePath, 555)
else:
rv = True
logger.info(f"No Remote Binaries to Download")
@@ -124,6 +116,25 @@ class PluginBrowser:
return folder
except:
logger.debug(f"skipping {folder}")
def set_plugin_dir_permissions(self, plugin_dir: str) -> bool:
plugin_json_path = path.join(plugin_dir, 'plugin.json')
logger.debug(f"Checking plugin.json at {plugin_json_path}")
root_plugin = False
if access(plugin_json_path, R_OK):
with open(plugin_json_path, "r", encoding="utf-8") as f:
plugin_json = json.load(f)
if "flags" in plugin_json and "root" in plugin_json["flags"]:
root_plugin = True
logger.debug("root_plugin %d, dir %s", root_plugin, plugin_dir)
if get_chown_plugin_path():
return chown(plugin_dir, UserType.EFFECTIVE_USER if root_plugin else UserType.HOST_USER, True) and chown(plugin_dir, UserType.EFFECTIVE_USER, False) and chmod(plugin_dir, 755) and chown(plugin_json_path, UserType.EFFECTIVE_USER, False) and chmod(plugin_json_path, 755)
else:
logger.debug("chown disabled by environment")
return True
async def uninstall_plugin(self, name: str):
if self.loader.watcher:
@@ -266,6 +277,7 @@ class PluginBrowser:
plugin_dir = path.join(self.plugin_path, plugin_folder)
await self.loader.ws.emit("loader/plugin_download_info", 95, "Store.download_progress_info.download_remote")
ret = await self._download_remote_binaries_for_plugin_with_name(plugin_dir)
chown_ret = self.set_plugin_dir_permissions(plugin_dir)
if ret:
logger.info(f"Installed {name} (Version: {version})")
if name in self.loader.plugins:
@@ -278,6 +290,9 @@ class PluginBrowser:
self.settings.setSetting("pluginOrder", current_plugin_order)
logger.debug("Plugin %s was added to the pluginOrder setting", name)
await self.loader.import_plugin(path.join(plugin_dir, "main.py"), plugin_folder)
elif not chown_ret:
logger.error("Could not chown plugin")
return
else:
logger.error("Could not download remote binaries")
return
+2 -3
View File
@@ -1,9 +1,8 @@
from enum import IntEnum
class UserType(IntEnum):
HOST_USER = 1
EFFECTIVE_USER = 2
ROOT = 3
HOST_USER = 1 # usually deck
EFFECTIVE_USER = 2 # usually root
class PluginLoadType(IntEnum):
LEGACY_EVAL_IIFE = 0 # legacy, uses legacy serverAPI
+2 -1
View File
@@ -181,7 +181,8 @@ def get_user_group_id() -> int:
# Get the default home path unless a user is specified
def get_home_path(username: str | None = None) -> str:
return localplatform.get_home_path(UserType.ROOT if username == "root" else UserType.HOST_USER)
# TODO hardcoded root is kinda a hack
return localplatform.get_home_path(UserType.EFFECTIVE_USER if username == "root" else UserType.HOST_USER)
async def is_systemd_unit_active(unit_name: str) -> bool:
return await localplatform.service_active(unit_name)
+6 -1
View File
@@ -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")
@@ -59,8 +59,6 @@ def chown(path : str, user : UserType = UserType.HOST_USER, recursive : bool =
user_str = _get_user()+":"+_get_user_group()
elif user == UserType.EFFECTIVE_USER:
user_str = _get_effective_user()+":"+_get_effective_user_group()
elif user == UserType.ROOT:
user_str = "root:root"
else:
raise Exception("Unknown User Type")
@@ -87,7 +85,7 @@ def chmod(path : str, permissions : int, recursive : bool = True) -> bool:
return True
def folder_owner(path : str) -> UserType|None:
def file_owner(path : str) -> UserType|None:
user_owner = _get_user_owner(path)
if (user_owner == _get_user()):
@@ -106,13 +104,14 @@ def get_home_path(user : UserType = UserType.HOST_USER) -> str:
user_name = _get_user()
elif user == UserType.EFFECTIVE_USER:
user_name = _get_effective_user()
elif user == UserType.ROOT:
pass
else:
raise Exception("Unknown User Type")
return pwd.getpwnam(user_name).pw_dir
def get_effective_username() -> str:
return _get_effective_user()
def get_username() -> str:
return _get_user()
@@ -121,8 +120,8 @@ def setgid(user : UserType = UserType.HOST_USER):
if user == UserType.HOST_USER:
user_id = _get_user_group_id()
elif user == UserType.ROOT:
pass
elif user == UserType.EFFECTIVE_USER:
pass # we already are
else:
raise Exception("Unknown user type")
@@ -133,8 +132,8 @@ def setuid(user : UserType = UserType.HOST_USER):
if user == UserType.HOST_USER:
user_id = _get_user_id()
elif user == UserType.ROOT:
pass
elif user == UserType.EFFECTIVE_USER:
pass # we already are
else:
raise Exception("Unknown user type")
@@ -7,7 +7,7 @@ def chown(path : str, user : UserType = UserType.HOST_USER, recursive : bool =
def chmod(path : str, permissions : int, recursive : bool = True) -> bool:
return True # Stubbed
def folder_owner(path : str) -> UserType|None:
def file_owner(path : str) -> UserType|None:
return UserType.HOST_USER # Stubbed
def get_home_path(user : UserType = UserType.HOST_USER) -> str:
@@ -34,6 +34,9 @@ async def service_restart(service_name : str, block : bool = True) -> bool:
return True # Stubbed
def get_effective_username() -> str:
return os.getlogin()
def get_username() -> str:
return os.getlogin()
+1 -1
View File
@@ -50,7 +50,7 @@ def chown_plugin_dir():
if not path.exists(plugin_path): # For safety, create the folder before attempting to do anything with it
mkdir_as_user(plugin_path)
if not chown(plugin_path, UserType.HOST_USER) or not chmod(plugin_path, 555):
if not chown(plugin_path, UserType.EFFECTIVE_USER, False) or not chmod(plugin_path, 755, False):
logger.error(f"chown/chmod exited with a non-zero exit code")
if get_chown_plugin_path() == True:
+20 -4
View File
@@ -8,7 +8,8 @@ from traceback import format_exc
from .sandboxed_plugin import SandboxedPlugin
from .messages import MethodCallRequest, SocketMessageType
from ..enums import PluginLoadType
from ..enums import PluginLoadType, UserType
from ..localplatform.localplatform import file_owner, chown, chmod, get_chown_plugin_path
from ..localplatform.localsocket import LocalSocket
from ..helpers import get_homebrew_path, mkdir_as_user
@@ -26,9 +27,12 @@ class PluginWrapper:
self.load_type = PluginLoadType.LEGACY_EVAL_IIFE.value
json = load(open(path.join(plugin_path, plugin_directory, "plugin.json"), "r", encoding="utf-8"))
if path.isfile(path.join(plugin_path, plugin_directory, "package.json")):
package_json = load(open(path.join(plugin_path, plugin_directory, "package.json"), "r", encoding="utf-8"))
plugin_dir_path = path.join(plugin_path, plugin_directory)
plugin_json_path = path.join(plugin_dir_path, "plugin.json")
json = load(open(plugin_json_path, "r", encoding="utf-8"))
if path.isfile(path.join(plugin_dir_path, "package.json")):
package_json = load(open(path.join(plugin_dir_path, "package.json"), "r", encoding="utf-8"))
self.version = package_json["version"]
if ("type" in package_json and package_json["type"] == "module"):
self.load_type = PluginLoadType.ESMODULE_V1.value
@@ -37,11 +41,23 @@ 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)
self.log = getLogger("plugin")
if get_chown_plugin_path():
# ensure plugin folder ownership
if file_owner(plugin_dir_path) != UserType.EFFECTIVE_USER:
chown(plugin_dir_path, UserType.EFFECTIVE_USER if "root" in self.flags else UserType.HOST_USER, True)
chown(plugin_dir_path, UserType.EFFECTIVE_USER, False)
chmod(plugin_dir_path, 755, True)
# fix plugin.json permissions
if file_owner(plugin_json_path) != UserType.EFFECTIVE_USER:
chown(plugin_json_path, UserType.EFFECTIVE_USER, False)
chmod(plugin_json_path, 755, False)
self.sandboxed_plugin = SandboxedPlugin(self.name, self.passive, self.flags, self.file, self.plugin_directory, self.plugin_path, self.version, self.author, self.api_version)
self.proc: Process | None = None
self._socket = LocalSocket()
@@ -13,7 +13,7 @@ from .messages import SocketResponseDict, SocketMessageType
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, settings, injector # pyright: ignore [reportUnusedImport]
from .. import helpers
from typing import List, TypeVar, Any
@@ -61,10 +61,10 @@ class SandboxedPlugin:
if self.passive:
return
setgid(UserType.ROOT if "root" in self.flags else UserType.HOST_USER)
setuid(UserType.ROOT if "root" in self.flags else UserType.HOST_USER)
setgid(UserType.EFFECTIVE_USER if "root" in self.flags else UserType.HOST_USER)
setuid(UserType.EFFECTIVE_USER if "root" in self.flags else UserType.HOST_USER)
# export a bunch of environment variables to help plugin developers
environ["HOME"] = get_home_path(UserType.ROOT if "root" in self.flags else UserType.HOST_USER)
environ["HOME"] = get_home_path(UserType.EFFECTIVE_USER if "root" in self.flags else UserType.HOST_USER)
environ["USER"] = "root" if "root" in self.flags else get_username()
environ["DECKY_VERSION"] = helpers.get_loader_version()
environ["DECKY_USER"] = get_username()
+3 -3
View File
@@ -1,7 +1,7 @@
from json import dump, load
from os import mkdir, path, listdir, rename
from typing import Any, Dict
from .localplatform.localplatform import chown, folder_owner, get_chown_plugin_path
from .localplatform.localplatform import chown, file_owner, get_chown_plugin_path
from .enums import UserType
from .helpers import get_homebrew_path
@@ -28,8 +28,8 @@ class SettingsManager:
#If the owner of the settings directory is not the user, then set it as the user:
expected_user = UserType.HOST_USER if get_chown_plugin_path() else UserType.ROOT
if folder_owner(settings_directory) != expected_user:
expected_user = UserType.HOST_USER if get_chown_plugin_path() else UserType.EFFECTIVE_USER
if file_owner(settings_directory) != expected_user:
chown(settings_directory, expected_user, False)
self.settings: Dict[str, Any] = {}
+22 -2
View File
@@ -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,22 @@ 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):
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)
plugin = self.context.plugin_loader.plugins[name]
plugin.start()
await self.context.plugin_loader.dispatch_plugin(plugin.name, plugin.version, plugin.load_type)
+1 -3
View File
@@ -7,7 +7,7 @@ from aiohttp.web import Application, WebSocketResponse, Request, Response, get
from enum import IntEnum
from typing import Callable, Coroutine, Dict, Any, cast, TypeVar
from typing import Callable, Coroutine, Dict, Any, cast
from traceback import format_exc
@@ -29,8 +29,6 @@ class WSMessageExtra(WSMessage):
# see wsrouter.ts for typings
DataType = TypeVar("DataType")
Route = Callable[..., Coroutine[Any, Any, Any]]
class WSRouter:
+24 -24
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
[[package]]
name = "aiohappyeyeballs"
@@ -190,7 +190,7 @@ description = "Timeout context manager for asyncio programs"
optional = false
python-versions = ">=3.7"
groups = ["main"]
markers = "python_version < \"3.11\""
markers = "python_version == \"3.10\""
files = [
{file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"},
{file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"},
@@ -678,32 +678,32 @@ files = [
[[package]]
name = "pyinstaller"
version = "6.8.0"
version = "6.14.2"
description = "PyInstaller bundles a Python application and all its dependencies into a single package."
optional = false
python-versions = "<3.13,>=3.8"
python-versions = "<3.14,>=3.8"
groups = ["dev"]
files = [
{file = "pyinstaller-6.8.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:5ff6bc2784c1026f8e2f04aa3760cbed41408e108a9d4cf1dd52ee8351a3f6e1"},
{file = "pyinstaller-6.8.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:39ac424d2ee2457d2ab11a5091436e75a0cccae207d460d180aa1fcbbafdd528"},
{file = "pyinstaller-6.8.0-py3-none-manylinux2014_i686.whl", hash = "sha256:355832a3acc7de90a255ecacd4b9f9e166a547a79c8905d49f14e3a75c1acdb9"},
{file = "pyinstaller-6.8.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:6303c7a009f47e6a96ef65aed49f41e36ece8d079b9193ca92fe807403e5fe80"},
{file = "pyinstaller-6.8.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2b71509468c811968c0b5decb5bbe85b6292ea52d7b1f26313d2aabb673fa9a5"},
{file = "pyinstaller-6.8.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:ff31c5b99e05a4384bbe2071df67ec8b2b347640a375eae9b40218be2f1754c6"},
{file = "pyinstaller-6.8.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:000c36b13fe4cd8d0d8c2bc855b1ddcf39867b5adf389e6b5ca45b25fa3e619d"},
{file = "pyinstaller-6.8.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:fe0af018d7d5077180e3144ada89a4da5df8d07716eb7e9482834a56dc57a4e8"},
{file = "pyinstaller-6.8.0-py3-none-win32.whl", hash = "sha256:d257f6645c7334cbd66f38a4fac62c3ad614cc46302b2b5d9f8cc48c563bce0e"},
{file = "pyinstaller-6.8.0-py3-none-win_amd64.whl", hash = "sha256:81cccfa9b16699b457f4788c5cc119b50f3cd4d0db924955f15c33f2ad27a50d"},
{file = "pyinstaller-6.8.0-py3-none-win_arm64.whl", hash = "sha256:1c3060a263758cf7f0144ab4c016097b20451b2469d468763414665db1bb743d"},
{file = "pyinstaller-6.8.0.tar.gz", hash = "sha256:3f4b6520f4423fe19bcc2fd63ab7238851ae2bdcbc98f25bc5d2f97cc62012e9"},
{file = "pyinstaller-6.14.2-py3-none-macosx_10_13_universal2.whl", hash = "sha256:d77d18bf5343a1afef2772393d7a489d4ec2282dee5bca549803fc0d74b78330"},
{file = "pyinstaller-6.14.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:3fa0c391e1300a9fd7752eb1ffe2950112b88fba9d2743eee2ef218a15f4705f"},
{file = "pyinstaller-6.14.2-py3-none-manylinux2014_i686.whl", hash = "sha256:077efb2d01d16d9c8fdda3ad52788f0fead2791c5cec9ed6ce058af7e26eb74b"},
{file = "pyinstaller-6.14.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:fdd2bd020a18736806a6bd5d3c4352f1209b427a96ad6c459d88aec1d90c4f21"},
{file = "pyinstaller-6.14.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:03862c6b3cf7b16843d24b529f89cd4077cbe467883cd54ce7a81940d6da09d3"},
{file = "pyinstaller-6.14.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:78827a21ada2a848e98671852d20d74b2955b6e2aaf2359ed13a462e1a603d84"},
{file = "pyinstaller-6.14.2-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:185710ab1503dfdfa14c43237d394d96ac183422d588294be42531480dfa6c38"},
{file = "pyinstaller-6.14.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:6c673a7e761bd4a2560cfd5dbe1ccdcfe2dff304b774e6e5242fc5afed953661"},
{file = "pyinstaller-6.14.2-py3-none-win32.whl", hash = "sha256:1697601aa788e3a52f0b5e620b4741a34b82e6f222ec6e1318b3a1349f566bb2"},
{file = "pyinstaller-6.14.2-py3-none-win_amd64.whl", hash = "sha256:e10e0e67288d6dcb5898a917dd1d4272aa0ff33f197ad49a0e39618009d63ed9"},
{file = "pyinstaller-6.14.2-py3-none-win_arm64.whl", hash = "sha256:69fd11ca57e572387826afaa4a1b3d4cb74927d76f231f0308c0bd7872ca5ac1"},
{file = "pyinstaller-6.14.2.tar.gz", hash = "sha256:142cce0719e79315f0cc26400c2e5c45d9b6b17e7e0491fee444a9f8f16f4917"},
]
[package.dependencies]
altgraph = "*"
macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""}
packaging = ">=22.0"
pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""}
pyinstaller-hooks-contrib = ">=2024.6"
pefile = {version = ">=2022.5.30,<2024.8.26 || >2024.8.26", markers = "sys_platform == \"win32\""}
pyinstaller-hooks-contrib = ">=2025.5"
pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""}
setuptools = ">=42.0.0"
@@ -713,14 +713,14 @@ hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"]
[[package]]
name = "pyinstaller-hooks-contrib"
version = "2024.7"
version = "2025.8"
description = "Community maintained hooks for PyInstaller"
optional = false
python-versions = ">=3.7"
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pyinstaller_hooks_contrib-2024.7-py2.py3-none-any.whl", hash = "sha256:8bf0775771fbaf96bcd2f4dfd6f7ae6c1dd1b1efe254c7e50477b3c08e7841d8"},
{file = "pyinstaller_hooks_contrib-2024.7.tar.gz", hash = "sha256:fd5f37dcf99bece184e40642af88be16a9b89613ecb958a8bd1136634fc9fac5"},
{file = "pyinstaller_hooks_contrib-2025.8-py3-none-any.whl", hash = "sha256:8d0b8cfa0cb689a619294ae200497374234bd4e3994b3ace2a4442274c899064"},
{file = "pyinstaller_hooks_contrib-2025.8.tar.gz", hash = "sha256:3402ad41dfe9b5110af134422e37fc5d421ba342c6cb980bd67cb30b7415641c"},
]
[package.dependencies]
@@ -1041,5 +1041,5 @@ propcache = ">=0.2.0"
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.13"
content-hash = "3c9488709e61f3aa21ab47ceb9c677927ce770d8e1e327602a1a6afb09164475"
python-versions = ">=3.10,<3.14"
content-hash = "9a331b42c52134230384c1a7348c2856903d82d6007e06cd75eed13842aa21ea"
+1 -1
View File
@@ -14,7 +14,7 @@ include = [
]
[tool.poetry.dependencies]
python = ">=3.10,<3.13"
python = ">=3.10,<3.14"
aiohttp = "^3.10.11"
aiohttp-jinja2 = "^1.5.1"
+1 -1
View File
@@ -47,7 +47,7 @@
}
},
"dependencies": {
"@decky/ui": "^4.10.2",
"@decky/ui": "^4.10.5",
"compare-versions": "^6.1.1",
"filesize": "^10.1.2",
"i18next": "^23.11.5",
+5 -5
View File
@@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@decky/ui':
specifier: ^4.10.2
version: 4.10.2
specifier: ^4.10.5
version: 4.10.5
compare-versions:
specifier: ^6.1.1
version: 6.1.1
@@ -218,8 +218,8 @@ packages:
'@decky/api@1.1.1':
resolution: {integrity: sha512-R5fkBRHBt5QIQY7Q0AlbVIhlIZ/nTzwBOoi8Rt4Go2fjFnoMKPInCJl6cPjXzimGwl2pyqKJgY6VnH6ar0XrHQ==}
'@decky/ui@4.10.2':
resolution: {integrity: sha512-dfY/OEI/rhG4d3Tvx4Y3TLmBrJ+nAm2RTbkoOJ3VAglql3Lu7RY2ixeDFbs21ZWWzWh/C+9dAQKjQ4lx4d1f2g==}
'@decky/ui@4.10.5':
resolution: {integrity: sha512-IsQiNtIbNJ5NQQJB9Dir0ir2pgE4QpcsrRB7eic2ClbPwYDuBKc6GLFs3ZQt43Dd4SwCJkzg/CeC+wgkBOMd1w==}
'@esbuild/aix-ppc64@0.20.2':
resolution: {integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==}
@@ -2295,7 +2295,7 @@ snapshots:
'@decky/api@1.1.1': {}
'@decky/ui@4.10.2': {}
'@decky/ui@4.10.5': {}
'@esbuild/aix-ppc64@0.20.2':
optional: true
+14 -1
View File
@@ -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[];
disabled: 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,
disabled: 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();
}
@@ -0,0 +1,46 @@
import { ConfirmModal, Spinner } from '@decky/ui';
import { FC, useState } from 'react';
import { disablePlugin } from '../../plugin';
interface PluginUninstallModalProps {
name: string;
title: string;
buttonText: string;
description: string;
closeModal?(): void;
}
const PluginDisableModal: FC<PluginUninstallModalProps> = ({ name, title, buttonText, description, closeModal }) => {
const [disabling, setDisabling] = useState<boolean>(false);
return (
<ConfirmModal
closeModal={closeModal}
onOK={async () => {
setDisabling(true);
await disablePlugin(name);
//not sure about this yet
// 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();
await DeckyPluginLoader.hiddenPluginsService.invalidate();
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;
@@ -1,15 +1,16 @@
import { FC } from 'react';
import { useTranslation } from 'react-i18next';
import { FaEyeSlash, FaLock } from 'react-icons/fa';
import { FaEyeSlash, FaLock, FaMoon } 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',
}}
>
<FaMoon />
{t('PluginListLabel.disabled')}
</div>
)}
</div>
);
};
@@ -13,7 +13,7 @@ import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FaDownload, FaEllipsisH, FaRecycle } from 'react-icons/fa';
import { InstallType } from '../../../../plugin';
import { enablePlugin, InstallType } from '../../../../plugin';
import {
StorePluginVersion,
getPluginList,
@@ -35,6 +35,7 @@ async function reinstallPlugin(pluginName: string, currentVersion?: string) {
type PluginTableData = PluginData & {
name: string;
disabled: boolean;
frozen: boolean;
onFreeze(): void;
onUnfreeze(): void;
@@ -54,7 +55,7 @@ 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(
@@ -82,6 +83,25 @@ function PluginInteractables(props: { entry: ReorderableEntry<PluginTableData> }
>
{t('PluginListIndex.uninstall')}
</MenuItem>
{disabled ?
<MenuItem
onSelected={() => enablePlugin(name)}
>
{t('PluginListIndex.plugin_enable')}
</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.plugin_disable')}
</MenuItem>
}
{hidden ? (
<MenuItem onSelected={onShow}>{t('PluginListIndex.show')}</MenuItem>
) : (
@@ -147,10 +167,11 @@ type PluginData = {
};
export default function PluginList({ isDeveloper }: { isDeveloper: boolean }) {
const { plugins, updates, pluginOrder, setPluginOrder, frozenPlugins, hiddenPlugins } = useDeckyState();
const { installedPlugins, disabled, updates, pluginOrder, setPluginOrder, frozenPlugins, hiddenPlugins } = useDeckyState();
const [_, setPluginOrderSetting] = useSetting<string[]>(
'pluginOrder',
plugins.map((plugin) => plugin.name),
installedPlugins.map((plugin) => plugin.name),
);
const { t } = useTranslation();
@@ -164,15 +185,17 @@ 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={disabled.find(p => p.name == name) !== undefined} />,
position: pluginOrder.indexOf(name),
data: {
name,
disabled: disabled.some(disabledPlugin => disabledPlugin.name === name),
frozen,
hidden,
isDeveloper,
@@ -186,9 +209,9 @@ export default function PluginList({ isDeveloper }: { isDeveloper: boolean }) {
};
}),
);
}, [plugins, updates, hiddenPlugins]);
}, [installedPlugins, updates, hiddenPlugins]);
if (plugins.length === 0) {
if (installedPlugins.length === 0) {
return (
<div>
<p>{t('PluginListIndex.no_plugin')}</p>
+2 -2
View File
@@ -3,13 +3,13 @@ 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 }) => {
+1 -1
View File
@@ -105,7 +105,7 @@ const BrowseTab: FC<{ setPluginCount: Dispatch<SetStateAction<number | null>> }>
})();
}, []);
const { plugins: installedPlugins } = useDeckyState();
const { installedPlugins } = useDeckyState();
return (
<>
+34 -8
View File
@@ -30,7 +30,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';
@@ -39,6 +39,7 @@ import Toaster from './toaster';
import { getVersionInfo } from './updater';
import { getSetting, setSetting } from './utils/settings';
import TranslationHelper, { TranslationClass } from './utils/TranslationHelper';
import PluginDisablelModal from './components/modals/PluginDisablelModal';
const StorePage = lazy(() => import('./components/store/Store'));
const SettingsPage = lazy(() => import('./components/settings'));
@@ -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',
@@ -197,7 +199,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');
@@ -206,10 +208,10 @@ class PluginLoader extends Logger {
let registration: any;
const uiMode = await new Promise(
(r) =>
(registration = SteamClient.UI.RegisterForUIModeChanged((mode: EUIMode) => {
r(mode);
registration.unregister();
})),
(registration = SteamClient.UI.RegisterForUIModeChanged((mode: EUIMode) => {
r(mode);
registration.unregister();
})),
);
if (uiMode == EUIMode.GamePad) {
// wait for SP window to exist before loading plugins
@@ -220,10 +222,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();
@@ -335,6 +343,10 @@ class PluginLoader extends Logger {
showModal(<PluginUninstallModal name={name} title={title} buttonText={buttonText} description={description} />);
}
public disablePlugin(name: string, title: string, buttonText: string, description: string) {
showModal(<PluginDisablelModal name={name} title={title} buttonText={buttonText} description={description} />);
}
public hasPlugin(name: string) {
return Boolean(this.plugins.find((plugin) => plugin.name == name));
}
@@ -373,6 +385,17 @@ 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().disabled,
{ 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?.();
@@ -392,12 +415,15 @@ class PluginLoader extends Logger {
return;
}
this.deckyState.setDisabledPlugins(this.deckyState.publicState().disabled.filter(d => d.name !== name))
try {
if (useQueue) this.reloadLock = true;
this.log(`Trying to load ${name}`);
this.unloadPlugin(name, true);
const startTime = performance.now();
await this.importReactPlugin(name, version, loadType);
const endTime = performance.now();
+4
View File
@@ -14,6 +14,8 @@ export interface Plugin {
titleView?: JSX.Element;
}
export type DisabledPlugin = Pick<Plugin, 'name' | 'version'>;
export enum InstallType {
INSTALL,
REINSTALL,
@@ -55,3 +57,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');
+12 -5
View File
@@ -20,7 +20,10 @@ if [ -z "$INSIDE_NIX_RANDOMSTRING" ] && command -v nix &> /dev/null; then
exit $?
fi
required_dependencies=(websocat jq curl chromium)
[[ -f "$HOME/.config/deckdebug/config.sh" ]] && source "$HOME/.config/deckdebug/config.sh"
CHROMIUM="${CHROMIUM:-chromium}"
required_dependencies=(websocat jq curl $CHROMIUM)
# Check if the dependencies are installed
for cmd in "${required_dependencies[@]}"; do
@@ -30,7 +33,7 @@ for cmd in "${required_dependencies[@]}"; do
fi
done
chromium --remote-debugging-port=9222 &
$CHROMIUM --remote-debugging-port=9222 &
sleep 2
ADDR=$1
@@ -49,10 +52,14 @@ while :; do
TARGET=$NEWTARGET
TARGETURL="http://$ADDR/devtools/inspector.html?ws=$ADDR/devtools/page/$TARGET"
LOCALTARGET=$(echo '{"id": 1, "method": "Target.createTarget", "params": {"background": true, "url": "'$TARGETURL'"}}
echo '{"id": 1, "method": "Target.createTarget", "params": {"background": true, "url": "'$TARGETURL'"}}
{"id": 2, "method": "Target.closeTarget", "params": {"targetId": "'$LOCALTARGET'"}}' \
| websocat ws://$LOCAL/devtools/page/$LOCALTARGET \
| jq -r '.result.targetId')
| websocat -t ws://$LOCAL/devtools/page/$LOCALTARGET
sleep 2
LOCALTARGETS=$(curl -s http://$LOCAL/json/list)
LOCALTARGET=$(jq -r '.[] | select(.title | startswith("DevTools")) | .id' <<< "$LOCALTARGETS")
echo started devtools at $LOCALTARGET
fi