summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsuchmememanyskill <38142618+suchmememanyskill@users.noreply.github.com>2023-03-22 01:37:23 +0100
committerGitHub <noreply@github.com>2023-03-21 17:37:23 -0700
commitfd325ef1cc1d3e78b5e7686819e05606cc79d963 (patch)
tree1372e0efa0ca47b0045b4d29c40bb3a8caeadfc1
parentfaf46ba53354b6dcfbfae25e605bf567acd19376 (diff)
downloaddecky-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
-rw-r--r--.github/workflows/build.yml43
-rw-r--r--backend/browser.py19
-rw-r--r--backend/customtypes.py6
-rw-r--r--backend/helpers.py171
-rw-r--r--backend/loader.py1
-rw-r--r--backend/localplatform.py11
-rw-r--r--backend/localplatformlinux.py138
-rw-r--r--backend/localplatformwin.py38
-rw-r--r--backend/localsocket.py132
-rw-r--r--backend/main.py41
-rw-r--r--backend/plugin.py139
-rw-r--r--backend/settings.py14
-rw-r--r--backend/updater.py78
-rw-r--r--backend/utilities.py6
14 files changed, 588 insertions, 249 deletions
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 8b8975a7..982068aa 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -84,6 +84,49 @@ jobs:
with:
path: ./dist/PluginLoader
+ build-win:
+ name: Build PluginLoader for Win
+ runs-on: windows-2022
+
+ steps:
+ - name: Checkout 🧰
+ uses: actions/checkout@v3
+
+ - name: Set up NodeJS 18 💎
+ uses: actions/setup-node@v3
+ with:
+ node-version: 18
+
+ - name: Set up Python 3.10.2 🐍
+ uses: actions/setup-python@v4
+ with:
+ python-version: "3.10.2"
+
+ - name: Install Python dependencies ⬇️
+ run: |
+ python -m pip install --upgrade pip
+ pip install pyinstaller==5.5
+ pip install -r requirements.txt
+
+ - name: Install JS dependencies ⬇️
+ working-directory: ./frontend
+ run: |
+ npm i -g pnpm
+ pnpm i --frozen-lockfile
+
+ - name: Build JS Frontend 🛠️
+ working-directory: ./frontend
+ run: pnpm run build
+
+ - name: Build Python Backend 🛠️
+ run: pyinstaller --noconfirm --onefile --name "PluginLoader" --add-data "./backend/static;/static" --add-data "./backend/legacy;/legacy" --add-data "./plugin;/plugin" ./backend/main.py
+
+ - name: Upload package artifact ⬆️
+ uses: actions/upload-artifact@v3
+ with:
+ name: PluginLoader Win
+ path: ./dist/PluginLoader.exe
+
release:
name: Release stable version of the package
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release == 'release' }}
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):