diff options
Diffstat (limited to 'backend')
| -rw-r--r-- | backend/browser.py | 78 | ||||
| -rw-r--r-- | backend/helpers.py | 30 | ||||
| -rw-r--r-- | backend/loader.py | 15 | ||||
| -rw-r--r-- | backend/main.py | 15 | ||||
| -rw-r--r-- | backend/plugin.py | 38 | ||||
| -rw-r--r-- | backend/updater.py | 36 | ||||
| -rw-r--r-- | backend/utilities.py | 30 |
7 files changed, 199 insertions, 43 deletions
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 + } |
