Compare commits

...

41 Commits

Author SHA1 Message Date
AAGaming 7e1406c0bf add killall to flake
Co-authored-by: Party Wumpus <48649272+PartyWumpus@users.noreply.github.com>
2024-10-11 15:05:16 -04:00
AAGaming 7b32df0948 Add routerhook for desktop UI and a basic sidebar menu for Decky in desktop UI 2024-10-11 15:05:15 -04:00
AAGaming 306b0ff8d6 "fix" react-devtools
works well enough lol
2024-10-11 15:05:15 -04:00
AAGaming dbd7488d8f lint 2024-10-11 15:05:07 -04:00
AAGaming cff3ca504d bump @decky/ui to fix issues on beta 2024-10-11 15:00:54 -04:00
AAGaming 456ccf479a fix dropdownmultiselect on beta 2024-10-11 14:58:37 -04:00
AAGaming 4f05a001fb fix another toString 2024-10-11 14:58:05 -04:00
AAGaming 43aa0497f4 prevent future issues where toString may not be a function (what) 2024-10-11 14:47:20 -04:00
AAGaming 1781c19c11 Fix broken checkboxes on Beta Steam client (#710) 2024-10-04 13:26:00 -04:00
Lukas Senionis 1ef3cb8307 fix(http_request): remove conflicting CORS headers (#708) 2024-10-04 13:15:58 -04:00
WerWolvTranslationBot 5bc4dc684d Translations update from Weblate (#707)
Co-authored-by: ayssia <nynaevealmearah@gmail.com>
2024-10-03 17:28:00 -04:00
AAGaming 4cff530b52 Fix missing components on Oct 2 2024 Steam Beta (#709) 2024-10-03 17:08:35 -04:00
Marco Rodolfi 2f90a4fcf7 Rebase semver parsing on main (#677)
Co-authored-by: Marco Rodolfi <marco.rodolfi.1992@gmail.com>
2024-09-17 06:21:31 -07:00
AAGaming 24fce1e093 bump @decky/ui to include findSP fix 2024-09-16 19:31:04 -04:00
AAGaming 97b12972ee shut ts up 2024-09-16 16:21:00 -04:00
AAGaming f69eb72df9 wait for SteamApp init stage 1 to finish before loading decky's frontend bundle
should fix the startup race condition
2024-09-16 16:17:20 -04:00
WerWolvTranslationBot 24215c0732 Translations update from Weblate (#704)
* Translated using Weblate (German)

Currently translated at 100.0% (158 of 158 strings)

Translation: Decky/Decky
Translate-URL: https://weblate.werwolv.net/projects/decky/decky/de/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (158 of 158 strings)

Translation: Decky/Decky
Translate-URL: https://weblate.werwolv.net/projects/decky/decky/nl/

* Translated using Weblate (Bulgarian)

Currently translated at 100.0% (158 of 158 strings)

Translation: Decky/Decky
Translate-URL: https://weblate.werwolv.net/projects/decky/decky/bg/

---------

Co-authored-by: SecularSteve <fairfull.playing@gmail.com>
2024-09-14 23:01:47 -07:00
Sims e87ce625fb Test (#701) 2024-09-13 16:59:35 -07:00
AAGaming 1284075d02 update release systemd service 2024-09-11 22:54:16 -04:00
TrainDoctor d2c5aef58b Update release.yml 2024-09-11 19:49:39 -07:00
AAGaming caac379b08 just sleep 500ms for now to work around startup race condition 2024-09-11 21:50:48 -04:00
AAGaming c487a6e15a deprecate install scripts in repo (use decky-installer/cli instead, they're the same scripts but more up to date) 2024-09-11 21:02:41 -04:00
AAGaming 9df5f00068 drop TimeoutStopSec to 15s 2024-09-11 21:00:19 -04:00
AAGaming 508408ad5a use signals to shut down plugins instead of sending a socket message
should reduce or outright prevent shutdown stalls
2024-09-11 20:35:24 -04:00
AAGaming ef4ca204bd potentially fix startup race condition 2024-09-11 20:16:49 -04:00
AAGaming 1d7af36a2b add cache bust param to index.js of esmodule plugins
should fix hot reload
2024-09-11 19:38:58 -04:00
TrainDoctor 6b78e0ae9c Update bug_report.yml 2024-09-05 12:21:58 -07:00
TrainDoctor 1f5d5f9f1a Update bug_report.yml 2024-09-05 12:20:32 -07:00
TrainDoctor f7a47127a7 Update bug_report.yml 2024-09-05 12:19:12 -07:00
TrainDoctor 494f8dac5e Update bug_report.yml
Add new field requiring all installed plugins to be listed.
2024-09-05 12:14:33 -07:00
TrainDoctor bcc14848c5 Create plugin-info.sh
Add plugin-info script for debugging, thanks @Jaynator495!
2024-09-05 12:09:39 -07:00
AAGaming 0e40374b10 This also shouldn't have applied to stabls 2024-09-04 08:45:54 -04:00
AAGaming 81ffe11106 This shouldn't have applied to stable 2024-09-04 08:45:26 -04:00
AAGaming d06494885a fix external links softlocking the ui in testing store cta 2024-09-01 20:40:12 -04:00
Sims a6e4bcf052 Fix updater taking a long time (#696)
Replaces subprocess with asyncio.subprocess in some localplatformlinux functions and improves shutdown handling
Co-authored-by: AAGaming <aagaming@riseup.net>
2024-09-01 19:45:47 -04:00
AAGaming c1f7ca7f20 chore(backend): remove unused import in plugin.py 2024-09-01 14:18:33 -04:00
AAGaming 6ae6f5ee67 chore(backend): .warn -> .warning 2024-09-01 14:17:11 -04:00
Sims 016ed6e998 Fix shutdown timeouts (#695)
Co-authored-by: AAGaming <aagaming@riseup.net>
2024-09-01 14:15:49 -04:00
WerWolvTranslationBot 4842a599e0 Translated using Weblate (Russian) (#690)
Currently translated at 100.0% (158 of 158 strings)

Translation: Decky/Decky
Translate-URL: https://weblate.werwolv.net/projects/decky/decky/ru/

Co-authored-by: Andrew <www.andru90@gmail.com>
2024-08-28 10:25:51 +00:00
AAGaming 927f912eb3 lint 2024-08-21 14:40:42 -04:00
AAGaming 7c9b68c1dd only grab the first 8 lines of the component stack 2024-08-20 14:55:59 -04:00
53 changed files with 1937 additions and 478 deletions
+19 -3
View File
@@ -57,18 +57,34 @@ body:
validations:
required: true
- type: input
attributes:
label: Decky Loader Version
description: Specify the exact version of Decky.
placeholder: v3.0.0-pre12
validations:
required: true
- type: textarea
attributes:
label: Plugin Info
description: "Include all plugins installed including their version. Helpful script here: https://github.com/SteamDeckHomebrew/decky-loader/blob/main/scripts/plugin-info.sh"
placeholder: "If you don't want to collect this info manually you can download a helpful script linked in this item's description and place it into your home directory, chmod +x plugin-info.sh and then run it with ./plugin-info.sh"
validations:
required: true
- type: input
attributes:
label: Have you modified the read-only filesystem at any point?
description: Describe how here, if you haven't done anything you can leave this blank
placeholder: Yes, I've installed neofetch via pacman.
description: "Describe how here, if you haven't done anything you can leave this blank"
placeholder: "Yes, I've installed neofetch via pacman."
validations:
required: false
- type: textarea
attributes:
label: Backend Logs
description: Please reboot your deck (if possible) when attempting to recreate the issue, then run ``cd ~ && journalctl -b0 -u plugin_loader.service > deckylog.txt``. This will save the log file to ``~`` aka ``/home/deck``. Please upload the file here
description: Please reboot your deck (if possible) when attempting to recreate the issue, then run ``cd ~ && journalctl -b0 -u plugin_loader.service > deckylog.txt``. This will save the log file to ``~`` aka ``/home/deck``. Please upload the file here.
placeholder: deckylog.txt
validations:
required: true
+2 -2
View File
@@ -36,10 +36,10 @@ jobs:
uses: actions/checkout@v4
- name: Install semver-tool asdf
uses: asdf-vm/actions/install@v1
uses: asdf-vm/actions/install@v3
with:
tool_versions: |
semver 3.3.0
semver 3.4.0
- name: Get latest release
uses: rez0n/actions-github-release@main
+2 -2
View File
@@ -84,7 +84,7 @@ def get_loader_version() -> str:
return version_str
except Exception as e:
logger.warn(f"Failed to execute get_loader_version(): {str(e)}")
logger.warning(f"Failed to execute get_loader_version(): {str(e)}")
return "unknown"
user_agent = f"Decky/{get_loader_version()} (https://decky.xyz)"
@@ -102,7 +102,7 @@ def get_system_pythonpaths() -> list[str]:
versions = [x.strip() for x in proc.stdout.decode().strip().split("\n")]
return [x for x in versions if x and not x.isspace()]
except Exception as e:
logger.warn(f"Failed to execute get_system_pythonpaths(): {str(e)}")
logger.warning(f"Failed to execute get_system_pythonpaths(): {str(e)}")
return []
# Download Remote Binaries to local Plugin
+3 -3
View File
@@ -46,7 +46,7 @@ class Tab:
async for message in self.websocket:
data = message.json()
yield data
logger.warn(f"The Tab {self.title} socket has been disconnected while listening for messages.")
logger.warning(f"The Tab {self.title} socket has been disconnected while listening for messages.")
await self.close_websocket()
async def _send_devtools_cmd(self, dc: Dict[str, Any], receive: bool = True):
@@ -381,10 +381,10 @@ async def get_tabs() -> List[Tab]:
na = True
await sleep(5)
except ClientOSError:
logger.warn(f"The request to {BASE_ADDRESS}/json was reset")
logger.warning(f"The request to {BASE_ADDRESS}/json was reset")
await sleep(1)
except TimeoutError:
logger.warn(f"The request to {BASE_ADDRESS}/json timed out")
logger.warning(f"The request to {BASE_ADDRESS}/json timed out")
await sleep(1)
else:
break
+6 -1
View File
@@ -104,10 +104,15 @@ class Loader:
async def enable_reload_wait(self):
if self.live_reload:
await sleep(10)
if self.watcher:
if self.watcher and self.live_reload:
self.logger.info("Hot reload enabled")
self.watcher.disabled = False
async def disable_reload(self):
if self.watcher:
self.watcher.disabled = True
self.live_reload = False
async def handle_frontend_assets(self, request: web.Request):
file = Path(__file__).parent.joinpath("static").joinpath(request.match_info["path"])
return web.FileResponse(file, headers={"Cache-Control": "no-cache"})
+33 -2
View File
@@ -99,12 +99,14 @@
}
},
"PluginListIndex": {
"freeze": "Замразяване на актуализациите",
"hide": "Бърз достъп: Скриване",
"no_plugin": "Няма инсталирани добавки!",
"plugin_actions": "Действия с добавката",
"reinstall": "Преинсталиране",
"reload": "Презареждане",
"show": "Бърз достъп: Показване",
"unfreeze": "Разрешаване на актуализациите",
"uninstall": "Деинсталиране",
"update_all_one": "Обновяване на 1 добавка",
"update_all_other": "Обновяване на {{count}} добавки",
@@ -192,9 +194,19 @@
"SettingsIndex": {
"developer_title": "Разработчик",
"general_title": "Общи",
"plugins_title": "Добавки"
"plugins_title": "Добавки",
"testing_title": "Тестване"
},
"Store": {
"download_progress_info": {
"download_zip": "Изтегляне на плъгина",
"increment_count": "Увеличаване на броя изтегляния",
"installing_plugin": "Инсталиране на плъгина",
"open_zip": "Отваряне на zip файла",
"parse_zip": "Разглеждане на zip файла",
"start": "Иницииране",
"uninstalling_previous": "Деинсталиране на предишното копие"
},
"store_contrib": {
"desc": "Ако искате да допринесете към магазина за добавки на Decky, разгледайте хранилището SteamDeckHomebrew/decky-plugin-template в GitHub. Може да намерите информация относно разработката и разпространението във файла README.",
"label": "Допринасяне"
@@ -218,9 +230,17 @@
"about": "Относно",
"alph_asce": "По азбучен ред (Я -> А)",
"alph_desc": "По азбучен ред (А -> Я)",
"date_asce": "Най-старият първи",
"date_desc": "Най-новият първи",
"downloads_asce": "Най-малко изтеглени първи",
"downloads_desc": "Най-изтеглени първи",
"title": "Разглеждане"
},
"store_testing_cta": "Помислете дали искате да тествате новите добавки, за да помогнете на екипа на Decky Loader!"
"store_testing_cta": "Помислете дали искате да тествате новите добавки, за да помогнете на екипа на Decky Loader!",
"store_testing_warning": {
"desc": "Можете да използвате този канал на магазина, за да тествате най-новите версии на плъгините. Не забравяйте да оставите обратна връзка в GitHub, за да може плъгинът да бъде актуализиран за всички потребители.",
"label": "Добре дошли в канала на магазина за тестване"
}
},
"StoreSelect": {
"custom_store": {
@@ -234,6 +254,17 @@
"testing": "Тестване"
}
},
"Testing": {
"download": "Изтегляне",
"error": "Грешка при инсталирането на PR",
"header": "Следните версии на Decky Loader са създадени от отворени заявки за изтегляне от трети страни. Екипът на Decky Loader не е проверявал тяхната функционалност или сигурност и е възможно те да са остарели.",
"loading": "Зареждане на отворени заявки за изтегляне...",
"start_download_toast": "Изтегляне на PR #{{id}}"
},
"TitleView": {
"decky_store_desc": "Отворете Decky Store",
"settings_desc": "Отворете настройките на Decky"
},
"Updater": {
"decky_updates": "Обновления на Decky",
"no_patch_notes_desc": "няма бележки за промените в тази версия",
+14 -1
View File
@@ -198,6 +198,15 @@
"testing_title": "Testen"
},
"Store": {
"download_progress_info": {
"download_zip": "Plugin herunterladen",
"increment_count": "Erhöhen der Downloadanzahl",
"installing_plugin": "Plugin installieren",
"open_zip": "Öffnen der Zip-Datei",
"parse_zip": "Parsen der Zip-Datei",
"start": "Initialisieren",
"uninstalling_previous": "Vorherige Kopie deinstallieren"
},
"store_contrib": {
"desc": "Wenn du Erweiterungen im Decky Store veröffentlichen willst, besuche die SteamDeckHomebrew/decky-plugin-template Repository auf GitHub. Informationen rund um Entwicklung und Veröffentlichung findest du in der README.",
"label": "Mitwirken"
@@ -246,7 +255,11 @@
}
},
"Testing": {
"download": "Download"
"download": "Download",
"error": "Fehler beim Installieren von PR",
"header": "Die folgenden Versionen von Decky Loader wurden aus offenen Pull Requests von Dritten erstellt. Das Decky Loader-Team hat ihre Funktionalität oder Sicherheit nicht überprüft, und sie können veraltet sein.",
"loading": "Offene Pull Requests laden...",
"start_download_toast": "Herunterladen von PR #{{id}}"
},
"TitleView": {
"decky_store_desc": "Decky Store Öffnen",
+19 -6
View File
@@ -2,9 +2,9 @@
"BranchSelect": {
"update_channel": {
"label": "Updatekanaal",
"prerelease": "Prerelease",
"prerelease": "Vooruitgave",
"stable": "Stabiel",
"testing": "Testing"
"testing": "Testen"
}
},
"Developer": {
@@ -188,7 +188,7 @@
"header": "Overige"
},
"updates": {
"header": "Updates"
"header": "Bijwerkingen"
}
},
"SettingsIndex": {
@@ -198,6 +198,15 @@
"testing_title": "Testen"
},
"Store": {
"download_progress_info": {
"download_zip": "Plugin downloaden",
"increment_count": "Aantal downloads verhogen",
"installing_plugin": "Plugin installeren",
"open_zip": "Zip-bestand openen",
"parse_zip": "Zip-bestand parseren",
"start": "Initialiseren",
"uninstalling_previous": "Vorige kopie verwijderen"
},
"store_contrib": {
"desc": "Als je wilt bijdragen aan de Decky Plugin Store, kijk dan in de SteamDeckHomebrew/decky-plugin-template repository op GitHub. Informatie over ontwikkeling en distributie is beschikbaar in de README.",
"label": "Bijdragen"
@@ -242,11 +251,15 @@
"custom": "Aangepast",
"default": "Standaard",
"label": "Winkelkanaal",
"testing": "Testing"
"testing": "Testen"
}
},
"Testing": {
"download": "Downloaden"
"download": "Downloaden",
"error": "Fout bij installatie van PR",
"header": "De volgende versies van Decky Loader zijn gebouwd op basis van open Pull Requests van derden. Het Decky Loader-team heeft hun functionaliteit of veiligheid niet gecontroleerd en ze kunnen verouderd zijn.",
"loading": "Openstaande Pull Requests laden...",
"start_download_toast": "PR #{{id}} downloaden"
},
"TitleView": {
"decky_store_desc": "Decky Store openen",
@@ -261,7 +274,7 @@
"checking": "Bezig met controleren op updates",
"cur_version": "Huidige versie: {{ver}}",
"install_button": "Bijwerken",
"label": "Updates",
"label": "Bijwerkingen",
"lat_version": "Bijwerkt: versie {{ver}}",
"reloading": "Bezig met herstarten",
"updating": "Bezig met bijwerken"
+14 -1
View File
@@ -205,6 +205,15 @@
"testing_title": "Тестирование"
},
"Store": {
"download_progress_info": {
"download_zip": "Скачивание плагина",
"increment_count": "Увеличение количества загрузок",
"installing_plugin": "Установка плагина",
"open_zip": "Открытие zip файла",
"parse_zip": "Распаковка zip файла",
"start": "Инициализация",
"uninstalling_previous": "Удаление предыдущей копии"
},
"store_contrib": {
"desc": "Если вы хотите внести свой вклад в магазин плагинов Decky, проверьте репозиторий SteamDeckHomebrew/decky-plugin-template на GitHub. Информация о разработке и распространении доступна в README.",
"label": "Помощь проекту"
@@ -253,7 +262,11 @@
}
},
"Testing": {
"download": "Загрузить"
"download": "Загрузить",
"error": "Ошибка при установке PR",
"header": "Данные версии Decky Loader созданы на основе сторонних pull requst. Команда Decky Loader не проверяла их функциональность и безопасность, и они могут быть устаревшими.",
"loading": "Загрузка открытых pull requst'ов...",
"start_download_toast": "Загрузка PR#{{id}}"
},
"TitleView": {
"decky_store_desc": "Открыть магазин Decky",
+14 -1
View File
@@ -191,6 +191,15 @@
"testing_title": "测试"
},
"Store": {
"download_progress_info": {
"download_zip": "正在下载插件",
"increment_count": "正在计入下载次数",
"installing_plugin": "正在安装插件",
"open_zip": "正在打开 ZIP 文件",
"parse_zip": "正在解析 ZIP 文件",
"start": "正在初始化",
"uninstalling_previous": "正在卸载之前的版本"
},
"store_contrib": {
"desc": "如果你想要提交你的插件到 Decky 插件商店,请访问 GitHub 上的 SteamDeckHomebrew/decky-plugin-template 存储库。有关开发和分发插件的信息,请查看 README 文件。",
"label": "贡献"
@@ -239,7 +248,11 @@
}
},
"Testing": {
"download": "下载"
"download": "下载",
"error": "安装 PR 时出错",
"header": "以下版本的 Decky Loader 是根据开放的第三方 Pull Request 构建的。Decky Loader 团队尚未验证这些版本的功能或安全性,且它们可能已经过期。",
"loading": "正在加载尚未合并的 Pull Request ...",
"start_download_toast": "正在下载 PR #{{id}}"
},
"TitleView": {
"decky_store_desc": "打开 Decky 商店",
@@ -1,11 +1,21 @@
from re import compile
from asyncio import Lock
from asyncio import Lock, create_subprocess_exec
from asyncio.subprocess import PIPE, DEVNULL, STDOUT, Process
from subprocess import call as call_sync
import os, pwd, grp, sys, logging
from subprocess import call, run, DEVNULL, PIPE, STDOUT
from typing import IO, Any, Mapping
from ..enums import UserType
logger = logging.getLogger("localplatform")
# subprocess._ENV
ENV = Mapping[str, str]
ProcessIO = int | IO[Any] | None
async def run(args: list[str], stdin: ProcessIO = DEVNULL, stdout: ProcessIO = PIPE, stderr: ProcessIO = PIPE, env: ENV | None = None) -> tuple[Process, bytes | None, bytes | None]:
proc = await create_subprocess_exec(args[0], *(args[1:]), stdin=stdin, stdout=stdout, stderr=stderr, env=env)
proc_stdout, proc_stderr = await proc.communicate()
return (proc, proc_stdout, proc_stderr)
# Get the user id hosting the plugin loader
def _get_user_id() -> int:
return pwd.getpwnam(_get_user()).pw_uid
@@ -54,7 +64,7 @@ def chown(path : str, user : UserType = UserType.HOST_USER, recursive : bool =
else:
raise Exception("Unknown User Type")
result = call(["chown", "-R", user_str, path] if recursive else ["chown", user_str, path])
result = call_sync(["chown", "-R", user_str, path] if recursive else ["chown", user_str, path])
return result == 0
def chmod(path : str, permissions : int, recursive : bool = True) -> bool:
@@ -131,13 +141,17 @@ def setuid(user : UserType = UserType.HOST_USER):
os.setuid(user_id)
async def service_active(service_name : str) -> bool:
res = run(["systemctl", "is-active", service_name], stdout=DEVNULL, stderr=DEVNULL)
res, _, _ = await run(["systemctl", "is-active", service_name], stdout=DEVNULL, stderr=DEVNULL)
return res.returncode == 0
async def service_restart(service_name : str) -> bool:
call(["systemctl", "daemon-reload"])
async def service_restart(service_name : str, block : bool = True) -> bool:
await run(["systemctl", "daemon-reload"])
cmd = ["systemctl", "restart", service_name]
res = run(cmd, stdout=PIPE, stderr=STDOUT)
if not block:
cmd.append("--no-block")
res, _, _ = await run(cmd, stdout=PIPE, stderr=STDOUT)
return res.returncode == 0
async def service_stop(service_name : str) -> bool:
@@ -146,7 +160,7 @@ async def service_stop(service_name : str) -> bool:
return True
cmd = ["systemctl", "stop", service_name]
res = run(cmd, stdout=PIPE, stderr=STDOUT)
res, _, _ = await run(cmd, stdout=PIPE, stderr=STDOUT)
return res.returncode == 0
async def service_start(service_name : str) -> bool:
@@ -155,13 +169,13 @@ async def service_start(service_name : str) -> bool:
return True
cmd = ["systemctl", "start", service_name]
res = run(cmd, stdout=PIPE, stderr=STDOUT)
res, _, _ = await run(cmd, stdout=PIPE, stderr=STDOUT)
return res.returncode == 0
async def restart_webhelper() -> bool:
logger.info("Restarting steamwebhelper")
# TODO move to pkill
res = run(["killall", "-s", "SIGTERM", "steamwebhelper"], stdout=DEVNULL, stderr=DEVNULL)
res, _, _ = await run(["killall", "-s", "SIGTERM", "steamwebhelper"], stdout=DEVNULL, stderr=DEVNULL)
return res.returncode == 0
def get_privileged_path() -> str:
@@ -203,7 +217,7 @@ def get_unprivileged_path() -> str:
path = None
if path == None:
logger.warn("Unprivileged path is not properly configured. Defaulting to /home/deck/homebrew")
logger.warning("Unprivileged path is not properly configured. Defaulting to /home/deck/homebrew")
path = "/home/deck/homebrew" # We give up
os.makedirs(path, exist_ok=True)
@@ -225,7 +239,7 @@ def get_unprivileged_user() -> str:
break
if user == None:
logger.warn("Unprivileged user is not properly configured. Defaulting to 'deck'")
logger.warning("Unprivileged user is not properly configured. Defaulting to 'deck'")
user = 'deck'
return user
@@ -238,15 +252,15 @@ close_cef_socket_lock = Lock()
async def close_cef_socket():
async with close_cef_socket_lock:
if _get_effective_user_id() != 0:
logger.warn("Can't close CEF socket as Decky isn't running as root.")
logger.warning("Can't close CEF socket as Decky isn't running as root.")
return
# Look for anything listening TCP on port 8080
lsof = run(["lsof", "-F", "-iTCP:8080", "-sTCP:LISTEN"], capture_output=True, text=True)
if lsof.returncode != 0 or len(lsof.stdout) < 1:
lsof, stdout, _ = await run(["lsof", "-F", "-iTCP:8080", "-sTCP:LISTEN"], stdout=PIPE)
if not stdout or lsof.returncode != 0 or len(stdout) < 1:
logger.error(f"lsof call failed in close_cef_socket! return code: {str(lsof.returncode)}")
return
lsof_data = cef_socket_lsof_regex.match(lsof.stdout)
lsof_data = cef_socket_lsof_regex.match(stdout.decode())
if not lsof_data:
logger.error("lsof regex match failed in close_cef_socket!")
@@ -258,7 +272,7 @@ async def close_cef_socket():
logger.info(f"Closing CEF socket with PID {pid} and FD {fd}")
# Use gdb to inject a close() call for the socket fd into steamwebhelper
gdb_ret = run(["gdb", "--nx", "-p", pid, "--batch", "--eval-command", f"call (int)close({fd})"], env={"LD_LIBRARY_PATH": ""})
gdb_ret, _, _ = await run(["gdb", "--nx", "-p", pid, "--batch", "--eval-command", f"call (int)close({fd})"], env={"LD_LIBRARY_PATH": ""})
if gdb_ret.returncode != 0:
logger.error(f"Failed to close CEF socket with gdb! return code: {str(gdb_ret.returncode)}", exc_info=True)
@@ -28,7 +28,7 @@ async def service_stop(service_name : str) -> bool:
async def service_start(service_name : str) -> bool:
return True # Stubbed
async def service_restart(service_name : str) -> bool:
async def service_restart(service_name : str, block : bool = True) -> bool:
if service_name == "plugin_loader":
sys.exit(42)
@@ -7,22 +7,24 @@ from .localplatform import ON_WINDOWS
BUFFER_LIMIT = 2 ** 20 # 1 MiB
class UnixSocket:
def __init__(self, on_new_message: Callable[[str], Coroutine[Any, Any, Any]]):
def __init__(self):
'''
on_new_message takes 1 string argument.
It's return value gets used, if not None, to write data to the socket.
Method should be async
'''
self.socket_addr = f"/tmp/plugin_socket_{time.time()}"
self.on_new_message = on_new_message
self.on_new_message = None
self.socket = None
self.reader = None
self.writer = None
self.server_writer = None
self.open_lock = asyncio.Lock()
self.active = True
async def setup_server(self):
async def setup_server(self, on_new_message: Callable[[str], Coroutine[Any, Any, Any]]):
try:
self.on_new_message = on_new_message
self.socket = await asyncio.start_unix_server(self._listen_for_method_call, path=self.socket_addr, limit=BUFFER_LIMIT)
except asyncio.CancelledError:
await self.close_socket_connection()
@@ -58,6 +60,8 @@ class UnixSocket:
if self.socket:
self.socket.close()
await self.socket.wait_closed()
self.active = False
async def read_single_line(self) -> str|None:
reader, _ = await self.get_socket_connection()
@@ -81,7 +85,7 @@ class UnixSocket:
async def _read_single_line(self, reader: asyncio.StreamReader) -> str:
line = bytearray()
while True:
while self.active:
try:
line.extend(await reader.readuntil())
except asyncio.LimitOverrunError:
@@ -91,7 +95,7 @@ class UnixSocket:
line.extend(err.partial)
break
except asyncio.CancelledError:
break
raise
else:
break
@@ -111,7 +115,7 @@ class UnixSocket:
async def _listen_for_method_call(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
self.server_writer = writer
while True:
while self.active and self.on_new_message:
def _(task: asyncio.Task[str|None]):
res = task.result()
@@ -122,18 +126,19 @@ class UnixSocket:
asyncio.create_task(self.on_new_message(line)).add_done_callback(_)
class PortSocket (UnixSocket):
def __init__(self, on_new_message: Callable[[str], Coroutine[Any, Any, Any]]):
def __init__(self):
'''
on_new_message takes 1 string argument.
It's return value gets used, if not None, to write data to the socket.
Method should be async
'''
super().__init__(on_new_message)
super().__init__()
self.host = "127.0.0.1"
self.port = random.sample(range(40000, 60000), 1)[0]
async def setup_server(self):
async def setup_server(self, on_new_message: Callable[[str], Coroutine[Any, Any, Any]]):
try:
self.on_new_message = on_new_message
self.socket = await asyncio.start_server(self._listen_for_method_call, host=self.host, port=self.port, limit=BUFFER_LIMIT)
except asyncio.CancelledError:
await self.close_socket_connection()
+12 -5
View File
@@ -101,10 +101,12 @@ class PluginManager:
self.web_app.add_routes([static("/static", path.join(path.dirname(__file__), 'static'))])
async def handle_crash(self):
if not self.reinject:
return
new_time = time()
if (new_time - self.last_webhelper_exit < 60):
self.webhelper_crash_count += 1
logger.warn(f"webhelper crashed within a minute from last crash! crash count: {self.webhelper_crash_count}")
logger.warning(f"webhelper crashed within a minute from last crash! crash count: {self.webhelper_crash_count}")
else:
self.webhelper_crash_count = 0
self.last_webhelper_exit = new_time
@@ -118,9 +120,13 @@ class PluginManager:
async def shutdown(self, _: Application):
try:
logger.info(f"Shutting down...")
logger.info("Disabling reload...")
await self.plugin_loader.disable_reload()
logger.info("Killing plugins...")
await self.plugin_loader.shutdown_plugins()
await self.ws.disconnect()
logger.info("Disconnecting from WS...")
self.reinject = False
await self.ws.disconnect()
if self.js_ctx_tab:
await self.js_ctx_tab.close_websocket()
self.js_ctx_tab = None
@@ -132,16 +138,17 @@ class PluginManager:
tasks = all_tasks()
current = current_task()
async def cancel_task(task: Task[Any]):
logger.debug(f"Cancelling task {task}")
name = task.get_coro().__qualname__
logger.debug(f"Cancelling task {name}")
try:
task.cancel()
try:
await task
except CancelledError:
pass
logger.debug(f"Task {task} finished")
logger.debug(f"Task {name} finished")
except:
logger.warn(f"Failed to cancel task {task}:\n" + format_exc())
logger.warning(f"Failed to cancel task {name}:\n" + format_exc())
pass
if current:
tasks.remove(current)
+39 -24
View File
@@ -1,8 +1,10 @@
from asyncio import CancelledError, Task, create_task, sleep
from asyncio import CancelledError, Task, create_task, sleep, wait
from json import dumps, load, loads
from logging import getLogger
from os import path
from multiprocessing import Process
from time import time
from traceback import format_exc
from .sandboxed_plugin import SandboxedPlugin
from .messages import MethodCallRequest, SocketMessageType
@@ -42,8 +44,7 @@ class PluginWrapper:
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
# TODO: Maybe make LocalSocket not require on_new_message to make this cleaner
self._socket = LocalSocket(self.sandboxed_plugin.on_new_message)
self._socket = LocalSocket()
self._listener_task: Task[Any]
self._method_call_requests: Dict[str, MethodCallRequest] = {}
@@ -65,7 +66,7 @@ class PluginWrapper:
return self.name
async def _response_listener(self):
while True:
while self._socket.active:
try:
line = await self._socket.read_single_line()
if line != None:
@@ -84,7 +85,7 @@ class PluginWrapper:
async def execute_legacy_method(self, method_name: str, kwargs: Dict[Any, Any]):
if not self.legacy_method_warning:
self.legacy_method_warning = True
self.log.warn(f"Plugin {self.name} is using legacy method calls. This will be removed in a future release.")
self.log.warning(f"Plugin {self.name} is using legacy method calls. This will be removed in a future release.")
if self.passive:
raise RuntimeError("This plugin is passive (aka does not implement main.py)")
@@ -115,29 +116,43 @@ class PluginWrapper:
return self
async def stop(self, uninstall: bool = False):
self.log.info(f"Stopping plugin {self.name}")
if self.passive:
return
if hasattr(self, "_socket"):
await self._socket.write_single_line(dumps({ "stop": True, "uninstall": uninstall }, ensure_ascii=False))
await self._socket.close_socket_connection()
if self.proc:
self.proc.join()
await self.kill_if_still_running()
if hasattr(self, "_listener_task"):
self._listener_task.cancel()
try:
start_time = time()
if self.passive:
return
self.log.info(f"Shutting down {self.name}")
pending: set[Task[None]] | None = None;
if uninstall:
_, pending = await wait([
create_task(self._socket.write_single_line(dumps({ "uninstall": uninstall }, ensure_ascii=False)))
], timeout=1)
self.terminate() # the plugin process will handle SIGTERM and shut down cleanly without a socket message
if hasattr(self, "_listener_task"):
self._listener_task.cancel()
await self.kill_if_still_running()
if pending:
for pending_task in pending:
pending_task.cancel()
self.log.info(f"Plugin {self.name} has been stopped in {time() - start_time:.1f}s")
except Exception as e:
self.log.error(f"Error during shutdown for plugin {self.name}: {str(e)}\n{format_exc()}")
async def kill_if_still_running(self):
time = 0
start_time = time()
while self.proc and self.proc.is_alive():
await sleep(0.1)
time += 1
if time == 100:
self.log.warn(f"Plugin {self.name} still alive 10 seconds after stop request! Sending SIGTERM!")
self.terminate()
elif time == 200:
self.log.warn(f"Plugin {self.name} still alive 20 seconds after stop request! Sending SIGKILL!")
elapsed_time = time() - start_time
if elapsed_time >= 5:
self.log.warning(f"Plugin {self.name} still alive 5 seconds after stop request! Sending SIGKILL!")
self.terminate(True)
await sleep(0.1)
def terminate(self, kill: bool = False):
if self.proc and self.proc.is_alive():
+28 -23
View File
@@ -1,17 +1,17 @@
import sys
from os import path, environ
from signal import SIG_IGN, SIGINT, SIGTERM, getsignal, signal
from importlib.util import module_from_spec, spec_from_file_location
from json import dumps, loads
from logging import getLogger
from traceback import format_exc
from asyncio import (get_event_loop, new_event_loop,
from asyncio import (ensure_future, get_event_loop, new_event_loop,
set_event_loop)
from signal import SIGINT, SIGTERM
from setproctitle import setproctitle, setthreadtitle
from .messages import SocketResponseDict, SocketMessageType
from ..localplatform.localsocket import LocalSocket
from ..localplatform.localplatform import setgid, setuid, get_username, get_home_path
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]
@@ -19,8 +19,6 @@ from typing import List, TypeVar, Any
DataType = TypeVar("DataType")
original_term_handler = getsignal(SIGTERM)
class SandboxedPlugin:
def __init__(self,
name: str,
@@ -41,6 +39,8 @@ class SandboxedPlugin:
self.version = version
self.author = author
self.api_version = api_version
self.shutdown_running = False
self.uninstalling = False
self.log = getLogger("sandboxed_plugin")
@@ -48,17 +48,19 @@ class SandboxedPlugin:
self._socket = socket
try:
# Ignore signals meant for parent Process
# TODO SURELY there's a better way to do this.
signal(SIGINT, SIG_IGN)
signal(SIGTERM, SIG_IGN)
setproctitle(f"{self.name} ({self.file})")
setthreadtitle(self.name)
set_event_loop(new_event_loop())
loop = new_event_loop()
set_event_loop(loop)
# When running Decky manually in a terminal, ctrl-c will trigger this, so we have to handle it properly
if ON_LINUX:
loop.add_signal_handler(SIGINT, lambda: ensure_future(self.shutdown()))
loop.add_signal_handler(SIGTERM, lambda: ensure_future(self.shutdown()))
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)
# export a bunch of environment variables to help plugin developers
@@ -120,7 +122,7 @@ class SandboxedPlugin:
get_event_loop().create_task(self.Plugin._main())
else:
get_event_loop().create_task(self.Plugin._main(self.Plugin))
get_event_loop().create_task(socket.setup_server())
get_event_loop().create_task(socket.setup_server(self.on_new_message))
except:
self.log.error("Failed to start " + self.name + "!\n" + format_exc())
sys.exit(0)
@@ -163,24 +165,27 @@ class SandboxedPlugin:
self.log.error("Failed to uninstall " + self.name + "!\n" + format_exc())
pass
async def on_new_message(self, message : str) -> str|None:
data = loads(message)
if "stop" in data:
# Incase the loader needs to terminate our process soon
signal(SIGTERM, original_term_handler)
async def shutdown(self):
if not self.shutdown_running:
self.shutdown_running = True
self.log.info(f"Calling Loader unload function for {self.name}.")
await self._unload()
if data.get('uninstall'):
if self.uninstalling:
self.log.info("Calling Loader uninstall function.")
await self._uninstall()
self.log.debug("Stopping event loop")
self.log.debug("Stopping event loop")
loop = get_event_loop()
loop.call_soon_threadsafe(loop.stop)
sys.exit(0)
loop = get_event_loop()
loop.call_soon_threadsafe(loop.stop)
sys.exit(0)
async def on_new_message(self, message : str) -> str|None:
data = loads(message)
if "uninstall" in data:
self.uninstalling = data.get("uninstall")
d: SocketResponseDict = {"type": SocketMessageType.RESPONSE, "res": None, "success": True, "id": data["id"]}
try:
+5 -2
View File
@@ -24,6 +24,7 @@ logger = getLogger("Updater")
class RemoteVerAsset(TypedDict):
name: str
size: int
browser_download_url: str
class RemoteVer(TypedDict):
tag_name: str
@@ -198,11 +199,13 @@ class Updater:
version = self.remoteVer["tag_name"]
download_url = None
size_in_bytes = None
download_filename = "PluginLoader" if ON_LINUX else "PluginLoader.exe"
for x in self.remoteVer["assets"]:
if x["name"] == download_filename:
download_url = x["browser_download_url"]
size_in_bytes = x["size"]
break
if download_url == None:
@@ -238,10 +241,10 @@ class Updater:
os.mkdir(path.join(getcwd(), ".systemd"))
shutil.move(service_file_path, path.join(getcwd(), ".systemd")+"/plugin_loader.service")
await self.download_decky_binary(download_url, version)
await self.download_decky_binary(download_url, version, size_in_bytes=size_in_bytes)
async def do_restart(self):
await service_restart("plugin_loader")
await service_restart("plugin_loader", block=False)
async def do_shutdown(self):
await service_stop("plugin_loader")
+44 -16
View File
@@ -9,7 +9,7 @@ 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 aiohttp import ClientSession
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
@@ -153,14 +153,14 @@ class Utilities:
headers["User-Agent"] = helpers.user_agent
for excluded_header in excluded_default_headers:
self.logger.debug(f"Excluding default header {excluded_header}")
if excluded_header in headers:
self.logger.debug(f"Excluding default header {excluded_header}: {headers[excluded_header]}")
del headers[excluded_header]
if "X-Decky-Fetch-Excluded-Headers" in req.headers:
for excluded_header in req.headers["X-Decky-Fetch-Excluded-Headers"].split(", "):
self.logger.debug(f"Excluding header {excluded_header}")
if excluded_header in headers:
self.logger.debug(f"Excluding header {excluded_header}: {headers[excluded_header]}")
del headers[excluded_header]
for header in req.headers:
@@ -187,7 +187,21 @@ class Utilities:
# defeat the point of this proxy.
async with ClientSession(auto_decompress=False) as web:
async with web.request(req.method, url, headers=headers, data=body, ssl=helpers.get_ssl_context()) as web_res:
res = StreamResponse(headers=web_res.headers, status=web_res.status)
# Whenever the aiohttp_cors is used, it expects a near complete control over whatever headers are needed
# for `aiohttp_cors.ResourceOptions`. As a server, if you delegate CORS handling to aiohttp_cors,
# the headers below must NOT be set. Otherwise they would be overwritten by aiohttp_cors and there would be
# logic bugs, so it was probably a smart choice to assert if the headers are present.
#
# However, this request handler method does not act like our own local server, it always acts like a proxy
# where we do not have control over the response headers. For responses that do not allow CORS, we add the support
# via aiohttp_cors. For responses that allow CORS, we have to remove the conflicting headers to allow
# aiohttp_cors handle it for us as if there was no CORS support.
aiohttp_cors_compatible_headers = web_res.headers.copy()
aiohttp_cors_compatible_headers.popall(hdrs.ACCESS_CONTROL_ALLOW_ORIGIN, default=None)
aiohttp_cors_compatible_headers.popall(hdrs.ACCESS_CONTROL_ALLOW_CREDENTIALS, default=None)
aiohttp_cors_compatible_headers.popall(hdrs.ACCESS_CONTROL_EXPOSE_HEADERS, default=None)
res = StreamResponse(headers=aiohttp_cors_compatible_headers, status=web_res.status)
if web_res.headers.get('Transfer-Encoding', '').lower() == 'chunked':
res.enable_chunked_encoding()
@@ -415,17 +429,28 @@ class Utilities:
async with ClientSession() as web:
res = await web.request("GET", "http://" + ip + ":8097", ssl=helpers.get_ssl_context())
script = """
if (!window.deckyHasConnectedRDT) {
window.deckyHasConnectedRDT = true;
// This fixes the overlay when hovering over an element in RDT
Object.defineProperty(window, '__REACT_DEVTOOLS_TARGET_WINDOW__', {
enumerable: true,
configurable: true,
get: function() {
return (GamepadNavTree?.m_context?.m_controller || FocusNavController)?.m_ActiveContext?.ActiveWindow || window;
}
});
""" + await res.text() + "\n}"
try {
if (!window.deckyHasConnectedRDT) {
window.deckyHasConnectedRDT = true;
// This fixes the overlay when hovering over an element in RDT
Object.defineProperty(window, '__REACT_DEVTOOLS_TARGET_WINDOW__', {
enumerable: true,
configurable: true,
get: function() {
return window?.DFL?.findSP?.() || window;
}
});
""" + await res.text() + """
// they broke the script so we have to do this ourselves
ReactDevToolsBackend.initialize({
appendComponentStack: true,
breakOnConsoleErrors: false,
showInlineWarningsAndErrors: true,
hideConsoleLogsInStrictMode: false
});
ReactDevToolsBackend.connectToDevTools({port: 8097, host: 'localhost', useHttps: false});
} } catch(e) {console.error('RDT LOAD ERROR', e);}console.log('LOADED RDT');
"""
if res.status != 200:
self.logger.error("Failed to connect to React DevTools at " + ip)
return False
@@ -433,7 +458,10 @@ class Utilities:
self.logger.info("Connected to React DevTools, loading script")
tab = await get_gamepadui_tab()
# RDT needs to load before React itself to work.
await close_old_tabs()
try:
await close_old_tabs()
except Exception:
pass
result = await tab.reload_and_evaluate(script)
self.logger.info(result)
+3 -3
View File
@@ -50,7 +50,7 @@ class WSRouter:
if self.ws != None:
await self.ws.send_json(data)
else:
self.logger.warn("Dropping message as there is no connected socket: %s", data)
self.logger.warning("Dropping message as there is no connected socket: %s", data)
def add_route(self, name: str, route: Route):
self.routes[name] = route
@@ -69,9 +69,9 @@ class WSRouter:
if instance_id != self.instance_id:
try:
self.logger.warn("Ignoring %s reply from stale instance %d with args %s and response %s", route, instance_id, args, res)
self.logger.warning("Ignoring %s reply from stale instance %d with args %s and response %s", route, instance_id, args, res)
except:
self.logger.warn("Ignoring %s reply from stale instance %d (failed to log event data)", route, instance_id)
self.logger.warning("Ignoring %s reply from stale instance %d (failed to log event data)", route, instance_id)
finally:
return
+2 -66
View File
@@ -1,67 +1,3 @@
#!/bin/sh
[ "$UID" -eq 0 ] || exec sudo "$0" "$@"
echo "Installing Steam Deck Plugin Loader pre-release..."
USER_DIR="$(getent passwd $SUDO_USER | cut -d: -f6)"
HOMEBREW_FOLDER="${USER_DIR}/homebrew"
# Create folder structure
rm -rf "${HOMEBREW_FOLDER}/services"
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services"
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins"
touch "${USER_DIR}/.steam/steam/.cef-enable-remote-debugging"
# Download latest release and install it
RELEASE=$(curl -s 'https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases' | jq -r "first(.[] | select(.prerelease == "true"))")
VERSION=$(jq -r '.tag_name' <<< ${RELEASE} )
DOWNLOADURL=$(jq -r '.assets[].browser_download_url | select(endswith("PluginLoader"))' <<< ${RELEASE})
printf "Installing version %s...\n" "${VERSION}"
curl -L $DOWNLOADURL --output ${HOMEBREW_FOLDER}/services/PluginLoader
chmod +x ${HOMEBREW_FOLDER}/services/PluginLoader
echo $VERSION > ${HOMEBREW_FOLDER}/services/.loader.version
systemctl --user stop plugin_loader 2> /dev/null
systemctl --user disable plugin_loader 2> /dev/null
systemctl stop plugin_loader 2> /dev/null
systemctl disable plugin_loader 2> /dev/null
curl -L https://raw.githubusercontent.com/SteamDeckHomebrew/decky-loader/main/dist/plugin_loader-prerelease.service --output ${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service
cat > "${HOMEBREW_FOLDER}/services/plugin_loader-backup.service" <<- EOM
[Unit]
Description=SteamDeck Plugin Loader
[Service]
Type=simple
User=root
Restart=always
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
WorkingDirectory=${HOMEBREW_FOLDER}/services
Environment=UNPRIVILEGED_PATH=${HOMEBREW_FOLDER}
Environment=PRIVILEGED_PATH=${HOMEBREW_FOLDER}
Environment=LOG_LEVEL=DEBUG
[Install]
WantedBy=multi-user.target
EOM
if [[ -f "${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service" ]]; then
printf "Grabbed latest prerelease service.\n"
sed -i -e "s|\${HOMEBREW_FOLDER}|${HOMEBREW_FOLDER}|" "${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service"
cp -f "${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service" "/etc/systemd/system/plugin_loader.service"
else
printf "Could not curl latest prerelease systemd service, using built-in service as a backup!\n"
rm -f "/etc/systemd/system/plugin_loader.service"
cp "${HOMEBREW_FOLDER}/services/plugin_loader-backup.service" "/etc/systemd/system/plugin_loader.service"
fi
mkdir -p ${HOMEBREW_FOLDER}/services/.systemd
cp ${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service ${HOMEBREW_FOLDER}/services/.systemd/plugin_loader-prerelease.service
cp ${HOMEBREW_FOLDER}/services/plugin_loader-backup.service ${HOMEBREW_FOLDER}/services/.systemd/plugin_loader-backup.service
rm ${HOMEBREW_FOLDER}/services/plugin_loader-backup.service ${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service
systemctl daemon-reload
systemctl start plugin_loader
systemctl enable plugin_loader
echo This script is deprecated! Use https://github.com/SteamDeckHomebrew/decky-installer/raw/main/cli/install_prerelease.sh instead!
exit 1
+2 -66
View File
@@ -1,67 +1,3 @@
#!/bin/sh
[ "$UID" -eq 0 ] || exec sudo "$0" "$@"
echo "Installing Steam Deck Plugin Loader release..."
USER_DIR="$(getent passwd $SUDO_USER | cut -d: -f6)"
HOMEBREW_FOLDER="${USER_DIR}/homebrew"
# Create folder structure
rm -rf "${HOMEBREW_FOLDER}/services"
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services"
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins"
touch "${USER_DIR}/.steam/steam/.cef-enable-remote-debugging"
# Download latest release and install it
RELEASE=$(curl -s 'https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases' | jq -r "first(.[] | select(.prerelease == "false"))")
VERSION=$(jq -r '.tag_name' <<< ${RELEASE} )
DOWNLOADURL=$(jq -r '.assets[].browser_download_url | select(endswith("PluginLoader"))' <<< ${RELEASE})
printf "Installing version %s...\n" "${VERSION}"
curl -L $DOWNLOADURL --output ${HOMEBREW_FOLDER}/services/PluginLoader
chmod +x ${HOMEBREW_FOLDER}/services/PluginLoader
echo $VERSION > ${HOMEBREW_FOLDER}/services/.loader.version
systemctl --user stop plugin_loader 2> /dev/null
systemctl --user disable plugin_loader 2> /dev/null
systemctl stop plugin_loader 2> /dev/null
systemctl disable plugin_loader 2> /dev/null
curl -L https://raw.githubusercontent.com/SteamDeckHomebrew/decky-loader/main/dist/plugin_loader-release.service --output ${HOMEBREW_FOLDER}/services/plugin_loader-release.service
cat > "${HOMEBREW_FOLDER}/services/plugin_loader-backup.service" <<- EOM
[Unit]
Description=SteamDeck Plugin Loader
[Service]
Type=simple
User=root
Restart=always
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
WorkingDirectory=${HOMEBREW_FOLDER}/services
Environment=UNPRIVILEGED_PATH=${HOMEBREW_FOLDER}
Environment=PRIVILEGED_PATH=${HOMEBREW_FOLDER}
Environment=LOG_LEVEL=INFO
[Install]
WantedBy=multi-user.target
EOM
if [[ -f "${HOMEBREW_FOLDER}/services/plugin_loader-release.service" ]]; then
printf "Grabbed latest release service.\n"
sed -i -e "s|\${HOMEBREW_FOLDER}|${HOMEBREW_FOLDER}|" "${HOMEBREW_FOLDER}/services/plugin_loader-release.service"
cp -f "${HOMEBREW_FOLDER}/services/plugin_loader-release.service" "/etc/systemd/system/plugin_loader.service"
else
printf "Could not curl latest release systemd service, using built-in service as a backup!\n"
rm -f "/etc/systemd/system/plugin_loader.service"
cp "${HOMEBREW_FOLDER}/services/plugin_loader-backup.service" "/etc/systemd/system/plugin_loader.service"
fi
mkdir -p ${HOMEBREW_FOLDER}/services/.systemd
cp ${HOMEBREW_FOLDER}/services/plugin_loader-release.service ${HOMEBREW_FOLDER}/services/.systemd/plugin_loader-release.service
cp ${HOMEBREW_FOLDER}/services/plugin_loader-backup.service ${HOMEBREW_FOLDER}/services/.systemd/plugin_loader-backup.service
rm ${HOMEBREW_FOLDER}/services/plugin_loader-backup.service ${HOMEBREW_FOLDER}/services/plugin_loader-release.service
systemctl daemon-reload
systemctl start plugin_loader
systemctl enable plugin_loader
echo This script is deprecated! Use https://github.com/SteamDeckHomebrew/decky-installer/raw/main/cli/install_release.sh instead!
exit 1
+2 -1
View File
@@ -5,7 +5,8 @@ After=network.target
Type=simple
User=root
Restart=always
TimeoutStopSec=45
KillMode=process
TimeoutStopSec=15
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
WorkingDirectory=${HOMEBREW_FOLDER}/services
Environment=UNPRIVILEGED_PATH=${HOMEBREW_FOLDER}
+2 -1
View File
@@ -5,7 +5,8 @@ After=network.target
Type=simple
User=root
Restart=always
TimeoutStopSec=45
KillMode=process
TimeoutStopSec=15
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
WorkingDirectory=${HOMEBREW_FOLDER}/services
Environment=UNPRIVILEGED_PATH=${HOMEBREW_FOLDER}
+2
View File
@@ -40,6 +40,8 @@
nodePackages.pnpm
poetry
jq
electron_30-bin
killall
# fixes local pyright not being able to see the pythonpath properly.
(pkgs.writeShellScriptBin "pyright" ''
${pkgs.pyright}/bin/pyright --pythonpath `which python3` "$@" '')
+5 -2
View File
@@ -10,7 +10,8 @@
"lint": "prettier -c src",
"typecheck": "tsc --noEmit",
"format": "prettier -c src -w",
"localize": "i18next"
"localize": "i18next",
"react-devtools": "electron node_modules/react-devtools/app"
},
"devDependencies": {
"@decky/api": "^1.1.1",
@@ -31,6 +32,7 @@
"prettier": "^3.3.2",
"prettier-plugin-import-sort": "^0.0.7",
"react": "18.3.1",
"react-devtools": "^6.0.0",
"react-dom": "18.3.1",
"rollup": "^4.18.0",
"rollup-plugin-delete": "^2.0.0",
@@ -47,7 +49,8 @@
}
},
"dependencies": {
"@decky/ui": "^4.7.1",
"@decky/ui": "^4.8.1",
"compare-versions": "^6.1.1",
"filesize": "^10.1.2",
"i18next": "^23.11.5",
"i18next-http-backend": "^2.5.2",
+1098 -9
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
import { FC, useEffect, useRef, useState } from 'react';
import { useDeckyState } from './DeckyState';
import PluginView from './PluginView';
import { QuickAccessVisibleState } from './QuickAccessVisibleState';
const DeckyDesktopSidebar: FC = () => {
const { desktopMenuOpen, setDesktopMenuOpen } = useDeckyState();
const [closed, setClosed] = useState<boolean>(!desktopMenuOpen);
const [openAnimStart, setOpenAnimStart] = useState<boolean>(desktopMenuOpen);
const closedInterval = useRef<number | null>(null);
useEffect(() => {
const anim = requestAnimationFrame(() => setOpenAnimStart(desktopMenuOpen));
return () => cancelAnimationFrame(anim);
}, [desktopMenuOpen]);
useEffect(() => {
closedInterval.current && clearTimeout(closedInterval.current);
if (desktopMenuOpen) {
setClosed(false);
} else {
closedInterval.current = setTimeout(() => setClosed(true), 500);
}
}, [desktopMenuOpen]);
return (
<>
<div
className="deckyDesktopSidebarDim"
style={{
position: 'absolute',
height: 'calc(100% - 78px - 50px)',
width: '100%',
top: '78px',
left: '0px',
zIndex: 998,
background: 'rgba(0, 0, 0, 0.7)',
opacity: openAnimStart ? 1 : 0,
display: desktopMenuOpen || !closed ? 'flex' : 'none',
transition: 'opacity 0.4s cubic-bezier(0.65, 0, 0.35, 1)',
}}
onClick={() => setDesktopMenuOpen(false)}
/>
<div
className="deckyDesktopSidebar"
style={{
position: 'absolute',
height: 'calc(100% - 78px - 50px)',
width: '350px',
paddingLeft: '16px',
top: '78px',
right: '0px',
zIndex: 999,
transition: 'transform 0.4s cubic-bezier(0.65, 0, 0.35, 1)',
transform: openAnimStart ? 'translateX(0px)' : 'translateX(366px)',
overflowY: 'scroll',
// prevents chromium border jank
display: desktopMenuOpen || !closed ? 'flex' : 'none',
flexDirection: 'column',
background: '#171d25',
}}
>
<QuickAccessVisibleState.Provider value={desktopMenuOpen || !closed}>
<PluginView desktop={true} />
</QuickAccessVisibleState.Provider>
</div>
</>
);
};
export default DeckyDesktopSidebar;
@@ -0,0 +1,44 @@
import { CSSProperties, FC } from 'react';
import DeckyDesktopSidebar from './DeckyDesktopSidebar';
import DeckyIcon from './DeckyIcon';
import { useDeckyState } from './DeckyState';
const DeckyDesktopUI: FC = () => {
const { desktopMenuOpen, setDesktopMenuOpen } = useDeckyState();
return (
<>
<style>
{`
.deckyDesktopIcon {
color: #67707b;
}
.deckyDesktopIcon:hover {
color: #fff;
}
`}
</style>
<DeckyIcon
className="deckyDesktopIcon"
width={24}
height={24}
onClick={() => setDesktopMenuOpen(!desktopMenuOpen)}
style={
{
position: 'absolute',
top: '36px', // nav text is 34px but 36px looks nicer to me
right: '10px', // <- is 16px but 10px looks nicer to me
width: '24px',
height: '24px',
cursor: 'pointer',
transition: 'color 0.3s linear',
'-webkit-app-region': 'no-drag',
} as CSSProperties
}
/>
<DeckyDesktopSidebar />
</>
);
};
export default DeckyDesktopUI;
@@ -1,12 +1,17 @@
import { FC, ReactNode, createContext, useContext, useEffect, useState } from 'react';
import { UIMode } from '../enums';
interface PublicDeckyGlobalComponentsState {
components: Map<string, FC>;
components: Map<UIMode, Map<string, FC>>;
}
export class DeckyGlobalComponentsState {
// TODO a set would be better
private _components = new Map<string, FC>();
private _components = new Map<UIMode, Map<string, FC>>([
[UIMode.BigPicture, new Map()],
[UIMode.Desktop, new Map()],
]);
public eventBus = new EventTarget();
@@ -14,13 +19,19 @@ export class DeckyGlobalComponentsState {
return { components: this._components };
}
addComponent(path: string, component: FC) {
this._components.set(path, component);
addComponent(path: string, component: FC, uiMode: UIMode) {
const components = this._components.get(uiMode);
if (!components) throw new Error(`UI mode ${uiMode} not supported.`);
components.set(path, component);
this.notifyUpdate();
}
removeComponent(path: string) {
this._components.delete(path);
removeComponent(path: string, uiMode: UIMode) {
const components = this._components.get(uiMode);
if (!components) throw new Error(`UI mode ${uiMode} not supported.`);
components.delete(path);
this.notifyUpdate();
}
@@ -30,8 +41,8 @@ export class DeckyGlobalComponentsState {
}
interface DeckyGlobalComponentsContext extends PublicDeckyGlobalComponentsState {
addComponent(path: string, component: FC): void;
removeComponent(path: string): void;
addComponent(path: string, component: FC, uiMode: UIMode): void;
removeComponent(path: string, uiMode: UIMode): void;
}
const DeckyGlobalComponentsContext = createContext<DeckyGlobalComponentsContext>(null as any);
+20 -10
View File
@@ -1,6 +1,8 @@
import { ComponentType, FC, ReactNode, createContext, useContext, useEffect, useState } from 'react';
import type { RouteProps } from 'react-router';
import { UIMode } from '../enums';
export interface RouterEntry {
props: Omit<RouteProps, 'path' | 'children'>;
component: ComponentType;
@@ -10,12 +12,16 @@ export type RoutePatch = (route: RouteProps) => RouteProps;
interface PublicDeckyRouterState {
routes: Map<string, RouterEntry>;
routePatches: Map<string, Set<RoutePatch>>;
routePatches: Map<UIMode, Map<string, Set<RoutePatch>>>;
}
export class DeckyRouterState {
private _routes = new Map<string, RouterEntry>();
private _routePatches = new Map<string, Set<RoutePatch>>();
// Update when support for new UIModes is added
private _routePatches = new Map<UIMode, Map<string, Set<RoutePatch>>>([
[UIMode.BigPicture, new Map()],
[UIMode.Desktop, new Map()],
]);
public eventBus = new EventTarget();
@@ -28,22 +34,26 @@ export class DeckyRouterState {
this.notifyUpdate();
}
addPatch(path: string, patch: RoutePatch) {
let patchList = this._routePatches.get(path);
addPatch(path: string, patch: RoutePatch, uiMode: UIMode) {
const patchesForMode = this._routePatches.get(uiMode);
if (!patchesForMode) throw new Error(`UI mode ${uiMode} not supported.`);
let patchList = patchesForMode.get(path);
if (!patchList) {
patchList = new Set();
this._routePatches.set(path, patchList);
patchesForMode.set(path, patchList);
}
patchList.add(patch);
this.notifyUpdate();
return patch;
}
removePatch(path: string, patch: RoutePatch) {
const patchList = this._routePatches.get(path);
removePatch(path: string, patch: RoutePatch, uiMode: UIMode) {
const patchesForMode = this._routePatches.get(uiMode);
if (!patchesForMode) throw new Error(`UI mode ${uiMode} not supported.`);
const patchList = patchesForMode.get(path);
patchList?.delete(patch);
if (patchList?.size == 0) {
this._routePatches.delete(path);
patchesForMode.delete(path);
}
this.notifyUpdate();
}
@@ -60,8 +70,8 @@ export class DeckyRouterState {
interface DeckyRouterStateContext extends PublicDeckyRouterState {
addRoute(path: string, component: RouterEntry['component'], props: RouterEntry['props']): void;
addPatch(path: string, patch: RoutePatch): RoutePatch;
removePatch(path: string, patch: RoutePatch): void;
addPatch(path: string, patch: RoutePatch, uiMode?: UIMode): RoutePatch;
removePatch(path: string, patch: RoutePatch, uiMode?: UIMode): void;
removeRoute(path: string): void;
}
+11
View File
@@ -17,6 +17,7 @@ interface PublicDeckyState {
versionInfo: VerInfo | null;
notificationSettings: NotificationSettings;
userInfo: UserInfo | null;
desktopMenuOpen: boolean;
}
export interface UserInfo {
@@ -36,6 +37,7 @@ export class DeckyState {
private _versionInfo: VerInfo | null = null;
private _notificationSettings = DEFAULT_NOTIFICATION_SETTINGS;
private _userInfo: UserInfo | null = null;
private _desktopMenuOpen: boolean = false;
public eventBus = new EventTarget();
@@ -52,6 +54,7 @@ export class DeckyState {
versionInfo: this._versionInfo,
notificationSettings: this._notificationSettings,
userInfo: this._userInfo,
desktopMenuOpen: this._desktopMenuOpen,
};
}
@@ -115,6 +118,11 @@ export class DeckyState {
this.notifyUpdate();
}
setDesktopMenuOpen(open: boolean) {
this._desktopMenuOpen = open;
this.notifyUpdate();
}
private notifyUpdate() {
this.eventBus.dispatchEvent(new Event('update'));
}
@@ -126,6 +134,7 @@ interface DeckyStateContext extends PublicDeckyState {
setActivePlugin(name: string): void;
setPluginOrder(pluginOrder: string[]): void;
closeActivePlugin(): void;
setDesktopMenuOpen(open: boolean): void;
}
const DeckyStateContext = createContext<DeckyStateContext>(null as any);
@@ -155,6 +164,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 setDesktopMenuOpen = deckyState.setDesktopMenuOpen.bind(deckyState);
return (
<DeckyStateContext.Provider
@@ -165,6 +175,7 @@ export const DeckyStateContextProvider: FC<Props> = ({ children, deckyState }) =
setActivePlugin,
closeActivePlugin,
setPluginOrder,
setDesktopMenuOpen,
}}
>
{children}
+16
View File
@@ -0,0 +1,16 @@
import { Navigation } from '@decky/ui';
import { AnchorHTMLAttributes, FC } from 'react';
const ExternalLink: FC<AnchorHTMLAttributes<HTMLAnchorElement>> = (props) => {
return (
<a
{...props}
onClick={(e) => {
e.preventDefault();
props.onClick ? props.onClick(e) : props.href && Navigation.NavigateToExternalWeb(props.href);
}}
/>
);
};
export default ExternalLink;
+5
View File
@@ -24,6 +24,11 @@ const Markdown: FunctionComponent<MarkdownProps> = (props) => {
props.onDismiss?.();
Navigation.NavigateToExternalWeb(aRef.current!.href);
}}
onClick={(e) => {
e.preventDefault();
props.onDismiss?.();
Navigation.NavigateToExternalWeb(aRef.current!.href);
}}
style={{ display: 'inline' }}
>
<a ref={aRef} {...nodeProps.node.properties}>
+7 -3
View File
@@ -9,7 +9,11 @@ import NotificationBadge from './NotificationBadge';
import { useQuickAccessVisible } from './QuickAccessVisibleState';
import TitleView from './TitleView';
const PluginView: FC = () => {
interface PluginViewProps {
desktop?: boolean;
}
const PluginView: FC<PluginViewProps> = ({ desktop = false }) => {
const { hiddenPlugins } = useDeckyState();
const { plugins, updates, activePlugin, pluginOrder, setActivePlugin, closeActivePlugin } = useDeckyState();
const visible = useQuickAccessVisible();
@@ -27,7 +31,7 @@ const PluginView: FC = () => {
if (activePlugin) {
return (
<Focusable onCancelButton={closeActivePlugin}>
<TitleView />
<TitleView desktop={desktop} />
<div style={{ height: '100%', paddingTop: '16px' }}>
<ErrorBoundary>{(visible || activePlugin.alwaysRender) && activePlugin.content}</ErrorBoundary>
</div>
@@ -36,7 +40,7 @@ const PluginView: FC = () => {
}
return (
<>
<TitleView />
<TitleView desktop={desktop} />
<div
style={{
paddingTop: '16px',
@@ -1,6 +1,6 @@
import { FC, ReactNode, createContext, useContext, useState } from 'react';
const QuickAccessVisibleState = createContext<boolean>(false);
export const QuickAccessVisibleState = createContext<boolean>(false);
export const useQuickAccessVisible = () => useContext(QuickAccessVisibleState);
+21 -8
View File
@@ -14,18 +14,34 @@ const titleStyles: CSSProperties = {
top: '0px',
};
const TitleView: FC = () => {
const { activePlugin, closeActivePlugin } = useDeckyState();
interface TitleViewProps {
desktop?: boolean;
}
const TitleView: FC<TitleViewProps> = ({ desktop }) => {
const { activePlugin, closeActivePlugin, setDesktopMenuOpen } = useDeckyState();
const { t } = useTranslation();
const onSettingsClick = () => {
Navigation.Navigate('/decky/settings');
Navigation.CloseSideMenus();
setDesktopMenuOpen(false);
};
const onStoreClick = () => {
Navigation.Navigate('/decky/store');
Navigation.CloseSideMenus();
setDesktopMenuOpen(false);
};
const buttonStyles = {
height: '28px',
width: '40px',
minWidth: 0,
padding: desktop ? '' : '10px 12px',
display: 'flex',
alignItems: desktop ? 'center' : '',
justifyContent: desktop ? 'center' : '',
};
if (activePlugin === null) {
@@ -33,14 +49,14 @@ const TitleView: FC = () => {
<Focusable style={titleStyles} className={staticClasses.Title}>
<div style={{ marginRight: 'auto', flex: 0.9 }}>Decky</div>
<DialogButton
style={{ height: '28px', width: '40px', minWidth: 0, padding: '10px 12px' }}
style={buttonStyles}
onClick={onStoreClick}
onOKActionDescription={t('TitleView.decky_store_desc')}
>
<FaStore style={{ marginTop: '-4px', display: 'block' }} />
</DialogButton>
<DialogButton
style={{ height: '28px', width: '40px', minWidth: 0, padding: '10px 12px' }}
style={buttonStyles}
onClick={onSettingsClick}
onOKActionDescription={t('TitleView.settings_desc')}
>
@@ -52,10 +68,7 @@ const TitleView: FC = () => {
return (
<Focusable className={staticClasses.Title} style={titleStyles}>
<DialogButton
style={{ height: '28px', width: '40px', minWidth: 0, padding: '10px 12px' }}
onClick={closeActivePlugin}
>
<DialogButton style={buttonStyles} onClick={closeActivePlugin}>
<FaArrowLeft style={{ marginTop: '-4px', display: 'block' }} />
</DialogButton>
{activePlugin?.titleView || <div style={{ flex: 0.9 }}>{activePlugin.name}</div>}
@@ -2,11 +2,11 @@ import {
DialogButton,
DialogCheckbox,
DialogCheckboxProps,
Export,
Marquee,
Menu,
MenuItem,
findModuleExport,
Module,
findModule,
showContextMenu,
} from '@decky/ui';
import { FC, useCallback, useEffect, useState } from 'react';
@@ -14,9 +14,7 @@ import { useTranslation } from 'react-i18next';
import { FaChevronDown } from 'react-icons/fa';
// TODO add to dfl
const dropDownControlButtonClass = findModuleExport((e: Export) =>
e?.toString()?.includes('gamepaddropdown_DropDownControlButton'),
);
const dropDownControlButtonClasses = findModule((m: Module) => m?.DropDownControlButton && m?.['duration-app-launch']);
const DropdownMultiselectItem: FC<
{
@@ -76,7 +74,7 @@ const DropdownMultiselect: FC<{
alignItems: 'center',
maxWidth: '100%',
}}
className={dropDownControlButtonClass}
className={dropDownControlButtonClasses?.DropDownControlButton}
onClick={(evt) => {
evt.preventDefault();
showContextMenu(
@@ -80,7 +80,10 @@ const MultiplePluginsInstallModal: FC<MultiplePluginsInstallModalProps> = ({
onOK={async () => {
setLoading(true);
await onOK();
setTimeout(() => Navigation.OpenQuickAccessMenu(QuickAccessTab.Decky), 250);
setTimeout(() => {
Navigation.OpenQuickAccessMenu(QuickAccessTab.Decky);
DeckyPluginLoader.setDesktopMenuOpen(true);
}, 250);
setTimeout(() => DeckyPluginLoader.checkPluginUpdates(), 1000);
}}
onCancel={async () => {
@@ -51,7 +51,10 @@ const PluginInstallModal: FC<PluginInstallModalProps> = ({
onOK={async () => {
setLoading(true);
await onOK();
setTimeout(() => Navigation.OpenQuickAccessMenu(QuickAccessTab.Decky), 250);
setTimeout(() => {
Navigation.OpenQuickAccessMenu(QuickAccessTab.Decky);
DeckyPluginLoader.setDesktopMenuOpen(true);
}, 250);
setTimeout(() => DeckyPluginLoader.checkPluginUpdates(), 1000);
}}
onCancel={async () => {
+16 -1
View File
@@ -53,5 +53,20 @@ export default function SettingsPage() {
},
];
return <SidebarNavigation pages={pages} />;
return (
<div className="deckySettingsHeightHack">
<style>
{/* hacky fix to work around height: 720px in desktop ui */}
{`
.deckySettingsHeightHack {
height: 100% !important;
}
.deckySettingsHeightHack > div {
height: 100% !important;
}
`}
</style>
<SidebarNavigation pages={pages} />
</div>
);
}
@@ -6,8 +6,8 @@ import {
Focusable,
ProgressBarWithInfo,
Spinner,
findSP,
showModal,
useWindowRef,
} from '@decky/ui';
import { Suspense, lazy, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -21,45 +21,48 @@ import WithSuspense from '../../../WithSuspense';
const MarkdownRenderer = lazy(() => import('../../../Markdown'));
function PatchNotesModal({ versionInfo, closeModal }: { versionInfo: VerInfo | null; closeModal?: () => {} }) {
const SP = findSP();
const [outerRef, win] = useWindowRef<HTMLDivElement>();
const { t } = useTranslation();
// TODO proper desktop scrolling
return (
<Focusable onCancelButton={closeModal}>
<Focusable ref={outerRef} onCancelButton={closeModal}>
<FocusRing>
<Carousel
fnItemRenderer={(id: number) => (
<Focusable
style={{
marginTop: '40px',
height: 'calc( 100% - 40px )',
overflowY: 'scroll',
display: 'flex',
justifyContent: 'center',
margin: '40px',
}}
>
<div>
<h1>{versionInfo?.all?.[id]?.name || 'Invalid Update Name'}</h1>
{versionInfo?.all?.[id]?.body ? (
<WithSuspense>
<MarkdownRenderer onDismiss={closeModal}>{versionInfo.all[id].body}</MarkdownRenderer>
</WithSuspense>
) : (
t('Updater.no_patch_notes_desc')
)}
</div>
</Focusable>
)}
fnGetId={(id) => id}
nNumItems={versionInfo?.all?.length}
nHeight={SP.innerHeight - 40}
nItemHeight={SP.innerHeight - 40}
nItemMarginX={0}
initialColumn={0}
autoFocus={true}
fnGetColumnWidth={() => SP.innerWidth}
name={t('Updater.decky_updates') as string}
/>
{win && (
<Carousel
fnItemRenderer={(id: number) => (
<Focusable
style={{
marginTop: '40px',
height: 'calc( 100% - 40px )',
overflowY: 'scroll',
display: 'flex',
justifyContent: 'center',
margin: '40px',
}}
>
<div>
<h1>{versionInfo?.all?.[id]?.name || 'Invalid Update Name'}</h1>
{versionInfo?.all?.[id]?.body ? (
<WithSuspense>
<MarkdownRenderer onDismiss={closeModal}>{versionInfo.all[id].body}</MarkdownRenderer>
</WithSuspense>
) : (
t('Updater.no_patch_notes_desc')
)}
</div>
</Focusable>
)}
fnGetId={(id) => id}
nNumItems={versionInfo?.all?.length}
nHeight={(win?.innerHeight || 800) - 40}
nItemHeight={(win?.innerHeight || 800) - 40}
nItemMarginX={0}
initialColumn={0}
autoFocus={true}
fnGetColumnWidth={() => win?.innerHeight || 1280}
name={t('Updater.decky_updates') as string}
/>
)}
</FocusRing>
</Focusable>
);
@@ -72,6 +75,8 @@ export default function UpdaterSettings() {
const [updateProgress, setUpdateProgress] = useState<number>(-1);
const [reloading, setReloading] = useState<boolean>(false);
const [windowRef, win] = useWindowRef<HTMLDivElement>();
const { t } = useTranslation();
useEffect(() => {
@@ -91,11 +96,12 @@ export default function UpdaterSettings() {
}, []);
const showPatchNotes = useCallback(() => {
showModal(<PatchNotesModal versionInfo={versionInfo} />);
}, [versionInfo]);
// TODO set width and height on desktop - needs fixing in DFL?
showModal(<PatchNotesModal versionInfo={versionInfo} />, win!);
}, [versionInfo, win]);
return (
<>
<div ref={windowRef}>
<Field
onOptionsActionDescription={versionInfo?.all ? t('Updater.patch_notes_desc') : undefined}
onOptionsButton={versionInfo?.all ? showPatchNotes : undefined}
@@ -164,6 +170,6 @@ export default function UpdaterSettings() {
</Suspense>
</InlinePatchNotes>
)}
</>
</div>
);
}
+3 -2
View File
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { InstallType } from '../../plugin';
import { StorePlugin, StorePluginVersion, requestPluginInstall } from '../../store';
import ExternalLink from '../ExternalLink';
interface PluginCardProps {
plugin: StorePlugin;
@@ -108,7 +109,7 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
}}
>
<i>{t('PluginCard.plugin_full_access')}</i>{' '}
<a
<ExternalLink
className="deckyStoreCardDescriptionRootLink"
href="https://deckbrew.xyz/root"
target="_blank"
@@ -118,7 +119,7 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
}}
>
deckbrew.xyz/root
</a>
</ExternalLink>
</div>
)}
</div>
+5 -4
View File
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
import logo from '../../../assets/plugin_store.png';
import Logger from '../../logger';
import { SortDirections, SortOptions, Store, StorePlugin, getPluginList, getStore } from '../../store';
import ExternalLink from '../ExternalLink';
import PluginCard from './PluginCard';
const logger = new Logger('Store');
@@ -207,7 +208,7 @@ const BrowseTab: FC<{ setPluginCount: Dispatch<SetStateAction<number | null>> }>
<h2 style={{ margin: 0 }}>{t('Store.store_testing_warning.label')}</h2>
<span>
{`${t('Store.store_testing_warning.desc')} `}
<a
<ExternalLink
href="https://decky.xyz/testing"
target="_blank"
style={{
@@ -215,7 +216,7 @@ const BrowseTab: FC<{ setPluginCount: Dispatch<SetStateAction<number | null>> }>
}}
>
decky.xyz/testing
</a>
</ExternalLink>
</span>
</div>
)}
@@ -269,7 +270,7 @@ const AboutTab: FC<{}> = () => {
<span className="deckyStoreAboutHeader">Testing</span>
<span>
{t('Store.store_testing_cta')}{' '}
<a
<ExternalLink
href="https://decky.xyz/testing"
target="_blank"
style={{
@@ -277,7 +278,7 @@ const AboutTab: FC<{}> = () => {
}}
>
decky.xyz/testing
</a>
</ExternalLink>
</span>
<span className="deckyStoreAboutHeader">{t('Store.store_contrib.label')}</span>
<span>{t('Store.store_contrib.desc')}</span>
+4
View File
@@ -0,0 +1,4 @@
export enum UIMode {
BigPicture = 4,
Desktop = 7,
}
+8 -15
View File
@@ -5,23 +5,16 @@ interface Window {
}
(async () => {
// Wait for main webpack chunks to definitely be loaded
console.time('[Decky:Boot] Waiting for main Webpack chunks...');
while (!window.webpackChunksteamui || window.webpackChunksteamui.length < 5) {
await new Promise((r) => setTimeout(r, 10)); // Can't use DFL sleep here.
}
console.timeEnd('[Decky:Boot] Waiting for main Webpack chunks...');
console.debug('[Decky:Boot] Frontend init');
// Wait for the React root to be mounted
console.time('[Decky:Boot] Waiting for React root mount...');
let root;
while (
!(root = document.getElementById('root')) ||
!(root as any)[Object.keys(root).find((k) => k.startsWith('__reactContainer$')) as string]
) {
await new Promise((r) => setTimeout(r, 10)); // Can't use DFL sleep here.
console.time('[Decky:Boot] Waiting for SteamApp init stage 1 to finish...');
// @ts-expect-error TODO type BFinishedInitStageOne in @decky/ui
while (!window.App?.BFinishedInitStageOne()) {
await new Promise((r) => setTimeout(r, 0)); // Can't use DFL sleep here.
}
console.timeEnd('[Decky:Boot] Waiting for React root mount...');
console.timeEnd('[Decky:Boot] Waiting for SteamApp init stage 1 to finish...');
if (!window.SP_REACT) {
console.debug('[Decky:Boot] Setting up Webpack & React globals...');
+39 -8
View File
@@ -1,4 +1,4 @@
import { ToastNotification } from '@decky/api';
import type { ToastNotification } from '@decky/api';
import {
ModalRoot,
Navigation,
@@ -13,6 +13,7 @@ import {
import { FC, lazy } from 'react';
import { FaDownload, FaExclamationCircle, FaPlug } from 'react-icons/fa';
import DeckyDesktopUI from './components/DeckyDesktopUI';
import DeckyIcon from './components/DeckyIcon';
import { DeckyState, DeckyStateContextProvider, UserInfo, useDeckyState } from './components/DeckyState';
import { File, FileSelectionType } from './components/modals/filepicker';
@@ -24,13 +25,14 @@ import NotificationBadge from './components/NotificationBadge';
import PluginView from './components/PluginView';
import { useQuickAccessVisible } from './components/QuickAccessVisibleState';
import WithSuspense from './components/WithSuspense';
import { UIMode } from './enums';
import ErrorBoundaryHook from './errorboundary-hook';
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 RouterHook, { UIMode } from './router-hook';
import RouterHook from './router-hook';
import { deinitSteamFixes, initSteamFixes } from './steamfixes';
import { checkForPluginUpdates } from './store';
import TabsHook from './tabs-hook';
@@ -160,6 +162,21 @@ class PluginLoader extends Logger {
);
});
// needs the 1s wait or the entire app becomes drag target lol
sleep(1000).then(() =>
this.routerHook.addGlobalComponent(
'DeckyDesktopUI',
() => {
return (
<DeckyStateContextProvider deckyState={this.deckyState}>
<DeckyDesktopUI />
</DeckyStateContextProvider>
);
},
UIMode.Desktop,
),
);
initSteamFixes();
initFilepickerPatches();
@@ -168,8 +185,9 @@ class PluginLoader extends Logger {
Promise.all([this.getUserInfo(), this.updateVersion()])
.then(() => this.loadPlugins())
.then(() => this.checkPluginUpdates())
.then(() => this.log('Initialized'));
.then(() => this.log('Initialized'))
.then(() => sleep(30000)) // Internet might not immediately be up
.then(() => this.checkPluginUpdates());
}
private checkForSP(): boolean {
@@ -361,6 +379,7 @@ class PluginLoader extends Logger {
public deinit() {
this.routerHook.removeRoute('/decky/store');
this.routerHook.removeRoute('/decky/settings');
this.routerHook.removeGlobalComponent('DeckyDesktopUI', UIMode.Desktop);
deinitSteamFixes();
deinitFilepickerPatches();
this.routerHook.deinit();
@@ -421,7 +440,7 @@ class PluginLoader extends Logger {
try {
switch (loadType) {
case PluginLoadType.ESMODULE_V1:
const plugin_exports = await import(`http://127.0.0.1:1337/plugins/${name}/dist/index.js`);
const plugin_exports = await import(`http://127.0.0.1:1337/plugins/${name}/dist/index.js?t=${Date.now()}`);
let plugin = plugin_exports.default();
this.plugins.push({
@@ -626,8 +645,8 @@ class PluginLoader extends Logger {
// Things will break *very* badly if plugin code touches this outside of @decky/api, so lets make that clear.
window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit = {
connect: (version: number, pluginName: string) => {
if (version < 1 || version > 2) {
console.warn(`Plugin ${pluginName} requested unsupported api version ${version}.`);
if (version < 1 || version > 3) {
console.warn(`Plugin ${pluginName} requested unsupported API version ${version}.`);
}
const eventListeners: listenerMap = new Map();
@@ -670,12 +689,20 @@ class PluginLoader extends Logger {
_version: 1,
} as any;
// adds useQuickAccessVisible
if (version >= 2) {
backendAPI._version = 2;
backendAPI.useQuickAccessVisible = useQuickAccessVisible;
}
this.debug(`${pluginName} connected to loader API.`);
// adds uiMode param to route patching and global component functions. no functional changes, but we should warn anyway.
if (version >= 3) {
backendAPI._version = 3;
}
this.debug(
`${pluginName} connected to loader API version ${backendAPI._version} (requested version ${version}).`,
);
return backendAPI;
},
};
@@ -732,6 +759,10 @@ class PluginLoader extends Logger {
return pluginAPI;
}
public setDesktopMenuOpen(open: boolean) {
this.deckyState.setDesktopMenuOpen(open);
}
}
export default PluginLoader;
+131 -96
View File
@@ -6,7 +6,9 @@ import {
findInTree,
findModuleByExport,
getReactRoot,
injectFCTrampoline,
sleep,
wrapReactType,
} from '@decky/ui';
import { FC, ReactElement, ReactNode, cloneElement, createElement } from 'react';
import type { Route } from 'react-router';
@@ -23,6 +25,7 @@ import {
RouterEntry,
useDeckyRouterState,
} from './components/DeckyRouterState';
import { UIMode } from './enums';
import Logger from './logger';
declare global {
@@ -31,18 +34,18 @@ declare global {
}
}
export enum UIMode {
BigPicture = 4,
Desktop = 7,
}
const isPatched = Symbol('is patched');
class RouterHook extends Logger {
private routerState: DeckyRouterState = new DeckyRouterState();
private globalComponentsState: DeckyGlobalComponentsState = new DeckyGlobalComponentsState();
private renderedComponents: ReactElement[] = [];
private renderedComponents = new Map<UIMode, ReactElement[]>([
[UIMode.BigPicture, []],
[UIMode.Desktop, []],
]);
private Route: any;
private DesktopRoute: any;
private wrappedDesktopLibraryMemo?: any;
private DeckyGamepadRouterWrapper = this.gamepadRouterWrapper.bind(this);
private DeckyDesktopRouterWrapper = this.desktopRouterWrapper.bind(this);
private DeckyGlobalComponentsWrapper = this.globalComponentsWrapper.bind(this);
@@ -76,6 +79,21 @@ class RouterHook extends Logger {
this.error('Failed to find router stack module');
}
const routerModule = findModuleByExport((e) => e?.displayName == 'Router');
if (routerModule) {
this.DesktopRoute = Object.values(routerModule).find(
(e) =>
typeof e == 'function' &&
e?.prototype?.render?.toString()?.includes('props.computedMatch') &&
e?.prototype?.render?.toString()?.includes('.Children.count('),
);
if (!this.DesktopRoute) {
this.error('Failed to find DesktopRoute component');
}
} else {
this.error('Failed to find router module, desktop routes will not work');
}
this.modeChangeRegistration = SteamClient.UI.RegisterForUIModeChanged((mode: UIMode) => {
this.debug(`UI mode changed to ${mode}`);
if (this.patchedModes.has(mode)) return;
@@ -87,10 +105,10 @@ class RouterHook extends Logger {
this.patchGamepadRouter();
break;
// Not fully implemented yet
// case UIMode.Desktop:
// this.debug("Patching desktop router");
// this.patchDesktopRouter();
// break;
case UIMode.Desktop:
this.debug('Patching desktop router');
this.patchDesktopRouter();
break;
default:
this.warn(`Router patch not implemented for UI mode ${mode}`);
break;
@@ -109,7 +127,7 @@ class RouterHook extends Logger {
await this.waitForUnlock();
let routerNode = findRouterNode();
while (!routerNode) {
this.warn('Failed to find Router node, reattempting in 5 seconds.');
this.warn('Failed to find GamepadUI Router node, reattempting in 5 seconds.');
await sleep(5000);
await this.waitForUnlock();
routerNode = findRouterNode();
@@ -130,50 +148,34 @@ class RouterHook extends Logger {
}
}
// Currently unused
// @ts-expect-error 6133
private async patchDesktopRouter() {
const root = getReactRoot(document.getElementById('root') as any);
const findRouterNode = () =>
findInReactTree(root, (node) => node?.elementType?.type?.toString()?.includes('bShowDesktopUIContent:'));
findInReactTree(root, (node) => {
const typeStr = node?.elementType?.toString?.();
return (
typeStr &&
typeStr?.includes('.IsMainDesktopWindow') &&
typeStr?.includes('.IN_STEAMUI_SHARED_CONTEXT') &&
typeStr?.includes('.ContentFrame') &&
typeStr?.includes('.Console()')
);
});
let routerNode = findRouterNode();
while (!routerNode) {
this.warn('Failed to find Router node, reattempting in 5 seconds.');
this.warn('Failed to find DesktopUI Router node, reattempting in 5 seconds.');
await sleep(5000);
routerNode = findRouterNode();
}
if (routerNode) {
// this.debug("desktop router node", routerNode);
// Patch the component globally
this.desktopRouterPatch = afterPatch(routerNode.elementType, 'type', this.handleDesktopRouterRender.bind(this));
// Swap out the current instance
routerNode.type = routerNode.elementType.type;
if (routerNode?.alternate) {
routerNode.alternate.type = routerNode.type;
}
const patchedRenderer = injectFCTrampoline(routerNode.elementType);
this.desktopRouterPatch = afterPatch(patchedRenderer, 'component', this.handleDesktopRouterRender.bind(this));
// Force a full rerender via our custom error boundary
const errorBoundaryNode = findInTree(routerNode, (e) => e?.stateNode?._deckyForceRerender, {
walkable: ['return'],
});
errorBoundaryNode?.stateNode?._deckyForceRerender?.();
// this.debug("desktop router node", routerNode);
// // Patch the component globally
// this.desktopRouterPatch = afterPatch(routerNode.type.prototype, 'render', this.handleDesktopRouterRender.bind(this));
// const stateNodeClone = { render: routerNode.stateNode.render } as any;
// // Patch the current instance. render is readonly so we have to do this.
// Object.assign(stateNodeClone, routerNode.stateNode);
// Object.setPrototypeOf(stateNodeClone, Object.getPrototypeOf(routerNode.stateNode));
// this.desktopRouterFirstInstancePatch = afterPatch(stateNodeClone, 'render', this.handleDesktopRouterRender.bind(this));
// routerNode.stateNode = stateNodeClone;
// // Swap out the current instance
// if (routerNode?.alternate) {
// routerNode.alternate.type = routerNode.type;
// routerNode.alternate.stateNode = routerNode.stateNode;
// }
// routerNode.stateNode.forceUpdate();
// Force a full rerender via our custom error boundary
// const errorBoundaryNode = findInTree(routerNode, e => e?.stateNode?._deckyForceRerender, { walkable: ["return"] });
// errorBoundaryNode?.stateNode?._deckyForceRerender?.();
}
}
@@ -197,10 +199,18 @@ class RouterHook extends Logger {
const returnVal = (
<>
<DeckyRouterStateContextProvider deckyRouterState={this.routerState}>
<style>
{`
.deckyDesktopDialogPaddingHack + * .DialogContent_InnerWidth {
max-width: unset !important;
}
`}
</style>
<div className="deckyDesktopDialogPaddingHack" />
<DeckyDesktopRouterWrapper>{ret}</DeckyDesktopRouterWrapper>
</DeckyRouterStateContextProvider>
<DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}>
<DeckyGlobalComponentsWrapper />
<DeckyGlobalComponentsWrapper uiMode={UIMode.Desktop} />
</DeckyGlobalComponentsStateContextProvider>
</>
);
@@ -220,7 +230,7 @@ class RouterHook extends Logger {
<DeckyGamepadRouterWrapper>{ret}</DeckyGamepadRouterWrapper>
</DeckyRouterStateContextProvider>
<DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}>
<DeckyGlobalComponentsWrapper />
<DeckyGlobalComponentsWrapper uiMode={UIMode.BigPicture} />
</DeckyGlobalComponentsStateContextProvider>
</>
);
@@ -228,13 +238,21 @@ class RouterHook extends Logger {
return returnVal;
}
private globalComponentsWrapper() {
private globalComponentsWrapper({ uiMode }: { uiMode: UIMode }) {
const { components } = useDeckyGlobalComponentsState();
if (this.renderedComponents.length != components.size) {
this.debug('Rerendering global components');
this.renderedComponents = Array.from(components.values()).map((GComponent) => <GComponent />);
const componentsForMode = components.get(uiMode);
if (!componentsForMode) {
this.warn(`Couldn't find global components map for uimode ${uiMode}`);
return null;
}
return <>{this.renderedComponents}</>;
if (!this.renderedComponents.has(uiMode) || this.renderedComponents.get(uiMode)?.length != componentsForMode.size) {
this.debug('Rerendering global components for uiMode', uiMode);
this.renderedComponents.set(
uiMode,
Array.from(componentsForMode.values()).map((GComponent) => <GComponent />),
);
}
return <>{this.renderedComponents.get(uiMode)}</>;
}
private gamepadRouterWrapper({ children }: { children: ReactElement }) {
@@ -248,8 +266,8 @@ class RouterHook extends Logger {
}
const mainRouteList = children.props.children[0].props.children;
const ingameRouteList = children.props.children[1].props.children; // /appoverlay and /apprunning
this.processList(mainRouteList, routes, routePatches, true);
this.processList(ingameRouteList, null, routePatches, false);
this.processList(mainRouteList, routes, routePatches.get(UIMode.BigPicture), true, this.Route);
this.processList(ingameRouteList, null, routePatches.get(UIMode.BigPicture), false, this.Route);
this.debug('Rerendered gamepadui routes list');
return children;
@@ -257,22 +275,38 @@ class RouterHook extends Logger {
private desktopRouterWrapper({ children }: { children: ReactElement }) {
// Used to store the new replicated routes we create to allow routes to be unpatched.
this.debug('desktop router wrapper render', children);
const { routes, routePatches } = useDeckyRouterState();
const routeList = findInReactTree(
const mainRouteList = findInReactTree(
children,
(node) => node?.length > 2 && node?.find((elem: any) => elem?.props?.path == '/library/home'),
(node) => node?.length > 2 && node?.find((elem: any) => elem?.props?.path == '/console'),
);
if (!routeList) {
if (!mainRouteList) {
this.debug('routerWrapper wrong component?', children);
return children;
}
const library = children.props.children[1].props.children.props;
if (!Array.isArray(library.children)) {
library.children = [library.children];
this.processList(mainRouteList, routes, routePatches.get(UIMode.Desktop), true, this.DesktopRoute);
const libraryRouteWrapper = mainRouteList.find(
(r: any) => r?.props && 'cm' in r.props && 'bShowDesktopUIContent' in r.props,
);
if (!this.wrappedDesktopLibraryMemo) {
wrapReactType(libraryRouteWrapper);
afterPatch(libraryRouteWrapper.type, 'type', (_, ret) => {
const { routePatches } = useDeckyRouterState();
const libraryRouteList = findInReactTree(
ret,
(node) => node?.length > 1 && node?.find((elem: any) => elem?.props?.path == '/library/downloads'),
);
if (!libraryRouteList) {
this.warn('failed to find library route list', ret);
return ret;
}
this.processList(libraryRouteList, null, routePatches.get(UIMode.Desktop), false, this.DesktopRoute);
return ret;
});
this.wrappedDesktopLibraryMemo = libraryRouteWrapper.type;
} else {
libraryRouteWrapper.type = this.wrappedDesktopLibraryMemo;
}
this.debug('library', library);
this.processList(library.children, routes, routePatches, true);
this.debug('Rerendered desktop routes list');
return children;
@@ -280,11 +314,11 @@ class RouterHook extends Logger {
private processList(
routeList: any[],
routes: Map<string, RouterEntry> | null,
routePatches: Map<string, Set<RoutePatch>>,
routes: Map<string, RouterEntry> | null | undefined,
routePatches: Map<string, Set<RoutePatch>> | null | undefined,
save: boolean,
RouteComponent: any,
) {
const Route = this.Route;
this.debug('Route list: ', routeList);
if (save) this.routes = routeList;
let routerIndex = routeList.length;
@@ -294,59 +328,60 @@ class RouterHook extends Logger {
const newRouterArray: (ReactElement | JSX.Element)[] = [];
routes.forEach(({ component, props }, path) => {
newRouterArray.push(
<Route path={path} {...props}>
<RouteComponent path={path} {...props}>
<ErrorBoundary>{createElement(component)}</ErrorBoundary>
</Route>,
</RouteComponent>,
);
});
routeList[routerIndex] = newRouterArray;
}
}
routeList.forEach((route: Route, index: number) => {
const replaced = this.toReplace.get(route?.props?.path as string);
if (replaced) {
routeList[index].props.children = replaced;
this.toReplace.delete(route?.props?.path as string);
}
if (route?.props?.path && routePatches.has(route.props.path as string)) {
this.toReplace.set(
route?.props?.path as string,
// @ts-ignore
routeList[index].props.children,
);
routePatches.get(route.props.path as string)?.forEach((patch) => {
const oType = routeList[index].props.children.type;
routeList[index].props.children = patch({
...routeList[index].props,
children: {
...cloneElement(routeList[index].props.children),
type: routeList[index].props.children[isPatched] ? oType : (props) => createElement(oType, props),
},
}).children;
routeList[index].props.children[isPatched] = true;
});
}
});
routePatches &&
routeList.forEach((route: Route, index: number) => {
const replaced = this.toReplace.get(route?.props?.path as string);
if (replaced) {
routeList[index].props.children = replaced;
this.toReplace.delete(route?.props?.path as string);
}
if (route?.props?.path && routePatches.has(route.props.path as string)) {
this.toReplace.set(
route?.props?.path as string,
// @ts-ignore
routeList[index].props.children,
);
routePatches.get(route.props.path as string)?.forEach((patch) => {
const oType = routeList[index].props.children.type;
routeList[index].props.children = patch({
...routeList[index].props,
children: {
...cloneElement(routeList[index].props.children),
type: routeList[index].props.children[isPatched] ? oType : (props) => createElement(oType, props),
},
}).children;
routeList[index].props.children[isPatched] = true;
});
}
});
}
addRoute(path: string, component: RouterEntry['component'], props: RouterEntry['props'] = {}) {
this.routerState.addRoute(path, component, props);
}
addPatch(path: string, patch: RoutePatch) {
return this.routerState.addPatch(path, patch);
addPatch(path: string, patch: RoutePatch, uiMode: UIMode = UIMode.BigPicture) {
return this.routerState.addPatch(path, patch, uiMode);
}
addGlobalComponent(name: string, component: FC) {
this.globalComponentsState.addComponent(name, component);
addGlobalComponent(name: string, component: FC, uiMode: UIMode = UIMode.BigPicture) {
this.globalComponentsState.addComponent(name, component, uiMode);
}
removeGlobalComponent(name: string) {
this.globalComponentsState.removeComponent(name);
removeGlobalComponent(name: string, uiMode: UIMode = UIMode.BigPicture) {
this.globalComponentsState.removeComponent(name, uiMode);
}
removePatch(path: string, patch: RoutePatch) {
this.routerState.removePatch(path, patch);
removePatch(path: string, patch: RoutePatch, uiMode: UIMode = UIMode.BigPicture) {
this.routerState.removePatch(path, patch, uiMode);
}
removeRoute(path: string) {
+11 -1
View File
@@ -1,3 +1,5 @@
import { compare } from 'compare-versions';
import { InstallType, Plugin, installPlugin, installPlugins } from './plugin';
import { getSetting, setSetting } from './utils/settings';
@@ -137,7 +139,15 @@ export async function checkForPluginUpdates(plugins: Plugin[]): Promise<PluginUp
const updateMap = new Map<string, StorePluginVersion>();
for (let plugin of plugins) {
const remotePlugin = serverData?.find((x) => x.name == plugin.name);
if (remotePlugin && remotePlugin.versions?.length > 0 && plugin.version != remotePlugin?.versions?.[0]?.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';
if (
remotePlugin &&
remotePlugin.versions?.length > 0 &&
plugin.version != remotePlugin?.versions?.[0]?.name &&
compare(remotePlugin?.versions?.[0]?.name, curVer, '>')
) {
updateMap.set(plugin.name, remotePlugin.versions[0]);
}
}
+2 -2
View File
@@ -41,9 +41,9 @@ 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 qamModule = findModuleByExport((e) => e?.type?.toString?.()?.includes('QuickAccessMenuBrowserView'));
const qamRenderer = Object.values(qamModule).find((e: any) =>
e?.type?.toString()?.includes('QuickAccessMenuBrowserView'),
e?.type?.toString?.()?.includes('QuickAccessMenuBrowserView'),
);
const patchHandler = createReactTreePatcher(
+1 -1
View File
@@ -28,7 +28,7 @@ class Toaster extends Logger {
window.__TOASTER_INSTANCE?.deinit?.();
window.__TOASTER_INSTANCE = this;
const ValveToastRenderer = findModuleExport((e) => e?.toString()?.includes(`controller:"notification",method:`));
const ValveToastRenderer = findModuleExport((e) => e?.toString?.()?.includes(`controller:"notification",method:`));
// TODO find a way to undo this if possible?
const patchedRenderer = injectFCTrampoline(ValveToastRenderer);
this.toastPatch = replacePatch(patchedRenderer, 'component', (args: any[]) => {
+4 -1
View File
@@ -22,7 +22,10 @@ export function getLikelyErrorSourceFromValveError(error: ValveError): ErrorSour
}
export function getLikelyErrorSourceFromValveReactError(error: ValveReactErrorInfo): ErrorSource {
return getLikelyErrorSource(error?.error?.stack + '\n' + error.info.componentStack);
// get the first 10 lines of the componentStack to avoid matching against the decky router wrapper for any route errors deeper in the tree
return getLikelyErrorSource(
error?.error?.stack + '\n' + error.info.componentStack?.split('\n').slice(0, 8).join('\n'),
);
}
export function getLikelyErrorSource(error?: string): ErrorSource {
+1 -1
View File
@@ -30,7 +30,7 @@ while :; do
if [[ $NEWTARGET != "" ]] && [[ $NEWTARGET != $TARGET ]]; then
echo found new tab at $NEWTARGET
TARGET=$NEWTARGET
TARGETURL="devtools://devtools/bundled/inspector.html?remoteFrontend=true&ws=$ADDR/devtools/page/$TARGET"
TARGETURL="http://$ADDR/devtools/inspector.html?ws=$ADDR/devtools/page/$TARGET"
LOCALTARGET=$(echo '{"id": 1, "method": "Target.createTarget", "params": {"background": true, "url": "'$TARGETURL'"}}
{"id": 2, "method": "Target.closeTarget", "params": {"targetId": "'$LOCALTARGET'"}}' \
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
# Adapted from a script provided by Jaynator495.
# Make sure to place in home directory, chmod +x plugin-info.sh and then run with ./plugin-info.sh
# Define the directory to scan
directory_to_scan="~/homebrew/plugins"
# Loop through each subdirectory (one level deep)
for dir in "$directory_to_scan"/*/; do
# Check if package.json exists in the subdirectory
if [ -f "${dir}package.json" ]; then
# Extract name and version from the package.json file using jq
name=$(jq -r '.name' "${dir}package.json")
version=$(jq -r '.version' "${dir}package.json")
# Output the name and version
echo "Directory: ${dir}"
echo "Package Name: $name"
echo "Version: $version"
echo "-----------------------------"
fi
done