diff options
| author | suchmememanyskill <38142618+suchmememanyskill@users.noreply.github.com> | 2023-03-22 01:37:23 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-03-21 17:37:23 -0700 |
| commit | fd325ef1cc1d3e78b5e7686819e05606cc79d963 (patch) | |
| tree | 1372e0efa0ca47b0045b4d29c40bb3a8caeadfc1 /backend | |
| parent | faf46ba53354b6dcfbfae25e605bf567acd19376 (diff) | |
| download | decky-loader-fd325ef1cc1d3e78b5e7686819e05606cc79d963.tar.gz decky-loader-fd325ef1cc1d3e78b5e7686819e05606cc79d963.zip | |
Add cross-platform support to decky (#387)
* Import generic watchdog observer over platform specific import
* Use os.path rather than genericpath
* Split off socket management in plugin.py
* Don't specify multiprocessing start type
Default on linux is already fork
* Move all platform-specific functions to seperate files
TODO: make plugin.py platform agnostic
* fix import
* add backwards compat to helpers.py
* add backwards compatibility to helpers.py harder
* Testing autobuild for win
* Testing autobuild for win, try 2
* Testing autobuild for win, try 3
* Testing autobuild for win, try 4
* Create the plugins folder before attempting to use it
* Implement win get_username()
* Create win install script
* Fix branch guess from version
* Create .loader.version in install script
* Add .cmd shim to facilitate auto-restarts
* Properly fix branch guess from version
* Fix updater on windows
* Try 2 of fixing updates for windows
* Test
* pain
* Update install script
* Powershell doesn't believe in utf8
* Powershell good
* add ON_LINUX variable to localplatform
* Fix more merge issues
* test
* Move custom imports to main.py
* Move custom imports to after __main__ check
Due to windows' default behaviour being spawn, it will spawn a new process and thus import into sys.path multiple times
* Log errors in get_system_pythonpaths() and get_loader_version() +
split get_system_pythonpaths() on newline
* Remove whitespace in result of get_system_pythonpaths()
* use python3 on linux and python on windows in get_system_pythonpaths()
* Remove fork-specific urls
* Fix MIME types not working on Windows
Diffstat (limited to 'backend')
| -rw-r--r-- | backend/browser.py | 19 | ||||
| -rw-r--r-- | backend/customtypes.py | 6 | ||||
| -rw-r--r-- | backend/helpers.py | 171 | ||||
| -rw-r--r-- | backend/loader.py | 1 | ||||
| -rw-r--r-- | backend/localplatform.py | 11 | ||||
| -rw-r--r-- | backend/localplatformlinux.py | 138 | ||||
| -rw-r--r-- | backend/localplatformwin.py | 38 | ||||
| -rw-r--r-- | backend/localsocket.py | 132 | ||||
| -rw-r--r-- | backend/main.py | 41 | ||||
| -rw-r--r-- | backend/plugin.py | 139 | ||||
| -rw-r--r-- | backend/settings.py | 14 | ||||
| -rw-r--r-- | backend/updater.py | 78 | ||||
| -rw-r--r-- | backend/utilities.py | 6 |
13 files changed, 545 insertions, 249 deletions
diff --git a/backend/browser.py b/backend/browser.py index c15e1ada..97fa3f11 100644 --- a/backend/browser.py +++ b/backend/browser.py @@ -12,12 +12,12 @@ from io import BytesIO from logging import getLogger 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 +from localplatform import chown, chmod # Local modules -from helpers import get_ssl_context, get_user, get_user_group, download_remote_binary_to_path +from helpers import get_ssl_context, download_remote_binary_to_path from injector import get_gamepadui_tab logger = getLogger("Browser") @@ -43,10 +43,9 @@ class PluginBrowser: zip_file = ZipFile(zip) zip_file.extractall(self.plugin_path) plugin_dir = path.join(self.plugin_path, 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})") + + if not chown(plugin_dir) or not chmod(plugin_dir, 555): + logger.error(f"chown/chmod exited with a non-zero exit code") return False return True @@ -61,14 +60,14 @@ class PluginBrowser: packageJson = json.load(f) if "remote_binary" in packageJson and len(packageJson["remote_binary"]) > 0: # create bin directory if needed. - rc=call(["chmod", "-R", "777", pluginBasePath]) + chmod(pluginBasePath, 777) if access(pluginBasePath, W_OK): if not path.exists(pluginBinPath): mkdir(pluginBinPath) if not access(pluginBinPath, W_OK): - rc=call(["chmod", "-R", "777", pluginBinPath]) + chmod(pluginBinPath, 777) rv = True for remoteBinary in packageJson["remote_binary"]: @@ -80,8 +79,8 @@ class PluginBrowser: 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]) + chown(self.plugin_path) + chmod(pluginBasePath, 555) else: rv = True logger.debug(f"No Remote Binaries to Download") diff --git a/backend/customtypes.py b/backend/customtypes.py new file mode 100644 index 00000000..84ebc235 --- /dev/null +++ b/backend/customtypes.py @@ -0,0 +1,6 @@ +from enum import Enum + +class UserType(Enum): + HOST_USER = 1 + EFFECTIVE_USER = 2 + ROOT = 3
\ No newline at end of file diff --git a/backend/helpers.py b/backend/helpers.py index af07b759..668a252d 100644 --- a/backend/helpers.py +++ b/backend/helpers.py @@ -1,19 +1,18 @@ -import grp -import pwd import re import ssl -import subprocess import uuid import os import sys -from subprocess import check_output -from time import sleep +import subprocess from hashlib import sha256 from io import BytesIO import certifi from aiohttp.web import Response, middleware from aiohttp import ClientSession +import localplatform +from customtypes import UserType +from logging import getLogger REMOTE_DEBUGGER_UNIT = "steam-web-debug-portforward.service" @@ -23,6 +22,7 @@ ssl_ctx = ssl.create_default_context(cafile=certifi.where()) assets_regex = re.compile("^/plugins/.*/assets/.*") frontend_regex = re.compile("^/frontend/.*") +logger = getLogger("Main") def get_ssl_context(): return ssl_ctx @@ -36,97 +36,41 @@ async def csrf_middleware(request, handler): return await handler(request) return Response(text='Forbidden', status='403') -# Deprecated -def set_user(): - pass - -# Get the user id hosting the plugin loader -def get_user_id() -> int: - proc_path = os.path.realpath(sys.argv[0]) - pws = sorted(pwd.getpwall(), reverse=True, key=lambda pw: len(pw.pw_dir)) - for pw in pws: - if proc_path.startswith(os.path.realpath(pw.pw_dir)): - return pw.pw_uid - raise PermissionError("The plugin loader does not seem to be hosted by any known user.") - -# Get the user hosting the plugin loader -def get_user() -> str: - return pwd.getpwuid(get_user_id()).pw_name - -# Get the effective user id of the running process -def get_effective_user_id() -> int: - return os.geteuid() - -# Get the effective user of the running process -def get_effective_user() -> str: - return pwd.getpwuid(get_effective_user_id()).pw_name - -# Get the effective user group id of the running process -def get_effective_user_group_id() -> int: - return os.getegid() - -# Get the effective user group of the running process -def get_effective_user_group() -> str: - return grp.getgrgid(get_effective_user_group_id()).gr_name - -# Get the user owner of the given file path. -def get_user_owner(file_path) -> str: - return pwd.getpwuid(os.stat(file_path).st_uid).pw_name - -# Get the user group of the given file path. -def get_user_group(file_path) -> str: - return grp.getgrgid(os.stat(file_path).st_gid).gr_name - -# Deprecated -def set_user_group() -> str: - return get_user_group() - -# Get the group id of the user hosting the plugin loader -def get_user_group_id() -> int: - return pwd.getpwuid(get_user_id()).pw_gid - -# Get the group of the user hosting the plugin loader -def get_user_group() -> str: - return grp.getgrgid(get_user_group_id()).gr_name - -# Get the default home path unless a user is specified -def get_home_path(username = None) -> str: - if username == None: - username = get_user() - return pwd.getpwnam(username).pw_dir - # Get the default homebrew path unless a home_path is specified def get_homebrew_path(home_path = None) -> str: - if home_path == None: - home_path = get_home_path() - return os.path.join(home_path, "homebrew") + return os.path.join(home_path if home_path != None else localplatform.get_home_path(), "homebrew") # Recursively create path and chown as user def mkdir_as_user(path): path = os.path.realpath(path) os.makedirs(path, exist_ok=True) - chown_path = get_home_path() - parts = os.path.relpath(path, chown_path).split(os.sep) - uid = get_user_id() - gid = get_user_group_id() - for p in parts: - chown_path = os.path.join(chown_path, p) - os.chown(chown_path, uid, gid) + localplatform.chown(path) # Fetches the version of loader def get_loader_version() -> str: try: with open(os.path.join(os.getcwd(), ".loader.version"), "r", encoding="utf-8") as version_file: return version_file.readline().strip() - except: + except Exception as e: + logger.warn(f"Failed to execute get_loader_version(): {str(e)}") return "unknown" # returns the appropriate system python paths def get_system_pythonpaths() -> list[str]: - # run as normal normal user to also include user python paths - proc = subprocess.run(["python3", "-c", "import sys; print(':'.join(x for x in sys.path if x))"], - user=get_user_id(), env={}, capture_output=True) - return proc.stdout.decode().strip().split(":") + extra_args = {} + + if localplatform.ON_LINUX: + # run as normal normal user to also include user python paths + extra_args["user"] = localplatform.localplatform._get_user_id() + extra_args["env"] = {} + + try: + proc = subprocess.run(["python3" if localplatform.ON_LINUX else "python", "-c", "import sys; print('\\n'.join(x for x in sys.path if x))"], + capture_output=True, **extra_args) + return [x.strip() for x in proc.stdout.decode().strip().split("\n")] + except Exception as e: + logger.warn(f"Failed to execute get_system_pythonpaths(): {str(e)}") + return [] # Download Remote Binaries to local Plugin async def download_remote_binary_to_path(url, binHash, path) -> bool: @@ -152,16 +96,67 @@ async def download_remote_binary_to_path(url, binHash, path) -> bool: 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 +# Deprecated +def set_user(): + pass + +# Deprecated +def set_user_group() -> str: + return get_user_group() + +######### +# Below is legacy code, provided for backwards compatibility. This will break on windows +######### + +# Get the user id hosting the plugin loader +def get_user_id() -> int: + return localplatform.localplatform._get_user_id() + +# Get the user hosting the plugin loader +def get_user() -> str: + return localplatform.localplatform._get_user() + +# Get the effective user id of the running process +def get_effective_user_id() -> int: + return localplatform.localplatform._get_effective_user_id() + +# Get the effective user of the running process +def get_effective_user() -> str: + return localplatform.localplatform._get_effective_user() + +# Get the effective user group id of the running process +def get_effective_user_group_id() -> int: + return localplatform.localplatform._get_effective_user_group_id() + +# Get the effective user group of the running process +def get_effective_user_group() -> str: + return localplatform.localplatform._get_effective_user_group() + +# Get the user owner of the given file path. +def get_user_owner(file_path) -> str: + return localplatform.localplatform._get_user_owner(file_path) + +# Get the user group of the given file path. +def get_user_group(file_path) -> str: + return localplatform.localplatform._get_user_group(file_path) -async def stop_systemd_unit(unit_name: str) -> subprocess.CompletedProcess: - cmd = ["systemctl", "stop", unit_name] +# Get the group id of the user hosting the plugin loader +def get_user_group_id() -> int: + return localplatform.localplatform._get_user_group_id() - return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) +# Get the group of the user hosting the plugin loader +def get_user_group() -> str: + return localplatform.localplatform._get_user_group() + +# Get the default home path unless a user is specified +def get_home_path(username = None) -> str: + return localplatform.get_home_path(UserType.ROOT if username == "root" else UserType.HOST_USER) + +async def is_systemd_unit_active(unit_name: str) -> bool: + return await localplatform.service_active(unit_name) -async def start_systemd_unit(unit_name: str) -> subprocess.CompletedProcess: - cmd = ["systemctl", "start", unit_name] +async def stop_systemd_unit(unit_name: str) -> bool: + return await localplatform.service_stop(unit_name) - return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) +async def start_systemd_unit(unit_name: str) -> bool: + return await localplatform.service_start(unit_name)
\ No newline at end of file diff --git a/backend/loader.py b/backend/loader.py index be216a15..a21aad09 100644 --- a/backend/loader.py +++ b/backend/loader.py @@ -13,7 +13,6 @@ from watchdog.observers import Observer from injector import get_tab, get_gamepadui_tab from plugin import PluginWrapper - class FileChangeHandler(RegexMatchingEventHandler): def __init__(self, queue, plugin_path) -> None: super().__init__(regexes=[r'^.*?dist\/index\.js$', r'^.*?main\.py$']) diff --git a/backend/localplatform.py b/backend/localplatform.py new file mode 100644 index 00000000..c1453227 --- /dev/null +++ b/backend/localplatform.py @@ -0,0 +1,11 @@ +import platform + +ON_WINDOWS = platform.system() == "Windows" +ON_LINUX = not ON_WINDOWS + +if ON_WINDOWS: + from localplatformwin import * + import localplatformwin as localplatform +else: + from localplatformlinux import * + import localplatformlinux as localplatform
\ No newline at end of file diff --git a/backend/localplatformlinux.py b/backend/localplatformlinux.py new file mode 100644 index 00000000..8b286019 --- /dev/null +++ b/backend/localplatformlinux.py @@ -0,0 +1,138 @@ +import os, pwd, grp, sys +from subprocess import call, run, DEVNULL, PIPE, STDOUT +from customtypes import UserType + +# Get the user id hosting the plugin loader +def _get_user_id() -> int: + proc_path = os.path.realpath(sys.argv[0]) + pws = sorted(pwd.getpwall(), reverse=True, key=lambda pw: len(pw.pw_dir)) + for pw in pws: + if proc_path.startswith(os.path.realpath(pw.pw_dir)): + return pw.pw_uid + raise PermissionError("The plugin loader does not seem to be hosted by any known user.") + +# Get the user hosting the plugin loader +def _get_user() -> str: + return pwd.getpwuid(_get_user_id()).pw_name + +# Get the effective user id of the running process +def _get_effective_user_id() -> int: + return os.geteuid() + +# Get the effective user of the running process +def _get_effective_user() -> str: + return pwd.getpwuid(_get_effective_user_id()).pw_name + +# Get the effective user group id of the running process +def _get_effective_user_group_id() -> int: + return os.getegid() + +# Get the effective user group of the running process +def _get_effective_user_group() -> str: + return grp.getgrgid(_get_effective_user_group_id()).gr_name + +# Get the user owner of the given file path. +def _get_user_owner(file_path) -> str: + return pwd.getpwuid(os.stat(file_path).st_uid).pw_name + +# Get the user group of the given file path. +def _get_user_group(file_path) -> str: + return grp.getgrgid(os.stat(file_path).st_gid).gr_name + +# Get the group id of the user hosting the plugin loader +def _get_user_group_id() -> int: + return pwd.getpwuid(_get_user_id()).pw_gid + +# Get the group of the user hosting the plugin loader +def _get_user_group() -> str: + return grp.getgrgid(_get_user_group_id()).gr_name + +def chown(path : str, user : UserType = UserType.HOST_USER, recursive : bool = True) -> bool: + user_str = "" + + if (user == UserType.HOST_USER): + user_str = _get_user()+":"+_get_user_group() + elif (user == UserType.EFFECTIVE_USER): + user_str = _get_effective_user()+":"+_get_effective_user_group() + else: + raise Exception("Unknown User Type") + + result = call(["chown", "-R", user_str, path] if recursive else ["chown", user_str, path]) + return result == 0 + +def chmod(path : str, permissions : int, recursive : bool = True) -> bool: + result = call(["chmod", "-R", str(permissions), path] if recursive else ["chmod", str(permissions), path]) + return result == 0 + +def folder_owner(path : str) -> UserType|None: + user_owner = _get_user_owner(path) + + if (user_owner == _get_user()): + return UserType.HOST_USER + + elif (user_owner == _get_effective_user()): + return UserType.EFFECTIVE_USER + + else: + return None + +def get_home_path(user : UserType = UserType.HOST_USER) -> str: + user_name = "root" + + if user == UserType.HOST_USER: + user_name = _get_user() + elif user == UserType.EFFECTIVE_USER: + user_name = _get_effective_user() + elif user == UserType.ROOT: + pass + else: + raise Exception("Unknown User Type") + + return pwd.getpwnam(user_name).pw_dir + +def get_username() -> str: + return _get_user() + +def setgid(user : UserType = UserType.HOST_USER): + user_id = 0 + + if user == UserType.HOST_USER: + user_id = _get_user_group_id() + elif user == UserType.ROOT: + pass + else: + raise Exception("Unknown user type") + + os.setgid(user_id) + +def setuid(user : UserType = UserType.HOST_USER): + user_id = 0 + + if user == UserType.HOST_USER: + user_id = _get_user_id() + elif user == UserType.ROOT: + pass + else: + raise Exception("Unknown user type") + + os.setuid(user_id) + +async def service_active(service_name : str) -> bool: + res = run(["systemctl", "is-active", service_name], stdout=DEVNULL, stderr=DEVNULL) + return res.returncode == 0 + +async def service_restart(service_name : str) -> bool: + call(["systemctl", "daemon-reload"]) + cmd = ["systemctl", "restart", service_name] + res = run(cmd, stdout=PIPE, stderr=STDOUT) + return res.returncode == 0 + +async def service_stop(service_name : str) -> bool: + cmd = ["systemctl", "stop", service_name] + res = run(cmd, stdout=PIPE, stderr=STDOUT) + return res.returncode == 0 + +async def service_start(service_name : str) -> bool: + cmd = ["systemctl", "start", service_name] + res = run(cmd, stdout=PIPE, stderr=STDOUT) + return res.returncode == 0
\ No newline at end of file diff --git a/backend/localplatformwin.py b/backend/localplatformwin.py new file mode 100644 index 00000000..3242403b --- /dev/null +++ b/backend/localplatformwin.py @@ -0,0 +1,38 @@ +from customtypes import UserType +import os, sys + +def chown(path : str, user : UserType = UserType.HOST_USER, recursive : bool = True) -> bool: + return True # Stubbed + +def chmod(path : str, permissions : int, recursive : bool = True) -> bool: + return True # Stubbed + +def folder_owner(path : str) -> UserType|None: + return UserType.HOST_USER # Stubbed + +def get_home_path(user : UserType = UserType.HOST_USER) -> str: + return os.path.expanduser("~") # Mostly stubbed + +def setgid(user : UserType = UserType.HOST_USER): + pass # Stubbed + +def setuid(user : UserType = UserType.HOST_USER): + pass # Stubbed + +async def service_active(service_name : str) -> bool: + return True # Stubbed + +async def service_stop(service_name : str) -> bool: + return True # Stubbed + +async def service_start(service_name : str) -> bool: + return True # Stubbed + +async def service_restart(service_name : str) -> bool: + if service_name == "plugin_loader": + sys.exit(42) + + return True # Stubbed + +def get_username() -> str: + return os.getlogin()
\ No newline at end of file diff --git a/backend/localsocket.py b/backend/localsocket.py new file mode 100644 index 00000000..ef0e3933 --- /dev/null +++ b/backend/localsocket.py @@ -0,0 +1,132 @@ +import asyncio, time, random +from localplatform import ON_WINDOWS + +BUFFER_LIMIT = 2 ** 20 # 1 MiB + +class UnixSocket: + def __init__(self, on_new_message): + ''' + on_new_message takes 1 string argument. + It's return value gets used, if not None, to write data to the socket. + Method should be async + ''' + self.socket_addr = f"/tmp/plugin_socket_{time.time()}" + self.on_new_message = on_new_message + self.socket = None + self.reader = None + self.writer = None + + async def setup_server(self): + self.socket = await asyncio.start_unix_server(self._listen_for_method_call, path=self.socket_addr, limit=BUFFER_LIMIT) + + async def _open_socket_if_not_exists(self): + if not self.reader: + retries = 0 + while retries < 10: + try: + self.reader, self.writer = await asyncio.open_unix_connection(self.socket_addr, limit=BUFFER_LIMIT) + return True + except: + await asyncio.sleep(2) + retries += 1 + return False + else: + return True + + async def get_socket_connection(self): + if not await self._open_socket_if_not_exists(): + return None, None + + return self.reader, self.writer + + async def close_socket_connection(self): + if self.writer != None: + self.writer.close() + + self.reader = None + + async def read_single_line(self) -> str|None: + reader, writer = await self.get_socket_connection() + + if self.reader == None: + return None + + return await self._read_single_line(reader) + + async def write_single_line(self, message : str): + reader, writer = await self.get_socket_connection() + + if self.writer == None: + return; + + await self._write_single_line(writer, message) + + async def _read_single_line(self, reader) -> str: + line = bytearray() + while True: + try: + line.extend(await reader.readuntil()) + except asyncio.LimitOverrunError: + line.extend(await reader.read(reader._limit)) + continue + except asyncio.IncompleteReadError as err: + line.extend(err.partial) + break + else: + break + + return line.decode("utf-8") + + async def _write_single_line(self, writer, message : str): + if not message.endswith("\n"): + message += "\n" + + writer.write(message.encode("utf-8")) + await writer.drain() + + async def _listen_for_method_call(self, reader, writer): + while True: + line = await self._read_single_line(reader) + + try: + res = await self.on_new_message(line) + except Exception as e: + return + + if res != None: + await self._write_single_line(writer, res) + +class PortSocket (UnixSocket): + def __init__(self, on_new_message): + ''' + on_new_message takes 1 string argument. + It's return value gets used, if not None, to write data to the socket. + Method should be async + ''' + super().__init__(on_new_message) + self.host = "127.0.0.1" + self.port = random.sample(range(40000, 60000), 1)[0] + + async def setup_server(self): + self.socket = await asyncio.start_server(self._listen_for_method_call, host=self.host, port=self.port, limit=BUFFER_LIMIT) + + async def _open_socket_if_not_exists(self): + if not self.reader: + retries = 0 + while retries < 10: + try: + self.reader, self.writer = await asyncio.open_connection(host=self.host, port=self.port, limit=BUFFER_LIMIT) + return True + except: + await asyncio.sleep(2) + retries += 1 + return False + else: + return True + +if ON_WINDOWS: + class LocalSocket (PortSocket): + pass +else: + class LocalSocket (UnixSocket): + pass
\ No newline at end of file diff --git a/backend/main.py b/backend/main.py index a2ac008a..de06e633 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,14 +1,15 @@ # Change PyInstaller files permissions import sys -from subprocess import call +from localplatform import chmod, chown, service_stop, service_start, ON_WINDOWS if hasattr(sys, '_MEIPASS'): - call(['chmod', '-R', '755', sys._MEIPASS]) + chmod(sys._MEIPASS, 755) # Full imports from asyncio import new_event_loop, set_event_loop, sleep from json import dumps, loads from logging import DEBUG, INFO, basicConfig, getLogger -from os import getenv, chmod, path +from os import getenv, path from traceback import format_exc +import multiprocessing import aiohttp_cors # Partial imports @@ -19,16 +20,15 @@ from aiohttp_jinja2 import setup as jinja_setup # local modules from browser import PluginBrowser from helpers import (REMOTE_DEBUGGER_UNIT, csrf_middleware, get_csrf_token, - get_home_path, get_homebrew_path, get_user, get_user_group, - stop_systemd_unit, start_systemd_unit) + get_homebrew_path, mkdir_as_user, get_system_pythonpaths) + from injector import get_gamepadui_tab, Tab, get_tabs, close_old_tabs from loader import Loader from settings import SettingsManager from updater import Updater from utilities import Utilities +from customtypes import UserType -USER = get_user() -GROUP = get_user_group() HOMEBREW_PATH = get_homebrew_path() CONFIG = { "plugin_path": getenv("PLUGIN_PATH", path.join(HOMEBREW_PATH, "plugins")), @@ -49,10 +49,11 @@ basicConfig( logger = getLogger("Main") def chown_plugin_dir(): - code_chown = call(["chown", "-R", USER+":"+GROUP, CONFIG["plugin_path"]]) - code_chmod = call(["chmod", "-R", "555", CONFIG["plugin_path"]]) - 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})") + if not path.exists(CONFIG["plugin_path"]): # For safety, create the folder before attempting to do anything with it + mkdir_as_user(CONFIG["plugin_path"]) + + if not chown(CONFIG["plugin_path"], UserType.HOST_USER) or not chmod(CONFIG["plugin_path"], 555): + logger.error(f"chown/chmod exited with a non-zero exit code") if CONFIG["chown_plugin_path"] == True: chown_plugin_dir() @@ -79,9 +80,9 @@ class PluginManager: async def startup(_): if self.settings.getSetting("cef_forward", False): - self.loop.create_task(start_systemd_unit(REMOTE_DEBUGGER_UNIT)) + self.loop.create_task(service_start(REMOTE_DEBUGGER_UNIT)) else: - self.loop.create_task(stop_systemd_unit(REMOTE_DEBUGGER_UNIT)) + self.loop.create_task(service_stop(REMOTE_DEBUGGER_UNIT)) self.loop.create_task(self.loader_reinjector()) self.loop.create_task(self.load_plugins()) @@ -173,6 +174,20 @@ class PluginManager: return run_app(self.web_app, host=CONFIG["server_host"], port=CONFIG["server_port"], loop=self.loop, access_log=None) if __name__ == "__main__": + if ON_WINDOWS: + # Fix windows/flask not recognising that .js means 'application/javascript' + import mimetypes + mimetypes.add_type('application/javascript', '.js') + + # Required for multiprocessing support in frozen files + multiprocessing.freeze_support() + + # Append the loader's plugin path to the recognized python paths + sys.path.append(path.join(path.dirname(__file__), "plugin")) + + # Append the system and user python paths + sys.path.extend(get_system_pythonpaths()) + loop = new_event_loop() set_event_loop(loop) PluginManager(loop).run() diff --git a/backend/plugin.py b/backend/plugin.py index dea35299..7bfe1bfe 100644 --- a/backend/plugin.py +++ b/backend/plugin.py @@ -1,32 +1,27 @@ import multiprocessing from asyncio import (Lock, get_event_loop, new_event_loop, - open_unix_connection, set_event_loop, sleep, - start_unix_server, IncompleteReadError, LimitOverrunError) + set_event_loop, sleep) 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, environ +from os import path, environ from signal import SIGINT, signal from sys import exit, path as syspath from time import time +from localsocket import LocalSocket +from localplatform import setgid, setuid, get_username, get_home_path +from customtypes import UserType import helpers -from updater import Updater - -multiprocessing.set_start_method("fork") - -BUFFER_LIMIT = 2 ** 20 # 1 MiB class PluginWrapper: def __init__(self, file, plugin_directory, plugin_path) -> None: self.file = file self.plugin_path = plugin_path self.plugin_directory = plugin_directory - self.reader = None - self.writer = None - self.socket_addr = f"/tmp/plugin_socket_{time()}" self.method_call_lock = Lock() + self.socket = LocalSocket(self._on_new_message) self.version = None @@ -35,7 +30,6 @@ class PluginWrapper: package_json = load(open(path.join(plugin_path, plugin_directory, "package.json"), "r", encoding="utf-8")) self.version = package_json["version"] - self.legacy = False self.main_view_html = json["main_view_html"] if "main_view_html" in json else "" self.tile_view_html = json["tile_view_html"] if "tile_view_html" in json else "" @@ -59,13 +53,13 @@ class PluginWrapper: set_event_loop(new_event_loop()) if self.passive: return - setgid(0 if "root" in self.flags else helpers.get_user_group_id()) - setuid(0 if "root" in self.flags else helpers.get_user_id()) + setgid(UserType.ROOT if "root" in self.flags else UserType.HOST_USER) + setuid(UserType.ROOT if "root" in self.flags else UserType.HOST_USER) # export a bunch of environment variables to help plugin developers - environ["HOME"] = helpers.get_home_path("root" if "root" in self.flags else helpers.get_user()) - environ["USER"] = "root" if "root" in self.flags else helpers.get_user() + environ["HOME"] = get_home_path(UserType.ROOT if "root" in self.flags else UserType.HOST_USER) + environ["USER"] = "root" if "root" in self.flags else get_username() environ["DECKY_VERSION"] = helpers.get_loader_version() - environ["DECKY_USER"] = helpers.get_user() + environ["DECKY_USER"] = get_username() environ["DECKY_USER_HOME"] = helpers.get_home_path() environ["DECKY_HOME"] = helpers.get_homebrew_path() environ["DECKY_PLUGIN_SETTINGS_DIR"] = path.join(environ["DECKY_HOME"], "settings", self.plugin_directory) @@ -78,12 +72,10 @@ class PluginWrapper: environ["DECKY_PLUGIN_NAME"] = self.name environ["DECKY_PLUGIN_VERSION"] = self.version environ["DECKY_PLUGIN_AUTHOR"] = self.author - # append the loader's plugin path to the recognized python paths - syspath.append(path.realpath(path.join(path.dirname(__file__), "plugin"))) + # append the plugin's `py_modules` to the recognized python paths syspath.append(path.join(environ["DECKY_PLUGIN_DIR"], "py_modules")) - # append the system and user python paths - syspath.extend(helpers.get_system_pythonpaths()) + spec = spec_from_file_location("_", self.file) module = module_from_spec(spec) spec.loader.exec_module(module) @@ -93,7 +85,7 @@ class PluginWrapper: get_event_loop().run_until_complete(self.Plugin._migration(self.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().create_task(self.socket.setup_server()) get_event_loop().run_forever() except: self.log.error("Failed to start " + self.name + "!\n" + format_exc()) @@ -111,55 +103,26 @@ class PluginWrapper: self.log.error("Failed to unload " + 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) - - async def _listen_for_method_call(self, reader, writer): - while True: - line = bytearray() - while True: - try: - line.extend(await reader.readuntil()) - except LimitOverrunError: - line.extend(await reader.read(reader._limit)) - continue - except IncompleteReadError as err: - line.extend(err.partial) - break - else: - break - data = loads(line.decode("utf-8")) - if "stop" in data: - self.log.info("Calling Loader unload function.") - await self._unload() - get_event_loop().stop() - while get_event_loop().is_running(): - await sleep(0) - get_event_loop().close() - return - d = {"res": None, "success": True} - try: - d["res"] = await getattr(self.Plugin, data["method"])(self.Plugin, **data["args"]) - except Exception as e: - d["res"] = str(e) - d["success"] = False - finally: - writer.write((dumps(d, ensure_ascii=False)+"\n").encode("utf-8")) - await writer.drain() - - async def _open_socket_if_not_exists(self): - if not self.reader: - retries = 0 - while retries < 10: - try: - self.reader, self.writer = await open_unix_connection(self.socket_addr, limit=BUFFER_LIMIT) - return True - except: - await sleep(2) - retries += 1 - return False - else: - return True + async def _on_new_message(self, message : str) -> str|None: + data = loads(message) + + if "stop" in data: + self.log.info("Calling Loader unload function.") + await self._unload() + get_event_loop().stop() + while get_event_loop().is_running(): + await sleep(0) + get_event_loop().close() + raise Exception("Closing message listener") + + d = {"res": None, "success": True} + try: + d["res"] = await getattr(self.Plugin, data["method"])(self.Plugin, **data["args"]) + except Exception as e: + d["res"] = str(e) + d["success"] = False + finally: + return dumps(d, ensure_ascii=False) def start(self): if self.passive: @@ -170,34 +133,24 @@ class PluginWrapper: def stop(self): if self.passive: return + async def _(self): - if await self._open_socket_if_not_exists(): - self.writer.write((dumps({ "stop": True }, ensure_ascii=False)+"\n").encode("utf-8")) - await self.writer.drain() - self.writer.close() + await self.socket.write_single_line(dumps({ "stop": True }, ensure_ascii=False)) + await self.socket.close_socket_connection() + get_event_loop().create_task(_(self)) async def execute_method(self, method_name, kwargs): if self.passive: raise RuntimeError("This plugin is passive (aka does not implement main.py)") async with self.method_call_lock: - if await self._open_socket_if_not_exists(): - self.writer.write( - (dumps({ "method": method_name, "args": kwargs }, ensure_ascii=False) + "\n").encode("utf-8")) - await self.writer.drain() - line = bytearray() - while True: - try: - line.extend(await self.reader.readuntil()) - except LimitOverrunError: - line.extend(await self.reader.read(self.reader._limit)) - continue - except IncompleteReadError as err: - line.extend(err.partial) - break - else: - break - res = loads(line.decode("utf-8")) + reader, writer = await self.socket.get_socket_connection() + + await self.socket.write_single_line(dumps({ "method": method_name, "args": kwargs }, ensure_ascii=False)) + + line = await self.socket.read_single_line() + if line != None: + res = loads(line) if not res["success"]: raise Exception(res["res"]) - return res["res"] + return res["res"]
\ No newline at end of file diff --git a/backend/settings.py b/backend/settings.py index 64b04c60..3bad6875 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -1,14 +1,13 @@ from json import dump, load from os import mkdir, path, listdir, rename -from shutil import chown +from localplatform import chown, folder_owner +from customtypes import UserType -from helpers import get_home_path, get_homebrew_path, get_user, get_user_group, get_user_owner +from helpers import get_homebrew_path class SettingsManager: def __init__(self, name, settings_directory = None) -> None: - USER = get_user() - GROUP = get_user_group() wrong_dir = get_homebrew_path() if settings_directory == None: settings_directory = path.join(wrong_dir, "settings") @@ -18,7 +17,7 @@ class SettingsManager: #Create the folder with the correct permission if not path.exists(settings_directory): mkdir(settings_directory) - chown(settings_directory, USER, GROUP) + chown(settings_directory) #Copy all old settings file in the root directory to the correct folder for file in listdir(wrong_dir): @@ -29,8 +28,9 @@ class SettingsManager: #If the owner of the settings directory is not the user, then set it as the user: - if get_user_owner(settings_directory) != USER: - chown(settings_directory, USER, GROUP) + + if folder_owner(settings_directory) != UserType.HOST_USER: + chown(settings_directory, UserType.HOST_USER, False) self.settings = {} diff --git a/backend/updater.py b/backend/updater.py index 31bd0591..349336b1 100644 --- a/backend/updater.py +++ b/backend/updater.py @@ -6,7 +6,7 @@ from ensurepip import version from json.decoder import JSONDecodeError from logging import getLogger from os import getcwd, path, remove -from subprocess import call +from localplatform import chmod, service_restart, ON_LINUX from aiohttp import ClientSession, web @@ -132,47 +132,54 @@ class Updater: async def do_update(self): logger.debug("Starting update.") version = self.remoteVer["tag_name"] - download_url = self.remoteVer["assets"][0]["browser_download_url"] + download_url = None + download_filename = "PluginLoader" if ON_LINUX else "PluginLoader.exe" + download_temp_filename = download_filename + ".new" + + for x in self.remoteVer["assets"]: + if x["name"] == download_filename: + download_url = x["browser_download_url"] + break + + if download_url == None: + raise Exception("Download url not found") + service_url = self.get_service_url() logger.debug("Retrieved service URL") tab = await get_gamepadui_tab() await tab.open_websocket() async with ClientSession() as web: - logger.debug("Downloading systemd service") - # download the relevant systemd service depending upon branch - async with web.request("GET", service_url, ssl=helpers.get_ssl_context(), allow_redirects=True) as res: - logger.debug("Downloading service file") - data = await res.content.read() - logger.debug(str(data)) - service_file_path = path.join(getcwd(), "plugin_loader.service") - try: - with open(path.join(getcwd(), "plugin_loader.service"), "wb") as out: - out.write(data) - except Exception as e: - logger.error(f"Error at %s", exc_info=e) - with open(path.join(getcwd(), "plugin_loader.service"), "r", encoding="utf-8") as service_file: - service_data = service_file.read() - service_data = service_data.replace("${HOMEBREW_FOLDER}", helpers.get_homebrew_path()) - with open(path.join(getcwd(), "plugin_loader.service"), "w", encoding="utf-8") as service_file: - service_file.write(service_data) + if ON_LINUX: + logger.debug("Downloading systemd service") + # download the relevant systemd service depending upon branch + async with web.request("GET", service_url, ssl=helpers.get_ssl_context(), allow_redirects=True) as res: + logger.debug("Downloading service file") + data = await res.content.read() + logger.debug(str(data)) + service_file_path = path.join(getcwd(), "plugin_loader.service") + try: + with open(path.join(getcwd(), "plugin_loader.service"), "wb") as out: + out.write(data) + except Exception as e: + logger.error(f"Error at %s", exc_info=e) + with open(path.join(getcwd(), "plugin_loader.service"), "r", encoding="utf-8") as service_file: + service_data = service_file.read() + service_data = service_data.replace("${HOMEBREW_FOLDER}", helpers.get_homebrew_path()) + with open(path.join(getcwd(), "plugin_loader.service"), "w", encoding="utf-8") as service_file: + service_file.write(service_data) - logger.debug("Saved service file") - logger.debug("Copying service file over current file.") - shutil.copy(service_file_path, "/etc/systemd/system/plugin_loader.service") - if not os.path.exists(path.join(getcwd(), ".systemd")): - os.mkdir(path.join(getcwd(), ".systemd")) - shutil.move(service_file_path, path.join(getcwd(), ".systemd")+"/plugin_loader.service") + logger.debug("Saved service file") + logger.debug("Copying service file over current file.") + shutil.copy(service_file_path, "/etc/systemd/system/plugin_loader.service") + if not os.path.exists(path.join(getcwd(), ".systemd")): + os.mkdir(path.join(getcwd(), ".systemd")) + shutil.move(service_file_path, path.join(getcwd(), ".systemd")+"/plugin_loader.service") logger.debug("Downloading binary") async with web.request("GET", download_url, ssl=helpers.get_ssl_context(), allow_redirects=True) as res: total = int(res.headers.get('content-length', 0)) - # we need to not delete the binary until we have downloaded the new binary! - try: - remove(path.join(getcwd(), "PluginLoader")) - except: - pass - with open(path.join(getcwd(), "PluginLoader"), "wb") as out: + with open(path.join(getcwd(), download_temp_filename), "wb") as out: progress = 0 raw = 0 async for c in res.content.iter_chunked(512): @@ -186,12 +193,15 @@ class Updater: with open(path.join(getcwd(), ".loader.version"), "w", encoding="utf-8") as out: out.write(version) - call(['chmod', '+x', path.join(getcwd(), "PluginLoader")]) + if ON_LINUX: + remove(path.join(getcwd(), download_filename)) + shutil.move(path.join(getcwd(), download_temp_filename), path.join(getcwd(), download_filename)) + chmod(path.join(getcwd(), download_filename), 777, False) + logger.info("Updated loader installation.") await tab.evaluate_js("window.DeckyUpdater.finish()", False, False) await self.do_restart() await tab.close_websocket() async def do_restart(self): - call(["systemctl", "daemon-reload"]) - call(["systemctl", "restart", "plugin_loader"]) + await service_restart("plugin_loader") diff --git a/backend/utilities.py b/backend/utilities.py index 618b1d3d..4abb16dc 100644 --- a/backend/utilities.py +++ b/backend/utilities.py @@ -10,7 +10,7 @@ from logging import getLogger from injector import inject_to_tab, get_gamepadui_tab, close_old_tabs import helpers import subprocess - +from localplatform import service_stop, service_start class Utilities: def __init__(self, context) -> None: @@ -174,11 +174,11 @@ class Utilities: return self.context.settings.setSetting(key, value) async def allow_remote_debugging(self): - await helpers.start_systemd_unit(helpers.REMOTE_DEBUGGER_UNIT) + await service_start(helpers.REMOTE_DEBUGGER_UNIT) return True async def disallow_remote_debugging(self): - await helpers.stop_systemd_unit(helpers.REMOTE_DEBUGGER_UNIT) + await service_stop(helpers.REMOTE_DEBUGGER_UNIT) return True async def filepicker_ls(self, path, include_files=True): |
