diff options
33 files changed, 1271 insertions, 252 deletions
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b8b27056..59203a09 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -80,31 +80,6 @@ jobs: with: path: ./dist/PluginLoader - # release: - # name: Release the package - # if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release == 'release' }} - # needs: build - # runs-on: ubuntu-latest - - # steps: - # - name: Checkout 🧰 - # uses: actions/checkout@v3 - - # - name: Fetch package artifact ⬇️ - # uses: actions/download-artifact@v3 - # with: - # name: PluginLoader - # path: dist - - # - name: Release 📦 - # uses: softprops/action-gh-release@v1 - # if: ${{ !env.ACT }} - # with: - # name: Release ${{ steps.tag_version.outputs.new_tag }} - # tag_name: ${{ steps.tag_version.outputs.new_tag }} - # files: ./dist/PluginLoader - # generate_release_notes: true - release: name: Release stable version of the package if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release == 'release' }} @@ -133,8 +108,7 @@ jobs: id: latest_release env: token: ${{ secrets.GITHUB_TOKEN }} - # repository: "SteamDeckHomebrew/decky-loader" - repository: "TrainDoctor/decky-loader" + repository: "SteamDeckHomebrew/decky-loader" type: "nodraft" - name: Prepare tag ⚙️ @@ -144,21 +118,25 @@ jobs: echo "VERS: $VERSION" OUT="notsemver" if [[ "$VERSION" =~ "-pre" ]]; then - printf "is prerelease, bumping release\n" + printf "is prerelease, bumping to release\n" OUT=$(semver bump release "$VERSION") - printf "OUT: ${OUT}\n" + printf "OUT: ${OUT}\n"\ + printf "bumping by selected type.\n" if [[ "${{github.event.inputs.bump}}" != "none" ]]; then OUT=$(semver bump ${{github.event.inputs.bump}} "$OUT") printf "OUT: ${OUT}\n" + else + printf "no type selected, defaulting to patch.\n" + OUT=$(semver bump patch "$OUT") + printf "OUT: ${OUT}\n" fi elif [[ ! "$VERSION" =~ "-pre" ]]; then - printf "is a release, bumping as selected\n" + printf "previous tag is a release, bumping by selected type.\n" if [[ "${{github.event.inputs.bump}}" != "none" ]]; then OUT=$(semver bump ${{github.event.inputs.bump}} "$VERSION") printf "OUT: ${OUT}\n" else - printf "none bump selected, but cannot have identical tag\n" - printf "bumping patch\n" + printf "previous tag is a release, but no bump selected. Defaulting to a patch bump.\n" OUT=$(semver bump patch "$VERSION") printf "OUT: ${OUT}\n" fi @@ -182,7 +160,7 @@ jobs: files: ./dist/PluginLoader prerelease: false generate_release_notes: true - + prerelease: name: Release the pre-release version of the package if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release == 'prerelease' }} @@ -220,24 +198,35 @@ jobs: export VERSION=${{ steps.latest_release.outputs.release }} echo "VERS: $VERSION" OUT="" - if [[ ! ${{ github.event.inputs.bump }} == "none" ]]; then - printf "bumping by release then by selected\n" - OUT=$(semver bump release "$VERSION") + if [[ ! "$VERSION" =~ "-pre" ]]; then + printf "pre-release from release, bumping by selected type and prerel\n" + if [[ ! ${{ github.event.inputs.bump }} == "none" ]]; then + OUT=$(semver bump ${{github.event.inputs.bump}} "$VERSION") printf "OUT: ${OUT}\n" - OUT=$(semver bump ${{github.event.inputs.bump}} "$OUT") + else + printf "type not selected, defaulting to patch\n" + OUT=$(semver bump patch "$VERSION") printf "OUT: ${OUT}\n" - if [[ ! "$OUT" =~ "-pre" ]]; then - printf "appending -pre to new prerelease\n" - OUT="${OUT}-pre" - printf "OUT: ${OUT}\n" - fi - fi - if [[ "$OUT" == "" ]]; then - OUT=$(semver bump prerel "$VERSION") - else + fi + OUT="$OUT-pre" OUT=$(semver bump prerel "$OUT") + printf "OUT: ${OUT}\n" + elif [[ "$VERSION" =~ "-pre" ]]; then + printf "pre-release to pre-release, bumping by selected type and or prerel version\n" + if [[ ! ${{ github.event.inputs.bump }} == "none" ]]; then + OUT=$(semver bump ${{github.event.inputs.bump}} "$VERSION") + printf "OUT: ${OUT}\n" + printf "bumping prerel\n" + OUT=$(semver bump prerel "$OUT") + printf "OUT: ${OUT}\n" + else + printf "type not selected, defaulting to new pre-release only\n" + printf "bumping prerel\n" + OUT=$(semver bump prerel "$VERSION") + printf "OUT: ${OUT}\n" + fi fi - echo "OUT: v$OUT" + printf "vOUT: v${OUT}\n" echo ::set-output name=tag_name::v$OUT - name: Push tag 📤 diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 9377a7e8..d07d7663 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -89,17 +89,6 @@ "command": "rsync -azp --delete --rsh='ssh -p ${config:deckport} ${config:deckkey}' --exclude='.git/' --exclude='.github/' --exclude='.vscode/' --exclude='frontend/' --exclude='dist/' --exclude='contrib/' --exclude='*.log' --exclude='requirements.txt' --exclude='backend/__pycache__/' --exclude='.gitignore' . deck@${config:deckip}:${config:deckdir}/homebrew/dev/pluginloader", "problemMatcher": [] }, - { - "label": "deployall", - "dependsOrder": "sequence", - "group": "none", - "dependsOn": [ - "createfolders", - "dependencies", - "deploy" - ], - "problemMatcher": [] - }, // RUN { "label": "runpydeck", @@ -107,7 +96,7 @@ "type": "shell", "group": "none", "dependsOn" : ["checkforsettings"], - "command": "ssh deck@${config:deckip} -p ${config:deckport} ${config:deckkey} 'export PLUGIN_PATH=${config:deckdir}/homebrew/dev/plugins; export CHOWN_PLUGIN_PATH=0; echo '${config:deckpass}' | sudo -SE python3 ${config:deckdir}/homebrew/dev/pluginloader/backend/main.py'", + "command": "ssh deck@${config:deckip} -p ${config:deckport} ${config:deckkey} 'export PLUGIN_PATH=${config:deckdir}/homebrew/dev/plugins; export CHOWN_PLUGIN_PATH=0; export LOG_LEVEL=DEBUG; cd ${config:deckdir}/homebrew/services; echo '${config:deckpass}' | sudo -SE python3 ${config:deckdir}/homebrew/dev/pluginloader/backend/main.py'", "problemMatcher": [] }, { @@ -126,12 +115,12 @@ "group": "none", "dependsOn": [ "buildall", - "deployall", + "deploy", ], "problemMatcher": [] }, { - "label": "allinone", + "label": "updateandrun", "detail": "Build, deploy and run", "dependsOrder": "sequence", "group": { @@ -140,7 +129,24 @@ }, "dependsOn": [ "buildall", - "deployall", + "deploy", + "runpydeck" + ], + "problemMatcher": [] + }, + { + "label": "allinone", + "detail": "Build, install dependencies, deploy and run", + "dependsOrder": "sequence", + "group": { + "kind": "build", + "isDefault": false + }, + "dependsOn": [ + "buildall", + "createfolders", + "dependencies", + "deploy", "runpydeck" ], "problemMatcher": [] @@ -1,29 +1,31 @@ -# Plugin Loader [](https://discord.gg/ZU74G2NJzk) +# Decky Loader [](https://discord.gg/ZU74G2NJzk) - + -Keep an eye on the [Wiki](https://deckbrew.xyz) for more information about Plugin Loader, documentation + tools for plugin development and more. +Keep an eye on the [Wiki](https://deckbrew.xyz) for more information about Decky Loader, documentation + tools for plugin development and more. ## Installation 1. Go into the Steam Deck Settings 2. Under System -> System Settings toggle `Enable Developer Mode` 3. Scroll the sidebar all the way down and click on `Developer` 4. Under Miscellaneous, enable `CEF Remote Debugging` -5. Click on the `STEAM` button and select `Power` -> `Switch to Desktop` -6. Make sure you have a password set with the "passwd" command in terminal to install it ([YouTube Guide](https://www.youtube.com/watch?v=1vOMYGj22rQ)). -7. Open a terminal and paste the following command into it: +5. Confirm dialog and wait for system reboot +6. Click on the `STEAM` button and select `Power` -> `Switch to Desktop` +7. Make sure you have a password set with the "passwd" command in terminal to install it ([YouTube Guide](https://www.youtube.com/watch?v=1vOMYGj22rQ)). + - It will look like the password isn't typing properly. That's normal, it's a security feature (Similar to `***` when typing passwords online) +8. Open a terminal ("Konsole" is the pre-installed terminal application) and paste the following command into it: + - For the latest release: + - `curl -L https://github.com/SteamDeckHomebrew/decky-loader/raw/main/dist/install_release.sh | sh` - For the latest pre-release: - `curl -L https://github.com/SteamDeckHomebrew/decky-loader/raw/main/dist/install_prerelease.sh | sh` - For testers/plugin developers: - `curl -L https://github.com/SteamDeckHomebrew/decky-loader/raw/main/dist/install_prerelease.sh | sh` - [Wiki Link](https://deckbrew.xyz/en/loader-dev/development) - - For the legacy version (unsupported): - - `curl -L https://github.com/SteamDeckHomebrew/decky-loader/raw/legacy/dist/install_release.sh | sh` -7. Done! Reboot back into Gaming mode and enjoy your plugins! +9. Done! Reboot back into Gaming mode and enjoy your plugins! ### Install/Uninstall Plugins -- Using the shopping bag button in the top right corner, you can go to the offical ["Plugin Store"](https://plugins.deckbrew.xyz/) -- Simply copy the plugin's folder into `~/homebrew/plugins` +- Using the shopping bag button in the top right corner of the plugin menu, you can go to the offical Plugin Store ([Web Preview](https://beta.deckbrew.xyz/)). +- Install from URL in the settings menu. - Use the settings menu to uninstall plugins, this will not remove any files made in different directories by plugins. ### Uninstall @@ -41,8 +43,8 @@ Keep an eye on the [Wiki](https://deckbrew.xyz) for more information about Plugi - There is no complete plugin development documentation yet. However a good starting point is the [Plugin Template](https://github.com/SteamDeckHomebrew/decky-plugin-template) repository. ## [Contribution](https://deckbrew.xyz/en/loader-dev/development) -- Please consult the [Wiki](https://deckbrew.xyz/en/loader-dev/development) for installing development versions of PluginLoader. - - This is also useful for Plugin Developers looking to target new but unreleased versions of PluginLoader. +- Please consult the [Wiki](https://deckbrew.xyz/en/loader-dev/development) for installing development versions of Decky Loader. + - This is also useful for Plugin Developers looking to target new but unreleased versions of Decky Loader. - [Here's how to get the Steam Deck UI on your enviroment of choice.](https://youtu.be/1IAbZte8e7E?t=112) - (The video shows Windows usage but unless you're using Arch WSL/cygwin this script is unsupported on Windows.) diff --git a/backend/browser.py b/backend/browser.py index 83c68d47..8e2209f1 100644 --- a/backend/browser.py +++ b/backend/browser.py @@ -3,19 +3,19 @@ import json # Partial imports from aiohttp import ClientSession, web -from asyncio import get_event_loop +from asyncio import get_event_loop, sleep from concurrent.futures import ProcessPoolExecutor from hashlib import sha256 from io import BytesIO from logging import getLogger -from os import path, rename, listdir +from os import R_OK, W_OK, path, rename, listdir, access, mkdir from shutil import rmtree from subprocess import call from time import time from zipfile import ZipFile # Local modules -from helpers import get_ssl_context, get_user, get_user_group +from helpers import get_ssl_context, get_user, get_user_group, download_remote_binary_to_path from injector import get_tab, inject_to_tab logger = getLogger("Browser") @@ -28,9 +28,10 @@ class PluginInstallContext: self.hash = hash class PluginBrowser: - def __init__(self, plugin_path, plugins) -> None: + def __init__(self, plugin_path, plugins, loader) -> None: self.plugin_path = plugin_path self.plugins = plugins + self.loader = loader self.install_requests = {} def _unzip_to_plugin_dir(self, zip, name, hash): @@ -39,12 +40,55 @@ class PluginBrowser: return False zip_file = ZipFile(zip) zip_file.extractall(self.plugin_path) - code_chown = call(["chown", "-R", get_user()+":"+get_user_group(), self.plugin_path]) - code_chmod = call(["chmod", "-R", "555", self.plugin_path]) + plugin_dir = self.find_plugin_folder(name) + code_chown = call(["chown", "-R", get_user()+":"+get_user_group(), plugin_dir]) + code_chmod = call(["chmod", "-R", "555", plugin_dir]) if code_chown != 0 or code_chmod != 0: logger.error(f"chown/chmod exited with a non-zero exit code (chown: {code_chown}, chmod: {code_chmod})") return False return True + + async def _download_remote_binaries_for_plugin_with_name(self, pluginBasePath): + rv = False + try: + packageJsonPath = path.join(pluginBasePath, 'package.json') + pluginBinPath = path.join(pluginBasePath, 'bin') + + if access(packageJsonPath, R_OK): + with open(packageJsonPath, 'r') as f: + packageJson = json.load(f) + if len(packageJson["remote_binary"]) > 0: + # create bin directory if needed. + rc=call(["chmod", "-R", "777", pluginBasePath]) + if access(pluginBasePath, W_OK): + + if not path.exists(pluginBinPath): + mkdir(pluginBinPath) + + if not access(pluginBinPath, W_OK): + rc=call(["chmod", "-R", "777", pluginBinPath]) + + rv = True + for remoteBinary in packageJson["remote_binary"]: + # Required Fields. If any Remote Binary is missing these fail the install. + binName = remoteBinary["name"] + binURL = remoteBinary["url"] + binHash = remoteBinary["sha256hash"] + if not await download_remote_binary_to_path(binURL, binHash, path.join(pluginBinPath, binName)): + rv = False + raise Exception(f"Error Downloading Remote Binary {binName}@{binURL} with hash {binHash} to {path.join(pluginBinPath, binName)}") + + code_chown = call(["chown", "-R", get_user()+":"+get_user_group(), self.plugin_path]) + rc=call(["chmod", "-R", "555", pluginBasePath]) + else: + rv = True + logger.debug(f"No Remote Binaries to Download") + + except Exception as e: + rv = False + logger.debug(str(e)) + + return rv def find_plugin_folder(self, name): for folder in listdir(self.plugin_path): @@ -58,6 +102,8 @@ class PluginBrowser: logger.debug(f"skipping {folder}") async def uninstall_plugin(self, name): + if self.loader.watcher: + self.loader.watcher.disabled = True tab = await get_tab("SP") try: logger.info("uninstalling " + name) @@ -74,8 +120,12 @@ class PluginBrowser: except Exception as e: logger.error(f"Plugin {name} in {self.find_plugin_folder(name)} was not uninstalled") logger.error(f"Error at %s", exc_info=e) + if self.loader.watcher: + self.loader.watcher.disabled = False async def _install(self, artifact, name, version, hash): + if self.loader.watcher: + self.loader.watcher.disabled = True try: await self.uninstall_plugin(name) except: @@ -92,10 +142,22 @@ class PluginBrowser: logger.debug("Unzipping...") ret = self._unzip_to_plugin_dir(res_zip, name, hash) if ret: - logger.info(f"Installed {name} (Version: {version})") - await inject_to_tab("SP", "window.syncDeckyPlugins()") + plugin_dir = self.find_plugin_folder(name) + ret = await self._download_remote_binaries_for_plugin_with_name(plugin_dir) + if ret: + logger.info(f"Installed {name} (Version: {version})") + if name in self.loader.plugins: + self.loader.plugins[name].stop() + self.loader.plugins.pop(name, None) + await sleep(1) + self.loader.import_plugin(path.join(plugin_dir, "main.py"), plugin_dir) + # await inject_to_tab("SP", "window.syncDeckyPlugins()") + else: + logger.fatal(f"Failed Downloading Remote Binaries") else: self.log.fatal(f"SHA-256 Mismatch!!!! {name} (Version: {version})") + if self.loader.watcher: + self.loader.watcher.disabled = False else: logger.fatal(f"Could not fetch from URL. {await res.text()}") diff --git a/backend/helpers.py b/backend/helpers.py index c54139cc..0b6e7746 100644 --- a/backend/helpers.py +++ b/backend/helpers.py @@ -2,11 +2,15 @@ import re import ssl import subprocess import uuid +import os from subprocess import check_output from time import sleep +from hashlib import sha256 +from io import BytesIO import certifi from aiohttp.web import Response, middleware +from aiohttp import ClientSession REMOTE_DEBUGGER_UNIT = "steam-web-debug-portforward.service" @@ -27,7 +31,7 @@ def get_csrf_token(): @middleware async def csrf_middleware(request, handler): - if str(request.method) == "OPTIONS" or request.headers.get('Authentication') == csrf_token or str(request.rel_url) == "/auth/token" or str(request.rel_url).startswith("/plugins/load_main/") or str(request.rel_url).startswith("/static/") or str(request.rel_url).startswith("/legacy/") or str(request.rel_url).startswith("/steam_resource/") or assets_regex.match(str(request.rel_url)) or frontend_regex.match(str(request.rel_url)): + if str(request.method) == "OPTIONS" or request.headers.get('Authentication') == csrf_token or str(request.rel_url) == "/auth/token" or str(request.rel_url).startswith("/plugins/load_main/") or str(request.rel_url).startswith("/static/") or str(request.rel_url).startswith("/legacy/") or str(request.rel_url).startswith("/steam_resource/") or str(request.rel_url).startswith("/frontend/") or assets_regex.match(str(request.rel_url)) or frontend_regex.match(str(request.rel_url)): return await handler(request) return Response(text='Forbidden', status='403') @@ -83,6 +87,30 @@ def get_homebrew_path(home_path = None) -> str: return str(home_path+"/homebrew") # return str(home_path+"/homebrew") +# Download Remote Binaries to local Plugin +async def download_remote_binary_to_path(url, binHash, path) -> bool: + rv = False + try: + if os.access(os.path.dirname(path), os.W_OK): + async with ClientSession() as client: + res = await client.get(url, ssl=get_ssl_context()) + if res.status == 200: + data = BytesIO(await res.read()) + remoteHash = sha256(data.getbuffer()).hexdigest() + if binHash == remoteHash: + data.seek(0) + with open(path, 'wb') as f: + f.write(data.getbuffer()) + rv = True + else: + raise Exception(f"Fatal Error: Hash Mismatch for remote binary {path}@{url}") + else: + rv = False + except: + rv = False + + return rv + async def is_systemd_unit_active(unit_name: str) -> bool: res = subprocess.run(["systemctl", "is-active", unit_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return res.returncode == 0 diff --git a/backend/loader.py b/backend/loader.py index 9c2b33f4..b4559180 100644 --- a/backend/loader.py +++ b/backend/loader.py @@ -25,8 +25,11 @@ class FileChangeHandler(RegexMatchingEventHandler): self.logger = getLogger("file-watcher") self.plugin_path = plugin_path self.queue = queue + self.disabled = True def maybe_reload(self, src_path): + if self.disabled: + return plugin_dir = Path(path.relpath(src_path, self.plugin_path)).parts[0] if exists(path.join(self.plugin_path, plugin_dir, "plugin.json")): self.queue.put_nowait((path.join(self.plugin_path, plugin_dir, "main.py"), plugin_dir, True)) @@ -66,13 +69,17 @@ class Loader: self.plugin_path = plugin_path self.logger.info(f"plugin_path: {self.plugin_path}") self.plugins = {} + self.watcher = None + self.live_reload = live_reload if live_reload: self.reload_queue = Queue() self.observer = Observer() - self.observer.schedule(FileChangeHandler(self.reload_queue, plugin_path), self.plugin_path, recursive=True) + self.watcher = FileChangeHandler(self.reload_queue, plugin_path) + self.observer.schedule(self.watcher, self.plugin_path, recursive=True) self.observer.start() self.loop.create_task(self.handle_reloads()) + self.loop.create_task(self.enable_reload_wait()) server_instance.add_routes([ web.get("/frontend/{path:.*}", self.handle_frontend_assets), @@ -87,6 +94,12 @@ class Loader: web.get("/steam_resource/{path:.+}", self.get_steam_resource) ]) + async def enable_reload_wait(self): + if self.live_reload: + await sleep(10) + self.logger.info("Hot reload enabled") + self.watcher.disabled = False + async def handle_frontend_assets(self, request): file = path.join(path.dirname(__file__), "static", request.match_info["path"]) diff --git a/backend/main.py b/backend/main.py index 83032be3..ab64a3d9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,9 +1,14 @@ +# Change PyInstaller files permissions +import sys +from subprocess import call +if hasattr(sys, '_MEIPASS'): + call(['chmod', '-R', '755', sys._MEIPASS]) # Full imports from asyncio import get_event_loop, sleep from json import dumps, loads from logging import DEBUG, INFO, basicConfig, getLogger -from os import getenv, path -from subprocess import call +from os import getenv, chmod +from traceback import format_exc import aiohttp_cors # Partial imports @@ -70,7 +75,7 @@ class PluginManager: ) }) self.plugin_loader = Loader(self.web_app, CONFIG["plugin_path"], self.loop, CONFIG["live_reload"]) - self.plugin_browser = PluginBrowser(CONFIG["plugin_path"], self.plugin_loader.plugins) + self.plugin_browser = PluginBrowser(CONFIG["plugin_path"], self.plugin_loader.plugins, self.plugin_loader) self.settings = SettingsManager("loader", path.join(HOMEBREW_PATH, "settings")) self.utilities = Utilities(self) self.updater = Updater(self) @@ -123,9 +128,9 @@ class PluginManager: async def inject_javascript(self, request=None): try: - await inject_to_tab("SP", "try{window.deckyHasLoaded = true;(async()=>{while(!window.SP_REACT){await new Promise(r => setTimeout(r, 10))};await import('http://localhost:1337/frontend/index.js')})();}catch(e){console.error(e)}", True) + await inject_to_tab("SP", "try{if (window.deckyHasLoaded) location.reload();window.deckyHasLoaded = true;(async()=>{while(!window.SP_REACT){await new Promise(r => setTimeout(r, 10))};await import('http://localhost:1337/frontend/index.js')})();}catch(e){console.error(e)}", True) except: - logger.info("Failed to inject JavaScript into tab") + logger.info("Failed to inject JavaScript into tab\n" + format_exc()) pass def run(self): diff --git a/backend/plugin.py b/backend/plugin.py index b16d40d8..9d9a22c6 100644 --- a/backend/plugin.py +++ b/backend/plugin.py @@ -5,6 +5,8 @@ from asyncio import (Lock, get_event_loop, new_event_loop, from concurrent.futures import ProcessPoolExecutor from importlib.util import module_from_spec, spec_from_file_location from json import dumps, load, loads +from logging import getLogger +from traceback import format_exc from os import path, setgid, setuid from signal import SIGINT, signal from sys import exit @@ -40,28 +42,34 @@ class PluginWrapper: self.author = json["author"] self.flags = json["flags"] + self.log = getLogger("plugin") + self.passive = not path.isfile(self.file) def __str__(self) -> str: return self.name def _init(self): - signal(SIGINT, lambda s, f: exit(0)) + try: + signal(SIGINT, lambda s, f: exit(0)) - set_event_loop(new_event_loop()) - if self.passive: - return - setgid(0 if "root" in self.flags else 1000) - setuid(0 if "root" in self.flags else 1000) - spec = spec_from_file_location("_", self.file) - module = module_from_spec(spec) - spec.loader.exec_module(module) - self.Plugin = module.Plugin - - if hasattr(self.Plugin, "_main"): - get_event_loop().create_task(self.Plugin._main(self.Plugin)) - get_event_loop().create_task(self._setup_socket()) - get_event_loop().run_forever() + set_event_loop(new_event_loop()) + if self.passive: + return + setgid(0 if "root" in self.flags else 1000) + setuid(0 if "root" in self.flags else 1000) + spec = spec_from_file_location("_", self.file) + module = module_from_spec(spec) + spec.loader.exec_module(module) + self.Plugin = module.Plugin + + if hasattr(self.Plugin, "_main"): + get_event_loop().create_task(self.Plugin._main(self.Plugin)) + get_event_loop().create_task(self._setup_socket()) + get_event_loop().run_forever() + except: + self.log.error("Failed to start " + self.name + "!\n" + format_exc()) + exit(0) async def _setup_socket(self): self.socket = await start_unix_server(self._listen_for_method_call, path=self.socket_addr, limit=BUFFER_LIMIT) diff --git a/backend/updater.py b/backend/updater.py index ba62efd8..402c152b 100644 --- a/backend/updater.py +++ b/backend/updater.py @@ -20,7 +20,7 @@ class Updater: self.settings = self.context.settings # Exposes updater methods to frontend self.updater_methods = { - "get_branch": self.get_branch, + "get_branch": self._get_branch, "get_version": self.get_version, "do_update": self.do_update, "do_restart": self.do_restart, @@ -29,18 +29,18 @@ class Updater: self.remoteVer = None self.allRemoteVers = None try: - self.currentBranch = self.get_branch(self.context.settings) - if int(self.currentBranch) == -1: - raise ValueError("get_branch could not determine branch!") - except: - self.currentBranch = 0 - logger.error("Current branch could not be determined, defaulting to \"Stable\"") - try: + logger.info(getcwd()) with open(path.join(getcwd(), ".loader.version"), 'r') as version_file: self.localVer = version_file.readline().replace("\n", "") except: self.localVer = False + try: + self.currentBranch = self.get_branch(self.context.settings) + except: + self.currentBranch = 0 + logger.error("Current branch could not be determined, defaulting to \"Stable\"") + if context: context.web_app.add_routes([ web.post("/updater/{method_name}", self._handle_server_method_call) @@ -63,9 +63,21 @@ class Updater: res["success"] = False return web.json_response(res) - async def get_branch(self, manager: SettingsManager): - logger.debug("current branch: %i" % manager.getSetting("branch", -1)) - return manager.getSetting("branch", -1) + def get_branch(self, manager: SettingsManager): + ver = manager.getSetting("branch", -1) + logger.debug("current branch: %i" % ver) + if ver == -1: + logger.info("Current branch is not set, determining branch from version...") + if self.localVer.startswith("v") and self.localVer.find("-pre"): + logger.info("Current version determined to be pre-release") + return 1 + else: + logger.info("Current version determined to be stable") + return 0 + return ver + + async def _get_branch(self, manager: SettingsManager): + return self.get_branch(manager) async def get_version(self): if self.localVer: @@ -80,7 +92,7 @@ class Updater: async def check_for_updates(self): logger.debug("checking for updates") - selectedBranch = await self.get_branch(self.context.settings) + selectedBranch = self.get_branch(self.context.settings) async with ClientSession() as web: async with web.request("GET", "https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases", ssl=helpers.get_ssl_context()) as res: remoteVersions = await res.json() diff --git a/backend/utilities.py b/backend/utilities.py index b3431cb6..853f60d2 100644 --- a/backend/utilities.py +++ b/backend/utilities.py @@ -1,4 +1,5 @@ import uuid +import os from json.decoder import JSONDecodeError from aiohttp import ClientSession, web @@ -24,7 +25,8 @@ class Utilities: "allow_remote_debugging": self.allow_remote_debugging, "disallow_remote_debugging": self.disallow_remote_debugging, "set_setting": self.set_setting, - "get_setting": self.get_setting + "get_setting": self.get_setting, + "filepicker_ls": self.filepicker_ls } if context: @@ -166,3 +168,29 @@ class Utilities: async def disallow_remote_debugging(self): await helpers.stop_systemd_unit(helpers.REMOTE_DEBUGGER_UNIT) return True + + async def filepicker_ls(self, path, include_files=True): + # def sorter(file): # Modification time + # if os.path.isdir(os.path.join(path, file)) or os.path.isfile(os.path.join(path, file)): + # return os.path.getmtime(os.path.join(path, file)) + # return 0 + # file_names = sorted(os.listdir(path), key=sorter, reverse=True) # TODO provide more sort options + file_names = sorted(os.listdir(path)) # Alphabetical + + files = [] + + for file in file_names: + full_path = os.path.join(path, file) + is_dir = os.path.isdir(full_path) + + if is_dir or include_files: + files.append({ + "isdir": is_dir, + "name": file, + "realpath": os.path.realpath(full_path) + }) + + return { + "realpath": os.path.realpath(path), + "files": files + } diff --git a/dist/install_release.sh b/dist/install_release.sh index 009997ad..3656e10e 100644 --- a/dist/install_release.sh +++ b/dist/install_release.sh @@ -13,8 +13,13 @@ sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services" sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins" # Download latest release and install it -curl -L https://github.com/SteamDeckHomebrew/PluginLoader/releases/latest/download/PluginLoader --output "${HOMEBREW_FOLDER}/services/PluginLoader" -chmod +x "${HOMEBREW_FOLDER}/services/PluginLoader" +RELEASE=$(curl -s 'https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases' | jq -r "first(.[] | select(.prerelease == "false"))") +read VERSION DOWNLOADURL < <(echo $(jq -r '.tag_name, .assets[].browser_download_url' <<< ${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 diff --git a/frontend/package.json b/frontend/package.json index 5ce04122..1e093699 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "decky_frontend", - "version": "0.0.1", + "version": "2.1.1", "private": true, "license": "GPLV2", "scripts": { @@ -17,6 +17,7 @@ "@rollup/plugin-replace": "^4.0.0", "@rollup/plugin-typescript": "^8.3.3", "@types/react": "16.14.0", + "@types/react-file-icon": "^1.0.1", "@types/react-router": "5.1.18", "@types/webpack": "^5.28.0", "husky": "^8.0.1", @@ -27,6 +28,7 @@ "react": "16.14.0", "react-dom": "16.14.0", "rollup": "^2.76.0", + "rollup-plugin-delete": "^2.0.0", "rollup-plugin-external-globals": "^0.6.1", "rollup-plugin-polyfill-node": "^0.10.2", "tslib": "^2.4.0", @@ -39,7 +41,8 @@ } }, "dependencies": { - "decky-frontend-lib": "^2.0.0", + "decky-frontend-lib": "^3.1.3", + "react-file-icon": "^1.2.0", "react-icons": "^4.4.0", "react-markdown": "^8.0.3", "remark-gfm": "^3.0.1" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index a66dd98e..1119ec7d 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -7,9 +7,10 @@ specifiers: '@rollup/plugin-replace': ^4.0.0 '@rollup/plugin-typescript': ^8.3.3 '@types/react': 16.14.0 + '@types/react-file-icon': ^1.0.1 '@types/react-router': 5.1.18 '@types/webpack': ^5.28.0 - decky-frontend-lib: ^2.0.0 + decky-frontend-lib: ^3.1.3 husky: ^8.0.1 import-sort-style-module: ^6.0.0 inquirer: ^8.2.4 @@ -17,17 +18,20 @@ specifiers: prettier-plugin-import-sort: ^0.0.7 react: 16.14.0 react-dom: 16.14.0 + react-file-icon: ^1.2.0 react-icons: ^4.4.0 react-markdown: ^8.0.3 remark-gfm: ^3.0.1 rollup: ^2.76.0 + rollup-plugin-delete: ^2.0.0 rollup-plugin-external-globals: ^0.6.1 rollup-plugin-polyfill-node: ^0.10.2 tslib: ^2.4.0 typescript: ^4.7.4 dependencies: - decky-frontend-lib: 2.0.0 + decky-frontend-lib: 3.1.3 + react-file-icon: 1.2.0_wcqkhtmu7mswc6yz4uyexck3ty react-icons: 4.4.0_react@16.14.0 react-markdown: 8.0.3_vshvapmxg47tngu7tvrsqpq55u remark-gfm: 3.0.1 @@ -39,6 +43,7 @@ devDependencies: '@rollup/plugin-replace': 4.0.0_rollup@2.76.0 '@rollup/plugin-typescript': 8.3.3_mrkdcqv53wzt2ybukxlrvz47fu '@types/react': 16.14.0 + '@types/react-file-icon': 1.0.1 '@types/react-router': 5.1.18 '@types/webpack': 5.28.0 husky: 8.0.1 @@ -49,6 +54,7 @@ devDependencies: react: 16.14.0 react-dom: 16.14.0_react@16.14.0 rollup: 2.76.0 + rollup-plugin-delete: 2.0.0 rollup-plugin-external-globals: 0.6.1_rollup@2.76.0 rollup-plugin-polyfill-node: 0.10.2_rollup@2.76.0 tslib: 2.4.0 @@ -296,6 +302,27 @@ packages: '@jridgewell/sourcemap-codec': 1.4.14 dev: true + /@nodelib/fs.scandir/2.1.5: + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + dev: true + + /@nodelib/fs.stat/2.0.5: + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + dev: true + + /@nodelib/fs.walk/1.2.8: + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.13.0 + dev: true + /@rollup/plugin-commonjs/21.1.0_rollup@2.76.0: resolution: {integrity: sha512-6ZtHx3VHIp2ReNNDxHjuUml6ur+WcQ28N1yHgCQwsbNkQg2suhxGMDQGJOn/KuDxKtd1xuZP5xSTwBA4GQ8hbA==} engines: {node: '>= 8.0.0'} @@ -427,6 +454,13 @@ packages: resolution: {integrity: sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==} dev: true + /@types/glob/7.2.0: + resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} + dependencies: + '@types/minimatch': 5.1.2 + '@types/node': 18.0.4 + dev: true + /@types/hast/2.3.4: resolution: {integrity: sha512-wLEm0QvaoawEDoTRwzTXp4b4jpwiJDvR5KMnFnVodm3scufTlBOWRD6N1OBf9TZMhjlNsSfcO5V+7AF4+Vy+9g==} dependencies: @@ -451,6 +485,10 @@ packages: resolution: {integrity: sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==} dev: false + /@types/minimatch/5.1.2: + resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} + dev: true + /@types/ms/0.7.31: resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==} dev: false @@ -462,6 +500,12 @@ packages: /@types/prop-types/15.7.5: resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==} + /@types/react-file-icon/1.0.1: + resolution: {integrity: sha512-QTdYCkYXzh/PfKEIwcPxRdaPQkii5R4Ke7fcO+KB++IDPbYAG1jj+ulEcTA7pRf0gZ5jAvjWcTXBJJRtfYHjlw==} + dependencies: + '@types/react': 16.14.0 + dev: true + /@types/react-router/5.1.18: resolution: {integrity: sha512-YYknwy0D0iOwKQgz9v8nOzt2J6l4gouBmDnWqUUznltOTaon+r8US8ky8HvN0tXvc38U9m6z/t2RsVsnd1zM0g==} dependencies: @@ -626,6 +670,14 @@ packages: hasBin: true dev: true + /aggregate-error/3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + dev: true + /ajv-keywords/3.5.2_ajv@6.12.6: resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: @@ -675,6 +727,11 @@ packages: sprintf-js: 1.0.3 dev: true + /array-union/2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + dev: true + /bail/2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} dev: false @@ -702,6 +759,13 @@ packages: concat-map: 0.0.1 dev: true + /braces/3.0.2: + resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} + engines: {node: '>=8'} + dependencies: + fill-range: 7.0.1 + dev: true + /browserslist/4.21.2: resolution: {integrity: sha512-MonuOgAtUB46uP5CezYbRaYKBNt2LxP0yX+Pmj4LkcDFGkn9Cbpi83d9sCjwQDErXsIJSzY5oKGDbgOlF/LPAA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -786,6 +850,11 @@ packages: engines: {node: '>=6.0'} dev: true + /clean-stack/2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + dev: true + /cli-cursor/3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -875,8 +944,8 @@ packages: dependencies: ms: 2.1.2 - /decky-frontend-lib/2.0.0: - resolution: {integrity: sha512-H7+JpKHlClECVpo+MCEwej7R9wDWk9M2uMSyTvuhTfLZe3RThsxWCiqY640Cjh/zIW2A7GyVRd4SjLtn6Isdeg==} + /decky-frontend-lib/3.1.3: + resolution: {integrity: sha512-X6DGi90VdXnyoQi8Q4jEYhvBiNQualMNwXWKwgFitdor2ktNj5xp3a4uIi1ijbnS/vdW2AGKjraTfOMXtHUNAg==} dependencies: minimist: 1.2.6 dev: false @@ -898,6 +967,20 @@ packages: clone: 1.0.4 dev: true + /del/5.1.0: + resolution: {integrity: sha512-wH9xOVHnczo9jN2IW68BabcecVPxacIA3g/7z6vhSU/4stOKQzeCRK0yD0A24WiAAUJmmVpWqrERcTxnLo3AnA==} + engines: {node: '>=8'} + dependencies: + globby: 10.0.2 + graceful-fs: 4.2.10 + is-glob: 4.0.3 + is-path-cwd: 2.2.0 + is-path-inside: 3.0.3 + p-map: 3.0.0 + rimraf: 3.0.2 + slash: 3.0.0 + dev: true + /dequal/2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -913,6 +996,13 @@ packages: engines: {node: '>=0.3.1'} dev: false + /dir-glob/3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + dependencies: + path-type: 4.0.0 + dev: true + /electron-to-chromium/1.4.189: resolution: {integrity: sha512-dQ6Zn4ll2NofGtxPXaDfY2laIa6NyCQdqXYHdwH90GJQW0LpJJib0ZU/ERtbb0XkBEmUD2eJtagbOie3pdMiPg==} dev: true @@ -1015,10 +1105,27 @@ packages: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: true + /fast-glob/3.2.11: + resolution: {integrity: sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==} + engines: {node: '>=8.6.0'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.5 + dev: true + /fast-json-stable-stringify/2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} dev: true + /fastq/1.13.0: + resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==} + dependencies: + reusify: 1.0.4 + dev: true + /figures/3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -1026,6 +1133,13 @@ packages: escape-string-regexp: 1.0.5 dev: true + /fill-range/7.0.1: + resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + engines: {node: '>=8'} + dependencies: + to-regex-range: 5.0.1 + dev: true + /find-line-column/0.5.2: resolution: {integrity: sha512-eNhNkDt5RbxY4X++JwyDURP62FYhV1bh9LF4dfOiwpVCTk5vvfEANhnui5ypUEELGR02QZSrWFtaTgd4ulW5tw==} dev: true @@ -1055,6 +1169,13 @@ packages: engines: {node: '>=6.9.0'} dev: true + /glob-parent/5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + dependencies: + is-glob: 4.0.3 + dev: true + /glob-to-regexp/0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} dev: true @@ -1075,6 +1196,20 @@ packages: engines: {node: '>=4'} dev: true + /globby/10.0.2: + resolution: {integrity: sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg==} + engines: {node: '>=8'} + dependencies: + '@types/glob': 7.2.0 + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.2.11 + glob: 7.2.3 + ignore: 5.2.0 + merge2: 1.4.1 + slash: 3.0.0 + dev: true + /graceful-fs/4.2.10: resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} dev: true @@ -1117,6 +1252,11 @@ packages: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} dev: true + /ignore/5.2.0: + resolution: {integrity: sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==} + engines: {node: '>= 4'} + dev: true + /import-fresh/2.0.0: resolution: {integrity: sha512-eZ5H8rcgYazHbKC3PG4ClHNykCSxtAhxSSEM+2mb+7evD2CKF5V7c0dNum7AdpDh0ZdICwZY9sRSn8f+KH96sg==} engines: {node: '>=4'} @@ -1174,6 +1314,11 @@ packages: resolve: 1.22.1 dev: true + /indent-string/4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + dev: true + /inflight/1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} dependencies: @@ -1237,11 +1382,23 @@ packages: engines: {node: '>=0.10.0'} dev: true + /is-extglob/2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + dev: true + /is-fullwidth-code-point/3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} dev: true + /is-glob/4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + dependencies: + is-extglob: 2.1.1 + dev: true + /is-interactive/1.0.0: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} @@ -1251,6 +1408,21 @@ packages: resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} dev: true + /is-number/7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + dev: true + + /is-path-cwd/2.2.0: + resolution: {integrity: sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==} + engines: {node: '>=6'} + dev: true + + /is-path-inside/3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + dev: true + /is-plain-obj/4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -1321,6 +1493,10 @@ packages: engines: {node: '>=6.11.5'} dev: true + /lodash.uniqueid/4.0.1: + resolution: {integrity: sha512-GQQWaIeGlL6DIIr06kj1j6sSmBxyNMwI8kaX9aKpHR/XsMTiaXDVPNPAkiboOTK9OJpTJF/dXT3xYoFQnj386Q==} + dev: false + /lodash/4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} dev: true @@ -1483,6 +1659,11 @@ packages: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} dev: true + /merge2/1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + dev: true + /micromark-core-commonmark/1.0.6: resolution: {integrity: sha512-K+PkJTxqjFfSNkfAhp4GB+cZPfQd6dxtTXnf+RjZOV7T4EEXnvgzOcnp+eSTmpGk9d1S9sL6/lqrgSNn/s0HZA==} dependencies: @@ -1732,6 +1913,14 @@ packages: - supports-color dev: false + /micromatch/4.0.5: + resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} + engines: {node: '>=8.6'} + dependencies: + braces: 3.0.2 + picomatch: 2.3.1 + dev: true + /mime-db/1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -1816,6 +2005,13 @@ packages: engines: {node: '>=0.10.0'} dev: true + /p-map/3.0.0: + resolution: {integrity: sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==} + engines: {node: '>=8'} + dependencies: + aggregate-error: 3.1.0 + dev: true + /parse-json/4.0.0: resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} engines: {node: '>=4'} @@ -1833,6 +2029,11 @@ packages: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} dev: true + /path-type/4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + dev: true + /picocolors/1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} dev: true @@ -1878,6 +2079,10 @@ packages: engines: {node: '>=6'} dev: true + /queue-microtask/1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + dev: true + /randombytes/2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} dependencies: @@ -1894,7 +2099,19 @@ packages: prop-types: 15.8.1 react: 16.14.0 scheduler: 0.19.1 - dev: true + + /react-file-icon/1.2.0_wcqkhtmu7mswc6yz4uyexck3ty: + resolution: {integrity: sha512-BI8CTyZu/k8AmhjGJiGYOqgjfp2si2Lt5PUNF6kfF31c7BFYJeerpfHnZBfpPjrb2M/DAdW1qNub17Rt+xuefQ==} + peerDependencies: + react: ^18.0.0 || ^17.0.0 || ^16.2.0 + react-dom: ^18.0.0 || ^17.0.0 || ^16.2.0 + dependencies: + lodash.uniqueid: 4.0.1 + prop-types: 15.8.1 + react: 16.14.0 + react-dom: 16.14.0_react@16.14.0 + tinycolor2: 1.4.2 + dev: false /react-icons/4.4.0_react@16.14.0: resolution: {integrity: sha512-fSbvHeVYo/B5/L4VhB7sBA1i2tS8MkT0Hb9t2H1AVPkwGfVHLJCqyr2Py9dKMxsyM63Eng1GkdZfbWj+Fmv8Rg==} @@ -2012,6 +2229,25 @@ packages: signal-exit: 3.0.7 dev: true + /reusify/1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + dev: true + + /rimraf/3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + hasBin: true + dependencies: + glob: 7.2.3 + dev: true + + /rollup-plugin-delete/2.0.0: + resolution: {integrity: sha512-/VpLMtDy+8wwRlDANuYmDa9ss/knGsAgrDhM+tEwB1npHwNu4DYNmDfUL55csse/GHs9Q+SMT/rw9uiaZ3pnzA==} + engines: {node: '>=10'} + dependencies: + del: 5.1.0 + dev: true + /rollup-plugin-external-globals/0.6.1_rollup@2.76.0: resolution: {integrity: sha512-mlp3KNa5sE4Sp9UUR2rjBrxjG79OyZAh/QC18RHIjM+iYkbBwNXSo8DHRMZWtzJTrH8GxQ+SJvCTN3i14uMXIA==} peerDependencies: @@ -2046,6 +2282,12 @@ packages: engines: {node: '>=0.12.0'} dev: true + /run-parallel/1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + dependencies: + queue-microtask: 1.2.3 + dev: true + /rxjs/7.5.6: resolution: {integrity: sha512-dnyv2/YsXhnm461G+R/Pe5bWP41Nm6LBXEYWI6eiFP4fiwx6WRI/CD0zbdVAudd9xwLEF2IDcKXLHit0FYjUzw==} dependencies: @@ -2076,7 +2318,6 @@ packages: dependencies: loose-envify: 1.4.0 object-assign: 4.1.1 - dev: true /schema-utils/3.1.1: resolution: {integrity: sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==} @@ -2102,6 +2343,11 @@ packages: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} dev: true + /slash/3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + dev: true + /source-map-support/0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} dependencies: @@ -2224,6 +2470,10 @@ packages: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} dev: true + /tinycolor2/1.4.2: + resolution: {integrity: sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA==} + dev: false + /tmp/0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -2236,6 +2486,13 @@ packages: engines: {node: '>=4'} dev: true + /to-regex-range/5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + dependencies: + is-number: 7.0.0 + dev: true + /trim-lines/3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} dev: false diff --git a/frontend/rollup.config.js b/frontend/rollup.config.js index f472b816..c4bcd0a2 100644 --- a/frontend/rollup.config.js +++ b/frontend/rollup.config.js @@ -2,6 +2,7 @@ import commonjs from '@rollup/plugin-commonjs'; import json from '@rollup/plugin-json'; import { nodeResolve } from '@rollup/plugin-node-resolve'; import externalGlobals from "rollup-plugin-external-globals"; +import del from 'rollup-plugin-delete' import replace from '@rollup/plugin-replace'; import typescript from '@rollup/plugin-typescript'; import { defineConfig } from 'rollup'; @@ -9,6 +10,7 @@ import { defineConfig } from 'rollup'; export default defineConfig({ input: 'src/index.tsx', plugins: [ + del({ targets: "../backend/static/*", force: true }), commonjs(), nodeResolve(), externalGlobals({ diff --git a/frontend/src/components/Markdown.tsx b/frontend/src/components/Markdown.tsx index 7b187f14..278e49cd 100644 --- a/frontend/src/components/Markdown.tsx +++ b/frontend/src/components/Markdown.tsx @@ -1,9 +1,42 @@ -import { FunctionComponent } from 'react'; +import { Focusable } from 'decky-frontend-lib'; +import { FunctionComponent, useRef } from 'react'; import ReactMarkdown, { Options as ReactMarkdownOptions } from 'react-markdown'; import remarkGfm from 'remark-gfm'; -const Markdown: FunctionComponent<ReactMarkdownOptions> = (props) => { - return <ReactMarkdown remarkPlugins={[remarkGfm]} {...props} />; +interface MarkdownProps extends ReactMarkdownOptions { + onDismiss?: () => void; +} + +const Markdown: FunctionComponent<MarkdownProps> = (props) => { + return ( + <Focusable> + <ReactMarkdown + remarkPlugins={[remarkGfm]} + components={{ + div: (nodeProps) => <Focusable {...nodeProps.node.properties}>{nodeProps.children}</Focusable>, + a: (nodeProps) => { + const aRef = useRef<HTMLAnchorElement>(null); + return ( + // TODO fix focus ring + <Focusable + onActivate={() => {}} + onOKButton={() => { + aRef?.current?.click(); + props.onDismiss?.(); + }} + style={{ display: 'inline' }} + > + <a ref={aRef} {...nodeProps.node.properties}> + {nodeProps.children} + </a> + </Focusable> + ); + }, + }} + {...props} + /> + </Focusable> + ); }; export default Markdown; diff --git a/frontend/src/components/PluginView.tsx b/frontend/src/components/PluginView.tsx index 67a203c9..0d0a52cf 100644 --- a/frontend/src/components/PluginView.tsx +++ b/frontend/src/components/PluginView.tsx @@ -32,8 +32,8 @@ const PluginView: VFC = () => { .map(({ name, icon }) => ( <PanelSectionRow key={name}> <ButtonItem layout="below" onClick={() => setActivePlugin(name)}> - <div style={{ display: 'flex', justifyContent: 'space-between' }}> - <div>{icon}</div> + <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> + {icon} <div>{name}</div> <NotificationBadge show={updates?.has(name)} style={{ top: '-5px', right: '-5px' }} /> </div> diff --git a/frontend/src/components/WithSuspense.tsx b/frontend/src/components/WithSuspense.tsx new file mode 100644 index 00000000..402f5e5b --- /dev/null +++ b/frontend/src/components/WithSuspense.tsx @@ -0,0 +1,38 @@ +import { Focusable, SteamSpinner } from 'decky-frontend-lib'; +import { FunctionComponent, ReactElement, ReactNode, Suspense } from 'react'; + +interface WithSuspenseProps { + children: ReactNode; + route?: boolean; +} + +// Nice little wrapper around Suspense so we don't have to duplicate the styles and code for the loading spinner +const WithSuspense: FunctionComponent<WithSuspenseProps> = (props) => { + const propsCopy = { ...props }; + delete propsCopy.children; + (props.children as ReactElement)?.props && Object.assign((props.children as ReactElement).props, propsCopy); // There is probably a better way to do this but valve does it this way so ¯\_(ツ)_/¯ + return ( + <Suspense + fallback={ + <Focusable + // needed to enable focus ring so that the focus properly resets on load + onActivate={() => {}} + style={{ + overflowY: 'scroll', + backgroundColor: 'transparent', + ...(props.route && { + marginTop: '40px', + height: 'calc( 100% - 40px )', + }), + }} + > + <SteamSpinner /> + </Focusable> + } + > + {props.children} + </Suspense> + ); +}; + +export default WithSuspense; diff --git a/frontend/src/components/modals/PluginInstallModal.tsx b/frontend/src/components/modals/PluginInstallModal.tsx index 2c0c0bba..8b927523 100644 --- a/frontend/src/components/modals/PluginInstallModal.tsx +++ b/frontend/src/components/modals/PluginInstallModal.tsx @@ -1,4 +1,4 @@ -import { ModalRoot, QuickAccessTab, Router, Spinner, staticClasses } from 'decky-frontend-lib'; +import { ConfirmModal, QuickAccessTab, Router, Spinner, staticClasses } from 'decky-frontend-lib'; import { FC, useState } from 'react'; interface PluginInstallModalProps { @@ -14,13 +14,14 @@ interface PluginInstallModalProps { const PluginInstallModal: FC<PluginInstallModalProps> = ({ artifact, version, hash, onOK, onCancel, closeModal }) => { const [loading, setLoading] = useState<boolean>(false); return ( - <ModalRoot + <ConfirmModal bOKDisabled={loading} closeModal={closeModal} onOK={async () => { setLoading(true); await onOK(); setTimeout(() => Router.OpenQuickAccessMenu(QuickAccessTab.Decky), 250); + setTimeout(() => window.DeckyPluginLoader.checkPluginUpdates(), 1000); }} onCancel={async () => { await onCancel(); @@ -34,7 +35,7 @@ const PluginInstallModal: FC<PluginInstallModalProps> = ({ artifact, version, ha {!loading && '?'} </div> </div> - </ModalRoot> + </ConfirmModal> ); }; diff --git a/frontend/src/components/modals/filepicker/iconCustomizations.ts b/frontend/src/components/modals/filepicker/iconCustomizations.ts new file mode 100644 index 00000000..e09c9e67 --- /dev/null +++ b/frontend/src/components/modals/filepicker/iconCustomizations.ts @@ -0,0 +1,170 @@ +// https://codesandbox.io/s/react-file-icon-colored-tmwut?file=/src/App.js +import { FileIconProps } from 'react-file-icon'; + +type T_FileExtList = string[]; + +const styleDef: [FileIconProps, T_FileExtList][] = []; + +// video //////////////////////////////////// +const videoStyle = { + color: '#f00f0f', +}; +const videoExtList = [ + 'avi', + '3g2', + '3gp', + 'aep', + 'asf', + 'flv', + 'm4v', + 'mkv', + 'mov', + 'mp4', + 'mpeg', + 'mpg', + 'ogv', + 'pr', + 'swfw', + 'webm', + 'wmv', + 'swf', + 'rm', +]; + +styleDef.push([videoStyle, videoExtList]); + +// image //////////////////////////////////// +const imageStyle = { + color: '#d18f00', +}; + +const imageExtList = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tif', 'tiff']; + +styleDef.push([imageStyle, imageExtList]); + +// zip //////////////////////////////////// +const zipStyle = { + color: '#f7b500', + labelTextColor: '#000', + // glyphColor: "#de9400" +}; + +const zipExtList = ['zip', 'zipx', '7zip', 'tar', 'sitx', 'gz', 'rar']; + +styleDef.push([zipStyle, zipExtList]); + +// audio //////////////////////////////////// +const audioStyle = { + color: '#f00f0f', +}; + +const audioExtList = ['aac', 'aif', 'aiff', 'flac', 'm4a', 'mid', 'mp3', 'ogg', 'wav']; + +styleDef.push([audioStyle, audioExtList]); + +// text //////////////////////////////////// +const textStyle = { + color: '#ffffff', + glyphColor: '#787878', +}; + +const textExtList = ['cue', 'odt', 'md', 'rtf', 'txt', 'tex', 'wpd', 'wps', 'xlr', 'fodt']; + +styleDef.push([textStyle, textExtList]); + +// system //////////////////////////////////// +const systemStyle = { + color: '#111', +}; + +const systemExtList = ['exe', 'ini', 'dll', 'plist', 'sys']; + +styleDef.push([systemStyle, systemExtList]); + +// srcCode //////////////////////////////////// +const srcCodeStyle = { + glyphColor: '#787878', + color: '#ffffff', +}; + +const srcCodeExtList = [ + 'asp', + 'aspx', + 'c', + 'cpp', + 'cs', + 'css', + 'scss', + 'py', + 'json', + 'htm', + 'html', + 'java', + 'yml', + 'php', + 'js', + 'ts', + 'rb', + 'jsx', + 'tsx', +]; + +styleDef.push([srcCodeStyle, srcCodeExtList]); + +// vector //////////////////////////////////// +const vectorStyle = { + color: '#ffe600', +}; + +const vectorExtList = ['dwg', 'dxf', 'ps', 'svg', 'eps']; + +styleDef.push([vectorStyle, vectorExtList]); + +// font //////////////////////////////////// +const fontStyle = { + color: '#555', +}; + +const fontExtList = ['fnt', 'ttf', 'otf', 'fon', 'eot', 'woff']; + +styleDef.push([fontStyle, fontExtList]); + +// objectModel //////////////////////////////////// +const objectModelStyle = { + color: '#bf6a02', + glyphColor: '#bf6a02', +}; + +const objectModelExtList = ['3dm', '3ds', 'max', 'obj', 'pkg']; + +styleDef.push([objectModelStyle, objectModelExtList]); + +// sheet //////////////////////////////////// +const sheetStyle = { + color: '#2a6e00', +}; + +const sheetExtList = ['csv', 'fods', 'ods', 'xlr']; + +styleDef.push([sheetStyle, sheetExtList]); + +// const defaultStyle: Record<string, FileIconProps> = { +// pdf: { +// glyphColor: "white", +// color: "#D93831" +// } +// }; + +////////////////////////////////////////////////// + +function createStyleObj(extList: T_FileExtList, styleObj: Partial<FileIconProps>) { + return Object.fromEntries( + extList.map((ext) => { + return [ext, { ...styleObj, glyphColor: 'white' }]; + }), + ); +} + +export const styleDefObj = styleDef.reduce((acc, [fileStyle, fileExtList]) => { + return { ...acc, ...createStyleObj(fileExtList, fileStyle) }; +}); diff --git a/frontend/src/components/modals/filepicker/index.tsx b/frontend/src/components/modals/filepicker/index.tsx new file mode 100644 index 00000000..dcf179a3 --- /dev/null +++ b/frontend/src/components/modals/filepicker/index.tsx @@ -0,0 +1,160 @@ +import { DialogButton, Focusable, SteamSpinner, TextField } from 'decky-frontend-lib'; +import { useEffect } from 'react'; +import { FunctionComponent, useState } from 'react'; +import { FileIcon, defaultStyles } from 'react-file-icon'; +import { FaArrowUp, FaFolder } from 'react-icons/fa'; + +import Logger from '../../../logger'; +import { styleDefObj } from './iconCustomizations'; + +const logger = new Logger('FilePicker'); + +export interface FilePickerProps { + startPath: string; + includeFiles?: boolean; + regex?: RegExp; + onSubmit: (val: { path: string; realpath: string }) => void; + closeModal?: () => void; +} + +interface File { + isdir: boolean; + name: string; + realpath: string; +} + +interface FileListing { + realpath: string; + files: File[]; +} + +function getList( + path: string, + includeFiles: boolean = true, +): Promise<{ result: FileListing | string; success: boolean }> { + return window.DeckyPluginLoader.callServerMethod('filepicker_ls', { path, include_files: includeFiles }); +} + +const iconStyles = { + paddingRight: '10px', + width: '1em', +}; + +const FilePicker: FunctionComponent<FilePickerProps> = ({ + startPath, + includeFiles = true, + regex, + onSubmit, + closeModal, +}) => { + if (startPath.endsWith('/')) startPath = startPath.substring(0, startPath.length - 1); // remove trailing path + const [path, setPath] = useState<string>(startPath); + const [listing, setListing] = useState<FileListing>({ files: [], realpath: path }); + const [error, setError] = useState<string | null>(null); + const [loading, setLoading] = useState<boolean>(true); + + useEffect(() => { + (async () => { + if (error) setError(null); + setLoading(true); + const listing = await getList(path, includeFiles); + if (!listing.success) { + setListing({ files: [], realpath: path }); + setLoading(false); + setError(listing.result as string); + logger.error(listing.result); + return; + } + setLoading(false); + setListing(listing.result as FileListing); + logger.log('reloaded', path, listing); + })(); + }, [path]); + + return ( + <div className="deckyFilePicker"> + <Focusable style={{ display: 'flex', flexDirection: 'row', paddingBottom: '10px' }}> + <DialogButton + style={{ + minWidth: 'unset', + width: '40px', + flexGrow: '0', + borderRadius: 'unset', + margin: '0', + padding: '10px', + }} + onClick={() => { + const newPathArr = path.split('/'); + newPathArr.pop(); + let newPath = newPathArr.join('/'); + if (newPath == '') newPath = '/'; + setPath(newPath); + }} + > + <FaArrowUp /> + </DialogButton> + <div style={{ flexGrow: '1', width: '100%' }}> + <TextField + value={path} + onChange={(e) => { + e.target.value && setPath(e.target.value); + }} + style={{ height: '100%' }} + /> + </div> + </Focusable> + <Focusable style={{ display: 'flex', flexDirection: 'column', height: '60vh', overflow: 'scroll' }}> + {loading && <SteamSpinner style={{ height: '100%' }} />} + {!loading && + listing.files + .filter((file) => (includeFiles || file.isdir) && (!regex || regex.test(file.name))) + .map((file) => { + let extension = file.realpath.split('.').pop() as string; + return ( + <DialogButton + style={{ borderRadius: 'unset', margin: '0', padding: '10px' }} + onClick={() => { + const fullPath = `${path}${path.endsWith('/') ? '' : '/'}${file.name}`; + if (file.isdir) setPath(fullPath); + else { + onSubmit({ path: fullPath, realpath: file.realpath }); + closeModal?.(); + } + }} + > + <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'flex-start' }}> + {file.isdir ? ( + <FaFolder style={iconStyles} /> + ) : ( + <div style={iconStyles}> + {file.realpath.includes('.') ? ( + <FileIcon {...defaultStyles[extension]} {...styleDefObj[extension]} extension={''} /> + ) : ( + <FileIcon /> + )} + </div> + )} + {file.name} + </div> + </DialogButton> + ); + })} + {error} + </Focusable> + {!loading && !error && !includeFiles && ( + <DialogButton + className="Primary" + style={{ marginTop: '10px', alignSelf: 'flex-end' }} + onClick={() => { + onSubmit({ path, realpath: listing.realpath }); + closeModal?.(); + }} + > + Use this folder + </DialogButton> + )} + </div> + ); +}; + +export default FilePicker; diff --git a/frontend/src/components/modals/filepicker/patches/README.md b/frontend/src/components/modals/filepicker/patches/README.md new file mode 100644 index 00000000..154914c5 --- /dev/null +++ b/frontend/src/components/modals/filepicker/patches/README.md @@ -0,0 +1 @@ +This directory contains patches that replace Valve's broken file picker with ours. diff --git a/frontend/src/components/modals/filepicker/patches/index.ts b/frontend/src/components/modals/filepicker/patches/index.ts new file mode 100644 index 00000000..310bfbf8 --- /dev/null +++ b/frontend/src/components/modals/filepicker/patches/index.ts @@ -0,0 +1,10 @@ +import library from './library'; +let patches: Function[] = []; + +export function deinitFilepickerPatches() { + patches.forEach((unpatch) => unpatch()); +} + +export async function initFilepickerPatches() { + patches.push(await library()); +} diff --git a/frontend/src/components/modals/filepicker/patches/library.ts b/frontend/src/components/modals/filepicker/patches/library.ts new file mode 100644 index 00000000..c9c7d53c --- /dev/null +++ b/frontend/src/components/modals/filepicker/patches/library.ts @@ -0,0 +1,57 @@ +import { Patch, findModuleChild, replacePatch } from 'decky-frontend-lib'; + +declare global { + interface Window { + SteamClient: any; + appDetailsStore: any; + } +} + +let patch: Patch; + +function rePatch() { + // If you patch anything on SteamClient within the first few seconds of the client having loaded it will get redefined for some reason, so repatch any of these changes that occur within the first 20s of the last patch + patch = replacePatch(window.SteamClient.Apps, 'PromptToChangeShortcut', async ([appid]: number[]) => { + try { + const details = window.appDetailsStore.GetAppDetails(appid); + console.log(details); + // strShortcutStartDir + const file = await window.DeckyPluginLoader.openFilePicker(details.strShortcutStartDir.replaceAll('"', '')); + console.log('user selected', file); + window.SteamClient.Apps.SetShortcutExe(appid, JSON.stringify(file.path)); + const pathArr = file.path.split('/'); + pathArr.pop(); + const folder = pathArr.join('/'); + window.SteamClient.Apps.SetShortcutStartDir(appid, JSON.stringify(folder)); + } catch (e) { + console.error(e); + } + }); +} + +// TODO type and add to frontend-lib +const History = findModuleChild((m) => { + if (typeof m !== 'object') return undefined; + for (let prop in m) { + if (m[prop]?.m_history) return m[prop].m_history; + } +}); + +export default async function libraryPatch() { + try { + rePatch(); + const unlisten = History.listen(() => { + if (window.SteamClient.Apps.PromptToChangeShortcut !== patch.patchedFunction) { + rePatch(); + } + }); + + return () => { + patch.unpatch(); + unlisten(); + }; + } catch (e) { + console.error('Error patching library file picker', e); + } + return () => {}; +} diff --git a/frontend/src/components/settings/pages/general/BranchSelect.tsx b/frontend/src/components/settings/pages/general/BranchSelect.tsx index d803f604..154bff9c 100644 --- a/frontend/src/components/settings/pages/general/BranchSelect.tsx +++ b/frontend/src/components/settings/pages/general/BranchSelect.tsx @@ -1,9 +1,12 @@ import { Dropdown, Field } from 'decky-frontend-lib'; import { FunctionComponent } from 'react'; +import Logger from '../../../../logger'; import { callUpdaterMethod } from '../../../../updater'; import { useSetting } from '../../../../utils/hooks/useSetting'; +const logger = new Logger('BranchSelect'); + enum UpdateBranch { Stable, Prerelease, @@ -28,7 +31,7 @@ const BranchSelect: FunctionComponent<{}> = () => { onChange={async (newVal) => { await setSelectedBranch(newVal.data); callUpdaterMethod('check_for_updates'); - console.log('switching branches!'); + logger.log('switching branches!'); }} /> </Field> diff --git a/frontend/src/components/settings/pages/general/Updater.tsx b/frontend/src/components/settings/pages/general/Updater.tsx index 7056ed13..b4ea8536 100644 --- a/frontend/src/components/settings/pages/general/Updater.tsx +++ b/frontend/src/components/settings/pages/general/Updater.tsx @@ -1,4 +1,13 @@ -import { Carousel, DialogButton, Field, Focusable, ProgressBarWithInfo, Spinner, showModal } from 'decky-frontend-lib'; +import { + Carousel, + DialogButton, + Field, + FocusRing, + Focusable, + ProgressBarWithInfo, + Spinner, + showModal, +} from 'decky-frontend-lib'; import { useCallback } from 'react'; import { Suspense, lazy } from 'react'; import { useEffect, useState } from 'react'; @@ -7,49 +16,48 @@ import { FaArrowDown } from 'react-icons/fa'; import { VerInfo, callUpdaterMethod, finishUpdate } from '../../../../updater'; import { useDeckyState } from '../../../DeckyState'; import InlinePatchNotes from '../../../patchnotes/InlinePatchNotes'; +import WithSuspense from '../../../WithSuspense'; const MarkdownRenderer = lazy(() => import('../../../Markdown')); -// import ReactMarkdown from 'react-markdown' -// import remarkGfm from 'remark-gfm' - function PatchNotesModal({ versionInfo, closeModal }: { versionInfo: VerInfo | null; closeModal?: () => {} }) { return ( <Focusable onCancelButton={closeModal}> - <Carousel - fnItemRenderer={(id: number) => ( - <Focusable - onActivate={() => {}} - style={{ - marginTop: '40px', - height: 'calc( 100% - 40px )', - overflowY: 'scroll', - display: 'flex', - justifyContent: 'center', - margin: '40px', - }} - > - <div> - <h1>{versionInfo?.all?.[id]?.name}</h1> - {versionInfo?.all?.[id]?.body ? ( - <Suspense fallback={<Spinner style={{ width: '24', height: '24' }} />}> - <MarkdownRenderer>{versionInfo.all[id].body}</MarkdownRenderer> - </Suspense> - ) : ( - 'no patch notes for this version' - )} - </div> - </Focusable> - )} - fnGetId={(id) => id} - nNumItems={versionInfo?.all?.length} - nHeight={window.innerHeight - 150} - nItemHeight={window.innerHeight - 200} - nItemMarginX={0} - initialColumn={0} - autoFocus={true} - fnGetColumnWidth={() => window.innerWidth} - /> + <FocusRing> + <Carousel + fnItemRenderer={(id: number) => ( + <Focusable + style={{ + marginTop: '40px', + height: 'calc( 100% - 40px )', + overflowY: 'scroll', + display: 'flex', + justifyContent: 'center', + margin: '40px', + }} + > + <div> + <h1>{versionInfo?.all?.[id]?.name}</h1> + {versionInfo?.all?.[id]?.body ? ( + <WithSuspense> + <MarkdownRenderer onDismiss={closeModal}>{versionInfo.all[id].body}</MarkdownRenderer> + </WithSuspense> + ) : ( + 'no patch notes for this version' + )} + </div> + </Focusable> + )} + fnGetId={(id) => id} + nNumItems={versionInfo?.all?.length} + nHeight={window.innerHeight - 40} + nItemHeight={window.innerHeight - 40} + nItemMarginX={0} + initialColumn={0} + autoFocus={true} + fnGetColumnWidth={() => window.innerWidth} + /> + </FocusRing> </Focusable> ); } @@ -126,7 +134,7 @@ export default function UpdaterSettings() { ) : ( <ProgressBarWithInfo layout="inline" - bottomSeparator={false} + bottomSeparator="none" nProgress={updateProgress} indeterminate={reloading} sOperationText={reloading ? 'Reloading' : 'Updating'} diff --git a/frontend/src/components/store/PluginCard.tsx b/frontend/src/components/store/PluginCard.tsx index a6e9458a..0155ff99 100644 --- a/frontend/src/components/store/PluginCard.tsx +++ b/frontend/src/components/store/PluginCard.tsx @@ -113,8 +113,22 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => { }} className="deckyStoreCardInfo" > - <p className={joinClassNames(staticClasses.PanelSectionRow)}> - <span>Author: {plugin.author}</span> + <p + className={joinClassNames(staticClasses.PanelSectionRow)} + style={{ marginTop: '0px', marginLeft: '16px' }} + > + <span style={{ paddingLeft: '0px' }}>Author: {plugin.author}</span> + </p> + <p + className={joinClassNames(staticClasses.PanelSectionRow)} + style={{ + marginLeft: '16px', + marginTop: '0px', + marginBottom: '0px', + marginRight: '16px', + }} + > + <span style={{ paddingLeft: '0px' }}>{plugin.description}</span> </p> <p className={joinClassNames('deckyStoreCardTagsContainer', staticClasses.PanelSectionRow)} diff --git a/frontend/src/components/store/Store.tsx b/frontend/src/components/store/Store.tsx index fd582edd..cb6f34ad 100644 --- a/frontend/src/components/store/Store.tsx +++ b/frontend/src/components/store/Store.tsx @@ -1,9 +1,12 @@ import { SteamSpinner } from 'decky-frontend-lib'; import { FC, useEffect, useState } from 'react'; +import Logger from '../../logger'; import { LegacyStorePlugin, StorePlugin, getLegacyPluginList, getPluginList } from '../../store'; import PluginCard from './PluginCard'; +const logger = new Logger('FilePicker'); + const StorePage: FC<{}> = () => { const [data, setData] = useState<StorePlugin[] | null>(null); const [legacyData, setLegacyData] = useState<LegacyStorePlugin[] | null>(null); @@ -11,12 +14,12 @@ const StorePage: FC<{}> = () => { useEffect(() => { (async () => { const res = await getPluginList(); - console.log(res); + logger.log('got data!', res); setData(res); })(); (async () => { const res = await getLegacyPluginList(); - console.log(res); + logger.log('got legacy data!', res); setLegacyData(res); })(); }, []); diff --git a/frontend/src/logger.ts b/frontend/src/logger.ts index 22036362..143bef16 100644 --- a/frontend/src/logger.ts +++ b/frontend/src/logger.ts @@ -19,7 +19,7 @@ export const debug = (name: string, ...args: any[]) => { }; export const error = (name: string, ...args: any[]) => { - console.log( + console.error( `%c Decky %c ${name} %c`, 'background: #16a085; color: black;', 'background: #FF0000;', @@ -40,6 +40,10 @@ class Logger { debug(...args: any[]) { debug(this.name, ...args); } + + error(...args: any[]) { + error(this.name, ...args); + } } export default Logger; diff --git a/frontend/src/plugin-loader.tsx b/frontend/src/plugin-loader.tsx index 4d3415c8..e7fc7031 100644 --- a/frontend/src/plugin-loader.tsx +++ b/frontend/src/plugin-loader.tsx @@ -1,13 +1,27 @@ -import { ModalRoot, QuickAccessTab, Router, SteamSpinner, showModal, sleep, staticClasses } from 'decky-frontend-lib'; -import { Suspense, lazy } from 'react'; +import { + ConfirmModal, + ModalRoot, + Patch, + QuickAccessTab, + Router, + callOriginal, + findModuleChild, + replacePatch, + showModal, + sleep, + staticClasses, +} from 'decky-frontend-lib'; +import { lazy } from 'react'; import { FaPlug } from 'react-icons/fa'; import { DeckyState, DeckyStateContextProvider, useDeckyState } from './components/DeckyState'; import LegacyPlugin from './components/LegacyPlugin'; +import { deinitFilepickerPatches, initFilepickerPatches } from './components/modals/filepicker/patches'; import PluginInstallModal from './components/modals/PluginInstallModal'; import NotificationBadge from './components/NotificationBadge'; import PluginView from './components/PluginView'; import TitleView from './components/TitleView'; +import WithSuspense from './components/WithSuspense'; import Logger from './logger'; import { Plugin } from './plugin'; import RouterHook from './router-hook'; @@ -16,6 +30,11 @@ import TabsHook from './tabs-hook'; import Toaster from './toaster'; import { VerInfo, callUpdaterMethod } from './updater'; +const StorePage = lazy(() => import('./components/store/Store')); +const SettingsPage = lazy(() => import('./components/settings')); + +const FilePicker = lazy(() => import('./components/modals/filepicker')); + declare global { interface Window {} } @@ -32,11 +51,13 @@ class PluginLoader extends Logger { // stores a list of plugin names which requested to be reloaded private pluginReloadQueue: { name: string; version?: string }[] = []; + private focusWorkaroundPatch?: Patch; + constructor() { super(PluginLoader.name); this.log('Initialized'); - const TabIcon = () => { + const TabBadge = () => { const { updates, hasLoaderUpdate } = useDeckyState(); return <NotificationBadge show={(updates && updates.size > 0) || hasLoaderUpdate} />; }; @@ -53,57 +74,72 @@ class PluginLoader extends Logger { icon: ( <DeckyStateContextProvider deckyState={this.deckyState}> <FaPlug /> - <TabIcon /> + <TabBadge /> </DeckyStateContextProvider> ), }); - const StorePage = lazy(() => import('./components/store/Store')); - const SettingsPage = lazy(() => import('./components/settings')); - this.routerHook.addRoute('/decky/store', () => ( - <Suspense - fallback={ - <div - style={{ - marginTop: '40px', - height: 'calc( 100% - 40px )', - overflowY: 'scroll', - }} - > - <SteamSpinner /> - </div> - } - > + <WithSuspense route={true}> <StorePage /> - </Suspense> + </WithSuspense> )); this.routerHook.addRoute('/decky/settings', () => { return ( <DeckyStateContextProvider deckyState={this.deckyState}> - <Suspense - fallback={ - <div - style={{ - marginTop: '40px', - height: 'calc( 100% - 40px )', - overflowY: 'scroll', - }} - > - <SteamSpinner /> - </div> - } - > + <WithSuspense route={true}> <SettingsPage /> - </Suspense> + </WithSuspense> </DeckyStateContextProvider> ); }); + + initFilepickerPatches(); + + this.updateVersion(); + + const self = this; + + try { + // TODO remove all of this once Valve fixes the bug + const focusManager = findModuleChild((m) => { + if (typeof m !== 'object') return false; + for (let prop in m) { + if (m[prop]?.prototype?.TakeFocus) return m[prop]; + } + return false; + }); + + this.focusWorkaroundPatch = replacePatch(focusManager.prototype, 'TakeFocus', function () { + // @ts-ignore + const classList = this.m_node?.m_element.classList; + if ( + // @ts-ignore + (this.m_node?.m_element && classList.contains(staticClasses.TabGroupPanel)) || + classList.contains('FriendsListTab') || + classList.contains('FriendsTabList') || + classList.contains('FriendsListAndChatsSteamDeck') + ) { + self.debug('Intercepted friends re-focus'); + return true; + } + + return callOriginal; + }); + } catch (e) { + this.error('Friends focus patch failed', e); + } } - public async notifyUpdates() { + public async updateVersion() { const versionInfo = (await callUpdaterMethod('get_version')).result as VerInfo; this.deckyState.setVersionInfo(versionInfo); + + return versionInfo; + } + + public async notifyUpdates() { + const versionInfo = await this.updateVersion(); if (versionInfo?.remote && versionInfo?.remote?.tag_name != versionInfo?.current) { this.toaster.toast({ title: 'Decky', @@ -147,7 +183,7 @@ class PluginLoader extends Logger { public uninstallPlugin(name: string) { showModal( - <ModalRoot + <ConfirmModal onOK={async () => { await this.callServerMethod('uninstall_plugin', { name }); }} @@ -158,7 +194,7 @@ class PluginLoader extends Logger { <div className={staticClasses.Title} style={{ flexDirection: 'column' }}> Uninstall {name}? </div> - </ModalRoot>, + </ConfirmModal>, ); } @@ -176,6 +212,8 @@ class PluginLoader extends Logger { public deinit() { this.routerHook.removeRoute('/decky/store'); this.routerHook.removeRoute('/decky/settings'); + deinitFilepickerPatches(); + this.focusWorkaroundPatch?.unpatch(); } public unloadPlugin(name: string) { @@ -225,7 +263,8 @@ class PluginLoader extends Logger { }, }); if (res.ok) { - let plugin = await eval(await res.text())(this.createPluginAPI(name)); + let plugin_export = await eval(await res.text()); + let plugin = plugin_export(this.createPluginAPI(name)); this.plugins.push({ ...plugin, name: name, @@ -257,11 +296,41 @@ class PluginLoader extends Logger { return response.json(); } + openFilePicker( + startPath: string, + includeFiles?: boolean, + regex?: RegExp, + ): Promise<{ path: string; realpath: string }> { + return new Promise((resolve, reject) => { + const Content = ({ closeModal }: { closeModal?: () => void }) => ( + // Purposely outside of the FilePicker component as lazy-loaded ModalRoots don't focus correctly + <ModalRoot + onCancel={() => { + reject('User canceled'); + closeModal?.(); + }} + > + <WithSuspense> + <FilePicker + startPath={startPath} + includeFiles={includeFiles} + regex={regex} + onSubmit={resolve} + closeModal={closeModal} + /> + </WithSuspense> + </ModalRoot> + ); + showModal(<Content />); + }); + } + createPluginAPI(pluginName: string) { return { routerHook: this.routerHook, toaster: this.toaster, callServerMethod: this.callServerMethod, + openFilePicker: this.openFilePicker, async callPluginMethod(methodName: string, args = {}) { const response = await fetch(`http://127.0.0.1:1337/plugins/${pluginName}/methods/${methodName}`, { method: 'POST', diff --git a/frontend/src/store.tsx b/frontend/src/store.tsx index 12c8972d..bdaae6f2 100644 --- a/frontend/src/store.tsx +++ b/frontend/src/store.tsx @@ -1,4 +1,4 @@ -import { ModalRoot, showModal, staticClasses } from 'decky-frontend-lib'; +import { ConfirmModal, showModal, staticClasses } from 'decky-frontend-lib'; import { Plugin } from './plugin'; @@ -51,7 +51,7 @@ export async function installFromURL(url: string) { export function requestLegacyPluginInstall(plugin: LegacyStorePlugin, selectedVer: string) { showModal( - <ModalRoot + <ConfirmModal onOK={() => { window.DeckyPluginLoader.callServerMethod('install_plugin', { name: plugin.artifact, @@ -70,7 +70,7 @@ export function requestLegacyPluginInstall(plugin: LegacyStorePlugin, selectedVe You are currently installing a <b>legacy</b> plugin. Legacy plugins are no longer supported and may have issues. Legacy plugins do not support gamepad input. To interact with a legacy plugin, you will need to use the touchscreen. - </ModalRoot>, + </ConfirmModal>, ); } diff --git a/frontend/src/tabs-hook.ts b/frontend/src/tabs-hook.ts index be413de0..e75e043d 100644 --- a/frontend/src/tabs-hook.ts +++ b/frontend/src/tabs-hook.ts @@ -47,18 +47,31 @@ class TabsHook extends Logger { const self = this; const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current; let scrollRoot: any; - let currentNode = tree; + async function findScrollRoot(currentNode: any, iters: number): Promise<any> { + if (iters >= 30) { + self.error( + 'Scroll root was not found before hitting the recursion limit, a developer will need to increase the limit.', + ); + return null; + } + currentNode = currentNode?.child; + if (currentNode?.type?.prototype?.RemoveSmartScrollContainer) { + self.log(`Scroll root was found in ${iters} recursion cycles`); + return currentNode; + } + if (!currentNode) return null; + if (currentNode.sibling) { + let node = await findScrollRoot(currentNode.sibling, iters + 1); + if (node !== null) return node; + } + return await findScrollRoot(currentNode, iters + 1); + } (async () => { - let iters = 0; + scrollRoot = await findScrollRoot(tree, 0); while (!scrollRoot) { - iters++; - currentNode = currentNode?.child; - if (iters >= 30 || !currentNode) { - iters = 0; - currentNode = tree; - await sleep(5000); - } - if (currentNode?.type?.prototype?.RemoveSmartScrollContainer) scrollRoot = currentNode; + this.log('Failed to find scroll root node, reattempting in 5 seconds'); + await sleep(5000); + scrollRoot = await findScrollRoot(tree, 0); } let newQA: any; let newQATabRenderer: any; @@ -101,6 +114,7 @@ class TabsHook extends Logger { }); this.cNode = scrollRoot; this.cNode.stateNode.forceUpdate(); + this.log('Finished initial injection'); })(); } diff --git a/frontend/src/toaster.tsx b/frontend/src/toaster.tsx index f42eb3f5..8eea35bc 100644 --- a/frontend/src/toaster.tsx +++ b/frontend/src/toaster.tsx @@ -1,4 +1,5 @@ import { Patch, ToastData, afterPatch, findInReactTree, findModuleChild, sleep } from 'decky-frontend-lib'; +import { ReactNode } from 'react'; import Toast from './components/Toast'; import Logger from './logger'; @@ -14,6 +15,7 @@ class Toaster extends Logger { private instanceRetPatch?: Patch; private node: any; private settingsModule: any; + private ready: boolean = false; constructor() { super('Toaster'); @@ -24,14 +26,8 @@ class Toaster extends Logger { } async init() { - this.settingsModule = findModuleChild((m) => { - if (typeof m !== 'object') return undefined; - for (let prop in m) { - if (typeof m[prop]?.settings?.bDisableToastsInGame !== 'undefined') return m[prop]; - } - }); - let instance: any; + while (true) { instance = findInReactTree( (document.getElementById('root') as any)._reactRootContainer._internalRoot.current, @@ -43,13 +39,26 @@ class Toaster extends Logger { } this.node = instance.return.return; + let toast: any; + let renderedToast: ReactNode = null; this.node.stateNode.render = (...args: any[]) => { const ret = this.node.stateNode.__proto__.render.call(this.node.stateNode, ...args); if (ret) { this.instanceRetPatch = afterPatch(ret, 'type', (_: any, ret: any) => { - if (ret?.props?.children[1]?.children?.props?.notification?.decky) { - const toast = ret.props.children[1].children.props.notification; - ret.props.children[1].children.type = () => <Toast toast={toast} />; + if (ret?.props?.children[1]?.children?.props) { + const currentToast = ret.props.children[1].children.props.notification; + if (currentToast?.decky) { + if (currentToast == toast) { + ret.props.children[1].children = renderedToast; + } else { + toast = currentToast; + renderedToast = <Toast toast={toast} />; + ret.props.children[1].children = renderedToast; + } + } else { + toast = null; + renderedToast = null; + } } return ret; }); @@ -57,11 +66,21 @@ class Toaster extends Logger { return ret; }; this.node.stateNode.forceUpdate(); + this.settingsModule = findModuleChild((m) => { + if (typeof m !== 'object') return undefined; + for (let prop in m) { + if (typeof m[prop]?.settings && m[prop]?.communityPreferences) return m[prop]; + } + }); this.log('Initialized'); + this.ready = true; } - toast(toast: ToastData) { - const settings = this.settingsModule.settings; + async toast(toast: ToastData) { + while (!this.ready) { + await sleep(100); + } + const settings = this.settingsModule?.settings; let toastData = { nNotificationID: window.NotificationStore.m_nNextTestNotificationID++, rtCreated: Date.now(), @@ -73,13 +92,12 @@ class Toaster extends Logger { // @ts-ignore toastData.data.appid = () => 0; if ( - (settings.bDisableAllToasts && !toast.critical) || - (settings.bDisableToastsInGame && !toast.critical && window.NotificationStore.BIsUserInGame()) + (settings?.bDisableAllToasts && !toast.critical) || + (settings?.bDisableToastsInGame && !toast.critical && window.NotificationStore.BIsUserInGame()) ) return; window.NotificationStore.m_rgNotificationToasts.push(toastData); window.NotificationStore.DispatchNextToast(); - window.NotificationStore.m_rgNotificationToasts.pop(); } deinit() { diff --git a/frontend/src/updater.ts b/frontend/src/updater.ts index ff9cb591..2c0b66fe 100644 --- a/frontend/src/updater.ts +++ b/frontend/src/updater.ts @@ -1,5 +1,3 @@ -import { sleep } from 'decky-frontend-lib'; - export enum Branches { Release, Prerelease, @@ -46,6 +44,4 @@ export async function callUpdaterMethod(methodName: string, args = {}) { export async function finishUpdate() { callUpdaterMethod('do_restart'); - await sleep(3000); - location.reload(); } |
