mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-06-23 11:39:12 +00:00
Compare commits
36 Commits
aa/new-hooks
...
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 | |||
| b8acc849bc | |||
| 65b6883dcc | |||
| 166c7ea8a7 | |||
| ddc807340c | |||
| 7fc51c8b7d | |||
| 131f0961ff | |||
| 75aa1e4851 |
@@ -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
|
||||
|
||||
Vendored
+1
-1
@@ -120,7 +120,7 @@
|
||||
"dependsOn": [
|
||||
"checkforsettings"
|
||||
],
|
||||
"command": "ssh ${config:deckuser}@${config:deckip} -p ${config:deckport} ${config:deckkey} 'export PATH=${config:deckdir}/.local/bin:$PATH; export PLUGIN_PATH=${config:deckdir}/homebrew/plugins; export CHOWN_PLUGIN_PATH=0; export LOG_LEVEL=DEBUG; cd ${config:deckdir}/homebrew/dev/pluginloader/backend; echo '${config:deckpass}' | poetry run sh -c \"cd ${config:deckdir}/homebrew/services; sudo -SE env \"PATH=\\$PATH\" python3 ${config:deckdir}/homebrew/dev/pluginloader/backend/main.py\"'",
|
||||
"command": "ssh -t ${config:deckuser}@${config:deckip} -p ${config:deckport} ${config:deckkey} 'export PATH=${config:deckdir}/.local/bin:$PATH; export PLUGIN_PATH=${config:deckdir}/homebrew/plugins; export CHOWN_PLUGIN_PATH=0; export LOG_LEVEL=DEBUG; cd ${config:deckdir}/homebrew/dev/pluginloader/backend; echo '${config:deckpass}' | poetry run sh -c \"cd ${config:deckdir}/homebrew/services; sudo -SE env \"PATH=\\$PATH\" python3 ${config:deckdir}/homebrew/dev/pluginloader/backend/main.py\"'",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
|
||||
@@ -52,6 +52,9 @@ async def csrf_middleware(request: Request, handler: Handler):
|
||||
return await handler(request)
|
||||
return Response(text='Forbidden', status=403)
|
||||
|
||||
def create_inject_script(script: str) -> str:
|
||||
return "try{if (window.deckyHasLoaded){setTimeout(() => SteamClient.Browser.RestartJSContext(), 100)}else{window.deckyHasLoaded = true;(async()=>{try{await import('http://localhost:1337/frontend/%s?v=%s')}catch(e){console.error(e)};})();}}catch(e){console.error(e)}" % (script, get_loader_version(), )
|
||||
|
||||
# Get the default homebrew path unless a home_path is specified. home_path argument is deprecated
|
||||
def get_homebrew_path() -> str:
|
||||
return localplatform.get_unprivileged_path()
|
||||
@@ -81,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)"
|
||||
@@ -93,9 +96,13 @@ def get_system_pythonpaths() -> list[str]:
|
||||
proc = subprocess.run(["python3" if localplatform.ON_LINUX else "python", "-c", "import sys; print('\\n'.join(x for x in sys.path if x))"],
|
||||
# TODO make this less insane
|
||||
capture_output=True, user=localplatform.localplatform._get_user_id() if localplatform.ON_LINUX else None, env={} if localplatform.ON_LINUX else None) # pyright: ignore [reportPrivateUsage]
|
||||
return [x.strip() for x in proc.stdout.decode().strip().split("\n")]
|
||||
|
||||
proc.check_returncode()
|
||||
|
||||
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
|
||||
@@ -415,7 +415,7 @@ CLOSEABLE_URLS = ["about:blank", "data:text/html,%3Cbody%3E%3C%2Fbody%3E"] # Clo
|
||||
DO_NOT_CLOSE_URLS = ["Valve Steam Gamepad/default", "Valve%20Steam%20Gamepad"] # Steam Big Picture Mode tab
|
||||
|
||||
def tab_is_gamepadui(t: Tab) -> bool:
|
||||
return "https://steamloopback.host/routes/" in t.url and t.title in SHARED_CTX_NAMES
|
||||
return ("https://steamloopback.host/routes/" in t.url or "https://steamloopback.host/index.html" in t.url) and t.title in SHARED_CTX_NAMES
|
||||
|
||||
async def get_gamepadui_tab() -> Tab:
|
||||
tabs = await get_tabs()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -95,12 +95,14 @@
|
||||
}
|
||||
},
|
||||
"PluginListIndex": {
|
||||
"freeze": "アップデートを凍結",
|
||||
"hide": "クイックアクセス: 非表示",
|
||||
"no_plugin": "プラグインがインストールされていません!",
|
||||
"plugin_actions": "プラグインアクション",
|
||||
"reinstall": "再インストール",
|
||||
"reload": "再読み込み",
|
||||
"show": "クイックアクセス: 表示",
|
||||
"unfreeze": "アップデートを許可",
|
||||
"uninstall": "アンインストール",
|
||||
"update_all_other": "{{count}} 個のプラグインをアップデート",
|
||||
"update_to": "{{name}} を更新"
|
||||
@@ -185,9 +187,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 Plugin Storeに貢献したい場合は、GitHubのSteamDeckHomebrew/decky-plugin-templateリポジトリを確認してください。 開発と配布に関する情報は README で入手できます。",
|
||||
"label": "貢献"
|
||||
@@ -211,6 +223,10 @@
|
||||
"about": "概要",
|
||||
"alph_asce": "アルファベット(Z to A)",
|
||||
"alph_desc": "アルファベット(A to Z)",
|
||||
"date_asce": "古い順",
|
||||
"date_desc": "新しい順",
|
||||
"downloads_asce": "ダウンロード数が少ない順",
|
||||
"downloads_desc": "ダウンロード数が多い順",
|
||||
"title": "閲覧"
|
||||
},
|
||||
"store_testing_cta": "Decky Loaderチームを支援するために、新しいプラグインのテストを検討してください!",
|
||||
@@ -231,6 +247,13 @@
|
||||
"testing": "テスト"
|
||||
}
|
||||
},
|
||||
"Testing": {
|
||||
"download": "ダウンロード",
|
||||
"error": "PRのインストールエラー",
|
||||
"header": "Decky Loaderの以下のバージョンは、公開されているサードパーティのPull Requestからビルドされたものです。 Decky Loaderチームはその機能や安全性を検証しておらず、内容も古い可能性があります。",
|
||||
"loading": "Pull Requestの読み込み中...",
|
||||
"start_download_toast": "PR #{{id}}のダウンロード中"
|
||||
},
|
||||
"TitleView": {
|
||||
"decky_store_desc": "Deckyストアを開く",
|
||||
"settings_desc": "Decky設定を開く"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -37,6 +37,9 @@ def get_live_reload() -> bool:
|
||||
def get_keep_systemd_service() -> bool:
|
||||
return os.getenv("KEEP_SYSTEMD_SERVICE", "0") == "1"
|
||||
|
||||
def get_use_cef_close_workaround() -> bool:
|
||||
return ON_LINUX and os.getenv("USE_CEF_CLOSE_WORKAROUND", "1") == "1"
|
||||
|
||||
def get_log_level() -> int:
|
||||
return {"CRITICAL": 50, "ERROR": 40, "WARNING": 30, "INFO": 20, "DEBUG": 10}[
|
||||
os.getenv("LOG_LEVEL", "INFO")
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
from re import compile
|
||||
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
|
||||
@@ -52,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:
|
||||
@@ -129,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:
|
||||
@@ -144,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:
|
||||
@@ -153,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:
|
||||
@@ -201,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)
|
||||
@@ -223,7 +239,43 @@ 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
|
||||
|
||||
# Works around the CEF debugger TCP socket not closing properly when Steam restarts
|
||||
# Group 1 is PID, group 2 is FD. this also filters for "steamwebhelper" in the process name.
|
||||
cef_socket_lsof_regex = compile(r"^p(\d+)(?:\s|.)+csteamwebhelper(?:\s|.)+f(\d+)(?:\s|.)+TST=LISTEN")
|
||||
close_cef_socket_lock = Lock()
|
||||
|
||||
async def close_cef_socket():
|
||||
async with close_cef_socket_lock:
|
||||
if _get_effective_user_id() != 0:
|
||||
logger.warning("Can't close CEF socket as Decky isn't running as root.")
|
||||
return
|
||||
# Look for anything listening TCP on port 8080
|
||||
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(stdout.decode())
|
||||
|
||||
if not lsof_data:
|
||||
logger.error("lsof regex match failed in close_cef_socket!")
|
||||
return
|
||||
|
||||
pid = lsof_data.group(1)
|
||||
fd = lsof_data.group(2)
|
||||
|
||||
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, _, _ = 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)
|
||||
return
|
||||
|
||||
logger.info("CEF socket closed")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -55,4 +55,7 @@ def get_unprivileged_user() -> str:
|
||||
return os.getenv("UNPRIVILEGED_USER", os.getlogin())
|
||||
|
||||
async def restart_webhelper() -> bool:
|
||||
return True # Stubbed
|
||||
return True # Stubbed
|
||||
|
||||
async def close_cef_socket():
|
||||
return # Stubbed
|
||||
@@ -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()
|
||||
|
||||
@@ -7,14 +7,17 @@ from .localplatform.localplatform import (chmod, chown, service_stop, service_st
|
||||
get_privileged_path, restart_webhelper)
|
||||
if hasattr(sys, '_MEIPASS'):
|
||||
chmod(sys._MEIPASS, 755) # type: ignore
|
||||
|
||||
# Full imports
|
||||
import multiprocessing
|
||||
multiprocessing.freeze_support()
|
||||
from asyncio import AbstractEventLoop, CancelledError, Task, all_tasks, current_task, gather, new_event_loop, set_event_loop, sleep
|
||||
from logging import basicConfig, getLogger
|
||||
from os import path
|
||||
from traceback import format_exc
|
||||
import multiprocessing
|
||||
|
||||
from time import time
|
||||
import aiohttp_cors # pyright: ignore [reportMissingTypeStubs]
|
||||
|
||||
# Partial imports
|
||||
from aiohttp import client_exceptions
|
||||
from aiohttp.web import Application, Response, Request, get, run_app, static # pyright: ignore [reportUnknownVariableType]
|
||||
@@ -23,7 +26,7 @@ from setproctitle import getproctitle, setproctitle, setthreadtitle
|
||||
|
||||
# local modules
|
||||
from .browser import PluginBrowser
|
||||
from .helpers import (REMOTE_DEBUGGER_UNIT, csrf_middleware, get_csrf_token, get_loader_version,
|
||||
from .helpers import (REMOTE_DEBUGGER_UNIT, create_inject_script, csrf_middleware, get_csrf_token, get_loader_version,
|
||||
mkdir_as_user, get_system_pythonpaths, get_effective_user_id)
|
||||
|
||||
from .injector import get_gamepadui_tab, Tab
|
||||
@@ -73,6 +76,9 @@ class PluginManager:
|
||||
self.plugin_browser = PluginBrowser(plugin_path, self.plugin_loader.plugins, self.plugin_loader, self.settings)
|
||||
self.utilities = Utilities(self)
|
||||
self.updater = Updater(self)
|
||||
self.last_webhelper_exit: float = 0
|
||||
self.webhelper_crash_count: int = 0
|
||||
self.inject_fallback: bool = False
|
||||
|
||||
jinja_setup(self.web_app)
|
||||
|
||||
@@ -94,12 +100,33 @@ class PluginManager:
|
||||
self.cors.add(route) # pyright: ignore [reportUnknownMemberType]
|
||||
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.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
|
||||
|
||||
# should never happen
|
||||
if (self.webhelper_crash_count > 4):
|
||||
await self.updater.do_shutdown()
|
||||
# Give up
|
||||
exit(0)
|
||||
|
||||
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
|
||||
@@ -111,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)
|
||||
@@ -185,6 +213,7 @@ class PluginManager:
|
||||
# If this is a forceful disconnect the loop will just stop without any failure message. In this case, injector.py will handle this for us so we don't need to close the socket.
|
||||
# This is because of https://github.com/aio-libs/aiohttp/blob/3ee7091b40a1bc58a8d7846e7878a77640e96996/aiohttp/client_ws.py#L321
|
||||
logger.info("CEF has disconnected...")
|
||||
await self.handle_crash()
|
||||
# At this point the loop starts again and we connect to the freshly started Steam client once it is ready.
|
||||
except Exception:
|
||||
if not self.reinject:
|
||||
@@ -192,6 +221,7 @@ class PluginManager:
|
||||
logger.error("Exception while reading page events " + format_exc())
|
||||
await tab.close_websocket()
|
||||
self.js_ctx_tab = None
|
||||
await self.handle_crash()
|
||||
pass
|
||||
# while True:
|
||||
# await sleep(5)
|
||||
@@ -207,8 +237,13 @@ class PluginManager:
|
||||
await tab.close_websocket()
|
||||
self.js_ctx_tab = None
|
||||
await restart_webhelper()
|
||||
await sleep(1) # To give CEF enough time to close down the websocket
|
||||
return # We'll catch the next tab in the main loop
|
||||
await tab.evaluate_js("try{if (window.deckyHasLoaded){setTimeout(() => SteamClient.Browser.RestartJSContext(), 100)}else{window.deckyHasLoaded = true;(async()=>{try{await import('http://localhost:1337/frontend/index.js?v=%s')}catch(e){console.error(e)};})();}}catch(e){console.error(e)}" % (get_loader_version(), ), False, False, False)
|
||||
await tab.evaluate_js(create_inject_script("index.js" if self.webhelper_crash_count < 3 else "fallback.js"), False, False, False)
|
||||
if self.webhelper_crash_count > 2:
|
||||
self.reinject = False
|
||||
await sleep(1)
|
||||
await self.updater.do_shutdown()
|
||||
except:
|
||||
logger.info("Failed to inject JavaScript into tab\n" + format_exc())
|
||||
pass
|
||||
@@ -223,9 +258,6 @@ def main():
|
||||
# Fix windows/flask not recognising that .js means 'application/javascript'
|
||||
import mimetypes
|
||||
mimetypes.add_type('application/javascript', '.js')
|
||||
|
||||
# Required for multiprocessing support in frozen files
|
||||
multiprocessing.freeze_support()
|
||||
else:
|
||||
if get_effective_user_id() != 0:
|
||||
logger.warning(f"decky is running as an unprivileged user, this is not officially supported and may cause issues")
|
||||
|
||||
@@ -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,26 +1,24 @@
|
||||
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 sys import exit, path as syspath, modules as sysmodules
|
||||
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 ..enums import UserType
|
||||
from .. import helpers
|
||||
from .. import helpers, settings, injector # pyright: ignore [reportUnusedImport]
|
||||
|
||||
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)
|
||||
@@ -78,12 +77,12 @@ class SandboxedPlugin:
|
||||
environ["DECKY_PLUGIN_AUTHOR"] = self.author
|
||||
|
||||
# append the plugin's `py_modules` to the recognized python paths
|
||||
syspath.append(path.join(environ["DECKY_PLUGIN_DIR"], "py_modules"))
|
||||
sys.path.append(path.join(environ["DECKY_PLUGIN_DIR"], "py_modules"))
|
||||
|
||||
#TODO: FIX IN A LESS CURSED WAY
|
||||
keys = [key for key in sysmodules if key.startswith("decky_loader.")]
|
||||
keys = [key for key in sys.modules if key.startswith("decky_loader.")]
|
||||
for key in keys:
|
||||
sysmodules[key.replace("decky_loader.", "")] = sysmodules[key]
|
||||
sys.modules[key.replace("decky_loader.", "")] = sys.modules[key]
|
||||
|
||||
from .imports import decky
|
||||
async def emit(event: str, *args: Any) -> None:
|
||||
@@ -95,9 +94,9 @@ class SandboxedPlugin:
|
||||
# copy the docstring over so we don't have to duplicate it
|
||||
emit.__doc__ = decky.emit.__doc__
|
||||
decky.emit = emit
|
||||
sysmodules["decky"] = decky
|
||||
sys.modules["decky"] = decky
|
||||
# provided for compatibility
|
||||
sysmodules["decky_plugin"] = decky
|
||||
sys.modules["decky_plugin"] = decky
|
||||
|
||||
spec = spec_from_file_location("_", self.file)
|
||||
assert spec is not None
|
||||
@@ -120,10 +119,10 @@ 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())
|
||||
exit(0)
|
||||
sys.exit(0)
|
||||
try:
|
||||
get_event_loop().run_forever()
|
||||
except SystemExit:
|
||||
@@ -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)
|
||||
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")
|
||||
|
||||
@@ -20,9 +20,8 @@ from .browser import PluginInstallRequest, PluginInstallType
|
||||
if TYPE_CHECKING:
|
||||
from .main import PluginManager
|
||||
from .injector import inject_to_tab, get_gamepadui_tab, close_old_tabs, get_tab
|
||||
from .localplatform.localplatform import ON_WINDOWS
|
||||
from . import helpers
|
||||
from .localplatform.localplatform import service_stop, service_start, get_home_path, get_username
|
||||
from .localplatform.localplatform import ON_WINDOWS, service_stop, service_start, get_home_path, get_username, get_use_cef_close_workaround, close_cef_socket, restart_webhelper
|
||||
|
||||
class FilePickerObj(TypedDict):
|
||||
file: Path
|
||||
@@ -78,6 +77,8 @@ class Utilities:
|
||||
context.ws.add_route("utilities/get_tab_id", self.get_tab_id)
|
||||
context.ws.add_route("utilities/get_user_info", self.get_user_info)
|
||||
context.ws.add_route("utilities/http_request", self.http_request_legacy)
|
||||
context.ws.add_route("utilities/restart_webhelper", self.restart_webhelper)
|
||||
context.ws.add_route("utilities/close_cef_socket", self.close_cef_socket)
|
||||
context.ws.add_route("utilities/_call_legacy_utility", self._call_legacy_utility)
|
||||
|
||||
context.web_app.add_routes([
|
||||
@@ -197,9 +198,9 @@ class Utilities:
|
||||
self.logger.debug(f"Finished stream for {url}")
|
||||
return res
|
||||
|
||||
async def http_request_legacy(self, method: str, url: str, extra_opts: Any = {}):
|
||||
async def http_request_legacy(self, method: str, url: str, extra_opts: Any = {}, timeout: int | None = None):
|
||||
async with ClientSession() as web:
|
||||
res = await web.request(method, url, ssl=helpers.get_ssl_context(), **extra_opts)
|
||||
res = await web.request(method, url, ssl=helpers.get_ssl_context(), timeout=timeout, **extra_opts)
|
||||
text = await res.text()
|
||||
return {
|
||||
"status": res.status,
|
||||
@@ -287,6 +288,13 @@ class Utilities:
|
||||
await service_stop(helpers.SSHD_UNIT)
|
||||
return True
|
||||
|
||||
async def close_cef_socket(self):
|
||||
if get_use_cef_close_workaround():
|
||||
await close_cef_socket()
|
||||
|
||||
async def restart_webhelper(self):
|
||||
await restart_webhelper()
|
||||
|
||||
async def filepicker_ls(self,
|
||||
path: str | None = None,
|
||||
include_files: bool = True,
|
||||
|
||||
@@ -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
|
||||
+3
-1
@@ -1,10 +1,12 @@
|
||||
[Unit]
|
||||
Description=SteamDeck Plugin Loader
|
||||
After=network.target
|
||||
[Service]
|
||||
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
+3
-1
@@ -1,10 +1,12 @@
|
||||
[Unit]
|
||||
Description=SteamDeck Plugin Loader
|
||||
After=network.target
|
||||
[Service]
|
||||
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}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"localize": "i18next"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@decky/api": "^1.1.0",
|
||||
"@decky/api": "^1.1.1",
|
||||
"@rollup/plugin-commonjs": "^26.0.1",
|
||||
"@rollup/plugin-image": "^3.0.3",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
@@ -47,7 +47,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@decky/ui": "^4.6.0",
|
||||
"@decky/ui": "^4.7.1",
|
||||
"filesize": "^10.1.2",
|
||||
"i18next": "^23.11.5",
|
||||
"i18next-http-backend": "^2.5.2",
|
||||
|
||||
Generated
+10
-10
@@ -9,8 +9,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@decky/ui':
|
||||
specifier: ^4.6.0
|
||||
version: 4.6.0
|
||||
specifier: ^4.7.1
|
||||
version: 4.7.1
|
||||
filesize:
|
||||
specifier: ^10.1.2
|
||||
version: 10.1.2
|
||||
@@ -37,8 +37,8 @@ importers:
|
||||
version: 4.0.0
|
||||
devDependencies:
|
||||
'@decky/api':
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
'@rollup/plugin-commonjs':
|
||||
specifier: ^26.0.1
|
||||
version: 26.0.1(rollup@4.18.0)
|
||||
@@ -212,11 +212,11 @@ packages:
|
||||
resolution: {integrity: sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@decky/api@1.1.0':
|
||||
resolution: {integrity: sha512-ECCLeI+xj13b89931S/ww1pM3Hgo7utseiww8HXkITkl4OkRfGSO/jtm0srNZPZpkoNyD5k6raXBbDQ02zgAFg==}
|
||||
'@decky/api@1.1.1':
|
||||
resolution: {integrity: sha512-R5fkBRHBt5QIQY7Q0AlbVIhlIZ/nTzwBOoi8Rt4Go2fjFnoMKPInCJl6cPjXzimGwl2pyqKJgY6VnH6ar0XrHQ==}
|
||||
|
||||
'@decky/ui@4.6.0':
|
||||
resolution: {integrity: sha512-hGofSF1VeBxZ6ewA1Fq9iAsg50hxSLcNSsSNWS6N9E5UzdeEhd/1/6PIExHbtnSnMQGJ3lk9FaBBaz6IbG0Mvg==}
|
||||
'@decky/ui@4.7.1':
|
||||
resolution: {integrity: sha512-yJwBgW+J2cMDfMkmcDFtzsubhUjekFZAtCnP55QEJ/1UKGR7sLNOvDLFYi1h5PI0K4L1XYcAMKHwbYFFTzcDTA==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.20.2':
|
||||
resolution: {integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==}
|
||||
@@ -2287,9 +2287,9 @@ snapshots:
|
||||
'@babel/helper-validator-identifier': 7.24.7
|
||||
to-fast-properties: 2.0.0
|
||||
|
||||
'@decky/api@1.1.0': {}
|
||||
'@decky/api@1.1.1': {}
|
||||
|
||||
'@decky/ui@4.6.0': {}
|
||||
'@decky/ui@4.7.1': {}
|
||||
|
||||
'@esbuild/aix-ppc64@0.20.2':
|
||||
optional: true
|
||||
|
||||
+57
-43
@@ -11,48 +11,62 @@ import { visualizer } from 'rollup-plugin-visualizer';
|
||||
|
||||
const hiddenWarnings = ['THIS_IS_UNDEFINED', 'EVAL'];
|
||||
|
||||
export default defineConfig({
|
||||
input: 'src/index.ts',
|
||||
plugins: [
|
||||
del({ targets: '../backend/decky_loader/static/*', force: true }),
|
||||
commonjs(),
|
||||
nodeResolve({
|
||||
browser: true,
|
||||
}),
|
||||
externalGlobals({
|
||||
react: 'SP_REACT',
|
||||
'react-dom': 'SP_REACTDOM',
|
||||
// hack to shut up react-markdown
|
||||
process: '{cwd: () => {}}',
|
||||
path: '{dirname: () => {}, join: () => {}, basename: () => {}, extname: () => {}}',
|
||||
url: '{fileURLToPath: (f) => f}',
|
||||
}),
|
||||
typescript(),
|
||||
json(),
|
||||
replace({
|
||||
preventAssignment: false,
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
}),
|
||||
image(),
|
||||
visualizer(),
|
||||
],
|
||||
preserveEntrySignatures: false,
|
||||
treeshake: {
|
||||
// Assume all external modules have imports with side effects (the default) while allowing decky libraries to treeshake
|
||||
pureExternalImports: true,
|
||||
preset: 'smallest'
|
||||
},
|
||||
output: {
|
||||
dir: '../backend/decky_loader/static',
|
||||
format: 'esm',
|
||||
chunkFileNames: (chunkInfo) => {
|
||||
return 'chunk-[hash].js';
|
||||
export default defineConfig([
|
||||
// Main bundle
|
||||
{
|
||||
input: 'src/index.ts',
|
||||
plugins: [
|
||||
del({ targets: ['../backend/decky_loader/static/*', '!../backend/decky_loader/static/fallback.js'], force: true }),
|
||||
commonjs(),
|
||||
nodeResolve({
|
||||
browser: true,
|
||||
}),
|
||||
externalGlobals({
|
||||
react: 'SP_REACT',
|
||||
'react-dom': 'SP_REACTDOM',
|
||||
// hack to shut up react-markdown
|
||||
process: '{cwd: () => {}}',
|
||||
path: '{dirname: () => {}, join: () => {}, basename: () => {}, extname: () => {}}',
|
||||
url: '{fileURLToPath: (f) => f}',
|
||||
}),
|
||||
typescript(),
|
||||
json(),
|
||||
replace({
|
||||
preventAssignment: false,
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
}),
|
||||
image(),
|
||||
visualizer(),
|
||||
],
|
||||
preserveEntrySignatures: false,
|
||||
treeshake: {
|
||||
// Assume all external modules have imports with side effects (the default) while allowing decky libraries to treeshake
|
||||
pureExternalImports: true,
|
||||
preset: 'smallest'
|
||||
},
|
||||
output: {
|
||||
dir: '../backend/decky_loader/static',
|
||||
format: 'esm',
|
||||
chunkFileNames: (chunkInfo) => {
|
||||
return 'chunk-[hash].js';
|
||||
},
|
||||
sourcemap: true,
|
||||
sourcemapPathTransform: (relativeSourcePath) => relativeSourcePath.replace(/^\.\.\//, `decky://decky/loader/`),
|
||||
},
|
||||
onwarn: function (message, handleWarning) {
|
||||
if (hiddenWarnings.some((warning) => message.code === warning)) return;
|
||||
handleWarning(message);
|
||||
},
|
||||
sourcemap: true,
|
||||
sourcemapPathTransform: (relativeSourcePath) => relativeSourcePath.replace(/^\.\.\//, `decky://decky/loader/`),
|
||||
},
|
||||
onwarn: function (message, handleWarning) {
|
||||
if (hiddenWarnings.some((warning) => message.code === warning)) return;
|
||||
handleWarning(message);
|
||||
},
|
||||
});
|
||||
// Fallback
|
||||
{
|
||||
input: 'src/fallback.ts',
|
||||
plugins: [
|
||||
typescript()
|
||||
],
|
||||
output: {
|
||||
file: '../backend/decky_loader/static/fallback.js',
|
||||
format: 'esm',
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
@@ -94,7 +94,7 @@ const DeckyErrorBoundary: FunctionComponent<DeckyErrorBoundaryProps> = ({ error,
|
||||
style={{ marginRight: '5px', padding: '5px' }}
|
||||
onClick={() => {
|
||||
addLogLine('Restarting Steam...');
|
||||
SteamClient.User.StartRestart();
|
||||
SteamClient.User.StartRestart(false);
|
||||
}}
|
||||
>
|
||||
Restart Steam
|
||||
@@ -121,7 +121,7 @@ const DeckyErrorBoundary: FunctionComponent<DeckyErrorBoundaryProps> = ({ error,
|
||||
doShutdown();
|
||||
await sleep(5000);
|
||||
addLogLine('Restarting Steam...');
|
||||
SteamClient.User.StartRestart();
|
||||
SteamClient.User.StartRestart(false);
|
||||
}}
|
||||
>
|
||||
Disable Decky until next boot
|
||||
@@ -166,7 +166,7 @@ const DeckyErrorBoundary: FunctionComponent<DeckyErrorBoundaryProps> = ({ error,
|
||||
await sleep(2000);
|
||||
addLogLine('Restarting Steam...');
|
||||
await sleep(500);
|
||||
SteamClient.User.StartRestart();
|
||||
SteamClient.User.StartRestart(false);
|
||||
}}
|
||||
>
|
||||
Uninstall {errorSource} and restart Decky
|
||||
|
||||
@@ -1,37 +1,39 @@
|
||||
export default function DeckyIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 456" width="512" height="456">
|
||||
<g>
|
||||
<path
|
||||
style={{ fill: 'none' }}
|
||||
d="M154.33,72.51v49.79c11.78-0.17,23.48,2,34.42,6.39c10.93,4.39,20.89,10.91,29.28,19.18
|
||||
c8.39,8.27,15.06,18.13,19.61,29c4.55,10.87,6.89,22.54,6.89,34.32c0,11.78-2.34,23.45-6.89,34.32
|
||||
c-4.55,10.87-11.21,20.73-19.61,29c-8.39,8.27-18.35,14.79-29.28,19.18c-10.94,4.39-22.63,6.56-34.42,6.39v49.77
|
||||
c36.78,0,72.05-14.61,98.05-40.62c26-26.01,40.61-61.28,40.61-98.05c0-36.78-14.61-72.05-40.61-98.05
|
||||
C226.38,87.12,191.11,72.51,154.33,72.51z"
|
||||
/>
|
||||
import { FC, SVGAttributes } from 'react';
|
||||
|
||||
<ellipse
|
||||
transform="matrix(0.982 -0.1891 0.1891 0.982 -37.1795 32.9988)"
|
||||
style={{ fill: 'none' }}
|
||||
cx="154.33"
|
||||
cy="211.33"
|
||||
rx="69.33"
|
||||
ry="69.33"
|
||||
/>
|
||||
<path style={{ fill: 'none' }} d="M430,97h-52v187h52c7.18,0,13-5.82,13-13V110C443,102.82,437.18,97,430,97z" />
|
||||
<path
|
||||
style={{ fill: 'currentColor' }}
|
||||
d="M432,27h-54V0H0v361c0,52.47,42.53,95,95,95h188c52.47,0,95-42.53,95-95v-7h54c44.18,0,80-35.82,80-80V107
|
||||
C512,62.82,476.18,27,432,27z M85,211.33c0-38.29,31.04-69.33,69.33-69.33c38.29,0,69.33,31.04,69.33,69.33
|
||||
c0,38.29-31.04,69.33-69.33,69.33C116.04,280.67,85,249.62,85,211.33z M252.39,309.23c-26.01,26-61.28,40.62-98.05,40.62v-49.77
|
||||
c11.78,0.17,23.48-2,34.42-6.39c10.93-4.39,20.89-10.91,29.28-19.18c8.39-8.27,15.06-18.13,19.61-29
|
||||
c4.55-10.87,6.89-22.53,6.89-34.32c0-11.78-2.34-23.45-6.89-34.32c-4.55-10.87-11.21-20.73-19.61-29
|
||||
c-8.39-8.27-18.35-14.79-29.28-19.18c-10.94-4.39-22.63-6.56-34.42-6.39V72.51c36.78,0,72.05,14.61,98.05,40.61
|
||||
c26,26.01,40.61,61.28,40.61,98.05C293,247.96,278.39,283.23,252.39,309.23z M443,271c0,7.18-5.82,13-13,13h-52V97h52
|
||||
c7.18,0,13,5.82,13,13V271z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const DeckyIcon: FC<SVGAttributes<SVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="100%" width="100%" viewBox="0 0 512 456" {...props}>
|
||||
<g>
|
||||
<path
|
||||
style={{ fill: 'none' }}
|
||||
d="M154.33,72.51v49.79c11.78-0.17,23.48,2,34.42,6.39c10.93,4.39,20.89,10.91,29.28,19.18
|
||||
c8.39,8.27,15.06,18.13,19.61,29c4.55,10.87,6.89,22.54,6.89,34.32c0,11.78-2.34,23.45-6.89,34.32
|
||||
c-4.55,10.87-11.21,20.73-19.61,29c-8.39,8.27-18.35,14.79-29.28,19.18c-10.94,4.39-22.63,6.56-34.42,6.39v49.77
|
||||
c36.78,0,72.05-14.61,98.05-40.62c26-26.01,40.61-61.28,40.61-98.05c0-36.78-14.61-72.05-40.61-98.05
|
||||
C226.38,87.12,191.11,72.51,154.33,72.51z"
|
||||
/>
|
||||
|
||||
<ellipse
|
||||
transform="matrix(0.982 -0.1891 0.1891 0.982 -37.1795 32.9988)"
|
||||
style={{ fill: 'none' }}
|
||||
cx="154.33"
|
||||
cy="211.33"
|
||||
rx="69.33"
|
||||
ry="69.33"
|
||||
/>
|
||||
<path style={{ fill: 'none' }} d="M430,97h-52v187h52c7.18,0,13-5.82,13-13V110C443,102.82,437.18,97,430,97z" />
|
||||
<path
|
||||
style={{ fill: 'currentColor' }}
|
||||
d="M432,27h-54V0H0v361c0,52.47,42.53,95,95,95h188c52.47,0,95-42.53,95-95v-7h54c44.18,0,80-35.82,80-80V107
|
||||
C512,62.82,476.18,27,432,27z M85,211.33c0-38.29,31.04-69.33,69.33-69.33c38.29,0,69.33,31.04,69.33,69.33
|
||||
c0,38.29-31.04,69.33-69.33,69.33C116.04,280.67,85,249.62,85,211.33z M252.39,309.23c-26.01,26-61.28,40.62-98.05,40.62v-49.77
|
||||
c11.78,0.17,23.48-2,34.42-6.39c10.93-4.39,20.89-10.91,29.28-19.18c8.39-8.27,15.06-18.13,19.61-29
|
||||
c4.55-10.87,6.89-22.53,6.89-34.32c0-11.78-2.34-23.45-6.89-34.32c-4.55-10.87-11.21-20.73-19.61-29
|
||||
c-8.39-8.27-18.35-14.79-29.28-19.18c-10.94-4.39-22.63-6.56-34.42-6.39V72.51c36.78,0,72.05,14.61,98.05,40.61
|
||||
c26,26.01,40.61,61.28,40.61,98.05C293,247.96,278.39,283.23,252.39,309.23z M443,271c0,7.18-5.82,13-13,13h-52V97h52
|
||||
c7.18,0,13,5.82,13,13V271z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default DeckyIcon;
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { ToastData } from '@decky/api';
|
||||
import { joinClassNames } from '@decky/ui';
|
||||
import { FC, ReactElement, useEffect, useState } from 'react';
|
||||
|
||||
import { useDeckyToasterState } from './DeckyToasterState';
|
||||
import Toast, { toastClasses } from './Toast';
|
||||
|
||||
interface DeckyToasterProps {}
|
||||
|
||||
interface RenderedToast {
|
||||
component: ReactElement;
|
||||
data: ToastData;
|
||||
}
|
||||
|
||||
const DeckyToaster: FC<DeckyToasterProps> = () => {
|
||||
const { toasts, removeToast } = useDeckyToasterState();
|
||||
const [renderedToast, setRenderedToast] = useState<RenderedToast | null>(null);
|
||||
console.log(toasts);
|
||||
if (toasts.size > 0) {
|
||||
const [activeToast] = toasts;
|
||||
if (!renderedToast || activeToast != renderedToast.data) {
|
||||
// TODO play toast soundReactElement
|
||||
console.log('rendering toast', activeToast);
|
||||
setRenderedToast({ component: <Toast key={Math.random()} toast={activeToast} />, data: activeToast });
|
||||
}
|
||||
} else {
|
||||
if (renderedToast) setRenderedToast(null);
|
||||
}
|
||||
useEffect(() => {
|
||||
// not actually node but TS is shit
|
||||
let interval: number | null;
|
||||
if (renderedToast) {
|
||||
interval = setTimeout(
|
||||
() => {
|
||||
interval = null;
|
||||
console.log('clear toast', renderedToast.data);
|
||||
removeToast(renderedToast.data);
|
||||
},
|
||||
(renderedToast.data.duration || 5e3) + 1000,
|
||||
);
|
||||
console.log('set int', interval);
|
||||
}
|
||||
return () => {
|
||||
if (interval) {
|
||||
console.log('clearing int', interval);
|
||||
clearTimeout(interval);
|
||||
}
|
||||
};
|
||||
}, [renderedToast]);
|
||||
return (
|
||||
<div className={joinClassNames('deckyToaster', toastClasses.ToastPlaceholder)}>
|
||||
{renderedToast && renderedToast.component}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeckyToaster;
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { ToastData } from '@decky/api';
|
||||
import { FC, ReactNode, createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
interface PublicDeckyToasterState {
|
||||
toasts: Set<ToastData>;
|
||||
}
|
||||
|
||||
export class DeckyToasterState {
|
||||
private _toasts: Set<ToastData> = new Set();
|
||||
|
||||
public eventBus = new EventTarget();
|
||||
|
||||
publicState(): PublicDeckyToasterState {
|
||||
return { toasts: this._toasts };
|
||||
}
|
||||
|
||||
addToast(toast: ToastData) {
|
||||
this._toasts.add(toast);
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
removeToast(toast: ToastData) {
|
||||
this._toasts.delete(toast);
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
private notifyUpdate() {
|
||||
this.eventBus.dispatchEvent(new Event('update'));
|
||||
}
|
||||
}
|
||||
|
||||
interface DeckyToasterContext extends PublicDeckyToasterState {
|
||||
addToast(toast: ToastData): void;
|
||||
removeToast(toast: ToastData): void;
|
||||
}
|
||||
|
||||
const DeckyToasterContext = createContext<DeckyToasterContext>(null as any);
|
||||
|
||||
export const useDeckyToasterState = () => useContext(DeckyToasterContext);
|
||||
|
||||
interface Props {
|
||||
deckyToasterState: DeckyToasterState;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const DeckyToasterStateContextProvider: FC<Props> = ({ children, deckyToasterState }) => {
|
||||
const [publicDeckyToasterState, setPublicDeckyToasterState] = useState<PublicDeckyToasterState>({
|
||||
...deckyToasterState.publicState(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function onUpdate() {
|
||||
setPublicDeckyToasterState({ ...deckyToasterState.publicState() });
|
||||
}
|
||||
|
||||
deckyToasterState.eventBus.addEventListener('update', onUpdate);
|
||||
|
||||
return () => deckyToasterState.eventBus.removeEventListener('update', onUpdate);
|
||||
}, []);
|
||||
|
||||
const addToast = deckyToasterState.addToast.bind(deckyToasterState);
|
||||
const removeToast = deckyToasterState.removeToast.bind(deckyToasterState);
|
||||
|
||||
return (
|
||||
<DeckyToasterContext.Provider value={{ ...publicDeckyToasterState, addToast, removeToast }}>
|
||||
{children}
|
||||
</DeckyToasterContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DialogButton, Focusable, Router, staticClasses } from '@decky/ui';
|
||||
import { DialogButton, Focusable, Navigation, staticClasses } from '@decky/ui';
|
||||
import { CSSProperties, FC } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BsGearFill } from 'react-icons/bs';
|
||||
@@ -19,13 +19,13 @@ const TitleView: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onSettingsClick = () => {
|
||||
Router.CloseSideMenus();
|
||||
Router.Navigate('/decky/settings');
|
||||
Navigation.Navigate('/decky/settings');
|
||||
Navigation.CloseSideMenus();
|
||||
};
|
||||
|
||||
const onStoreClick = () => {
|
||||
Router.CloseSideMenus();
|
||||
Router.Navigate('/decky/store');
|
||||
Navigation.Navigate('/decky/store');
|
||||
Navigation.CloseSideMenus();
|
||||
};
|
||||
|
||||
if (activePlugin === null) {
|
||||
|
||||
@@ -1,37 +1,38 @@
|
||||
import type { ToastData } from '@decky/api';
|
||||
import { findModule, joinClassNames } from '@decky/ui';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { Focusable, Navigation, findClassModule, joinClassNames } from '@decky/ui';
|
||||
import { FC, memo } from 'react';
|
||||
|
||||
import Logger from '../logger';
|
||||
|
||||
const logger = new Logger('ToastRenderer');
|
||||
|
||||
// TODO there are more of these
|
||||
export enum ToastLocation {
|
||||
/** Big Picture popup toasts */
|
||||
GAMEPADUI_POPUP = 1,
|
||||
/** QAM Notifications tab */
|
||||
GAMEPADUI_QAM = 3,
|
||||
}
|
||||
|
||||
interface ToastProps {
|
||||
toast: ToastData;
|
||||
newIndicator?: boolean;
|
||||
}
|
||||
|
||||
export const toastClasses = findModule((mod) => {
|
||||
if (typeof mod !== 'object') return false;
|
||||
interface ToastRendererProps extends ToastProps {
|
||||
location: ToastLocation;
|
||||
}
|
||||
|
||||
if (mod.ToastPlaceholder) {
|
||||
return true;
|
||||
}
|
||||
const templateClasses = findClassModule((m) => m.ShortTemplate) || {};
|
||||
|
||||
return false;
|
||||
});
|
||||
// These are memoized as they like to randomly rerender
|
||||
|
||||
const templateClasses = findModule((mod) => {
|
||||
if (typeof mod !== 'object') return false;
|
||||
|
||||
if (mod.ShortTemplate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const Toast: FunctionComponent<ToastProps> = ({ toast }) => {
|
||||
const GamepadUIPopupToast: FC<Omit<ToastProps, 'newIndicator'>> = memo(({ toast }) => {
|
||||
return (
|
||||
<div
|
||||
style={{ '--toast-duration': `${toast.duration}ms` } as React.CSSProperties}
|
||||
onClick={toast.onClick}
|
||||
className={joinClassNames(templateClasses.ShortTemplate, toast.className || '')}
|
||||
className={joinClassNames(templateClasses.ShortTemplate, toast.className || '', 'DeckyGamepadUIPopupToast')}
|
||||
>
|
||||
{toast.logo && <div className={templateClasses.StandardLogoDimensions}>{toast.logo}</div>}
|
||||
<div className={joinClassNames(templateClasses.Content, toast.contentClassName || '')}>
|
||||
@@ -43,6 +44,61 @@ const Toast: FunctionComponent<ToastProps> = ({ toast }) => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default Toast;
|
||||
const GamepadUIQAMToast: FC<ToastProps> = memo(({ toast, newIndicator }) => {
|
||||
// The fields aren't mismatched, the logic for these is just a bit weird.
|
||||
return (
|
||||
<Focusable
|
||||
onActivate={() => {
|
||||
toast.onClick?.();
|
||||
Navigation.CloseSideMenus();
|
||||
}}
|
||||
className={joinClassNames(
|
||||
templateClasses.StandardTemplateContainer,
|
||||
toast.className || '',
|
||||
'DeckyGamepadUIQAMToast',
|
||||
)}
|
||||
>
|
||||
<div className={templateClasses.StandardTemplate}>
|
||||
{toast.logo && <div className={templateClasses.StandardLogoDimensions}>{toast.logo}</div>}
|
||||
<div className={joinClassNames(templateClasses.Content, toast.contentClassName || '')}>
|
||||
<div className={templateClasses.Header}>
|
||||
{toast.icon && <div className={templateClasses.Icon}>{toast.icon}</div>}
|
||||
{toast.title && <div className={templateClasses.Title}>{toast.title}</div>}
|
||||
{/* timestamp should always be defined by toaster */}
|
||||
{/* TODO check how valve does this */}
|
||||
{toast.timestamp && (
|
||||
<div className={templateClasses.Timestamp}>
|
||||
{toast.timestamp.toLocaleTimeString(undefined, { timeStyle: 'short' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{toast.body && <div className={templateClasses.StandardNotificationDescription}>{toast.body}</div>}
|
||||
{toast.subtext && <div className={templateClasses.StandardNotificationSubText}>{toast.subtext}</div>}
|
||||
</div>
|
||||
{newIndicator && (
|
||||
<div className={templateClasses.NewIndicator}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="none">
|
||||
<circle fill="currentColor" cx="25" cy="25" r="25"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Focusable>
|
||||
);
|
||||
});
|
||||
|
||||
export const ToastRenderer: FC<ToastRendererProps> = memo(({ toast, location, newIndicator }) => {
|
||||
switch (location) {
|
||||
default:
|
||||
logger.warn(`Toast UI not implemented for location ${location}! Falling back to GamepadUIQAMToast.`);
|
||||
return <GamepadUIQAMToast toast={toast} newIndicator={false} />;
|
||||
case ToastLocation.GAMEPADUI_POPUP:
|
||||
return <GamepadUIPopupToast toast={toast} />;
|
||||
case ToastLocation.GAMEPADUI_QAM:
|
||||
return <GamepadUIQAMToast toast={toast} newIndicator={newIndicator} />;
|
||||
}
|
||||
});
|
||||
|
||||
export default ToastRenderer;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import library from './library';
|
||||
let patches: Function[] = [];
|
||||
// import library from './library';
|
||||
// let patches: Function[] = [];
|
||||
|
||||
export function deinitFilepickerPatches() {
|
||||
patches.forEach((unpatch) => unpatch());
|
||||
// patches.forEach((unpatch) => unpatch());
|
||||
}
|
||||
|
||||
export async function initFilepickerPatches() {
|
||||
patches.push(await library());
|
||||
// patches.push(await library());
|
||||
}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@decky/ui';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaDownload, FaInfo } from 'react-icons/fa';
|
||||
import { FaDownload, FaFlask, FaInfo } from 'react-icons/fa';
|
||||
|
||||
import { setSetting } from '../../../../utils/settings';
|
||||
import { UpdateBranch } from '../general/BranchSelect';
|
||||
@@ -91,17 +91,20 @@ export default function TestingVersionList() {
|
||||
<DialogButton
|
||||
style={{ height: '40px', minWidth: '60px', marginRight: '10px' }}
|
||||
onClick={async () => {
|
||||
DeckyPluginLoader.toaster.toast({
|
||||
const downloadToast = DeckyPluginLoader.toaster.toast({
|
||||
title: t('Testing.start_download_toast', { id: version.id }),
|
||||
body: null,
|
||||
icon: <FaFlask />,
|
||||
});
|
||||
try {
|
||||
await downloadTestingVersion(version.id, version.head_sha);
|
||||
downloadToast.dismiss();
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
DeckyPluginLoader.toaster.toast({
|
||||
title: t('Testing.error'),
|
||||
body: `${e.name}: ${e.message}`,
|
||||
icon: <FaFlask />,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -22,9 +22,7 @@ class ErrorBoundaryHook extends Logger {
|
||||
this.log('Initialized');
|
||||
window.__ERRORBOUNDARY_HOOK_INSTANCE?.deinit?.();
|
||||
window.__ERRORBOUNDARY_HOOK_INSTANCE = this;
|
||||
}
|
||||
|
||||
init() {
|
||||
// valve writes only the sanest of code
|
||||
const exp = /^\(\)=>\(.\|\|.\(new .\),.\)$/;
|
||||
const initErrorReportingStore = findModuleExport(
|
||||
@@ -71,11 +69,16 @@ class ErrorBoundaryHook extends Logger {
|
||||
});
|
||||
|
||||
if (!ErrorBoundary) {
|
||||
this.error('could not find ValveErrorBoundary');
|
||||
this.error('@decky/ui could not find ErrorBoundary, skipping patch');
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorBoundaryPatch = replacePatch(ErrorBoundary.prototype, 'render', function (this: any) {
|
||||
if (this.state._deckyForceRerender) {
|
||||
const stateClone = { ...this.state, _deckyForceRerender: null };
|
||||
this.setState(stateClone);
|
||||
return null;
|
||||
}
|
||||
if (this.state.error) {
|
||||
const store = Object.getPrototypeOf(this)?.constructor?.sm_ErrorReportingStore || errorReportingStore;
|
||||
return (
|
||||
@@ -89,6 +92,10 @@ class ErrorBoundaryHook extends Logger {
|
||||
}
|
||||
return callOriginal;
|
||||
});
|
||||
// Small hack that gives us a lot more flexibility to force rerenders.
|
||||
ErrorBoundary.prototype._deckyForceRerender = function (this: any) {
|
||||
this.setState({ ...this.state, _deckyForceRerender: true });
|
||||
};
|
||||
}
|
||||
|
||||
public temporarilyDisableReporting() {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// THIS FILE MUST BE ENTIRELY SELF-CONTAINED! DO NOT USE PACKAGES!
|
||||
interface Window {
|
||||
FocusNavController: any;
|
||||
GamepadNavTree: any;
|
||||
deckyFallbackLoaded?: boolean;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
if (window.deckyFallbackLoaded) return;
|
||||
window.deckyFallbackLoaded = true;
|
||||
|
||||
// #region utils
|
||||
function sleep(ms: number) {
|
||||
return new Promise((res) => setTimeout(res, ms));
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region DeckyIcon
|
||||
const fallbackIcon = `
|
||||
<svg class="fallbackDeckyIcon" xmlns="http://www.w3.org/2000/svg" height="100%" width="100%" viewBox="0 0 512 456">
|
||||
<g>
|
||||
<path
|
||||
style="fill: none;"
|
||||
d="M154.33,72.51v49.79c11.78-0.17,23.48,2,34.42,6.39c10.93,4.39,20.89,10.91,29.28,19.18
|
||||
c8.39,8.27,15.06,18.13,19.61,29c4.55,10.87,6.89,22.54,6.89,34.32c0,11.78-2.34,23.45-6.89,34.32
|
||||
c-4.55,10.87-11.21,20.73-19.61,29c-8.39,8.27-18.35,14.79-29.28,19.18c-10.94,4.39-22.63,6.56-34.42,6.39v49.77
|
||||
c36.78,0,72.05-14.61,98.05-40.62c26-26.01,40.61-61.28,40.61-98.05c0-36.78-14.61-72.05-40.61-98.05
|
||||
C226.38,87.12,191.11,72.51,154.33,72.51z"
|
||||
/>
|
||||
|
||||
<ellipse
|
||||
transform="matrix(0.982 -0.1891 0.1891 0.982 -37.1795 32.9988)"
|
||||
style="fill: none;"
|
||||
cx="154.33"
|
||||
cy="211.33"
|
||||
rx="69.33"
|
||||
ry="69.33"
|
||||
/>
|
||||
<path style="fill: none;" d="M430,97h-52v187h52c7.18,0,13-5.82,13-13V110C443,102.82,437.18,97,430,97z" />
|
||||
<path
|
||||
style="fill: currentColor;"
|
||||
d="M432,27h-54V0H0v361c0,52.47,42.53,95,95,95h188c52.47,0,95-42.53,95-95v-7h54c44.18,0,80-35.82,80-80V107
|
||||
C512,62.82,476.18,27,432,27z M85,211.33c0-38.29,31.04-69.33,69.33-69.33c38.29,0,69.33,31.04,69.33,69.33
|
||||
c0,38.29-31.04,69.33-69.33,69.33C116.04,280.67,85,249.62,85,211.33z M252.39,309.23c-26.01,26-61.28,40.62-98.05,40.62v-49.77
|
||||
c11.78,0.17,23.48-2,34.42-6.39c10.93-4.39,20.89-10.91,29.28-19.18c8.39-8.27,15.06-18.13,19.61-29
|
||||
c4.55-10.87,6.89-22.53,6.89-34.32c0-11.78-2.34-23.45-6.89-34.32c-4.55-10.87-11.21-20.73-19.61-29
|
||||
c-8.39-8.27-18.35-14.79-29.28-19.18c-10.94-4.39-22.63-6.56-34.42-6.39V72.51c36.78,0,72.05,14.61,98.05,40.61
|
||||
c26,26.01,40.61,61.28,40.61,98.05C293,247.96,278.39,283.23,252.39,309.23z M443,271c0,7.18-5.82,13-13,13h-52V97h52
|
||||
c7.18,0,13,5.82,13,13V271z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
// #endregion
|
||||
|
||||
// #region findSP
|
||||
// from @decky/ui
|
||||
function getFocusNavController(): any {
|
||||
return window.GamepadNavTree?.m_context?.m_controller || window.FocusNavController;
|
||||
}
|
||||
|
||||
function getGamepadNavigationTrees(): any {
|
||||
const focusNav = getFocusNavController();
|
||||
const context = focusNav.m_ActiveContext || focusNav.m_LastActiveContext;
|
||||
return context?.m_rgGamepadNavigationTrees;
|
||||
}
|
||||
|
||||
function findSP(): Window {
|
||||
// old (SP as host)
|
||||
if (document.title == 'SP') return window;
|
||||
// new (SP as popup)
|
||||
const navTrees = getGamepadNavigationTrees();
|
||||
return navTrees?.find((x: any) => x.m_ID == 'root_1_').Root.Element.ownerDocument.defaultView;
|
||||
}
|
||||
// #endregion
|
||||
|
||||
const fallbackCSS = `
|
||||
.fallbackContainer {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
z-index: 99999999;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
backdrop-filter: blur(8px) brightness(40%);
|
||||
}
|
||||
.fallbackDeckyIcon {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const fallbackHTML = `
|
||||
<style>${fallbackCSS}</style>
|
||||
${fallbackIcon}
|
||||
<span class="fallbackText">
|
||||
<b>A crash loop has been detected and Decky has been disabled for this boot.</b>
|
||||
<br>
|
||||
<i>Steam will restart in 10 seconds...</i>
|
||||
</span>
|
||||
`;
|
||||
|
||||
await sleep(4000);
|
||||
|
||||
const win = findSP() || window;
|
||||
|
||||
const container = Object.assign(document.createElement('div'), {
|
||||
innerHTML: fallbackHTML,
|
||||
});
|
||||
container.classList.add('fallbackContainer');
|
||||
|
||||
win.document.body.appendChild(container);
|
||||
|
||||
await sleep(10000);
|
||||
|
||||
SteamClient.User.StartShutdown(false);
|
||||
} catch (e) {
|
||||
console.error('Error showing fallback!', e);
|
||||
}
|
||||
})();
|
||||
+19
-3
@@ -5,13 +5,29 @@ interface Window {
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// Wait for react to definitely be loaded
|
||||
while (!window.webpackChunksteamui || window.webpackChunksteamui <= 3) {
|
||||
// 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...');
|
||||
|
||||
// Wait for the React root to be mounted
|
||||
console.time('[Decky:Boot] Waiting for React root mount...');
|
||||
let root;
|
||||
while (
|
||||
// Does React root node exist?
|
||||
!(root = document.getElementById('root')) ||
|
||||
// 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.
|
||||
}
|
||||
console.timeEnd('[Decky:Boot] Waiting for React root mount...');
|
||||
|
||||
if (!window.SP_REACT) {
|
||||
console.debug('[Decky:Boot] Setting up React globals...');
|
||||
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);
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { ToastNotification } from '@decky/api';
|
||||
import {
|
||||
ModalRoot,
|
||||
Navigation,
|
||||
PanelSection,
|
||||
PanelSectionRow,
|
||||
QuickAccessTab,
|
||||
Router,
|
||||
findSP,
|
||||
quickAccessMenuClasses,
|
||||
showModal,
|
||||
sleep,
|
||||
} from '@decky/ui';
|
||||
import { FC, lazy } from 'react';
|
||||
import { FaExclamationCircle, FaPlug } from 'react-icons/fa';
|
||||
import { FaDownload, FaExclamationCircle, FaPlug } from 'react-icons/fa';
|
||||
|
||||
import DeckyIcon from './components/DeckyIcon';
|
||||
import { DeckyState, DeckyStateContextProvider, UserInfo, useDeckyState } from './components/DeckyState';
|
||||
import { File, FileSelectionType } from './components/modals/filepicker';
|
||||
import { deinitFilepickerPatches, initFilepickerPatches } from './components/modals/filepicker/patches';
|
||||
@@ -28,7 +30,7 @@ import { HiddenPluginsService } from './hidden-plugins-service';
|
||||
import Logger from './logger';
|
||||
import { NotificationService } from './notification-service';
|
||||
import { InstallType, Plugin, PluginLoadType } from './plugin';
|
||||
import RouterHook from './router-hook';
|
||||
import RouterHook, { UIMode } from './router-hook';
|
||||
import { deinitSteamFixes, initSteamFixes } from './steamfixes';
|
||||
import { checkForPluginUpdates } from './store';
|
||||
import TabsHook from './tabs-hook';
|
||||
@@ -77,13 +79,14 @@ 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;
|
||||
|
||||
constructor() {
|
||||
super(PluginLoader.name);
|
||||
|
||||
this.errorBoundaryHook.init();
|
||||
|
||||
DeckyBackend.addEventListener('loader/notify_updates', this.notifyUpdates.bind(this));
|
||||
DeckyBackend.addEventListener('loader/import_plugin', this.importPlugin.bind(this));
|
||||
DeckyBackend.addEventListener('loader/unload_plugin', this.unloadPlugin.bind(this));
|
||||
@@ -169,16 +172,48 @@ class PluginLoader extends Logger {
|
||||
.then(() => this.log('Initialized'));
|
||||
}
|
||||
|
||||
private checkForSP(): boolean {
|
||||
try {
|
||||
return !!findSP();
|
||||
} catch (e) {
|
||||
this.warn('Error checking for SP tab', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async runCrashChecker() {
|
||||
const spExists = this.checkForSP();
|
||||
await sleep(5000);
|
||||
if (spExists && !this.checkForSP()) {
|
||||
// SP died after plugin loaded. Give up and let the loader's crash loop detection handle it.
|
||||
this.error('SP died during startup. Restarting webhelper.');
|
||||
await this.restartWebhelper();
|
||||
}
|
||||
}
|
||||
|
||||
private getPluginsFromBackend = DeckyBackend.callable<
|
||||
[],
|
||||
{ name: string; version: string; load_type: PluginLoadType }[]
|
||||
>('loader/get_plugins');
|
||||
|
||||
private restartWebhelper = DeckyBackend.callable<[], void>('utilities/restart_webhelper');
|
||||
|
||||
private async loadPlugins() {
|
||||
// wait for SP window to exist before loading plugins
|
||||
while (!findSP()) {
|
||||
await sleep(100);
|
||||
let registration: any;
|
||||
const uiMode = await new Promise(
|
||||
(r) =>
|
||||
(registration = SteamClient.UI.RegisterForUIModeChanged((mode: UIMode) => {
|
||||
r(mode);
|
||||
registration.unregister();
|
||||
})),
|
||||
);
|
||||
if (uiMode == UIMode.BigPicture) {
|
||||
// wait for SP window to exist before loading plugins
|
||||
while (!findSP()) {
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
this.runCrashChecker();
|
||||
const plugins = await this.getPluginsFromBackend();
|
||||
const pluginLoadPromises = [];
|
||||
const loadStart = performance.now();
|
||||
@@ -211,7 +246,9 @@ class PluginLoader extends Logger {
|
||||
if (versionInfo?.remote && versionInfo?.remote?.tag_name != versionInfo?.current) {
|
||||
this.deckyState.setHasLoaderUpdate(true);
|
||||
if (this.notificationService.shouldNotify('deckyUpdates')) {
|
||||
this.toaster.toast({
|
||||
this.loaderUpdateToast && this.loaderUpdateToast.dismiss();
|
||||
await this.routerHook.waitForUnlock();
|
||||
this.loaderUpdateToast = this.toaster.toast({
|
||||
title: <TranslationHelper transClass={TranslationClass.PLUGIN_LOADER} transText="decky_title" />,
|
||||
body: (
|
||||
<TranslationHelper
|
||||
@@ -220,7 +257,9 @@ class PluginLoader extends Logger {
|
||||
i18nArgs={{ tag_name: versionInfo?.remote?.tag_name }}
|
||||
/>
|
||||
),
|
||||
onClick: () => Router.Navigate('/decky/settings'),
|
||||
logo: <DeckyIcon />,
|
||||
icon: <FaDownload />,
|
||||
onClick: () => Navigation.Navigate('/decky/settings'),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -239,7 +278,8 @@ class PluginLoader extends Logger {
|
||||
public async notifyPluginUpdates() {
|
||||
const updates = await this.checkPluginUpdates();
|
||||
if (updates?.size > 0 && this.notificationService.shouldNotify('pluginUpdates')) {
|
||||
this.toaster.toast({
|
||||
this.pluginUpdateToast && this.pluginUpdateToast.dismiss();
|
||||
this.pluginUpdateToast = this.toaster.toast({
|
||||
title: <TranslationHelper transClass={TranslationClass.PLUGIN_LOADER} transText="decky_title" />,
|
||||
body: (
|
||||
<TranslationHelper
|
||||
@@ -248,7 +288,9 @@ class PluginLoader extends Logger {
|
||||
i18nArgs={{ count: updates.size }}
|
||||
/>
|
||||
),
|
||||
onClick: () => Router.Navigate('/decky/settings/plugins'),
|
||||
logo: <DeckyIcon />,
|
||||
icon: <FaDownload />,
|
||||
onClick: () => Navigation.Navigate('/decky/settings/plugins'),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -327,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(
|
||||
@@ -342,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;
|
||||
}
|
||||
|
||||
@@ -350,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();
|
||||
@@ -364,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -375,16 +417,18 @@ class PluginLoader extends Logger {
|
||||
version?: string,
|
||||
loadType: PluginLoadType = PluginLoadType.ESMODULE_V1,
|
||||
) {
|
||||
let spExists = this.checkForSP();
|
||||
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;
|
||||
|
||||
@@ -404,6 +448,7 @@ class PluginLoader extends Logger {
|
||||
...plugin,
|
||||
name: name,
|
||||
version: version,
|
||||
loadType,
|
||||
});
|
||||
} else throw new Error(`${name} frontend_bundle not OK`);
|
||||
break;
|
||||
@@ -422,7 +467,7 @@ class PluginLoader extends Logger {
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow>
|
||||
<pre style={{ overflowX: 'scroll' }}>
|
||||
<code>{e instanceof Error ? e.stack : JSON.stringify(e)}</code>
|
||||
<code>{e instanceof Error ? '' + e.stack : JSON.stringify(e)}</code>
|
||||
</pre>
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow>
|
||||
@@ -441,6 +486,7 @@ class PluginLoader extends Logger {
|
||||
version: version,
|
||||
content: <TheError />,
|
||||
icon: <FaExclamationCircle />,
|
||||
loadType,
|
||||
});
|
||||
this.toaster.toast({
|
||||
title: (
|
||||
@@ -454,6 +500,12 @@ class PluginLoader extends Logger {
|
||||
icon: <FaExclamationCircle />,
|
||||
});
|
||||
}
|
||||
|
||||
if (spExists && !this.checkForSP()) {
|
||||
// SP died after plugin loaded. Give up and let the loader's crash loop detection handle it.
|
||||
this.error('SP died after loading plugin. Restarting webhelper.');
|
||||
await this.restartWebhelper();
|
||||
}
|
||||
}
|
||||
|
||||
async callServerMethod(methodName: string, args = {}) {
|
||||
@@ -559,7 +611,6 @@ class PluginLoader extends Logger {
|
||||
method = request.method;
|
||||
delete req.method;
|
||||
}
|
||||
// this is terrible but a. we're going to redo this entire method anyway and b. it was already terrible
|
||||
try {
|
||||
const ret = await DeckyBackend.call<
|
||||
[method: string, url: string, extra_opts?: any],
|
||||
|
||||
@@ -6,6 +6,7 @@ export enum PluginLoadType {
|
||||
export interface Plugin {
|
||||
name: string;
|
||||
version?: string;
|
||||
loadType?: PluginLoadType;
|
||||
icon: JSX.Element;
|
||||
content?: JSX.Element;
|
||||
onDismount?(): void;
|
||||
|
||||
+288
-108
@@ -1,5 +1,14 @@
|
||||
import { ErrorBoundary, Focusable, Patch, afterPatch } from '@decky/ui';
|
||||
import { FC, ReactElement, ReactNode, cloneElement, createElement, memo } from 'react';
|
||||
import {
|
||||
ErrorBoundary,
|
||||
Patch,
|
||||
afterPatch,
|
||||
findInReactTree,
|
||||
findInTree,
|
||||
findModuleByExport,
|
||||
getReactRoot,
|
||||
sleep,
|
||||
} from '@decky/ui';
|
||||
import { FC, ReactElement, ReactNode, cloneElement, createElement } from 'react';
|
||||
import type { Route } from 'react-router';
|
||||
|
||||
import {
|
||||
@@ -22,16 +31,26 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export enum UIMode {
|
||||
BigPicture = 4,
|
||||
Desktop = 7,
|
||||
}
|
||||
|
||||
const isPatched = Symbol('is patched');
|
||||
|
||||
class RouterHook extends Logger {
|
||||
private router: any;
|
||||
private memoizedRouter: any;
|
||||
private gamepadWrapper: any;
|
||||
private routerState: DeckyRouterState = new DeckyRouterState();
|
||||
private globalComponentsState: DeckyGlobalComponentsState = new DeckyGlobalComponentsState();
|
||||
private wrapperPatch: Patch;
|
||||
private routerPatch?: Patch;
|
||||
private renderedComponents: ReactElement[] = [];
|
||||
private Route: any;
|
||||
private DeckyGamepadRouterWrapper = this.gamepadRouterWrapper.bind(this);
|
||||
private DeckyDesktopRouterWrapper = this.desktopRouterWrapper.bind(this);
|
||||
private DeckyGlobalComponentsWrapper = this.globalComponentsWrapper.bind(this);
|
||||
private toReplace = new Map<string, ReactNode>();
|
||||
private desktopRouterPatch?: Patch;
|
||||
private gamepadRouterPatch?: Patch;
|
||||
private modeChangeRegistration?: any;
|
||||
private patchedModes = new Set<number>();
|
||||
public routes?: any[];
|
||||
|
||||
constructor() {
|
||||
@@ -41,112 +60,272 @@ class RouterHook extends Logger {
|
||||
window.__ROUTER_HOOK_INSTANCE?.deinit?.();
|
||||
window.__ROUTER_HOOK_INSTANCE = this;
|
||||
|
||||
this.gamepadWrapper = Focusable;
|
||||
|
||||
let Route: new () => Route;
|
||||
// Used to store the new replicated routes we create to allow routes to be unpatched.
|
||||
const processList = (
|
||||
routeList: any[],
|
||||
routes: Map<string, RouterEntry> | null,
|
||||
routePatches: Map<string, Set<RoutePatch>>,
|
||||
save: boolean,
|
||||
) => {
|
||||
this.debug('Route list: ', routeList);
|
||||
if (save) this.routes = routeList;
|
||||
let routerIndex = routeList.length;
|
||||
if (routes) {
|
||||
if (!routeList[routerIndex - 1]?.length || routeList[routerIndex - 1]?.length !== routes.size) {
|
||||
if (routeList[routerIndex - 1]?.length && routeList[routerIndex - 1].length !== routes.size) routerIndex--;
|
||||
const newRouterArray: (ReactElement | JSX.Element)[] = [];
|
||||
routes.forEach(({ component, props }, path) => {
|
||||
newRouterArray.push(
|
||||
<Route path={path} {...props}>
|
||||
<ErrorBoundary>{createElement(component)}</ErrorBoundary>
|
||||
</Route>,
|
||||
);
|
||||
});
|
||||
routeList[routerIndex] = newRouterArray;
|
||||
}
|
||||
const reactRouterStackModule = findModuleByExport((e) => e == 'router-backstack', 20);
|
||||
if (reactRouterStackModule) {
|
||||
this.Route =
|
||||
Object.values(reactRouterStackModule).find(
|
||||
(e) => typeof e == 'function' && /routePath:.\.match\?\.path./.test(e.toString()),
|
||||
) ||
|
||||
Object.values(reactRouterStackModule).find(
|
||||
(e) => typeof e == 'function' && /routePath:null===\(.=.\.match\)/.test(e.toString()),
|
||||
);
|
||||
if (!this.Route) {
|
||||
this.error('Failed to find Route component');
|
||||
}
|
||||
routeList.forEach((route: Route, index: number) => {
|
||||
const replaced = toReplace.get(route?.props?.path as string);
|
||||
if (replaced) {
|
||||
routeList[index].props.children = replaced;
|
||||
toReplace.delete(route?.props?.path as string);
|
||||
}
|
||||
if (route?.props?.path && routePatches.has(route.props.path as string)) {
|
||||
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;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.error('Failed to find router stack module');
|
||||
}
|
||||
|
||||
this.modeChangeRegistration = SteamClient.UI.RegisterForUIModeChanged((mode: UIMode) => {
|
||||
this.debug(`UI mode changed to ${mode}`);
|
||||
if (this.patchedModes.has(mode)) return;
|
||||
this.patchedModes.add(mode);
|
||||
this.debug(`Patching router for UI mode ${mode}`);
|
||||
switch (mode) {
|
||||
case UIMode.BigPicture:
|
||||
this.debug('Patching gamepad router');
|
||||
this.patchGamepadRouter();
|
||||
break;
|
||||
// Not fully implemented yet
|
||||
// case UIMode.Desktop:
|
||||
// this.debug("Patching desktop router");
|
||||
// this.patchDesktopRouter();
|
||||
// break;
|
||||
default:
|
||||
this.warn(`Router patch not implemented for UI mode ${mode}`);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async patchGamepadRouter() {
|
||||
const root = getReactRoot(document.getElementById('root') as any);
|
||||
const findRouterNode = () =>
|
||||
findInReactTree(
|
||||
root,
|
||||
(node) =>
|
||||
typeof node?.pendingProps?.loggedIn == 'undefined' && node?.type?.toString().includes('Settings.Root()'),
|
||||
);
|
||||
await this.waitForUnlock();
|
||||
let routerNode = findRouterNode();
|
||||
while (!routerNode) {
|
||||
this.warn('Failed to find Router node, reattempting in 5 seconds.');
|
||||
await sleep(5000);
|
||||
await this.waitForUnlock();
|
||||
routerNode = findRouterNode();
|
||||
}
|
||||
if (routerNode) {
|
||||
// Patch the component globally
|
||||
this.gamepadRouterPatch = afterPatch(routerNode.elementType, 'type', this.handleGamepadRouterRender.bind(this));
|
||||
// Swap out the current instance
|
||||
routerNode.type = routerNode.elementType.type;
|
||||
if (routerNode?.alternate) {
|
||||
routerNode.alternate.type = routerNode.type;
|
||||
}
|
||||
// Force a full rerender via our custom error boundary
|
||||
const errorBoundaryNode = findInTree(routerNode, (e) => e?.stateNode?._deckyForceRerender, {
|
||||
walkable: ['return'],
|
||||
});
|
||||
};
|
||||
let toReplace = new Map<string, ReactNode>();
|
||||
const DeckyWrapper = ({ children }: { children: ReactElement }) => {
|
||||
const { routes, routePatches } = useDeckyRouterState();
|
||||
const mainRouteList = children.props.children[0].props.children;
|
||||
const ingameRouteList = children.props.children[1].props.children; // /appoverlay and /apprunning
|
||||
processList(mainRouteList, routes, routePatches, true);
|
||||
processList(ingameRouteList, null, routePatches, false);
|
||||
errorBoundaryNode?.stateNode?._deckyForceRerender?.();
|
||||
}
|
||||
}
|
||||
|
||||
this.debug('Rerendered routes list');
|
||||
return children;
|
||||
};
|
||||
|
||||
let renderedComponents: ReactElement[] = [];
|
||||
|
||||
const DeckyGlobalComponentsWrapper = () => {
|
||||
const { components } = useDeckyGlobalComponentsState();
|
||||
if (renderedComponents.length != components.size) {
|
||||
this.debug('Rerendering global components');
|
||||
renderedComponents = Array.from(components.values()).map((GComponent) => <GComponent />);
|
||||
// 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:'));
|
||||
let routerNode = findRouterNode();
|
||||
while (!routerNode) {
|
||||
this.warn('Failed to find 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;
|
||||
}
|
||||
return <>{renderedComponents}</>;
|
||||
};
|
||||
// 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?.();
|
||||
}
|
||||
}
|
||||
|
||||
this.wrapperPatch = afterPatch(this.gamepadWrapper, 'render', (_: any, ret: any) => {
|
||||
if (ret?.props?.children?.props?.children?.length == 5 || ret?.props?.children?.props?.children?.length == 4) {
|
||||
const idx = ret?.props?.children?.props?.children?.length == 4 ? 1 : 2;
|
||||
const potentialSettingsRootString =
|
||||
ret.props.children.props.children[idx]?.props?.children?.[0]?.type?.type?.toString() || '';
|
||||
if (potentialSettingsRootString?.includes('Settings.Root()')) {
|
||||
if (!this.router) {
|
||||
this.router = ret.props.children.props.children[idx]?.props?.children?.[0]?.type;
|
||||
this.routerPatch = afterPatch(this.router, 'type', (_: any, ret: any) => {
|
||||
if (!Route)
|
||||
Route = ret.props.children[0].props.children.find((x: any) => x.props.path == '/createaccount').type;
|
||||
const returnVal = (
|
||||
<DeckyRouterStateContextProvider deckyRouterState={this.routerState}>
|
||||
<DeckyWrapper>{ret}</DeckyWrapper>
|
||||
</DeckyRouterStateContextProvider>
|
||||
);
|
||||
return returnVal;
|
||||
});
|
||||
this.memoizedRouter = memo(this.router.type);
|
||||
this.memoizedRouter.isDeckyRouter = true;
|
||||
}
|
||||
ret.props.children.props.children.push(
|
||||
<DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}>
|
||||
<DeckyGlobalComponentsWrapper />
|
||||
</DeckyGlobalComponentsStateContextProvider>,
|
||||
);
|
||||
ret.props.children.props.children[idx].props.children[0].type = this.memoizedRouter;
|
||||
}
|
||||
public async waitForUnlock() {
|
||||
try {
|
||||
while (window?.securitystore?.IsLockScreenActive?.()) {
|
||||
await sleep(500);
|
||||
}
|
||||
} catch (e) {
|
||||
this.warn('Error while checking if unlocked:', e);
|
||||
}
|
||||
}
|
||||
|
||||
public handleDesktopRouterRender(_: any, ret: any) {
|
||||
const DeckyDesktopRouterWrapper = this.DeckyDesktopRouterWrapper;
|
||||
const DeckyGlobalComponentsWrapper = this.DeckyGlobalComponentsWrapper;
|
||||
this.debug('desktop router render', ret);
|
||||
if (ret._decky) {
|
||||
return ret;
|
||||
}
|
||||
const returnVal = (
|
||||
<>
|
||||
<DeckyRouterStateContextProvider deckyRouterState={this.routerState}>
|
||||
<DeckyDesktopRouterWrapper>{ret}</DeckyDesktopRouterWrapper>
|
||||
</DeckyRouterStateContextProvider>
|
||||
<DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}>
|
||||
<DeckyGlobalComponentsWrapper />
|
||||
</DeckyGlobalComponentsStateContextProvider>
|
||||
</>
|
||||
);
|
||||
(returnVal as any)._decky = true;
|
||||
return returnVal;
|
||||
}
|
||||
|
||||
public handleGamepadRouterRender(_: any, ret: any) {
|
||||
const DeckyGamepadRouterWrapper = this.DeckyGamepadRouterWrapper;
|
||||
const DeckyGlobalComponentsWrapper = this.DeckyGlobalComponentsWrapper;
|
||||
if (ret._decky) {
|
||||
return ret;
|
||||
}
|
||||
const returnVal = (
|
||||
<>
|
||||
<DeckyRouterStateContextProvider deckyRouterState={this.routerState}>
|
||||
<DeckyGamepadRouterWrapper>{ret}</DeckyGamepadRouterWrapper>
|
||||
</DeckyRouterStateContextProvider>
|
||||
<DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}>
|
||||
<DeckyGlobalComponentsWrapper />
|
||||
</DeckyGlobalComponentsStateContextProvider>
|
||||
</>
|
||||
);
|
||||
(returnVal as any)._decky = true;
|
||||
return returnVal;
|
||||
}
|
||||
|
||||
private globalComponentsWrapper() {
|
||||
const { components } = useDeckyGlobalComponentsState();
|
||||
if (this.renderedComponents.length != components.size) {
|
||||
this.debug('Rerendering global components');
|
||||
this.renderedComponents = Array.from(components.values()).map((GComponent) => <GComponent />);
|
||||
}
|
||||
return <>{this.renderedComponents}</>;
|
||||
}
|
||||
|
||||
private gamepadRouterWrapper({ children }: { children: ReactElement }) {
|
||||
// Used to store the new replicated routes we create to allow routes to be unpatched.
|
||||
|
||||
const { routes, routePatches } = useDeckyRouterState();
|
||||
// TODO make more redundant
|
||||
if (!children?.props?.children?.[0]?.props?.children) {
|
||||
this.debug('routerWrapper wrong component?', children);
|
||||
return children;
|
||||
}
|
||||
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.debug('Rerendered gamepadui routes list');
|
||||
return children;
|
||||
}
|
||||
|
||||
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(
|
||||
children,
|
||||
(node) => node?.length > 2 && node?.find((elem: any) => elem?.props?.path == '/library/home'),
|
||||
);
|
||||
if (!routeList) {
|
||||
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.debug('library', library);
|
||||
this.processList(library.children, routes, routePatches, true);
|
||||
|
||||
this.debug('Rerendered desktop routes list');
|
||||
return children;
|
||||
}
|
||||
|
||||
private processList(
|
||||
routeList: any[],
|
||||
routes: Map<string, RouterEntry> | null,
|
||||
routePatches: Map<string, Set<RoutePatch>>,
|
||||
save: boolean,
|
||||
) {
|
||||
const Route = this.Route;
|
||||
this.debug('Route list: ', routeList);
|
||||
if (save) this.routes = routeList;
|
||||
let routerIndex = routeList.length;
|
||||
if (routes) {
|
||||
if (!routeList[routerIndex - 1]?.length || routeList[routerIndex - 1]?.length !== routes.size) {
|
||||
if (routeList[routerIndex - 1]?.length && routeList[routerIndex - 1].length !== routes.size) routerIndex--;
|
||||
const newRouterArray: (ReactElement | JSX.Element)[] = [];
|
||||
routes.forEach(({ component, props }, path) => {
|
||||
newRouterArray.push(
|
||||
<Route path={path} {...props}>
|
||||
<ErrorBoundary>{createElement(component)}</ErrorBoundary>
|
||||
</Route>,
|
||||
);
|
||||
});
|
||||
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;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -175,8 +354,9 @@ class RouterHook extends Logger {
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.wrapperPatch.unpatch();
|
||||
this.routerPatch?.unpatch();
|
||||
this.modeChangeRegistration?.unregister();
|
||||
this.gamepadRouterPatch?.unpatch();
|
||||
this.desktopRouterPatch?.unpatch();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// import reloadFix from './reload';
|
||||
import restartFix from './restart';
|
||||
// import restartFix from './restart';
|
||||
import cefSocketFix from './socket';
|
||||
|
||||
let fixes: Function[] = [];
|
||||
|
||||
export function deinitSteamFixes() {
|
||||
@@ -7,6 +8,6 @@ export function deinitSteamFixes() {
|
||||
}
|
||||
|
||||
export async function initSteamFixes() {
|
||||
// fixes.push(await reloadFix());
|
||||
fixes.push(await restartFix());
|
||||
fixes.push(cefSocketFix());
|
||||
// fixes.push(await restartFix());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import Logger from '../logger';
|
||||
|
||||
const logger = new Logger('CEFSocketFix');
|
||||
|
||||
const closeCEFSocket = DeckyBackend.callable<[], void>('utilities/close_cef_socket');
|
||||
|
||||
export default function cefSocketFix() {
|
||||
const reg = window.SteamClient?.User?.RegisterForShutdownStart(async () => {
|
||||
logger.log('Closing CEF socket before shutdown');
|
||||
await closeCEFSocket();
|
||||
});
|
||||
|
||||
if (reg) logger.debug('CEF shutdown handler ready');
|
||||
|
||||
return () => reg?.unregister();
|
||||
}
|
||||
+34
-75
@@ -1,5 +1,14 @@
|
||||
// TabsHook for versions after the Desktop merge
|
||||
import { ErrorBoundary, Patch, QuickAccessTab, afterPatch, findInReactTree, getReactRoot, sleep } from '@decky/ui';
|
||||
import {
|
||||
ErrorBoundary,
|
||||
Patch,
|
||||
QuickAccessTab,
|
||||
afterPatch,
|
||||
createReactTreePatcher,
|
||||
findInReactTree,
|
||||
findModuleByExport,
|
||||
getReactRoot,
|
||||
} from '@decky/ui';
|
||||
|
||||
import { QuickAccessVisibleStateProvider } from './components/QuickAccessVisibleState';
|
||||
import Logger from './logger';
|
||||
@@ -20,7 +29,6 @@ interface Tab {
|
||||
class TabsHook extends Logger {
|
||||
// private keys = 7;
|
||||
tabs: Tab[] = [];
|
||||
private qAMRoot?: any;
|
||||
private qamPatch?: Patch;
|
||||
|
||||
constructor() {
|
||||
@@ -32,87 +40,38 @@ class TabsHook extends Logger {
|
||||
}
|
||||
|
||||
init() {
|
||||
const tree = getReactRoot(document.getElementById('root') as any);
|
||||
let qAMRoot: any;
|
||||
const findQAMRoot = (currentNode: any, iters: number): any => {
|
||||
if (iters >= 80) {
|
||||
// currently 67
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
(typeof currentNode?.memoizedProps?.visible == 'boolean' ||
|
||||
typeof currentNode?.memoizedProps?.active == 'boolean') &&
|
||||
currentNode?.type?.toString()?.includes('QuickAccessMenuBrowserView')
|
||||
) {
|
||||
this.log(`QAM root was found in ${iters} recursion cycles`);
|
||||
return currentNode;
|
||||
}
|
||||
if (currentNode.child) {
|
||||
let node = findQAMRoot(currentNode.child, iters + 1);
|
||||
if (node !== null) return node;
|
||||
}
|
||||
if (currentNode.sibling) {
|
||||
let node = findQAMRoot(currentNode.sibling, iters + 1);
|
||||
if (node !== null) return node;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
(async () => {
|
||||
qAMRoot = findQAMRoot(tree, 0);
|
||||
while (!qAMRoot) {
|
||||
this.error(
|
||||
'Failed to find QAM root node, reattempting in 5 seconds. A developer may need to increase the recursion limit.',
|
||||
);
|
||||
await sleep(5000);
|
||||
qAMRoot = findQAMRoot(tree, 0);
|
||||
}
|
||||
this.qAMRoot = qAMRoot;
|
||||
let patchedInnerQAM: any;
|
||||
this.qamPatch = afterPatch(qAMRoot.return, 'type', (_: any, ret: any) => {
|
||||
try {
|
||||
if (!qAMRoot?.child) {
|
||||
qAMRoot = findQAMRoot(tree, 0);
|
||||
this.qAMRoot = qAMRoot;
|
||||
}
|
||||
if (qAMRoot?.child && !qAMRoot?.child?.type?.decky) {
|
||||
afterPatch(qAMRoot.child, 'type', (_: any, ret: any) => {
|
||||
try {
|
||||
const qamTabsRenderer = findInReactTree(ret, (x) => x?.props?.onFocusNavDeactivated);
|
||||
if (patchedInnerQAM) {
|
||||
qamTabsRenderer.type = patchedInnerQAM;
|
||||
} else {
|
||||
afterPatch(qamTabsRenderer, 'type', (innerArgs: any, ret: any) => {
|
||||
const tabs = findInReactTree(ret, (x) => x?.props?.tabs);
|
||||
this.render(tabs.props.tabs, innerArgs[0].visible);
|
||||
return ret;
|
||||
});
|
||||
patchedInnerQAM = qamTabsRenderer.type;
|
||||
}
|
||||
} catch (e) {
|
||||
this.error('Error patching QAM inner', e);
|
||||
}
|
||||
return ret;
|
||||
});
|
||||
qAMRoot.child.type.decky = true;
|
||||
qAMRoot.child.alternate.type = qAMRoot.child.type;
|
||||
}
|
||||
} catch (e) {
|
||||
this.error('Error patching QAM', e);
|
||||
}
|
||||
// TODO patch the "embedded" renderer in this module too (seems to be for VR? unsure)
|
||||
const qamModule = findModuleByExport((e) => e?.type?.toString()?.includes('QuickAccessMenuBrowserView'));
|
||||
const qamRenderer = Object.values(qamModule).find((e: any) =>
|
||||
e?.type?.toString()?.includes('QuickAccessMenuBrowserView'),
|
||||
);
|
||||
|
||||
const patchHandler = createReactTreePatcher(
|
||||
[(tree) => findInReactTree(tree, (node) => node?.props?.onFocusNavDeactivated)],
|
||||
(args, ret) => {
|
||||
const tabs = findInReactTree(ret, (x) => x?.props?.tabs);
|
||||
this.render(tabs.props.tabs, args[0].visible);
|
||||
return ret;
|
||||
});
|
||||
},
|
||||
'TabsHook',
|
||||
);
|
||||
|
||||
if (qAMRoot.return.alternate) {
|
||||
qAMRoot.return.alternate.type = qAMRoot.return.type;
|
||||
this.qamPatch = afterPatch(qamRenderer, 'type', patchHandler);
|
||||
|
||||
// Patch already rendered qam
|
||||
const root = getReactRoot(document.getElementById('root') as any);
|
||||
const qamNode = root && findInReactTree(root, (n: any) => n.elementType == qamRenderer); // need elementType, because type is actually mobx wrapper
|
||||
if (qamNode) {
|
||||
// Only affects this fiber node so we don't need to unpatch here
|
||||
qamNode.type = qamNode.elementType.type;
|
||||
if (qamNode?.alternate) {
|
||||
qamNode.alternate.type = qamNode.type;
|
||||
}
|
||||
this.log('Finished initial injection');
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.qamPatch?.unpatch();
|
||||
this.qAMRoot.return.alternate.type = this.qAMRoot.return.type;
|
||||
}
|
||||
|
||||
add(tab: Tab) {
|
||||
|
||||
+85
-159
@@ -1,19 +1,16 @@
|
||||
import type { ToastData } from '@decky/api';
|
||||
import {
|
||||
Export,
|
||||
Patch,
|
||||
afterPatch,
|
||||
findClassByName,
|
||||
findInReactTree,
|
||||
findModuleExport,
|
||||
getReactRoot,
|
||||
sleep,
|
||||
} from '@decky/ui';
|
||||
import { ReactNode } from 'react';
|
||||
import type { ToastData, ToastNotification } from '@decky/api';
|
||||
import { ErrorBoundary, Patch, callOriginal, findModuleExport, injectFCTrampoline, replacePatch } from '@decky/ui';
|
||||
|
||||
import Toast from './components/Toast';
|
||||
import Logger from './logger';
|
||||
|
||||
// TODO export
|
||||
enum ToastType {
|
||||
New,
|
||||
Update,
|
||||
Remove,
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__TOASTER_INSTANCE: any;
|
||||
@@ -23,176 +20,105 @@ declare global {
|
||||
}
|
||||
|
||||
class Toaster extends Logger {
|
||||
// private routerHook: RouterHook;
|
||||
// private toasterState: DeckyToasterState = new DeckyToasterState();
|
||||
private node: any;
|
||||
private rNode: any;
|
||||
private audioModule: any;
|
||||
private finishStartup?: () => void;
|
||||
private ready: Promise<void> = new Promise((res) => (this.finishStartup = res));
|
||||
private toasterPatch?: Patch;
|
||||
private toastPatch?: Patch;
|
||||
|
||||
constructor() {
|
||||
super('Toaster');
|
||||
// this.routerHook = routerHook;
|
||||
|
||||
window.__TOASTER_INSTANCE?.deinit?.();
|
||||
window.__TOASTER_INSTANCE = this;
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
// this.routerHook.addGlobalComponent('DeckyToaster', () => (
|
||||
// <DeckyToasterStateContextProvider deckyToasterState={this.toasterState}>
|
||||
// <DeckyToaster />
|
||||
// </DeckyToasterStateContextProvider>
|
||||
// ));
|
||||
let instance: any;
|
||||
const tree = getReactRoot(document.getElementById('root') as any);
|
||||
const toasterClass1 = findClassByName('GamepadToastPlaceholder');
|
||||
const toasterClass2 = findClassByName('ToastPlaceholder');
|
||||
const toasterClass3 = findClassByName('ToastPopup');
|
||||
const toasterClass4 = findClassByName('GamepadToastPopup');
|
||||
const findToasterRoot = (currentNode: any, iters: number): any => {
|
||||
if (iters >= 80) {
|
||||
// currently 66
|
||||
return null;
|
||||
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[]) => {
|
||||
if (args?.[0]?.group?.decky || args?.[0]?.group?.notifications?.[0]?.decky) {
|
||||
return args[0].group.notifications.map((notification: any) => (
|
||||
<ErrorBoundary>
|
||||
<Toast toast={notification.data} newIndicator={notification.bNewIndicator} location={args?.[0]?.location} />
|
||||
</ErrorBoundary>
|
||||
));
|
||||
}
|
||||
if (
|
||||
currentNode?.memoizedProps?.className?.startsWith?.(toasterClass1) ||
|
||||
currentNode?.memoizedProps?.className?.startsWith?.(toasterClass2) ||
|
||||
currentNode?.memoizedProps?.className?.startsWith?.(toasterClass3) ||
|
||||
currentNode?.memoizedProps?.className?.startsWith?.(toasterClass4)
|
||||
) {
|
||||
this.log(`Toaster root was found in ${iters} recursion cycles`);
|
||||
return currentNode;
|
||||
}
|
||||
if (currentNode.sibling) {
|
||||
let node = findToasterRoot(currentNode.sibling, iters + 1);
|
||||
if (node !== null) return node;
|
||||
}
|
||||
if (currentNode.child) {
|
||||
let node = findToasterRoot(currentNode.child, iters + 1);
|
||||
if (node !== null) return node;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
instance = findToasterRoot(tree, 0);
|
||||
while (!instance) {
|
||||
this.warn(
|
||||
'Failed to find Toaster root node, reattempting in 5 seconds. A developer may need to increase the recursion limit.',
|
||||
);
|
||||
await sleep(5000);
|
||||
instance = findToasterRoot(tree, 0);
|
||||
}
|
||||
this.node = instance.return;
|
||||
this.rNode = findInReactTree(
|
||||
this.node.return.return,
|
||||
(node) => node?.stateNode && node.type?.InstallErrorReportingStore,
|
||||
);
|
||||
let toast: any;
|
||||
let renderedToast: ReactNode = null;
|
||||
let innerPatched: any;
|
||||
const repatch = () => {
|
||||
if (this.node && !this.node.type.decky) {
|
||||
this.toasterPatch = afterPatch(this.node, 'type', (_: any, ret: any) => {
|
||||
const inner = findInReactTree(ret.props.children, (x) => x?.props?.onDismiss);
|
||||
if (innerPatched) {
|
||||
inner.type = innerPatched;
|
||||
} else {
|
||||
afterPatch(inner, 'type', (innerArgs: any, ret: any) => {
|
||||
const currentToast = innerArgs[0]?.notification;
|
||||
if (currentToast?.decky) {
|
||||
if (currentToast == toast) {
|
||||
ret.props.children = renderedToast;
|
||||
} else {
|
||||
toast = currentToast;
|
||||
renderedToast = <Toast toast={toast.data} />;
|
||||
ret.props.children = renderedToast;
|
||||
}
|
||||
} else {
|
||||
toast = null;
|
||||
renderedToast = null;
|
||||
}
|
||||
return ret;
|
||||
});
|
||||
innerPatched = inner.type;
|
||||
}
|
||||
return ret;
|
||||
});
|
||||
this.node.type.decky = true;
|
||||
this.node.alternate.type = this.node.type;
|
||||
}
|
||||
};
|
||||
const oRender = Object.getPrototypeOf(this.rNode.stateNode).render;
|
||||
let int: number | undefined;
|
||||
this.rNode.stateNode.render = (...args: any[]) => {
|
||||
const ret = oRender.call(this.rNode.stateNode, ...args);
|
||||
if (ret && !this?.node?.return?.return) {
|
||||
int && clearInterval(int);
|
||||
int = setInterval(() => {
|
||||
const n = findToasterRoot(tree, 0);
|
||||
if (n?.return) {
|
||||
clearInterval(int);
|
||||
this.node = n.return;
|
||||
this.rNode = this.node.return;
|
||||
repatch();
|
||||
} else {
|
||||
this.error('Failed to re-grab Toaster node, trying again...');
|
||||
}
|
||||
}, 1200);
|
||||
}
|
||||
repatch();
|
||||
return ret;
|
||||
};
|
||||
|
||||
this.rNode.stateNode.shouldComponentUpdate = () => true;
|
||||
this.rNode.stateNode.forceUpdate();
|
||||
delete this.rNode.stateNode.shouldComponentUpdate;
|
||||
|
||||
this.audioModule = findModuleExport((e: Export) => e.PlayNavSound && e.RegisterCallbackOnPlaySound);
|
||||
return callOriginal;
|
||||
});
|
||||
|
||||
this.log('Initialized');
|
||||
this.finishStartup?.();
|
||||
}
|
||||
|
||||
async toast(toast: ToastData) {
|
||||
// toast.duration = toast.duration || 5e3;
|
||||
// this.toasterState.addToast(toast);
|
||||
await this.ready;
|
||||
toast(toast: ToastData): ToastNotification {
|
||||
if (toast.sound === undefined) toast.sound = 6;
|
||||
if (toast.playSound === undefined) toast.playSound = true;
|
||||
if (toast.showToast === undefined) toast.showToast = true;
|
||||
if (toast.timestamp === undefined) toast.timestamp = new Date();
|
||||
if (toast.showNewIndicator === undefined) toast.showNewIndicator = true;
|
||||
/* eType 13
|
||||
13: {
|
||||
proto: m.mu,
|
||||
fnTray: null,
|
||||
showToast: !0,
|
||||
sound: f.PN.ToastMisc,
|
||||
eFeature: l.uX
|
||||
}
|
||||
*/
|
||||
let toastData = {
|
||||
nNotificationID: window.NotificationStore.m_nNextTestNotificationID++,
|
||||
bNewIndicator: toast.showNewIndicator,
|
||||
rtCreated: Date.now(),
|
||||
eType: toast.eType || 11,
|
||||
eType: toast.eType || 13,
|
||||
eSource: 1, // Client
|
||||
nToastDurationMS: toast.duration || (toast.duration = 5e3),
|
||||
data: toast,
|
||||
decky: true,
|
||||
};
|
||||
// @ts-ignore
|
||||
toastData.data.appid = () => 0;
|
||||
if (toast.sound === undefined) toast.sound = 6;
|
||||
if (toast.playSound === undefined) toast.playSound = true;
|
||||
if (toast.showToast === undefined) toast.showToast = true;
|
||||
if (
|
||||
(window.settingsStore.settings.bDisableAllToasts && !toast.critical) ||
|
||||
(window.settingsStore.settings.bDisableToastsInGame &&
|
||||
!toast.critical &&
|
||||
window.NotificationStore.BIsUserInGame())
|
||||
)
|
||||
return;
|
||||
if (toast.playSound) this.audioModule?.PlayNavSound(toast.sound);
|
||||
if (toast.showToast) {
|
||||
window.NotificationStore.m_rgNotificationToasts.push(toastData);
|
||||
window.NotificationStore.DispatchNextToast();
|
||||
let group: any;
|
||||
function fnTray(toast: any, tray: any) {
|
||||
group = {
|
||||
eType: toast.eType,
|
||||
notifications: [toast],
|
||||
};
|
||||
tray.unshift(group);
|
||||
}
|
||||
const info = {
|
||||
showToast: toast.showToast,
|
||||
sound: toast.sound,
|
||||
eFeature: 0,
|
||||
toastDurationMS: toastData.nToastDurationMS,
|
||||
bCritical: toast.critical,
|
||||
fnTray,
|
||||
};
|
||||
const self = this;
|
||||
let expirationTimeout: number;
|
||||
const toastResult: ToastNotification = {
|
||||
data: toast,
|
||||
dismiss() {
|
||||
// it checks against the id of notifications[0]
|
||||
try {
|
||||
expirationTimeout && clearTimeout(expirationTimeout);
|
||||
group && window.NotificationStore.RemoveGroupFromTray(group);
|
||||
} catch (e) {
|
||||
self.error('Error while dismissing toast:', e);
|
||||
}
|
||||
},
|
||||
};
|
||||
if (toast.expiration) {
|
||||
expirationTimeout = setTimeout(() => {
|
||||
try {
|
||||
group && window.NotificationStore.RemoveGroupFromTray(group);
|
||||
} catch (e) {
|
||||
this.error('Error while dismissing expired toast:', e);
|
||||
}
|
||||
}, toast.expiration);
|
||||
}
|
||||
try {
|
||||
window.NotificationStore.ProcessNotification(info, toastData, ToastType.New);
|
||||
} catch (e) {
|
||||
this.error('Error while sending toast:', e);
|
||||
}
|
||||
return toastResult;
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.toasterPatch?.unpatch();
|
||||
this.node.alternate.type = this.node.type;
|
||||
delete this.rNode.stateNode.render;
|
||||
this.ready = new Promise((res) => (this.finishStartup = res));
|
||||
// this.routerHook.removeGlobalComponent('DeckyToaster');
|
||||
this.toastPatch?.unpatch();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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