mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-06-17 16:57:50 +00:00
Compare commits
29 Commits
v3.0.0-pre7
...
v3.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 1284075d02 | |||
| d2c5aef58b | |||
| caac379b08 | |||
| c487a6e15a | |||
| 9df5f00068 | |||
| 508408ad5a | |||
| ef4ca204bd | |||
| 1d7af36a2b | |||
| 6b78e0ae9c | |||
| 1f5d5f9f1a | |||
| f7a47127a7 | |||
| 494f8dac5e | |||
| bcc14848c5 | |||
| 0e40374b10 | |||
| 81ffe11106 | |||
| d06494885a | |||
| a6e4bcf052 | |||
| c1f7ca7f20 | |||
| 6ae6f5ee67 | |||
| 016ed6e998 | |||
| 4842a599e0 | |||
| 927f912eb3 | |||
| 7c9b68c1dd | |||
| e5e75cc16e | |||
| 3656f541e6 | |||
| f2b859b409 | |||
| 93b3919325 | |||
| 7a161a5b83 | |||
| 1265672067 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -205,6 +205,15 @@
|
||||
"testing_title": "Testování"
|
||||
},
|
||||
"Store": {
|
||||
"download_progress_info": {
|
||||
"download_zip": "Stahování pluginu",
|
||||
"increment_count": "Zvyšující se počet stahování",
|
||||
"installing_plugin": "Instalování pluginu",
|
||||
"open_zip": "Otevírání ZIP souboru",
|
||||
"parse_zip": "Analýza ZIP souboru",
|
||||
"start": "Inicializace",
|
||||
"uninstalling_previous": "Odinstalování předchozí kopie"
|
||||
},
|
||||
"store_contrib": {
|
||||
"desc": "Pokud byste chtěli přispět do obchodu Decky Plugin Store, podívejte se na repozitář SteamDeckHomebrew/decky-plugin-template na GitHubu. Informace o vývoji a distribuci jsou k dispozici v README.",
|
||||
"label": "Přispívání"
|
||||
@@ -253,7 +262,11 @@
|
||||
}
|
||||
},
|
||||
"Testing": {
|
||||
"download": "Stáhnout"
|
||||
"download": "Stáhnout",
|
||||
"error": "Chyba při instalaci PR",
|
||||
"header": "Následující verze Decky Loaderu jsou vytvořeny z otevřených Pull Requestů třetích stran. Tým Decky Loaderu neověřil jejich funkčnost ani zabezpečení a mohou být zastaralé.",
|
||||
"loading": "Načítání Pull Requestů...",
|
||||
"start_download_toast": "Stahování PR #{{id}}"
|
||||
},
|
||||
"TitleView": {
|
||||
"decky_store_desc": "Otevřít obchod Decky",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"label": "Canal de actualización",
|
||||
"prerelease": "Prelanzamiento",
|
||||
"stable": "Estable",
|
||||
"testing": "Pruebas"
|
||||
"testing": "En pruebas"
|
||||
}
|
||||
},
|
||||
"Developer": {
|
||||
@@ -12,9 +12,41 @@
|
||||
"disabling": "Desactivando DevTools de React",
|
||||
"enabling": "Activando DevTools de React"
|
||||
},
|
||||
"DropdownMultiselect": {
|
||||
"button": {
|
||||
"back": "Volver"
|
||||
}
|
||||
},
|
||||
"FilePickerError": {
|
||||
"errors": {
|
||||
"file_not_found": "La ruta especificada no es válida. Por favor revísala e introdúcela correctamente.",
|
||||
"perm_denied": "No tienes acceso a la ruta especificada. Por favor revisa si tu usuario (deck en Steam Deck) tiene el permiso correspondiente para acceder a la ruta/archivo especificado.",
|
||||
"unknown": "Ha ocurrido un error desconocido. El error sin procesar es:{{raw_error}}"
|
||||
}
|
||||
},
|
||||
"FilePickerIndex": {
|
||||
"file": {
|
||||
"select": "Selecciona este archivo"
|
||||
},
|
||||
"files": {
|
||||
"all_files": "Todos los archivos",
|
||||
"file_type": "Tipo de archivo",
|
||||
"show_hidden": "Mostrar archivos ocultos"
|
||||
},
|
||||
"filter": {
|
||||
"created_asce": "Creado (Más antiguo)",
|
||||
"created_desc": "Creado (Más reciente)",
|
||||
"modified_asce": "Modificado (Más antiguo)",
|
||||
"modified_desc": "Modificado (Más reciente)",
|
||||
"name_asce": "Z-A",
|
||||
"name_desc": "A-Z",
|
||||
"size_asce": "Tamaño (Más pequeño)",
|
||||
"size_desc": "Tamaño (Más grande)"
|
||||
},
|
||||
"folder": {
|
||||
"select": "Usar esta carpeta"
|
||||
"label": "Carpeta",
|
||||
"select": "Usar esta carpeta",
|
||||
"show_more": "Mostrar más archivos"
|
||||
}
|
||||
},
|
||||
"MultiplePluginsInstallModal": {
|
||||
@@ -44,9 +76,9 @@
|
||||
}
|
||||
},
|
||||
"PluginCard": {
|
||||
"plugin_full_access": "Este plugin tiene acceso completo a su Steam Deck.",
|
||||
"plugin_full_access": "Este plugin tiene acceso completo a tu Steam Deck.",
|
||||
"plugin_install": "Instalar",
|
||||
"plugin_no_desc": "No se proporcionó una descripción.",
|
||||
"plugin_no_desc": "No se ha proporcionado una descripción.",
|
||||
"plugin_version_label": "Versión de Plugin"
|
||||
},
|
||||
"PluginInstallModal": {
|
||||
@@ -71,19 +103,26 @@
|
||||
}
|
||||
},
|
||||
"PluginListIndex": {
|
||||
"freeze": "Congelar actualizaciones",
|
||||
"hide": "Acceso rápido: Esconder",
|
||||
"no_plugin": "¡No hay plugins instalados!",
|
||||
"plugin_actions": "Acciones de plugin",
|
||||
"plugin_actions": "Acciones de Plugin",
|
||||
"reinstall": "Reinstalar",
|
||||
"reload": "Recargar",
|
||||
"show": "Acceso rápido: Mostrar",
|
||||
"unfreeze": "Permitir actualizaciones",
|
||||
"uninstall": "Desinstalar",
|
||||
"update_all_many": "Actualizar {{count}} plugins",
|
||||
"update_all_one": "Actualizar 1 plugin",
|
||||
"update_all_other": "Actualizar {{count}} plugins",
|
||||
"update_to": "Actualizar a {{name}}"
|
||||
},
|
||||
"PluginListLabel": {
|
||||
"hidden": "Escondido del menú de acceso rápido"
|
||||
},
|
||||
"PluginLoader": {
|
||||
"decky_title": "Decky",
|
||||
"decky_update_available": "¡Actualización {{tag_name}} disponible!",
|
||||
"decky_update_available": "¡Actualización para {{tag_name}} disponible!",
|
||||
"error": "Error",
|
||||
"plugin_error_uninstall": "Al cargar {{name}} se ha producido una excepción como se muestra arriba. Esto suele significar que el plugin requiere una actualización para la nueva versión de SteamUI. Comprueba si hay una actualización disponible o valora eliminarlo en los ajustes de Decky, en la sección Plugins.",
|
||||
"plugin_load_error": {
|
||||
@@ -100,19 +139,19 @@
|
||||
"plugin_update_other": "¡Actualizaciones disponibles para {{count}} plugins!"
|
||||
},
|
||||
"PluginView": {
|
||||
"hidden_many": "",
|
||||
"hidden_one": "",
|
||||
"hidden_other": ""
|
||||
"hidden_many": "{{count}} plugins están escondidos de esta lista",
|
||||
"hidden_one": "1 plugin está escondido de esta lista",
|
||||
"hidden_other": "{{count}} plugins están escondidos de esta lista"
|
||||
},
|
||||
"RemoteDebugging": {
|
||||
"remote_cef": {
|
||||
"desc": "Permitir acceso no autenticado al CEF debugger a cualquier persona en su red",
|
||||
"desc": "Permitir acceso no autenticado al depurador del CEF a cualquier persona en tu red",
|
||||
"label": "Permitir depuración remota del CEF"
|
||||
}
|
||||
},
|
||||
"SettingsDeveloperIndex": {
|
||||
"cef_console": {
|
||||
"button": "Abrir consola",
|
||||
"button": "Abrir Consola",
|
||||
"desc": "Abre la consola del CEF. Solamente es útil para propósitos de depuración. Las cosas que hagas aquí pueden ser potencialmente peligrosas y solo se debería usar si eres un desarrollador de plugins, o uno te ha dirigido aquí.",
|
||||
"label": "Consola CEF"
|
||||
},
|
||||
@@ -147,6 +186,11 @@
|
||||
"developer_mode": {
|
||||
"label": "Modo desarrollador"
|
||||
},
|
||||
"notifications": {
|
||||
"decky_updates_label": "Actualización de Decky disponible",
|
||||
"header": "Notificaciones",
|
||||
"plugin_updates_label": "Actualizaciones de plugin disponibles"
|
||||
},
|
||||
"other": {
|
||||
"header": "Otros"
|
||||
},
|
||||
@@ -157,11 +201,21 @@
|
||||
"SettingsIndex": {
|
||||
"developer_title": "Desarrollador",
|
||||
"general_title": "General",
|
||||
"plugins_title": "Plugins"
|
||||
"plugins_title": "Plugins",
|
||||
"testing_title": "En pruebas"
|
||||
},
|
||||
"Store": {
|
||||
"download_progress_info": {
|
||||
"download_zip": "Descargando plugin",
|
||||
"increment_count": "Incrementando el contador de descargas",
|
||||
"installing_plugin": "Instalando plugin",
|
||||
"open_zip": "Abriendo archivo zip",
|
||||
"parse_zip": "Analizando archivo zip",
|
||||
"start": "Iniciando",
|
||||
"uninstalling_previous": "Desinstalando copia previa"
|
||||
},
|
||||
"store_contrib": {
|
||||
"desc": "Si desea contribuir a la tienda de plugins de Decky, mira el repositorio SteamDeckHomebrew/decky-plugin-template en GitHub. Hay información acerca del desarrollo y distribución en el archivo README.",
|
||||
"desc": "Si desea contribuir a la tienda de plugins de Decky, consulta el repositorio SteamDeckHomebrew/decky-plugin-template en GitHub. Hay información disponible acerca del desarrollo y distribución en el archivo README.",
|
||||
"label": "Contribuyendo"
|
||||
},
|
||||
"store_filter": {
|
||||
@@ -173,7 +227,7 @@
|
||||
},
|
||||
"store_sort": {
|
||||
"label": "Ordenar",
|
||||
"label_def": "Actualizado por última vez (Nuevos)"
|
||||
"label_def": "Actualizado por última vez (Más reciente)"
|
||||
},
|
||||
"store_source": {
|
||||
"desc": "El código fuente de los plugins está disponible en el repositiorio SteamDeckHomebrew/decky-plugin-database en GitHub.",
|
||||
@@ -183,9 +237,17 @@
|
||||
"about": "Información",
|
||||
"alph_asce": "Alfabéticamente (Z-A)",
|
||||
"alph_desc": "Alfabéticamente (A-Z)",
|
||||
"date_asce": "Más antiguo primero",
|
||||
"date_desc": "Más reciente primero",
|
||||
"downloads_asce": "Menos descargados primero",
|
||||
"downloads_desc": "Más descargados primero",
|
||||
"title": "Navegar"
|
||||
},
|
||||
"store_testing_cta": "¡Por favor considera probar plugins nuevos para ayudar al equipo de Decky Loader!"
|
||||
"store_testing_cta": "¡Por favor considera probar plugins nuevos para ayudar al equipo de Decky Loader!",
|
||||
"store_testing_warning": {
|
||||
"desc": "Puedes usar este canal de la tienda para probar versiones inestables de plugins. Recuerda compartir tu experiencia en GitHub con el fin de poder actualizar el plugin para todos los usuarios.",
|
||||
"label": "Bienvenido al canal En Pruebas de la Tienda"
|
||||
}
|
||||
},
|
||||
"StoreSelect": {
|
||||
"custom_store": {
|
||||
@@ -195,10 +257,21 @@
|
||||
"store_channel": {
|
||||
"custom": "Personalizada",
|
||||
"default": "Por defecto",
|
||||
"label": "Canál de la tienda",
|
||||
"testing": "Pruebas"
|
||||
"label": "Canal de la Tienda",
|
||||
"testing": "En pruebas"
|
||||
}
|
||||
},
|
||||
"Testing": {
|
||||
"download": "Descargar",
|
||||
"error": "Error instalando PR",
|
||||
"header": "Las siguientes versiones de Decky Loader han sido compiladas de solicitudes Pull de terceros. El equipo de Decky Loader no ha verificado su funcionalidad o seguridad, y es posible que estén desactulizadas.",
|
||||
"loading": "Cargando abrir Solicitudes de Pull...",
|
||||
"start_download_toast": "Descargando PR #{{id}}"
|
||||
},
|
||||
"TitleView": {
|
||||
"decky_store_desc": "Abrir la tienda de Decky",
|
||||
"settings_desc": "Abrir los ajustes de Decky"
|
||||
},
|
||||
"Updater": {
|
||||
"decky_updates": "Actualizaciones de Decky",
|
||||
"no_patch_notes_desc": "No hay notas de parche para esta versión",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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
|
||||
@@ -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,15 +48,14 @@ 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
|
||||
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)
|
||||
@@ -120,7 +119,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 +162,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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Vendored
+2
-66
@@ -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
|
||||
Vendored
+2
-66
@@ -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
@@ -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}
|
||||
|
||||
Vendored
+2
-1
@@ -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}
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@decky/ui": "^4.7.0",
|
||||
"@decky/ui": "^4.7.1",
|
||||
"filesize": "^10.1.2",
|
||||
"i18next": "^23.11.5",
|
||||
"i18next-http-backend": "^2.5.2",
|
||||
|
||||
Generated
+5
-5
@@ -9,8 +9,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@decky/ui':
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0
|
||||
specifier: ^4.7.1
|
||||
version: 4.7.1
|
||||
filesize:
|
||||
specifier: ^10.1.2
|
||||
version: 10.1.2
|
||||
@@ -215,8 +215,8 @@ packages:
|
||||
'@decky/api@1.1.1':
|
||||
resolution: {integrity: sha512-R5fkBRHBt5QIQY7Q0AlbVIhlIZ/nTzwBOoi8Rt4Go2fjFnoMKPInCJl6cPjXzimGwl2pyqKJgY6VnH6ar0XrHQ==}
|
||||
|
||||
'@decky/ui@4.7.0':
|
||||
resolution: {integrity: sha512-klNWF5tnZVqzuUgFbw+pThiZjK7gKEtwbEZAo4aAuPJSVobpl/euTx9NAxY95QPCFMDgxCo6X6ioEA2nMfHfLA==}
|
||||
'@decky/ui@4.7.1':
|
||||
resolution: {integrity: sha512-yJwBgW+J2cMDfMkmcDFtzsubhUjekFZAtCnP55QEJ/1UKGR7sLNOvDLFYi1h5PI0K4L1XYcAMKHwbYFFTzcDTA==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.20.2':
|
||||
resolution: {integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==}
|
||||
@@ -2289,7 +2289,7 @@ snapshots:
|
||||
|
||||
'@decky/api@1.1.1': {}
|
||||
|
||||
'@decky/ui@4.7.0': {}
|
||||
'@decky/ui@4.7.1': {}
|
||||
|
||||
'@esbuild/aix-ppc64@0.20.2':
|
||||
optional: true
|
||||
|
||||
@@ -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;
|
||||
@@ -66,8 +66,6 @@ function PluginInteractables(props: { entry: ReorderableEntry<PluginTableData> }
|
||||
} catch (err) {
|
||||
console.error('Error Reloading Plugin Backend', err);
|
||||
}
|
||||
|
||||
DeckyPluginLoader.importPlugin(name, version);
|
||||
}}
|
||||
>
|
||||
{t('PluginListIndex.reload')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 < 8) {
|
||||
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...');
|
||||
@@ -16,8 +16,10 @@ interface Window {
|
||||
console.time('[Decky:Boot] Waiting for React root mount...');
|
||||
let root;
|
||||
while (
|
||||
// Does React root node exist?
|
||||
!(root = document.getElementById('root')) ||
|
||||
!(root as any)[Object.keys(root).find((k) => k.startsWith('__reactContainer$')) as string]
|
||||
// Does it have a child element?
|
||||
!(root as any)[Object.keys(root).find((k) => k.startsWith('__reactContainer$')) as string].child
|
||||
) {
|
||||
await new Promise((r) => setTimeout(r, 10)); // Can't use DFL sleep here.
|
||||
}
|
||||
@@ -25,6 +27,7 @@ interface Window {
|
||||
|
||||
if (!window.SP_REACT) {
|
||||
console.debug('[Decky:Boot] Setting up Webpack & React globals...');
|
||||
await new Promise((r) => setTimeout(r, 500)); // Can't use DFL sleep here.
|
||||
// deliberate partial import
|
||||
const DFLWebpack = await import('@decky/ui/dist/webpack');
|
||||
window.SP_REACT = DFLWebpack.findModule((m) => m.Component && m.PureComponent && m.useLayoutEffect);
|
||||
|
||||
@@ -79,7 +79,7 @@ class PluginLoader extends Logger {
|
||||
|
||||
private reloadLock: boolean = false;
|
||||
// stores a list of plugin names which requested to be reloaded
|
||||
private pluginReloadQueue: { name: string; version?: string }[] = [];
|
||||
private pluginReloadQueue: { name: string; version?: string; loadType: PluginLoadType }[] = [];
|
||||
|
||||
private loaderUpdateToast?: ToastNotification;
|
||||
private pluginUpdateToast?: ToastNotification;
|
||||
@@ -369,11 +369,11 @@ class PluginLoader extends Logger {
|
||||
this.errorBoundaryHook.deinit();
|
||||
}
|
||||
|
||||
public unloadPlugin(name: string) {
|
||||
public unloadPlugin(name: string, skipStateUpdate: boolean = false) {
|
||||
const plugin = this.plugins.find((plugin) => plugin.name === name);
|
||||
plugin?.onDismount?.();
|
||||
this.plugins = this.plugins.filter((p) => p !== plugin);
|
||||
this.deckyState.setPlugins(this.plugins);
|
||||
if (!skipStateUpdate) this.deckyState.setPlugins(this.plugins);
|
||||
}
|
||||
|
||||
public async importPlugin(
|
||||
@@ -384,7 +384,7 @@ class PluginLoader extends Logger {
|
||||
) {
|
||||
if (useQueue && this.reloadLock) {
|
||||
this.log('Reload currently in progress, adding to queue', name);
|
||||
this.pluginReloadQueue.push({ name, version: version });
|
||||
this.pluginReloadQueue.push({ name, version: version, loadType });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -392,7 +392,7 @@ class PluginLoader extends Logger {
|
||||
if (useQueue) this.reloadLock = true;
|
||||
this.log(`Trying to load ${name}`);
|
||||
|
||||
this.unloadPlugin(name);
|
||||
this.unloadPlugin(name, true);
|
||||
const startTime = performance.now();
|
||||
await this.importReactPlugin(name, version, loadType);
|
||||
const endTime = performance.now();
|
||||
@@ -406,7 +406,7 @@ class PluginLoader extends Logger {
|
||||
this.reloadLock = false;
|
||||
const nextPlugin = this.pluginReloadQueue.shift();
|
||||
if (nextPlugin) {
|
||||
this.importPlugin(nextPlugin.name, nextPlugin.version);
|
||||
this.importPlugin(nextPlugin.name, nextPlugin.version, loadType);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -421,13 +421,14 @@ 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({
|
||||
...plugin,
|
||||
name: name,
|
||||
version: version,
|
||||
loadType,
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -447,6 +448,7 @@ class PluginLoader extends Logger {
|
||||
...plugin,
|
||||
name: name,
|
||||
version: version,
|
||||
loadType,
|
||||
});
|
||||
} else throw new Error(`${name} frontend_bundle not OK`);
|
||||
break;
|
||||
@@ -484,6 +486,7 @@ class PluginLoader extends Logger {
|
||||
version: version,
|
||||
content: <TheError />,
|
||||
icon: <FaExclamationCircle />,
|
||||
loadType,
|
||||
});
|
||||
this.toaster.toast({
|
||||
title: (
|
||||
|
||||
@@ -6,6 +6,7 @@ export enum PluginLoadType {
|
||||
export interface Plugin {
|
||||
name: string;
|
||||
version?: string;
|
||||
loadType?: PluginLoadType;
|
||||
icon: JSX.Element;
|
||||
content?: JSX.Element;
|
||||
onDismount?(): void;
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import type { ToastData, ToastNotification } from '@decky/api';
|
||||
import {
|
||||
ErrorBoundary,
|
||||
Patch,
|
||||
callOriginal,
|
||||
findModuleExport,
|
||||
injectFCTrampoline,
|
||||
replacePatch,
|
||||
sleep,
|
||||
} from '@decky/ui';
|
||||
import { ErrorBoundary, Patch, callOriginal, findModuleExport, injectFCTrampoline, replacePatch } from '@decky/ui';
|
||||
|
||||
import Toast from './components/Toast';
|
||||
import Logger from './logger';
|
||||
@@ -29,8 +21,6 @@ declare global {
|
||||
|
||||
class Toaster extends Logger {
|
||||
private toastPatch?: Patch;
|
||||
private markReady!: () => void;
|
||||
private ready = new Promise<void>((r) => (this.markReady = r));
|
||||
|
||||
constructor() {
|
||||
super('Toaster');
|
||||
@@ -53,7 +43,6 @@ class Toaster extends Logger {
|
||||
});
|
||||
|
||||
this.log('Initialized');
|
||||
sleep(4000).then(this.markReady);
|
||||
}
|
||||
|
||||
toast(toast: ToastData): ToastNotification {
|
||||
@@ -120,7 +109,11 @@ class Toaster extends Logger {
|
||||
}
|
||||
}, toast.expiration);
|
||||
}
|
||||
this.ready.then(() => window.NotificationStore.ProcessNotification(info, toastData, ToastType.New));
|
||||
try {
|
||||
window.NotificationStore.ProcessNotification(info, toastData, ToastType.New);
|
||||
} catch (e) {
|
||||
this.error('Error while sending toast:', e);
|
||||
}
|
||||
return toastResult;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user