mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-07-12 17:21:56 +00:00
Merge remote-tracking branch 'origin/main' into i18n
This commit is contained in:
@@ -130,9 +130,7 @@ jobs:
|
|||||||
OUT=$(semver bump ${{github.event.inputs.bump}} "$OUT")
|
OUT=$(semver bump ${{github.event.inputs.bump}} "$OUT")
|
||||||
printf "OUT: ${OUT}\n"
|
printf "OUT: ${OUT}\n"
|
||||||
else
|
else
|
||||||
printf "no type selected, defaulting to patch.\n"
|
printf "no type selected, not bumping for release.\n"
|
||||||
OUT=$(semver bump patch "$OUT")
|
|
||||||
printf "OUT: ${OUT}\n"
|
|
||||||
fi
|
fi
|
||||||
elif [[ ! "$VERSION" =~ "-pre" ]]; then
|
elif [[ ! "$VERSION" =~ "-pre" ]]; then
|
||||||
printf "previous tag is a release, bumping by selected type.\n"
|
printf "previous tag is a release, bumping by selected type.\n"
|
||||||
@@ -159,7 +157,7 @@ jobs:
|
|||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' && !env.ACT }}
|
if: ${{ github.event_name == 'workflow_dispatch' && !env.ACT }}
|
||||||
with:
|
with:
|
||||||
name: Prerelease ${{ steps.ready_tag.outputs.tag_name }}
|
name: Release ${{ steps.ready_tag.outputs.tag_name }}
|
||||||
tag_name: ${{ steps.ready_tag.outputs.tag_name }}
|
tag_name: ${{ steps.ready_tag.outputs.tag_name }}
|
||||||
files: ./dist/PluginLoader
|
files: ./dist/PluginLoader
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<h1 align="center">
|
<h1 align="center">
|
||||||
<a name="logo" href="https://deckbrew.xyz/"><img src="https://deckbrew.xyz/logo.png" alt="Deckbrew logo" width="200"></a>
|
<a name="logo" href="https://deckbrew.xyz/"><img src="https://deckbrew.xyz/static/icon-45ca1f5aea376a9ad37e92db906f283e.png" alt="Deckbrew logo" width="200"></a>
|
||||||
<br>
|
<br>
|
||||||
Decky Loader
|
Decky Loader
|
||||||
<br>
|
<br>
|
||||||
@@ -57,7 +57,7 @@ For more information about Decky Loader as well as documentation and development
|
|||||||
Intended for plugin developers. Pre-releases are unlikely to be fully stable but contain the latest changes. For more information on plugin development, please consult [the wiki page](https://deckbrew.xyz/en/loader-dev/development).
|
Intended for plugin developers. Pre-releases are unlikely to be fully stable but contain the latest changes. For more information on plugin development, please consult [the wiki page](https://deckbrew.xyz/en/loader-dev/development).
|
||||||
1. Open the Return to Gaming Mode shortcut on your desktop.
|
1. Open the Return to Gaming Mode shortcut on your desktop.
|
||||||
|
|
||||||
- There is also a fast install for those who can use Konsole. Run `curl -L https://github.com/SteamDeckHomebrew/decky-loader/raw/main/dist/install_release.sh | sh` and type your password when prompted.
|
- There is also a fast install for those who can use Konsole. Run `curl -L https://github.com/SteamDeckHomebrew/decky-installer/releases/latest/download/install_release.sh | sh` and type your password when prompted.
|
||||||
|
|
||||||
### 👋 Uninstallation
|
### 👋 Uninstallation
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ We are sorry to see you go! If you are considering uninstalling because you are
|
|||||||
1. Press the <img src="./docs/images/light/steam.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/steam.svg#gh-light-mode-only" height=16> button and open the Power menu.
|
1. Press the <img src="./docs/images/light/steam.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/steam.svg#gh-light-mode-only" height=16> button and open the Power menu.
|
||||||
1. Select "Switch to Desktop".
|
1. Select "Switch to Desktop".
|
||||||
1. Run the installer file again, and select `uninstall decky loader`
|
1. Run the installer file again, and select `uninstall decky loader`
|
||||||
- There is also a fast uninstall for those who can use Konsole. Run `curl -L https://github.com/SteamDeckHomebrew/decky-loader/raw/main/dist/uninstall.sh | sh` and type your password when prompted.
|
- There is also a fast uninstall for those who can use Konsole. Run `curl -L https://github.com/SteamDeckHomebrew/decky-installer/releases/latest/download/uninstall.sh | sh` and type your password when prompted.
|
||||||
|
|
||||||
## 🚀 Getting Started
|
## 🚀 Getting Started
|
||||||
|
|
||||||
|
|||||||
+64
-43
@@ -5,6 +5,7 @@ import ssl
|
|||||||
import subprocess
|
import subprocess
|
||||||
import uuid
|
import uuid
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from subprocess import check_output
|
from subprocess import check_output
|
||||||
from time import sleep
|
from time import sleep
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
@@ -19,8 +20,6 @@ REMOTE_DEBUGGER_UNIT = "steam-web-debug-portforward.service"
|
|||||||
# global vars
|
# global vars
|
||||||
csrf_token = str(uuid.uuid4())
|
csrf_token = str(uuid.uuid4())
|
||||||
ssl_ctx = ssl.create_default_context(cafile=certifi.where())
|
ssl_ctx = ssl.create_default_context(cafile=certifi.where())
|
||||||
user = None
|
|
||||||
group = None
|
|
||||||
|
|
||||||
assets_regex = re.compile("^/plugins/.*/assets/.*")
|
assets_regex = re.compile("^/plugins/.*/assets/.*")
|
||||||
frontend_regex = re.compile("^/frontend/.*")
|
frontend_regex = re.compile("^/frontend/.*")
|
||||||
@@ -37,65 +36,87 @@ async def csrf_middleware(request, handler):
|
|||||||
return await handler(request)
|
return await handler(request)
|
||||||
return Response(text='Forbidden', status='403')
|
return Response(text='Forbidden', status='403')
|
||||||
|
|
||||||
# Get the user by checking for the first logged in user. As this is run
|
# Deprecated
|
||||||
# by systemd at startup the process is likely to start before the user
|
|
||||||
# logs in, so we will wait here until they are available. Note that
|
|
||||||
# other methods such as getenv wont work as there was no $SUDO_USER to
|
|
||||||
# start the systemd service.
|
|
||||||
def set_user():
|
def set_user():
|
||||||
global user
|
pass
|
||||||
cmd = "who | awk '{print $1}' | sort | head -1"
|
|
||||||
while user == None:
|
|
||||||
name = check_output(cmd, shell=True).decode().strip()
|
|
||||||
if name not in [None, '']:
|
|
||||||
user = name
|
|
||||||
sleep(0.1)
|
|
||||||
|
|
||||||
# Get the global user. get_user must be called first.
|
# 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:
|
def get_user() -> str:
|
||||||
global user
|
return pwd.getpwuid(get_user_id()).pw_name
|
||||||
if user == None:
|
|
||||||
raise ValueError("helpers.get_user method called before user variable was set. Run helpers.set_user first.")
|
|
||||||
return user
|
|
||||||
|
|
||||||
#Get the user owner of the given file path.
|
# 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:
|
def get_user_owner(file_path) -> str:
|
||||||
return pwd.getpwuid(os.stat(file_path).st_uid)[0]
|
return pwd.getpwuid(os.stat(file_path).st_uid).pw_name
|
||||||
|
|
||||||
#Get the user group of the given file path.
|
# Get the user group of the given file path.
|
||||||
def get_user_group(file_path) -> str:
|
def get_user_group(file_path) -> str:
|
||||||
return grp.getgrgid(os.stat(file_path).st_gid)[0]
|
return grp.getgrgid(os.stat(file_path).st_gid).gr_name
|
||||||
|
|
||||||
# Set the global user group. get_user must be called first
|
# Deprecated
|
||||||
def set_user_group() -> str:
|
def set_user_group() -> str:
|
||||||
global group
|
return get_user_group()
|
||||||
global user
|
|
||||||
if user == None:
|
|
||||||
raise ValueError("helpers.set_user_dir method called before user variable was set. Run helpers.set_user first.")
|
|
||||||
if group == None:
|
|
||||||
group = check_output(["id", "-g", "-n", user]).decode().strip()
|
|
||||||
|
|
||||||
# Get the group of the global user. set_user_group must be called first.
|
# 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:
|
def get_user_group() -> str:
|
||||||
global group
|
return grp.getgrgid(get_user_group_id()).gr_name
|
||||||
if group == None:
|
|
||||||
raise ValueError("helpers.get_user_group method called before group variable was set. Run helpers.set_user_group first.")
|
|
||||||
return group
|
|
||||||
|
|
||||||
# Get the default home path unless a user is specified
|
# Get the default home path unless a user is specified
|
||||||
def get_home_path(username = None) -> str:
|
def get_home_path(username = None) -> str:
|
||||||
if username == None:
|
if username == None:
|
||||||
raise ValueError("Username not defined, no home path can be found.")
|
username = get_user()
|
||||||
else:
|
return pwd.getpwnam(username).pw_dir
|
||||||
return str("/home/"+username)
|
|
||||||
|
|
||||||
# Get the default homebrew path unless a user is specified
|
# Get the default homebrew path unless a home_path is specified
|
||||||
def get_homebrew_path(home_path = None) -> str:
|
def get_homebrew_path(home_path = None) -> str:
|
||||||
if home_path == None:
|
if home_path == None:
|
||||||
raise ValueError("Home path not defined, homebrew dir cannot be determined.")
|
home_path = get_home_path()
|
||||||
else:
|
return os.path.join(home_path, "homebrew")
|
||||||
return str(home_path+"/homebrew")
|
|
||||||
# return str(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)
|
||||||
|
|
||||||
|
# Fetches the version of loader
|
||||||
|
def get_loader_version() -> str:
|
||||||
|
with open(os.path.join(os.path.dirname(sys.argv[0]), ".loader.version"), "r", encoding="utf-8") as version_file:
|
||||||
|
return version_file.readline().replace("\n", "")
|
||||||
|
|
||||||
# Download Remote Binaries to local Plugin
|
# Download Remote Binaries to local Plugin
|
||||||
async def download_remote_binary_to_path(url, binHash, path) -> bool:
|
async def download_remote_binary_to_path(url, binHash, path) -> bool:
|
||||||
|
|||||||
+1
-1
@@ -415,4 +415,4 @@ async def close_old_tabs():
|
|||||||
if not t.title or (t.title != "Steam Shared Context presented by Valve™" and t.title != "Steam" and t.title != "SP"):
|
if not t.title or (t.title != "Steam Shared Context presented by Valve™" and t.title != "Steam" and t.title != "SP"):
|
||||||
logger.debug("Closing tab: " + getattr(t, "title", "Untitled"))
|
logger.debug("Closing tab: " + getattr(t, "title", "Untitled"))
|
||||||
await t.close()
|
await t.close()
|
||||||
await sleep(0.5)
|
await sleep(0.5)
|
||||||
+7
-14
@@ -19,8 +19,7 @@ from aiohttp_jinja2 import setup as jinja_setup
|
|||||||
# local modules
|
# local modules
|
||||||
from browser import PluginBrowser
|
from browser import PluginBrowser
|
||||||
from helpers import (REMOTE_DEBUGGER_UNIT, csrf_middleware, get_csrf_token,
|
from helpers import (REMOTE_DEBUGGER_UNIT, csrf_middleware, get_csrf_token,
|
||||||
get_home_path, get_homebrew_path, get_user,
|
get_home_path, get_homebrew_path, get_user, get_user_group,
|
||||||
get_user_group, set_user, set_user_group,
|
|
||||||
stop_systemd_unit, start_systemd_unit)
|
stop_systemd_unit, start_systemd_unit)
|
||||||
from injector import get_gamepadui_tab, Tab, get_tabs, close_old_tabs
|
from injector import get_gamepadui_tab, Tab, get_tabs, close_old_tabs
|
||||||
from loader import Loader
|
from loader import Loader
|
||||||
@@ -28,18 +27,11 @@ from settings import SettingsManager
|
|||||||
from updater import Updater
|
from updater import Updater
|
||||||
from utilities import Utilities
|
from utilities import Utilities
|
||||||
|
|
||||||
# Ensure USER and GROUP vars are set first.
|
|
||||||
# TODO: This isn't the best way to do this but supports the current
|
|
||||||
# implementation. All the config load and environment setting eventually be
|
|
||||||
# moved into init or a config/loader method.
|
|
||||||
set_user()
|
|
||||||
set_user_group()
|
|
||||||
USER = get_user()
|
USER = get_user()
|
||||||
GROUP = get_user_group()
|
GROUP = get_user_group()
|
||||||
HOME_PATH = "/home/"+USER
|
HOMEBREW_PATH = get_homebrew_path()
|
||||||
HOMEBREW_PATH = HOME_PATH+"/homebrew"
|
|
||||||
CONFIG = {
|
CONFIG = {
|
||||||
"plugin_path": getenv("PLUGIN_PATH", HOMEBREW_PATH+"/plugins"),
|
"plugin_path": getenv("PLUGIN_PATH", path.join(HOMEBREW_PATH, "plugins")),
|
||||||
"chown_plugin_path": getenv("CHOWN_PLUGIN_PATH", "1") == "1",
|
"chown_plugin_path": getenv("CHOWN_PLUGIN_PATH", "1") == "1",
|
||||||
"server_host": getenv("SERVER_HOST", "127.0.0.1"),
|
"server_host": getenv("SERVER_HOST", "127.0.0.1"),
|
||||||
"server_port": int(getenv("SERVER_PORT", "1337")),
|
"server_port": int(getenv("SERVER_PORT", "1337")),
|
||||||
@@ -56,12 +48,15 @@ basicConfig(
|
|||||||
|
|
||||||
logger = getLogger("Main")
|
logger = getLogger("Main")
|
||||||
|
|
||||||
async def chown_plugin_dir():
|
def chown_plugin_dir():
|
||||||
code_chown = call(["chown", "-R", USER+":"+GROUP, CONFIG["plugin_path"]])
|
code_chown = call(["chown", "-R", USER+":"+GROUP, CONFIG["plugin_path"]])
|
||||||
code_chmod = call(["chmod", "-R", "555", CONFIG["plugin_path"]])
|
code_chmod = call(["chmod", "-R", "555", CONFIG["plugin_path"]])
|
||||||
if code_chown != 0 or code_chmod != 0:
|
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})")
|
logger.error(f"chown/chmod exited with a non-zero exit code (chown: {code_chown}, chmod: {code_chmod})")
|
||||||
|
|
||||||
|
if CONFIG["chown_plugin_path"] == True:
|
||||||
|
chown_plugin_dir()
|
||||||
|
|
||||||
class PluginManager:
|
class PluginManager:
|
||||||
def __init__(self, loop) -> None:
|
def __init__(self, loop) -> None:
|
||||||
self.loop = loop
|
self.loop = loop
|
||||||
@@ -87,8 +82,6 @@ class PluginManager:
|
|||||||
self.loop.create_task(start_systemd_unit(REMOTE_DEBUGGER_UNIT))
|
self.loop.create_task(start_systemd_unit(REMOTE_DEBUGGER_UNIT))
|
||||||
else:
|
else:
|
||||||
self.loop.create_task(stop_systemd_unit(REMOTE_DEBUGGER_UNIT))
|
self.loop.create_task(stop_systemd_unit(REMOTE_DEBUGGER_UNIT))
|
||||||
if CONFIG["chown_plugin_path"] == True:
|
|
||||||
chown_plugin_dir()
|
|
||||||
self.loop.create_task(self.loader_reinjector())
|
self.loop.create_task(self.loader_reinjector())
|
||||||
self.loop.create_task(self.load_plugins())
|
self.loop.create_task(self.load_plugins())
|
||||||
|
|
||||||
|
|||||||
+22
-3
@@ -7,10 +7,12 @@ from importlib.util import module_from_spec, spec_from_file_location
|
|||||||
from json import dumps, load, loads
|
from json import dumps, load, loads
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from traceback import format_exc
|
from traceback import format_exc
|
||||||
from os import path, setgid, setuid
|
from os import path, setgid, setuid, environ
|
||||||
from signal import SIGINT, signal
|
from signal import SIGINT, signal
|
||||||
from sys import exit
|
from sys import exit
|
||||||
from time import time
|
from time import time
|
||||||
|
import helpers
|
||||||
|
from updater import Updater
|
||||||
|
|
||||||
multiprocessing.set_start_method("fork")
|
multiprocessing.set_start_method("fork")
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ BUFFER_LIMIT = 2 ** 20 # 1 MiB
|
|||||||
class PluginWrapper:
|
class PluginWrapper:
|
||||||
def __init__(self, file, plugin_directory, plugin_path) -> None:
|
def __init__(self, file, plugin_directory, plugin_path) -> None:
|
||||||
self.file = file
|
self.file = file
|
||||||
|
self.plugin_path = plugin_path
|
||||||
self.plugin_directory = plugin_directory
|
self.plugin_directory = plugin_directory
|
||||||
self.reader = None
|
self.reader = None
|
||||||
self.writer = None
|
self.writer = None
|
||||||
@@ -56,8 +59,24 @@ class PluginWrapper:
|
|||||||
set_event_loop(new_event_loop())
|
set_event_loop(new_event_loop())
|
||||||
if self.passive:
|
if self.passive:
|
||||||
return
|
return
|
||||||
setgid(0 if "root" in self.flags else 1000)
|
setgid(0 if "root" in self.flags else helpers.get_user_group_id())
|
||||||
setuid(0 if "root" in self.flags else 1000)
|
setuid(0 if "root" in self.flags else helpers.get_user_id())
|
||||||
|
# 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["DECKY_VERSION"] = helpers.get_loader_version()
|
||||||
|
environ["DECKY_USER"] = helpers.get_user()
|
||||||
|
environ["DECKY_HOME"] = helpers.get_homebrew_path()
|
||||||
|
environ["DECKY_PLUGIN_SETTINGS_DIR"] = path.join(environ["DECKY_HOME"], "settings", self.plugin_directory)
|
||||||
|
helpers.mkdir_as_user(environ["DECKY_PLUGIN_SETTINGS_DIR"])
|
||||||
|
environ["DECKY_PLUGIN_RUNTIME_DIR"] = path.join(environ["DECKY_HOME"], "data", self.plugin_directory)
|
||||||
|
helpers.mkdir_as_user(environ["DECKY_PLUGIN_RUNTIME_DIR"])
|
||||||
|
environ["DECKY_PLUGIN_LOG_DIR"] = path.join(environ["DECKY_HOME"], "logs", self.plugin_directory)
|
||||||
|
helpers.mkdir_as_user(environ["DECKY_PLUGIN_LOG_DIR"])
|
||||||
|
environ["DECKY_PLUGIN_DIR"] = path.join(self.plugin_path, self.plugin_directory)
|
||||||
|
environ["DECKY_PLUGIN_NAME"] = self.name
|
||||||
|
environ["DECKY_PLUGIN_VERSION"] = self.version
|
||||||
|
environ["DECKY_PLUGIN_AUTHOR"] = self.author
|
||||||
spec = spec_from_file_location("_", self.file)
|
spec = spec_from_file_location("_", self.file)
|
||||||
module = module_from_spec(spec)
|
module = module_from_spec(spec)
|
||||||
spec.loader.exec_module(module)
|
spec.loader.exec_module(module)
|
||||||
|
|||||||
+5
-5
@@ -2,14 +2,14 @@ from json import dump, load
|
|||||||
from os import mkdir, path, listdir, rename
|
from os import mkdir, path, listdir, rename
|
||||||
from shutil import chown
|
from shutil import chown
|
||||||
|
|
||||||
from helpers import get_home_path, get_homebrew_path, get_user, set_user, get_user_owner
|
from helpers import get_home_path, get_homebrew_path, get_user, get_user_group, get_user_owner
|
||||||
|
|
||||||
|
|
||||||
class SettingsManager:
|
class SettingsManager:
|
||||||
def __init__(self, name, settings_directory = None) -> None:
|
def __init__(self, name, settings_directory = None) -> None:
|
||||||
set_user()
|
|
||||||
USER = get_user()
|
USER = get_user()
|
||||||
wrong_dir = get_homebrew_path(get_home_path(USER))
|
GROUP = get_user_group()
|
||||||
|
wrong_dir = get_homebrew_path()
|
||||||
if settings_directory == None:
|
if settings_directory == None:
|
||||||
settings_directory = path.join(wrong_dir, "settings")
|
settings_directory = path.join(wrong_dir, "settings")
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ class SettingsManager:
|
|||||||
#Create the folder with the correct permission
|
#Create the folder with the correct permission
|
||||||
if not path.exists(settings_directory):
|
if not path.exists(settings_directory):
|
||||||
mkdir(settings_directory)
|
mkdir(settings_directory)
|
||||||
chown(settings_directory, USER, USER)
|
chown(settings_directory, USER, GROUP)
|
||||||
|
|
||||||
#Copy all old settings file in the root directory to the correct folder
|
#Copy all old settings file in the root directory to the correct folder
|
||||||
for file in listdir(wrong_dir):
|
for file in listdir(wrong_dir):
|
||||||
@@ -30,7 +30,7 @@ class SettingsManager:
|
|||||||
|
|
||||||
#If the owner of the settings directory is not the user, then set it as the user:
|
#If the owner of the settings directory is not the user, then set it as the user:
|
||||||
if get_user_owner(settings_directory) != USER:
|
if get_user_owner(settings_directory) != USER:
|
||||||
chown(settings_directory, USER, USER)
|
chown(settings_directory, USER, GROUP)
|
||||||
|
|
||||||
self.settings = {}
|
self.settings = {}
|
||||||
|
|
||||||
|
|||||||
+2
-4
@@ -31,9 +31,7 @@ class Updater:
|
|||||||
self.remoteVer = None
|
self.remoteVer = None
|
||||||
self.allRemoteVers = None
|
self.allRemoteVers = None
|
||||||
try:
|
try:
|
||||||
logger.info(getcwd())
|
self.localVer = helpers.get_loader_version()
|
||||||
with open(path.join(getcwd(), ".loader.version"), "r", encoding="utf-8") as version_file:
|
|
||||||
self.localVer = version_file.readline().replace("\n", "")
|
|
||||||
except:
|
except:
|
||||||
self.localVer = False
|
self.localVer = False
|
||||||
|
|
||||||
@@ -161,7 +159,7 @@ class Updater:
|
|||||||
logger.error(f"Error at %s", exc_info=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:
|
with open(path.join(getcwd(), "plugin_loader.service"), "r", encoding="utf-8") as service_file:
|
||||||
service_data = service_file.read()
|
service_data = service_file.read()
|
||||||
service_data = service_data.replace("${HOMEBREW_FOLDER}", "/home/"+helpers.get_user()+"/homebrew")
|
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:
|
with open(path.join(getcwd(), "plugin_loader.service"), "w", encoding="utf-8") as service_file:
|
||||||
service_file.write(service_data)
|
service_file.write(service_data)
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -233,7 +233,7 @@ class Utilities:
|
|||||||
self.rdt_proxy_server.close()
|
self.rdt_proxy_server.close()
|
||||||
self.rdt_proxy_task.cancel()
|
self.rdt_proxy_task.cancel()
|
||||||
|
|
||||||
async def enable_rdt(self):
|
async def _enable_rdt(self):
|
||||||
# TODO un-hardcode port
|
# TODO un-hardcode port
|
||||||
try:
|
try:
|
||||||
self.stop_rdt_proxy()
|
self.stop_rdt_proxy()
|
||||||
@@ -243,11 +243,22 @@ class Utilities:
|
|||||||
self.logger.info("Connecting to React DevTools at " + ip)
|
self.logger.info("Connecting to React DevTools at " + ip)
|
||||||
async with ClientSession() as web:
|
async with ClientSession() as web:
|
||||||
res = await web.request("GET", "http://" + ip + ":8097", ssl=helpers.get_ssl_context())
|
res = await web.request("GET", "http://" + ip + ":8097", ssl=helpers.get_ssl_context())
|
||||||
|
script = """
|
||||||
|
if (!window.deckyHasConnectedRDT) {
|
||||||
|
window.deckyHasConnectedRDT = true;
|
||||||
|
// This fixes the overlay when hovering over an element in RDT
|
||||||
|
Object.defineProperty(window, '__REACT_DEVTOOLS_TARGET_WINDOW__', {
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true,
|
||||||
|
get: function() {
|
||||||
|
return FocusNavController?.m_ActiveContext?.ActiveWindow || window;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
""" + await res.text() + "\n}"
|
||||||
if res.status != 200:
|
if res.status != 200:
|
||||||
self.logger.error("Failed to connect to React DevTools at " + ip)
|
self.logger.error("Failed to connect to React DevTools at " + ip)
|
||||||
return False
|
return False
|
||||||
self.start_rdt_proxy(ip, 8097)
|
self.start_rdt_proxy(ip, 8097)
|
||||||
script = "if(!window.deckyHasConnectedRDT){window.deckyHasConnectedRDT=true;\n" + await res.text() + "\n}"
|
|
||||||
self.logger.info("Connected to React DevTools, loading script")
|
self.logger.info("Connected to React DevTools, loading script")
|
||||||
tab = await get_gamepadui_tab()
|
tab = await get_gamepadui_tab()
|
||||||
# RDT needs to load before React itself to work.
|
# RDT needs to load before React itself to work.
|
||||||
@@ -259,6 +270,9 @@ class Utilities:
|
|||||||
self.logger.error("Failed to connect to React DevTools")
|
self.logger.error("Failed to connect to React DevTools")
|
||||||
self.logger.error(format_exc())
|
self.logger.error(format_exc())
|
||||||
|
|
||||||
|
async def enable_rdt(self):
|
||||||
|
self.context.loop.create_task(self._enable_rdt())
|
||||||
|
|
||||||
async def disable_rdt(self):
|
async def disable_rdt(self):
|
||||||
self.logger.info("Disabling React DevTools")
|
self.logger.info("Disabling React DevTools")
|
||||||
tab = await get_gamepadui_tab()
|
tab = await get_gamepadui_tab()
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
declare module '*.png';
|
||||||
|
declare module '*.jpg';
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-commonjs": "^21.1.0",
|
"@rollup/plugin-commonjs": "^21.1.0",
|
||||||
|
"@rollup/plugin-image": "^3.0.1",
|
||||||
"@rollup/plugin-json": "^4.1.0",
|
"@rollup/plugin-json": "^4.1.0",
|
||||||
"@rollup/plugin-node-resolve": "^13.3.0",
|
"@rollup/plugin-node-resolve": "^13.3.0",
|
||||||
"@rollup/plugin-replace": "^4.0.0",
|
"@rollup/plugin-replace": "^4.0.0",
|
||||||
@@ -41,7 +42,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"decky-frontend-lib": "^3.18.6",
|
"decky-frontend-lib": "^3.18.10",
|
||||||
"i18next": "^22.0.6",
|
"i18next": "^22.0.6",
|
||||||
"i18next-fs-backend": "^2.0.0",
|
"i18next-fs-backend": "^2.0.0",
|
||||||
"react-file-icon": "^1.2.0",
|
"react-file-icon": "^1.2.0",
|
||||||
|
|||||||
Generated
+43
-8
@@ -2,6 +2,7 @@ lockfileVersion: 5.4
|
|||||||
|
|
||||||
specifiers:
|
specifiers:
|
||||||
'@rollup/plugin-commonjs': ^21.1.0
|
'@rollup/plugin-commonjs': ^21.1.0
|
||||||
|
'@rollup/plugin-image': ^3.0.1
|
||||||
'@rollup/plugin-json': ^4.1.0
|
'@rollup/plugin-json': ^4.1.0
|
||||||
'@rollup/plugin-node-resolve': ^13.3.0
|
'@rollup/plugin-node-resolve': ^13.3.0
|
||||||
'@rollup/plugin-replace': ^4.0.0
|
'@rollup/plugin-replace': ^4.0.0
|
||||||
@@ -10,7 +11,7 @@ specifiers:
|
|||||||
'@types/react-file-icon': ^1.0.1
|
'@types/react-file-icon': ^1.0.1
|
||||||
'@types/react-router': 5.1.18
|
'@types/react-router': 5.1.18
|
||||||
'@types/webpack': ^5.28.0
|
'@types/webpack': ^5.28.0
|
||||||
decky-frontend-lib: ^3.18.6
|
decky-frontend-lib: ^3.18.10
|
||||||
husky: ^8.0.1
|
husky: ^8.0.1
|
||||||
i18next: ^22.0.6
|
i18next: ^22.0.6
|
||||||
i18next-fs-backend: ^2.0.0
|
i18next-fs-backend: ^2.0.0
|
||||||
@@ -33,7 +34,7 @@ specifiers:
|
|||||||
typescript: ^4.7.4
|
typescript: ^4.7.4
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
decky-frontend-lib: 3.18.6
|
decky-frontend-lib: 3.18.10
|
||||||
i18next: 22.0.6
|
i18next: 22.0.6
|
||||||
i18next-fs-backend: 2.0.0
|
i18next-fs-backend: 2.0.0
|
||||||
react-file-icon: 1.2.0_wcqkhtmu7mswc6yz4uyexck3ty
|
react-file-icon: 1.2.0_wcqkhtmu7mswc6yz4uyexck3ty
|
||||||
@@ -44,6 +45,7 @@ dependencies:
|
|||||||
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@rollup/plugin-commonjs': 21.1.0_rollup@2.76.0
|
'@rollup/plugin-commonjs': 21.1.0_rollup@2.76.0
|
||||||
|
'@rollup/plugin-image': 3.0.1_rollup@2.76.0
|
||||||
'@rollup/plugin-json': 4.1.0_rollup@2.76.0
|
'@rollup/plugin-json': 4.1.0_rollup@2.76.0
|
||||||
'@rollup/plugin-node-resolve': 13.3.0_rollup@2.76.0
|
'@rollup/plugin-node-resolve': 13.3.0_rollup@2.76.0
|
||||||
'@rollup/plugin-replace': 4.0.0_rollup@2.76.0
|
'@rollup/plugin-replace': 4.0.0_rollup@2.76.0
|
||||||
@@ -384,6 +386,20 @@ packages:
|
|||||||
rollup: 2.76.0
|
rollup: 2.76.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@rollup/plugin-image/3.0.1_rollup@2.76.0:
|
||||||
|
resolution: {integrity: sha512-F50Sko4Xcc576x7HG9f3MvJKKnBfSmqfVFWJkJgyIEkI8YxZxux28lDbuy0+GsAK6BFl9Gn+TRXOUgHHJbFh3w==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
rollup: ^1.20.0||^2.0.0||^3.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
rollup:
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@rollup/pluginutils': 5.0.2_rollup@2.76.0
|
||||||
|
mini-svg-data-uri: 1.4.4
|
||||||
|
rollup: 2.76.0
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@rollup/plugin-inject/4.0.4_rollup@2.76.0:
|
/@rollup/plugin-inject/4.0.4_rollup@2.76.0:
|
||||||
resolution:
|
resolution:
|
||||||
{ integrity: sha512-4pbcU4J/nS+zuHk+c+OL3WtmEQhqxlZ9uqfjQMQDOHOPld7PsCd8k5LWs8h5wjwJN7MgnAn768F2sDxEP4eNFQ== }
|
{ integrity: sha512-4pbcU4J/nS+zuHk+c+OL3WtmEQhqxlZ9uqfjQMQDOHOPld7PsCd8k5LWs8h5wjwJN7MgnAn768F2sDxEP4eNFQ== }
|
||||||
@@ -474,6 +490,21 @@ packages:
|
|||||||
picomatch: 2.3.1
|
picomatch: 2.3.1
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@rollup/pluginutils/5.0.2_rollup@2.76.0:
|
||||||
|
resolution: {integrity: sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
rollup: ^1.20.0||^2.0.0||^3.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
rollup:
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@types/estree': 1.0.0
|
||||||
|
estree-walker: 2.0.2
|
||||||
|
picomatch: 2.3.1
|
||||||
|
rollup: 2.76.0
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/debug/4.1.7:
|
/@types/debug/4.1.7:
|
||||||
resolution:
|
resolution:
|
||||||
{ integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg== }
|
{ integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg== }
|
||||||
@@ -1082,9 +1113,8 @@ packages:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.2
|
ms: 2.1.2
|
||||||
|
|
||||||
/decky-frontend-lib/3.18.6:
|
/decky-frontend-lib/3.18.10:
|
||||||
resolution:
|
resolution: {integrity: sha512-2mgbA3sSkuwQR/FnmhXVrcW6LyTS95IuL6muJAmQCruhBvXapDtjk1TcgxqMZxFZwGD1IPnemPYxHZll6IgnZw==}
|
||||||
{ integrity: sha512-kM+kH/EuYCW+zsdYnNZa39EsU1bTF/iYn03zbeaTSIVwe/oxqOoEG2mwVaQdhk98jqY1uWZgayBQYnnTksMXFw== }
|
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/decode-named-character-reference/1.0.2:
|
/decode-named-character-reference/1.0.2:
|
||||||
@@ -2234,9 +2264,14 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/mimic-fn/2.1.0:
|
/mimic-fn/2.1.0:
|
||||||
resolution:
|
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
|
||||||
{ integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== }
|
engines: {node: '>=6'}
|
||||||
engines: { node: '>=6' }
|
dev: true
|
||||||
|
|
||||||
|
/mini-svg-data-uri/1.4.4:
|
||||||
|
resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==}
|
||||||
|
|
||||||
|
hasBin: true
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/minimatch/3.1.2:
|
/minimatch/3.1.2:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import commonjs from '@rollup/plugin-commonjs';
|
import commonjs from '@rollup/plugin-commonjs';
|
||||||
|
import image from '@rollup/plugin-image';
|
||||||
import json from '@rollup/plugin-json';
|
import json from '@rollup/plugin-json';
|
||||||
import { nodeResolve } from '@rollup/plugin-node-resolve';
|
import { nodeResolve } from '@rollup/plugin-node-resolve';
|
||||||
import replace from '@rollup/plugin-replace';
|
import replace from '@rollup/plugin-replace';
|
||||||
@@ -29,6 +30,7 @@ export default defineConfig({
|
|||||||
preventAssignment: false,
|
preventAssignment: false,
|
||||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||||
}),
|
}),
|
||||||
|
image(),
|
||||||
],
|
],
|
||||||
preserveEntrySignatures: false,
|
preserveEntrySignatures: false,
|
||||||
output: {
|
output: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ConfirmModal, Navigation, QuickAccessTab, Spinner, staticClasses } from 'decky-frontend-lib';
|
import { ConfirmModal, Navigation, QuickAccessTab } from 'decky-frontend-lib';
|
||||||
import { FC, useState } from 'react';
|
import { FC, useState } from 'react';
|
||||||
|
|
||||||
interface PluginInstallModalProps {
|
interface PluginInstallModalProps {
|
||||||
@@ -26,15 +26,14 @@ const PluginInstallModal: FC<PluginInstallModalProps> = ({ artifact, version, ha
|
|||||||
onCancel={async () => {
|
onCancel={async () => {
|
||||||
await onCancel();
|
await onCancel();
|
||||||
}}
|
}}
|
||||||
|
strTitle={`Install ${artifact}`}
|
||||||
|
strOKButtonText={loading ? 'Installing' : 'Install'}
|
||||||
>
|
>
|
||||||
<div className={staticClasses.Title} style={{ flexDirection: 'column' }}>
|
{hash == 'False' ? (
|
||||||
{hash == 'False' ? <h3 style={{ color: 'red' }}>!!!!NO HASH PROVIDED!!!!</h3> : null}
|
<h3 style={{ color: 'red' }}>!!!!NO HASH PROVIDED!!!!</h3>
|
||||||
<div style={{ flexDirection: 'row' }}>
|
) : (
|
||||||
{loading && <Spinner style={{ width: '20px' }} />} {loading ? 'Installing' : 'Install'} {artifact}
|
`Are you sure you want to install ${artifact} ${version}?`
|
||||||
{version ? ' version ' + version : null}
|
)}
|
||||||
{!loading && '?'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ConfirmModal>
|
</ConfirmModal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
DialogButton,
|
ButtonItem,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
Focusable,
|
Focusable,
|
||||||
Navigation,
|
PanelSectionRow,
|
||||||
QuickAccessTab,
|
|
||||||
SingleDropdownOption,
|
SingleDropdownOption,
|
||||||
SuspensefulImage,
|
SuspensefulImage,
|
||||||
joinClassNames,
|
|
||||||
staticClasses,
|
|
||||||
} from 'decky-frontend-lib';
|
} from 'decky-frontend-lib';
|
||||||
import { FC, useRef, useState } from 'react';
|
import { FC, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { StorePlugin, StorePluginVersion, requestPluginInstall } from '../../store';
|
import { StorePlugin, StorePluginVersion, requestPluginInstall } from '../../store';
|
||||||
@@ -22,168 +19,162 @@ const { t } = useTranslation('PluginCard');
|
|||||||
|
|
||||||
const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
|
const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
|
||||||
const [selectedOption, setSelectedOption] = useState<number>(0);
|
const [selectedOption, setSelectedOption] = useState<number>(0);
|
||||||
const buttonRef = useRef<HTMLDivElement>(null);
|
const root: boolean = plugin.tags.some((tag) => tag === 'root');
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className="deckyStoreCard"
|
||||||
style={{
|
style={{
|
||||||
padding: '30px',
|
marginLeft: '20px',
|
||||||
paddingTop: '10px',
|
marginRight: '20px',
|
||||||
paddingBottom: '10px',
|
marginBottom: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* TODO: abstract this messy focus hackiness into a custom component in lib */}
|
<div
|
||||||
<Focusable
|
className="deckyStoreCardImageContainer"
|
||||||
className="deckyStoreCard"
|
|
||||||
ref={containerRef}
|
|
||||||
onActivate={(_: CustomEvent) => {
|
|
||||||
buttonRef.current!.focus();
|
|
||||||
}}
|
|
||||||
onCancel={(_: CustomEvent) => {
|
|
||||||
if (containerRef.current!.querySelectorAll('* :focus').length === 0) {
|
|
||||||
Navigation.NavigateBack();
|
|
||||||
setTimeout(() => Navigation.OpenQuickAccessMenu(QuickAccessTab.Decky), 1000);
|
|
||||||
} else {
|
|
||||||
containerRef.current!.focus();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
width: '320px',
|
||||||
flexDirection: 'column',
|
height: '200px',
|
||||||
background: '#ACB2C924',
|
position: 'relative',
|
||||||
height: 'unset',
|
|
||||||
marginBottom: 'unset',
|
|
||||||
// boxShadow: var(--gpShadow-Medium);
|
|
||||||
scrollSnapAlign: 'start',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="deckyStoreCardHeader" style={{ display: 'flex', alignItems: 'center' }}>
|
<SuspensefulImage
|
||||||
<div
|
className="deckyStoreCardImage"
|
||||||
style={{ fontSize: '18pt', padding: '10px' }}
|
suspenseHeight="200px"
|
||||||
className={joinClassNames(staticClasses.Text)}
|
suspenseWidth="320px"
|
||||||
// onClick={() => Router.NavigateToExternalWeb('https://github.com/' + plugin.artifact)}
|
|
||||||
>
|
|
||||||
{plugin.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
width: '320px',
|
||||||
flexDirection: 'row',
|
height: '200px',
|
||||||
|
objectFit: 'cover',
|
||||||
}}
|
}}
|
||||||
className="deckyStoreCardBody"
|
src={plugin.image_url}
|
||||||
>
|
/>
|
||||||
<SuspensefulImage
|
</div>
|
||||||
className="deckyStoreCardImage"
|
<div
|
||||||
suspenseWidth="256px"
|
className="deckyStoreCardInfo"
|
||||||
style={{
|
style={{
|
||||||
width: 'auto',
|
width: 'calc(100% - 320px)', // The calc is here so that the info section doesn't expand into the image
|
||||||
height: '160px',
|
display: 'flex',
|
||||||
}}
|
flexDirection: 'column',
|
||||||
src={plugin.image_url}
|
height: '100%',
|
||||||
/>
|
marginLeft: '1em',
|
||||||
<div
|
justifyContent: 'center',
|
||||||
style={{
|
}}
|
||||||
display: 'flex',
|
>
|
||||||
flexDirection: 'column',
|
<span
|
||||||
}}
|
className="deckyStoreCardTitle"
|
||||||
className="deckyStoreCardInfo"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
className={joinClassNames(staticClasses.PanelSectionRow)}
|
|
||||||
style={{ marginTop: '0px', marginLeft: '16px' }}
|
|
||||||
>
|
|
||||||
<span style={{ paddingLeft: '0px' }}>Author: {plugin.author}</span>
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
className={joinClassNames(staticClasses.PanelSectionRow)}
|
|
||||||
style={{
|
|
||||||
marginLeft: '16px',
|
|
||||||
marginTop: '0px',
|
|
||||||
marginBottom: '0px',
|
|
||||||
marginRight: '16px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ paddingLeft: '0px' }}>{plugin.description}</span>
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
className={joinClassNames('deckyStoreCardTagsContainer', staticClasses.PanelSectionRow)}
|
|
||||||
style={{
|
|
||||||
padding: '0 16px',
|
|
||||||
display: 'flex',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: '5px 10px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ padding: '5px 0' }}>Tags:</span>
|
|
||||||
{plugin.tags.map((tag: string) => (
|
|
||||||
<span
|
|
||||||
className="deckyStoreCardTag"
|
|
||||||
style={{
|
|
||||||
padding: '5px',
|
|
||||||
borderRadius: '5px',
|
|
||||||
background: tag == 'root' ? '#842029' : '#ACB2C947',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tag == 'root' ? 'Requires root' : tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="deckyStoreCardActionsContainer"
|
|
||||||
style={{
|
style={{
|
||||||
|
fontSize: '1.25em',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
width: '90%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{plugin.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="deckyStoreCardAuthor"
|
||||||
|
style={{
|
||||||
|
marginRight: 'auto',
|
||||||
|
fontSize: '1em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{plugin.author}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="deckyStoreCardDescription"
|
||||||
|
style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#969696',
|
||||||
|
WebkitLineClamp: root ? '2' : '3',
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: '-webkit-box',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{plugin.description ? (
|
||||||
|
plugin.description
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
<i style={{ color: '#666' }}>No description provided.</i>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{root && (
|
||||||
|
<span
|
||||||
|
className="deckyStoreCardDescription deckyStoreCardDescriptionRoot"
|
||||||
|
style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#fee75c',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i>This plugin has full access to your Steam Deck.</i>{' '}
|
||||||
|
<a
|
||||||
|
className="deckyStoreCardDescriptionRootLink"
|
||||||
|
href="https://deckbrew.xyz/root"
|
||||||
|
target="_blank"
|
||||||
|
style={{
|
||||||
|
color: '#fee75c',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
deckbrew.xyz/root
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="deckyStoreCardButtonRow"
|
||||||
|
style={{
|
||||||
|
marginTop: '1em',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
alignSelf: 'flex-end',
|
overflow: 'hidden',
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Focusable
|
<PanelSectionRow>
|
||||||
className="deckyStoreCardActions"
|
<Focusable style={{ display: 'flex', maxWidth: '100%' }}>
|
||||||
style={{
|
<div
|
||||||
display: 'flex',
|
className="deckyStoreCardInstallContainer"
|
||||||
flexDirection: 'row',
|
style={{
|
||||||
width: '100%',
|
paddingTop: '0px',
|
||||||
}}
|
paddingBottom: '0px',
|
||||||
>
|
width: '40%',
|
||||||
<div
|
}}
|
||||||
className="deckyStoreCardInstallButtonContainer"
|
|
||||||
style={{
|
|
||||||
flex: '1',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogButton
|
|
||||||
className="deckyStoreCardInstallButton"
|
|
||||||
ref={buttonRef}
|
|
||||||
onClick={() => requestPluginInstall(plugin.name, plugin.versions[selectedOption])}
|
|
||||||
>
|
>
|
||||||
Install
|
<ButtonItem
|
||||||
</DialogButton>
|
bottomSeparator="none"
|
||||||
</div>
|
layout="below"
|
||||||
<div
|
onClick={() => requestPluginInstall(plugin.name, plugin.versions[selectedOption])}
|
||||||
className="deckyStoreCardVersionDropdownContainer"
|
>
|
||||||
style={{
|
<span className="deckyStoreCardInstallText">Install</span>
|
||||||
flex: '0.2',
|
</ButtonItem>
|
||||||
}}
|
</div>
|
||||||
>
|
<div
|
||||||
<Dropdown
|
className="deckyStoreCardVersionContainer"
|
||||||
rgOptions={
|
style={{
|
||||||
plugin.versions.map((version: StorePluginVersion, index) => ({
|
marginLeft: '5%',
|
||||||
data: index,
|
width: '30%',
|
||||||
label: version.name,
|
}}
|
||||||
})) as SingleDropdownOption[]
|
>
|
||||||
}
|
<Dropdown
|
||||||
strDefaultLabel={t('select_version') as string}
|
rgOptions={
|
||||||
selectedOption={selectedOption}
|
plugin.versions.map((version: StorePluginVersion, index) => ({
|
||||||
onChange={({ data }) => setSelectedOption(data)}
|
data: index,
|
||||||
/>
|
label: version.name,
|
||||||
</div>
|
})) as SingleDropdownOption[]
|
||||||
</Focusable>
|
}
|
||||||
|
menuLabel="Plugin Version"
|
||||||
|
selectedOption={selectedOption}
|
||||||
|
onChange={({ data }) => setSelectedOption(data)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Focusable>
|
||||||
|
</PanelSectionRow>
|
||||||
</div>
|
</div>
|
||||||
</Focusable>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { SteamSpinner } from 'decky-frontend-lib';
|
import {
|
||||||
import { FC, useEffect, useState } from 'react';
|
Dropdown,
|
||||||
|
DropdownOption,
|
||||||
|
Focusable,
|
||||||
|
PanelSectionRow,
|
||||||
|
SteamSpinner,
|
||||||
|
Tabs,
|
||||||
|
TextField,
|
||||||
|
findModule,
|
||||||
|
} from 'decky-frontend-lib';
|
||||||
|
import { FC, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import logo from '../../../assets/plugin_store.png';
|
||||||
import Logger from '../../logger';
|
import Logger from '../../logger';
|
||||||
import { StorePlugin, getPluginList } from '../../store';
|
import { StorePlugin, getPluginList } from '../../store';
|
||||||
import PluginCard from './PluginCard';
|
import PluginCard from './PluginCard';
|
||||||
@@ -8,7 +18,12 @@ import PluginCard from './PluginCard';
|
|||||||
const logger = new Logger('FilePicker');
|
const logger = new Logger('FilePicker');
|
||||||
|
|
||||||
const StorePage: FC<{}> = () => {
|
const StorePage: FC<{}> = () => {
|
||||||
|
const [currentTabRoute, setCurrentTabRoute] = useState<string>('browse');
|
||||||
const [data, setData] = useState<StorePlugin[] | null>(null);
|
const [data, setData] = useState<StorePlugin[] | null>(null);
|
||||||
|
const { TabCount } = findModule((m) => {
|
||||||
|
if (m?.TabCount && m?.TabTitle) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -19,19 +34,12 @@ const StorePage: FC<{}> = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
style={{
|
|
||||||
marginTop: '40px',
|
|
||||||
height: 'calc( 100% - 40px )',
|
|
||||||
overflowY: 'scroll',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
marginTop: '40px',
|
||||||
flexWrap: 'nowrap',
|
height: 'calc( 100% - 40px )',
|
||||||
flexDirection: 'column',
|
background: '#0005',
|
||||||
height: '100%',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!data ? (
|
{!data ? (
|
||||||
@@ -39,13 +47,193 @@ const StorePage: FC<{}> = () => {
|
|||||||
<SteamSpinner />
|
<SteamSpinner />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<Tabs
|
||||||
{data.map((plugin: StorePlugin) => (
|
activeTab={currentTabRoute}
|
||||||
<PluginCard plugin={plugin} />
|
onShowTab={(tabId: string) => {
|
||||||
))}
|
setCurrentTabRoute(tabId);
|
||||||
</div>
|
}}
|
||||||
|
tabs={[
|
||||||
|
{
|
||||||
|
title: 'Browse',
|
||||||
|
content: <BrowseTab children={{ data: data }} />,
|
||||||
|
id: 'browse',
|
||||||
|
renderTabAddon: () => <span className={TabCount}>{data.length}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'About',
|
||||||
|
content: <AboutTab />,
|
||||||
|
id: 'about',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BrowseTab: FC<{ children: { data: StorePlugin[] } }> = (data) => {
|
||||||
|
const sortOptions = useMemo(
|
||||||
|
(): DropdownOption[] => [
|
||||||
|
{ data: 1, label: 'Alphabetical (A to Z)' },
|
||||||
|
{ data: 2, label: 'Alphabetical (Z to A)' },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// const filterOptions = useMemo((): DropdownOption[] => [{ data: 1, label: 'All' }], []);
|
||||||
|
|
||||||
|
const [selectedSort, setSort] = useState<number>(sortOptions[0].data);
|
||||||
|
// const [selectedFilter, setFilter] = useState<number>(filterOptions[0].data);
|
||||||
|
const [searchFieldValue, setSearchValue] = useState<string>('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{`
|
||||||
|
.deckyStoreCardInstallContainer > .Panel {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
{/* This should be used once filtering is added
|
||||||
|
|
||||||
|
<PanelSectionRow>
|
||||||
|
<Focusable style={{ display: 'flex', maxWidth: '100%' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: '47.5%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="DialogLabel">Sort</span>
|
||||||
|
<Dropdown
|
||||||
|
menuLabel="Sort"
|
||||||
|
rgOptions={sortOptions}
|
||||||
|
strDefaultLabel="Last Updated (Newest)"
|
||||||
|
selectedOption={selectedSort}
|
||||||
|
onChange={(e) => setSort(e.data)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: '47.5%',
|
||||||
|
marginLeft: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="DialogLabel">Filter</span>
|
||||||
|
<Dropdown
|
||||||
|
menuLabel="Filter"
|
||||||
|
rgOptions={filterOptions}
|
||||||
|
strDefaultLabel="All"
|
||||||
|
selectedOption={selectedFilter}
|
||||||
|
onChange={(e) => setFilter(e.data)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Focusable>
|
||||||
|
</PanelSectionRow>
|
||||||
|
<div style={{ justifyContent: 'center', display: 'flex' }}>
|
||||||
|
<Focusable style={{ display: 'flex', alignItems: 'center', width: '96%' }}>
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<TextField label="Search" value={searchFieldValue} onChange={(e) => setSearchValue(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</Focusable>
|
||||||
|
</div>
|
||||||
|
*/}
|
||||||
|
<PanelSectionRow>
|
||||||
|
<Focusable style={{ display: 'flex', maxWidth: '100%' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minWidth: '100%',
|
||||||
|
maxWidth: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="DialogLabel">Sort</span>
|
||||||
|
<Dropdown
|
||||||
|
menuLabel="Sort"
|
||||||
|
rgOptions={sortOptions}
|
||||||
|
strDefaultLabel="Last Updated (Newest)"
|
||||||
|
selectedOption={selectedSort}
|
||||||
|
onChange={(e) => setSort(e.data)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Focusable>
|
||||||
|
</PanelSectionRow>
|
||||||
|
<div style={{ justifyContent: 'center', display: 'flex' }}>
|
||||||
|
<Focusable style={{ display: 'flex', alignItems: 'center', width: '96%' }}>
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<TextField label="Search" value={searchFieldValue} onChange={(e) => setSearchValue(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</Focusable>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{data.children.data
|
||||||
|
.filter((plugin: StorePlugin) => {
|
||||||
|
return (
|
||||||
|
plugin.name.toLowerCase().includes(searchFieldValue.toLowerCase()) ||
|
||||||
|
plugin.description.toLowerCase().includes(searchFieldValue.toLowerCase()) ||
|
||||||
|
plugin.author.toLowerCase().includes(searchFieldValue.toLowerCase()) ||
|
||||||
|
plugin.tags.some((tag: string) => tag.toLowerCase().includes(searchFieldValue.toLowerCase()))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (selectedSort % 2 === 1) return a.name.localeCompare(b.name);
|
||||||
|
else return b.name.localeCompare(a.name);
|
||||||
|
})
|
||||||
|
.map((plugin: StorePlugin) => (
|
||||||
|
<PluginCard plugin={plugin} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AboutTab: FC<{}> = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style>{`
|
||||||
|
.deckyStoreAboutHeader {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<img
|
||||||
|
src={logo}
|
||||||
|
style={{
|
||||||
|
width: '256px',
|
||||||
|
height: 'auto',
|
||||||
|
alignSelf: 'center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="deckyStoreAboutHeader">Testing</span>
|
||||||
|
<span>
|
||||||
|
Please consider testing new plugins to help the Decky Loader team!{' '}
|
||||||
|
<a
|
||||||
|
href="https://deckbrew.xyz/testing"
|
||||||
|
target="_blank"
|
||||||
|
style={{
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
deckbrew.xyz/testing
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
<span className="deckyStoreAboutHeader">Contributing</span>
|
||||||
|
<span>
|
||||||
|
If you would like to contribute to the Decky Plugin Store, check the SteamDeckHomebrew/decky-plugin-template
|
||||||
|
repository on GitHub. Information on development and distribution is available in the README.
|
||||||
|
</span>
|
||||||
|
<span className="deckyStoreAboutHeader">Source Code</span>
|
||||||
|
<span>All plugin source code is available on SteamDeckHomebrew/decky-plugin-database repository on GitHub.</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import './i18n';
|
import './i18n';
|
||||||
|
|
||||||
|
import { Navigation, Router, sleep } from 'decky-frontend-lib';
|
||||||
|
|
||||||
import PluginLoader from './plugin-loader';
|
import PluginLoader from './plugin-loader';
|
||||||
import { DeckyUpdater } from './updater';
|
import { DeckyUpdater } from './updater';
|
||||||
|
|
||||||
@@ -16,6 +18,23 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
if (!Router.NavigateToAppProperties || !Router.NavigateToLibraryTab || !Router.NavigateToInvites) {
|
||||||
|
while (!Navigation.NavigateToAppProperties) await sleep(100);
|
||||||
|
const shims = {
|
||||||
|
NavigateToAppProperties: Navigation.NavigateToAppProperties,
|
||||||
|
NavigateToInvites: Navigation.NavigateToInvites,
|
||||||
|
NavigateToLibraryTab: Navigation.NavigateToLibraryTab,
|
||||||
|
};
|
||||||
|
(Router as unknown as any).deckyShim = true;
|
||||||
|
Object.assign(Router, shims);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[DECKY]: Error initializing Navigation interface shims', e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
window.deckyAuthToken = await fetch('http://127.0.0.1:1337/auth/token').then((r) => r.text());
|
window.deckyAuthToken = await fetch('http://127.0.0.1:1337/auth/token').then((r) => r.text());
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,4 @@
|
|||||||
import {
|
import { ConfirmModal, ModalRoot, Patch, QuickAccessTab, Router, showModal, sleep } from 'decky-frontend-lib';
|
||||||
ConfirmModal,
|
|
||||||
ModalRoot,
|
|
||||||
Patch,
|
|
||||||
QuickAccessTab,
|
|
||||||
Router,
|
|
||||||
showModal,
|
|
||||||
sleep,
|
|
||||||
staticClasses,
|
|
||||||
} from 'decky-frontend-lib';
|
|
||||||
import { FC, lazy } from 'react';
|
import { FC, lazy } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { FaCog, FaExclamationCircle, FaPlug } from 'react-icons/fa';
|
import { FaCog, FaExclamationCircle, FaPlug } from 'react-icons/fa';
|
||||||
@@ -163,10 +154,10 @@ class PluginLoader extends Logger {
|
|||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
// do nothing
|
// do nothing
|
||||||
}}
|
}}
|
||||||
|
strTitle={`Uninstall ${name}`}
|
||||||
|
strOKButtonText={'Uninstall'}
|
||||||
>
|
>
|
||||||
<div className={staticClasses.Title} style={{ flexDirection: 'column' }}>
|
{t('plugin_uninstall', name)}
|
||||||
{t('plugin_uninstall', name)}
|
|
||||||
</div>
|
|
||||||
</ConfirmModal>,
|
</ConfirmModal>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -353,6 +344,7 @@ class PluginLoader extends Logger {
|
|||||||
fetchNoCors(url: string, request: any = {}) {
|
fetchNoCors(url: string, request: any = {}) {
|
||||||
let args = { method: 'POST', headers: {} };
|
let args = { method: 'POST', headers: {} };
|
||||||
const req = { ...args, ...request, url, data: request.body };
|
const req = { ...args, ...request, url, data: request.body };
|
||||||
|
req?.body && delete req.body
|
||||||
return this.callServerMethod('http_request', req);
|
return this.callServerMethod('http_request', req);
|
||||||
},
|
},
|
||||||
executeInTab(tab: string, runAsync: boolean, code: string) {
|
executeInTab(tab: string, runAsync: boolean, code: string) {
|
||||||
|
|||||||
@@ -18,6 +18,6 @@
|
|||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src", "index.d.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -2,4 +2,4 @@ aiohttp==3.8.1
|
|||||||
aiohttp-jinja2==1.5.0
|
aiohttp-jinja2==1.5.0
|
||||||
aiohttp_cors==0.7.0
|
aiohttp_cors==0.7.0
|
||||||
watchdog==2.1.7
|
watchdog==2.1.7
|
||||||
certifi==2022.6.15
|
certifi==2022.12.7
|
||||||
Reference in New Issue
Block a user