Compare commits

...

12 Commits

Author SHA1 Message Date
Party Wumpus 922d0c4153 Appease prettier
i must have done a great deal of harm in a past life to deserve this mistreatment by formatting tools. why do they hate me.
2024-02-15 12:15:05 +00:00
Party Wumpus ecf480059b fix finding qam root node for feb 14th beta 2024-02-15 12:09:21 +00:00
Andrew Moore 7d6b8805df [Feature] Freeze updates for devs (#582) 2024-02-14 20:45:55 -08:00
eXhumer 0dce3a8cbe Get plugin name for development ZIP during installation (#578)
* fix: get plugin name for dev builds from ZIP (SteamDeckHomebrew/decky-loader#527)

Signed-off-by: eXhumer <exhumer1@protonmail.com>
2024-02-14 20:17:26 -08:00
Party Wumpus 9503d5cee0 Testing PRs from within decky (#496)
* git no work so manually uploading files :(

* argh i wish git was working

* ok next time i'll make git work

* Update updater.py

* git please work next time this took ages without you

* fix me locales

* Update updater.py

* Update en-US.json

* Update updater.py

* Update updater.py

* i wish my python LSP stuff was working

* fix it

* Update updater.py

* Update updater.py

* Only show testing branch as an option if it is already selected

* Initial implementation for fetching the open PRs. Still need testing and a token to complete this.

* Wrong filter capitalization

* Fix a couple of typos in the python backend updater.

* Fix typos pt 3

* This should be the last one

* Prepend the PR version number with PR- to make it clearer that's the PR number.

* Update prettier to the latest version otherwise it will never be happy with the formatting.

* fix merge mistake

* fix pyright errors & type hint most new code

* fix strict pyright errors...

* not sure why my local linter didn't catch this

* Reimplement the logic between PR and artifact build to limit API calls

* Fix pyright errors

* use nightly.link for downloads

* remove accidental dollar sign

* fix various logical errors. the code actually works now.

* set branch to testing when user downloads a testing version

---------

Co-authored-by: Marco Rodolfi <marco.rodolfi@tuta.io>
2024-02-14 18:32:58 -08:00
Jozen Blue Martinez 435dfa7884 fix(filepicker_ls): use case insensitive matching for file exts (#585) 2024-02-10 11:34:16 -08:00
Party Wumpus 2500b748ce Revert "Call plugin unload function after stopping event loop (#539)" (#584)
This reverts commit 39f4f2870b , because functions (seemingly) don't run after the event loop closes, so the unload function is never actually run.
2024-02-09 20:33:47 +00:00
Party Wumpus fd4ed811be Refactor plugin store and add sorting by downloads and release date (#547)
* untested first commit

* fix types & names

* comment out built in sorting for now

* rerun search when sort changes

* fix ts complaints

* use prettier

* stop switch-case fall through

* move spinner

* use locale instead of hardcoded string

* fix typo

* add sorting by downloads & try using the data field in the dropdown for data

* fix typing error

* fix asc/desc in dropdown

* fix asc/desc again. asc = smaller one go first aaaaa

* I don't think i know what ascending means maybe

* use props instead of children, like a normal component
2024-02-07 17:38:08 +00:00
Party Wumpus 3e4c255c5b Specify catthehacker/ubuntu:act-22.04 as container for act
Fixes an issue where act wouldn't use the correct container and so couldn't find a compatible python version, so it would fail to build.
2024-02-06 19:49:57 +00:00
AAGaming 62e3128d64 fix: bump dfl to fix error on latest steam beta 2024-02-03 00:33:39 -05:00
AAGaming 7f2caa3ea9 fix: use findInReactTree to find correct errorboundary for toaster
fixes toaster error on latest beta
2024-02-03 00:33:00 -05:00
AAGaming 6b4a56c7dc fix the tasks 2024-02-03 00:32:32 -05:00
24 changed files with 506 additions and 162 deletions
+2 -2
View File
@@ -38,7 +38,7 @@
"type": "shell",
"group": "none",
"detail": "Check for local runs, create a plugins folder",
"command": "rsync -azp --rsh='ssh -p ${config:deckport} ${config:deckkey}' requirements.txt deck@${config:deckip}:${config:deckdir}/homebrew/dev/pluginloader/requirements.txt && ssh deck@${config:deckip} -p ${config:deckport} ${config:deckkey} 'python -m ensurepip && python -m pip install --upgrade pip && python -m pip install --upgrade setuptools && python -m pip install -r ${config:deckdir}/homebrew/dev/pluginloader/requirements.txt'",
"command": "rsync -azp --rsh='ssh -p ${config:deckport} ${config:deckkey}' backend/requirements.txt deck@${config:deckip}:${config:deckdir}/homebrew/dev/pluginloader/backend/requirements.txt && ssh deck@${config:deckip} -p ${config:deckport} ${config:deckkey} 'python -m ensurepip && python -m pip install --upgrade --break-system-packages pip && python -m pip install --break-system-packages --upgrade setuptools && python -m pip install --break-system-packages -r ${config:deckdir}/homebrew/dev/pluginloader/backend/requirements.txt'",
"problemMatcher": []
},
{
@@ -105,7 +105,7 @@
"detail": "Deploy dev PluginLoader to deck",
"type": "shell",
"group": "none",
"command": "rsync -azp --delete --rsh='ssh -p ${config:deckport} ${config:deckkey}' --exclude='.git/' --exclude='.github/' --exclude='.vscode/' --exclude='frontend/' --exclude='dist/' --exclude='contrib/' --exclude='*.log' --exclude='requirements.txt' --exclude='backend/__pycache__/' --exclude='.gitignore' . deck@${config:deckip}:${config:deckdir}/homebrew/dev/pluginloader",
"command": "rsync -azp --delete --rsh='ssh -p ${config:deckport} ${config:deckkey}' --exclude='.git/' --exclude='.github/' --exclude='.vscode/' --exclude='frontend/' --exclude='dist/' --exclude='contrib/' --exclude='*.log' --exclude='requirements.txt' --exclude='**/__pycache__/' --exclude='.gitignore' . deck@${config:deckip}:${config:deckdir}/homebrew/dev/pluginloader",
"problemMatcher": []
},
// RUN
+2 -2
View File
@@ -26,10 +26,10 @@ cd ..
if [[ "$type" == "release" ]]; then
printf "release!\n"
act workflow_dispatch -e act/release.json --artifact-server-path act/artifacts --container-architecture linux/amd64
act workflow_dispatch -e act/release.json --artifact-server-path act/artifacts --container-architecture linux/amd64 --platform ubuntu-22.04=catthehacker/ubuntu:act-22.04
elif [[ "$type" == "prerelease" ]]; then
printf "prerelease!\n"
act workflow_dispatch -e act/prerelease.json --artifact-server-path act/artifacts --container-architecture linux/amd64
act workflow_dispatch -e act/prerelease.json --artifact-server-path act/artifacts --container-architecture linux/amd64 --platform ubuntu-22.04=catthehacker/ubuntu:act-22.04
else
printf "Release type unspecified/badly specified.\n"
printf "Options: 'release' or 'prerelease'\n"
+12 -2
View File
@@ -99,12 +99,14 @@
}
},
"PluginListIndex": {
"freeze": "Freeze updates",
"hide": "Quick access: Hide",
"no_plugin": "No plugins installed!",
"plugin_actions": "Plugin Actions",
"reinstall": "Reinstall",
"reload": "Reload",
"show": "Quick access: Show",
"unfreeze": "Allow updates",
"uninstall": "Uninstall",
"update_all_one": "Update 1 plugin",
"update_all_other": "Update {{count}} plugins",
@@ -192,7 +194,8 @@
"SettingsIndex": {
"developer_title": "Developer",
"general_title": "General",
"plugins_title": "Plugins"
"plugins_title": "Plugins",
"testing_title": "Testing"
},
"Store": {
"store_contrib": {
@@ -218,6 +221,10 @@
"about": "About",
"alph_asce": "Alphabetical (Z to A)",
"alph_desc": "Alphabetical (A to Z)",
"date_asce": "Oldest First",
"date_desc": "Newest First",
"downloads_asce": "Least Downloaded First",
"downloads_desc": "Most Downloaded First",
"title": "Browse"
},
"store_testing_cta": "Please consider testing new plugins to help the Decky Loader team!",
@@ -256,5 +263,8 @@
"reloading": "Reloading",
"updating": "Updating"
}
}
},
"Testing": {
"download": "Download"
}
}
+28 -7
View File
@@ -10,6 +10,7 @@ from hashlib import sha256
from io import BytesIO
from logging import getLogger
from os import R_OK, W_OK, path, listdir, access, mkdir
from re import sub
from shutil import rmtree
from time import time
from zipfile import ZipFile
@@ -162,12 +163,6 @@ class PluginBrowser:
current_plugin_order = self.settings.getSetting("pluginOrder")[:]
if self.loader.watcher:
self.loader.watcher.disabled = True
try:
pluginFolderPath = self.find_plugin_folder(name)
if pluginFolderPath:
isInstalled = True
except:
logger.error(f"Failed to determine if {name} is already installed, continuing anyway.")
# Check if the file is a local file or a URL
if artifact.startswith("file://"):
@@ -198,6 +193,28 @@ class PluginBrowser:
if res.status != 200:
logger.error(f"Server did not accept install count increment request. code: {res.status}")
if res_zip and version == "dev":
with ZipFile(res_zip) as plugin_zip:
plugin_json_list = [file for file in plugin_zip.namelist() if file.endswith("/plugin.json") and file.count("/") == 1]
if len(plugin_json_list) == 0:
logger.fatal("No plugin.json found in plugin ZIP")
return
elif len(plugin_json_list) > 1:
logger.fatal("Multiple plugin.json found in plugin ZIP")
return
else:
name = sub(r"/.+$", "", plugin_json_list[0])
try:
pluginFolderPath = self.find_plugin_folder(name)
if pluginFolderPath:
isInstalled = True
except:
logger.error(f"Failed to determine if {name} is already installed, continuing anyway.")
# Check to make sure we got the file
if res_zip is None:
logger.fatal(f"Could not fetch {artifact}")
@@ -272,12 +289,16 @@ class PluginBrowser:
Args:
name (string): The name of the plugin
"""
frozen_plugins = self.settings.getSetting("frozenPlugins", [])
if name in frozen_plugins:
frozen_plugins.remove(name)
self.settings.setSetting("frozenPlugins", frozen_plugins)
hidden_plugins = self.settings.getSetting("hiddenPlugins", [])
if name in hidden_plugins:
hidden_plugins.remove(name)
self.settings.setSetting("hiddenPlugins", hidden_plugins)
plugin_order = self.settings.getSetting("pluginOrder", [])
if name in plugin_order:
+1 -1
View File
@@ -118,11 +118,11 @@ class PluginWrapper:
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()
await self._unload()
raise Exception("Closing message listener")
# TODO there is definitely a better way to type this
+114 -40
View File
@@ -1,20 +1,29 @@
from __future__ import annotations
import os
import shutil
from asyncio import sleep
from json.decoder import JSONDecodeError
from logging import getLogger
import os
from os import getcwd, path, remove
from typing import TYPE_CHECKING, List, TypedDict
if TYPE_CHECKING:
from .main import PluginManager
from .localplatform import chmod, service_restart, ON_LINUX, get_keep_systemd_service, get_selinux
import shutil
from typing import List, TYPE_CHECKING, TypedDict
import zipfile
from aiohttp import ClientSession, web
from . import helpers
from .injector import get_gamepadui_tab
from .localplatform import (
ON_LINUX,
ON_WINDOWS,
chmod,
get_keep_systemd_service,
get_selinux,
service_restart,
)
from .settings import SettingsManager
if TYPE_CHECKING:
from .main import PluginManager
logger = getLogger("Updater")
@@ -25,6 +34,12 @@ class RemoteVer(TypedDict):
tag_name: str
prerelease: bool
assets: List[RemoteVerAsset]
class TestingVersion(TypedDict):
id: int
name: str
link: str
head_sha: str
class Updater:
def __init__(self, context: PluginManager) -> None:
@@ -36,7 +51,9 @@ class Updater:
"get_version": self.get_version,
"do_update": self.do_update,
"do_restart": self.do_restart,
"check_for_updates": self.check_for_updates
"check_for_updates": self.check_for_updates,
"get_testing_versions": self.get_testing_versions,
"download_testing_version": self.download_testing_version
}
self.remoteVer: RemoteVer | None = None
self.allRemoteVers: List[RemoteVer] = []
@@ -150,6 +167,53 @@ class Updater:
pass
await sleep(60 * 60 * 6) # 6 hours
async def download_decky_binary(self, download_url: str, version: str, is_zip: bool = False):
download_filename = "PluginLoader" if ON_LINUX else "PluginLoader.exe"
download_temp_filename = download_filename + ".new"
tab = await get_gamepadui_tab()
await tab.open_websocket()
async with ClientSession() as web:
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))
with open(path.join(getcwd(), download_temp_filename), "wb") as out:
progress = 0
raw = 0
async for c in res.content.iter_chunked(512):
out.write(c)
if total != 0:
raw += len(c)
new_progress = round((raw / total) * 100)
if progress != new_progress:
self.context.loop.create_task(tab.evaluate_js(f"window.DeckyUpdater.updateProgress({new_progress})", False, False, False))
progress = new_progress
with open(path.join(getcwd(), ".loader.version"), "w", encoding="utf-8") as out:
out.write(version)
if ON_LINUX:
remove(path.join(getcwd(), download_filename))
if (is_zip):
with zipfile.ZipFile(path.join(getcwd(), download_temp_filename), 'r') as file:
file.getinfo(download_filename).filename = download_filename + ".unzipped"
file.extract(download_filename)
remove(path.join(getcwd(), download_temp_filename))
shutil.move(path.join(getcwd(), download_filename + ".unzipped"), path.join(getcwd(), download_filename))
else:
shutil.move(path.join(getcwd(), download_temp_filename), path.join(getcwd(), download_filename))
chmod(path.join(getcwd(), download_filename), 777, False)
if get_selinux():
from asyncio.subprocess import create_subprocess_exec
process = await create_subprocess_exec("chcon", "-t", "bin_t", path.join(getcwd(), download_filename))
logger.info(f"Setting the executable flag with chcon returned {await process.wait()}")
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_update(self):
logger.debug("Starting update.")
try:
@@ -161,7 +225,6 @@ class Updater:
version = self.remoteVer["tag_name"]
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:
@@ -174,8 +237,6 @@ class Updater:
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:
if ON_LINUX and not get_keep_systemd_service():
logger.debug("Downloading systemd service")
@@ -203,36 +264,49 @@ class Updater:
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))
with open(path.join(getcwd(), download_temp_filename), "wb") as out:
progress = 0
raw = 0
async for c in res.content.iter_chunked(512):
out.write(c)
raw += len(c)
new_progress = round((raw / total) * 100)
if progress != new_progress:
self.context.loop.create_task(tab.evaluate_js(f"window.DeckyUpdater.updateProgress({new_progress})", False, False, False))
progress = new_progress
with open(path.join(getcwd(), ".loader.version"), "w", encoding="utf-8") as out:
out.write(version)
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)
if get_selinux():
from asyncio.subprocess import create_subprocess_exec
process = await create_subprocess_exec("chcon", "-t", "bin_t", path.join(getcwd(), download_filename))
logger.info(f"Setting the executable flag with chcon returned {await process.wait()}")
logger.info("Updated loader installation.")
await tab.evaluate_js("window.DeckyUpdater.finish()", False, False)
await self.do_restart()
await tab.close_websocket()
await self.download_decky_binary(download_url, version)
async def do_restart(self):
await service_restart("plugin_loader")
async def get_testing_versions(self) -> List[TestingVersion]:
result: List[TestingVersion] = []
async with ClientSession() as web:
async with web.request("GET", "https://api.github.com/repos/SteamDeckHomebrew/decky-loader/pulls",
headers={'X-GitHub-Api-Version': '2022-11-28'}, params={'state':'open'}, ssl=helpers.get_ssl_context()) as res:
open_prs = await res.json()
for pr in open_prs:
result.append({
"id": int(pr['number']),
"name": pr['title'],
"link": pr['html_url'],
"head_sha": pr['head']['sha'],
})
return result
async def download_testing_version(self, pr_id: int, sha_id: str):
down_id = ''
#Get all the associated workflow run for the given sha_id code hash
async with ClientSession() as web:
async with web.request("GET", "https://api.github.com/repos/SteamDeckHomebrew/decky-loader/actions/runs",
headers={'X-GitHub-Api-Version': '2022-11-28'}, params={'event':'pull_request', 'head_sha': sha_id}, ssl=helpers.get_ssl_context()) as res:
works = await res.json()
#Iterate over the workflow_run to get the two builds if they exists
for work in works['workflow_runs']:
if ON_WINDOWS and work['name'] == 'Builder Win':
down_id=work['id']
break
elif ON_LINUX and work['name'] == 'Builder':
down_id=work['id']
break
if down_id != '':
async with ClientSession() as web:
async with web.request("GET", f"https://api.github.com/repos/SteamDeckHomebrew/decky-loader/actions/runs/{down_id}/artifacts",
headers={'X-GitHub-Api-Version': '2022-11-28'}, ssl=helpers.get_ssl_context()) as res:
jresp = await res.json()
#If the request found at least one artifact to download...
if int(jresp['total_count']) != 0:
# this assumes that the artifact we want is the first one!
down_link = f"https://nightly.link/SteamDeckHomebrew/decky-loader/actions/artifacts/{jresp['artifacts'][0]['id']}.zip"
#Then fetch it and restart itself
await self.download_decky_binary(down_link, f'PR-{pr_id}' , True)
+1 -1
View File
@@ -238,7 +238,7 @@ class Utilities:
elif include_files:
# Handle requested extensions if present
if len(include_ext) == 0 or 'all_files' in include_ext \
or splitext(file.name)[1].lstrip('.') in include_ext:
or splitext(file.name)[1].lstrip('.').upper() in (ext.upper() for ext in include_ext):
if (is_hidden and include_hidden) or not is_hidden:
files.append({"file": file, "filest": filest, "is_dir": False})
# Filter logic
+2 -2
View File
@@ -25,7 +25,7 @@
"i18next-parser": "^8.0.0",
"import-sort-style-module": "^6.0.0",
"inquirer": "^8.2.5",
"prettier": "^2.8.8",
"prettier": "^3.2.5",
"prettier-plugin-import-sort": "^0.0.7",
"react": "16.14.0",
"react-dom": "16.14.0",
@@ -44,7 +44,7 @@
}
},
"dependencies": {
"decky-frontend-lib": "3.24.2",
"decky-frontend-lib": "3.24.5",
"filesize": "^10.0.7",
"i18next": "^23.2.1",
"i18next-http-backend": "^2.2.1",
+14 -12
View File
@@ -6,8 +6,8 @@ settings:
dependencies:
decky-frontend-lib:
specifier: 3.24.2
version: 3.24.2
specifier: 3.24.5
version: 3.24.5
filesize:
specifier: ^10.0.7
version: 10.0.7
@@ -77,11 +77,11 @@ devDependencies:
specifier: ^8.2.5
version: 8.2.5
prettier:
specifier: ^2.8.8
version: 2.8.8
specifier: ^3.2.5
version: 3.2.5
prettier-plugin-import-sort:
specifier: ^0.0.7
version: 0.0.7(prettier@2.8.8)
version: 0.0.7(prettier@3.2.5)
react:
specifier: 16.14.0
version: 16.14.0
@@ -1482,8 +1482,8 @@ packages:
dependencies:
ms: 2.1.2
/decky-frontend-lib@3.24.2:
resolution: {integrity: sha512-G6AEV/PTdOaw2AoGGs+tpEWIPhVODzM8XWAJcHwXVpCnAGH+qUhHZH/Mz56ZxYcbVdN3m6FRAQ2eHSUIEY9TjA==}
/decky-frontend-lib@3.24.5:
resolution: {integrity: sha512-eYlbKDOOcIBPI0b76Rqvlryq2ym/QNiry4xf2pFrXmBa1f95dflqbQAb2gTq9uHEa5gFmeV4lUcMPGJ3M14Xqw==}
dev: false
/decode-named-character-reference@1.0.2:
@@ -3108,7 +3108,7 @@ packages:
engines: {node: '>=8.6'}
dev: true
/prettier-plugin-import-sort@0.0.7(prettier@2.8.8):
/prettier-plugin-import-sort@0.0.7(prettier@3.2.5):
resolution: {integrity: sha512-O0KlUSq+lwvh+UiN3wZDT6wWkf7TNxTVv2/XXE5KqpRNbFJq3nRg2ftzBYFFO8QGpdWIrOB0uCTCtFjIxmVKQw==}
peerDependencies:
prettier: '>= 2.0'
@@ -3117,14 +3117,14 @@ packages:
import-sort-config: 6.0.0
import-sort-parser-babylon: 6.0.0
import-sort-parser-typescript: 6.0.0
prettier: 2.8.8
prettier: 3.2.5
transitivePeerDependencies:
- supports-color
dev: true
/prettier@2.8.8:
resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==}
engines: {node: '>=10.13.0'}
/prettier@3.2.5:
resolution: {integrity: sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==}
engines: {node: '>=14'}
hasBin: true
dev: true
@@ -3200,6 +3200,7 @@ packages:
prop-types: 15.8.1
react: 16.14.0
scheduler: 0.19.1
bundledDependencies: false
/react-file-icon@1.3.0(react-dom@16.14.0)(react@16.14.0):
resolution: {integrity: sha512-wxl/WwSX5twQKVXloPHbS71iZQUKO84KgZ44Kh7vYZGu1qH2kagx+RSTNfk/+IHtXfjPWPNIHPGi2Y8S94N1CQ==}
@@ -3283,6 +3284,7 @@ packages:
loose-envify: 1.4.0
object-assign: 4.1.1
prop-types: 15.8.1
bundledDependencies: false
/readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
+8
View File
@@ -8,6 +8,7 @@ import { VerInfo } from '../updater';
interface PublicDeckyState {
plugins: Plugin[];
pluginOrder: string[];
frozenPlugins: string[];
hiddenPlugins: string[];
activePlugin: Plugin | null;
updates: PluginUpdateMapping | null;
@@ -26,6 +27,7 @@ export interface UserInfo {
export class DeckyState {
private _plugins: Plugin[] = [];
private _pluginOrder: string[] = [];
private _frozenPlugins: string[] = [];
private _hiddenPlugins: string[] = [];
private _activePlugin: Plugin | null = null;
private _updates: PluginUpdateMapping | null = null;
@@ -41,6 +43,7 @@ export class DeckyState {
return {
plugins: this._plugins,
pluginOrder: this._pluginOrder,
frozenPlugins: this._frozenPlugins,
hiddenPlugins: this._hiddenPlugins,
activePlugin: this._activePlugin,
updates: this._updates,
@@ -67,6 +70,11 @@ export class DeckyState {
this.notifyUpdate();
}
setFrozenPlugins(frozenPlugins: string[]) {
this._frozenPlugins = frozenPlugins;
this.notifyUpdate();
}
setHiddenPlugins(hiddenPlugins: string[]) {
this._hiddenPlugins = hiddenPlugins;
this.notifyUpdate();
+8 -5
View File
@@ -30,11 +30,14 @@ const DeckyToaster: FC<DeckyToasterProps> = () => {
// not actually node but TS is shit
let interval: NodeJS.Timer | null;
if (renderedToast) {
interval = setTimeout(() => {
interval = null;
console.log('clear toast', renderedToast.data);
removeToast(renderedToast.data);
}, (renderedToast.data.duration || 5e3) + 1000);
interval = setTimeout(
() => {
interval = null;
console.log('clear toast', renderedToast.data);
removeToast(renderedToast.data);
},
(renderedToast.data.duration || 5e3) + 1000,
);
console.log('set int', interval);
}
return () => {
@@ -15,8 +15,9 @@ const PluginUninstallModal: FC<PluginUninstallModalProps> = ({ name, title, butt
closeModal={closeModal}
onOK={async () => {
await window.DeckyPluginLoader.callServerMethod('uninstall_plugin', { name });
// uninstalling a plugin resets the hidden setting for it server-side
// uninstalling a plugin resets the frozen and hidden setting for it server-side
// we invalidate here so if you re-install it, you won't have an out-of-date hidden filter
await window.DeckyPluginLoader.frozenPluginsService.invalidate();
await window.DeckyPluginLoader.hiddenPluginsService.invalidate();
}}
strTitle={title}
+14 -2
View File
@@ -1,7 +1,7 @@
import { SidebarNavigation } from 'decky-frontend-lib';
import { lazy } from 'react';
import { useTranslation } from 'react-i18next';
import { FaCode, FaPlug } from 'react-icons/fa';
import { FaCode, FaFlask, FaPlug } from 'react-icons/fa';
import { useSetting } from '../../utils/hooks/useSetting';
import DeckyIcon from '../DeckyIcon';
@@ -10,6 +10,7 @@ import GeneralSettings from './pages/general';
import PluginList from './pages/plugin_list';
const DeveloperSettings = lazy(() => import('./pages/developer'));
const TestingMenu = lazy(() => import('./pages/testing'));
export default function SettingsPage() {
const [isDeveloper, setIsDeveloper] = useSetting<boolean>('developer.enabled', false);
@@ -24,7 +25,7 @@ export default function SettingsPage() {
},
{
title: t('SettingsIndex.plugins_title'),
content: <PluginList />,
content: <PluginList isDeveloper={isDeveloper} />,
route: '/decky/settings/plugins',
icon: <FaPlug />,
},
@@ -39,6 +40,17 @@ export default function SettingsPage() {
icon: <FaCode />,
visible: isDeveloper,
},
{
title: t('SettingsIndex.testing_title'),
content: (
<WithSuspense>
<TestingMenu />
</WithSuspense>
),
route: '/decky/settings/testing',
icon: <FaFlask />,
visible: isDeveloper,
},
];
return <SidebarNavigation pages={pages} />;
@@ -8,10 +8,15 @@ import { useSetting } from '../../../../utils/hooks/useSetting';
const logger = new Logger('BranchSelect');
enum UpdateBranch {
export enum UpdateBranch {
Stable,
Prerelease,
Testing,
}
enum LessUpdateBranch {
Stable,
Prerelease,
// Testing,
}
const BranchSelect: FunctionComponent<{}> = () => {
@@ -24,11 +29,11 @@ const BranchSelect: FunctionComponent<{}> = () => {
const [selectedBranch, setSelectedBranch] = useSetting<UpdateBranch>('branch', UpdateBranch.Stable);
return (
// Returns numerical values from 0 to 2 (with current branch setup as of 8/28/22)
// 0 being stable, 1 being pre-release and 2 being nightly
// Returns numerical values from 0 to 2 (with current branch setup as of 6/16/23)
// 0 being stable, 1 being pre-release and 2 being testing (not a branch!)
<Field label={t('BranchSelect.update_channel.label')} childrenContainerWidth={'fixed'}>
<Dropdown
rgOptions={Object.values(UpdateBranch)
rgOptions={Object.values(selectedBranch == UpdateBranch.Testing ? UpdateBranch : LessUpdateBranch)
.filter((branch) => typeof branch == 'number')
.map((branch) => ({
label: tBranches[branch as number],
@@ -135,8 +135,8 @@ export default function UpdaterSettings() {
{checkingForUpdates
? t('Updater.updates.checking')
: !versionInfo?.remote || versionInfo?.remote?.tag_name == versionInfo?.current
? t('Updater.updates.check_button')
: t('Updater.updates.install_button')}
? t('Updater.updates.check_button')
: t('Updater.updates.install_button')}
</DialogButton>
) : (
<ProgressBarWithInfo
@@ -1,18 +1,34 @@
import { FC } from 'react';
import { useTranslation } from 'react-i18next';
import { FaEyeSlash } from 'react-icons/fa';
import { FaEyeSlash, FaLock } from 'react-icons/fa';
interface PluginListLabelProps {
frozen: boolean;
hidden: boolean;
name: string;
version?: string;
}
const PluginListLabel: FC<PluginListLabelProps> = ({ name, hidden, version }) => {
const PluginListLabel: FC<PluginListLabelProps> = ({ name, frozen, hidden, version }) => {
const { t } = useTranslation();
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<div>{version ? `${name} - ${version}` : name}</div>
<div>
{name}
{version && (
<>
{' - '}
<span style={{ color: frozen ? '#67707b' : 'inherit' }}>
{frozen && (
<>
<FaLock />{' '}
</>
)}
{version}
</span>
</>
)}
</div>
{hidden && (
<div
style={{
@@ -33,7 +33,16 @@ async function reinstallPlugin(pluginName: string, currentVersion?: string) {
}
}
type PluginTableData = PluginData & { name: string; hidden: boolean; onHide(): void; onShow(): void };
type PluginTableData = PluginData & {
name: string;
frozen: boolean;
onFreeze(): void;
onUnfreeze(): void;
hidden: boolean;
onHide(): void;
onShow(): void;
isDeveloper: boolean;
};
function PluginInteractables(props: { entry: ReorderableEntry<PluginTableData> }) {
const { t } = useTranslation();
@@ -43,7 +52,7 @@ function PluginInteractables(props: { entry: ReorderableEntry<PluginTableData> }
return null;
}
const { name, update, version, onHide, onShow, hidden } = props.entry.data;
const { name, update, version, onHide, onShow, hidden, onFreeze, onUnfreeze, frozen, isDeveloper } = props.entry.data;
const showCtxMenu = (e: MouseEvent | GamepadEvent) => {
showContextMenu(
@@ -84,6 +93,11 @@ function PluginInteractables(props: { entry: ReorderableEntry<PluginTableData> }
) : (
<MenuItem onSelected={onHide}>{t('PluginListIndex.hide')}</MenuItem>
)}
{frozen ? (
<MenuItem onSelected={onUnfreeze}>{t('PluginListIndex.unfreeze')}</MenuItem>
) : (
isDeveloper && <MenuItem onSelected={onFreeze}>{t('PluginListIndex.freeze')}</MenuItem>
)}
</Menu>,
e.currentTarget ?? window,
);
@@ -138,8 +152,8 @@ type PluginData = {
version?: string;
};
export default function PluginList() {
const { plugins, updates, pluginOrder, setPluginOrder, hiddenPlugins } = useDeckyState();
export default function PluginList({ isDeveloper }: { isDeveloper: boolean }) {
const { plugins, updates, pluginOrder, setPluginOrder, frozenPlugins, hiddenPlugins } = useDeckyState();
const [_, setPluginOrderSetting] = useSetting<string[]>(
'pluginOrder',
plugins.map((plugin) => plugin.name),
@@ -151,21 +165,27 @@ export default function PluginList() {
}, []);
const [pluginEntries, setPluginEntries] = useState<ReorderableEntry<PluginTableData>[]>([]);
const frozenPluginsService = window.DeckyPluginLoader.frozenPluginsService;
const hiddenPluginsService = window.DeckyPluginLoader.hiddenPluginsService;
useEffect(() => {
setPluginEntries(
plugins.map(({ name, version }) => {
const frozen = frozenPlugins.includes(name);
const hidden = hiddenPlugins.includes(name);
return {
label: <PluginListLabel name={name} hidden={hidden} version={version} />,
label: <PluginListLabel name={name} frozen={frozen} hidden={hidden} version={version} />,
position: pluginOrder.indexOf(name),
data: {
name,
frozen,
hidden,
isDeveloper,
version,
update: updates?.get(name),
onFreeze: () => frozenPluginsService.update([...frozenPlugins, name]),
onUnfreeze: () => frozenPluginsService.update(frozenPlugins.filter((pluginName) => name !== pluginName)),
onHide: () => hiddenPluginsService.update([...hiddenPlugins, name]),
onShow: () => hiddenPluginsService.update(hiddenPlugins.filter((pluginName) => name !== pluginName)),
},
@@ -0,0 +1,87 @@
import { DialogBody, DialogButton, DialogControlsSection, Focusable, Navigation } from 'decky-frontend-lib';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FaDownload, FaInfo } from 'react-icons/fa';
import { callUpdaterMethod } from '../../../../updater';
import { setSetting } from '../../../../utils/settings';
import { UpdateBranch } from '../general/BranchSelect';
interface TestingVersion {
id: number;
name: string;
link: string;
head_sha: string;
}
export default function TestingVersionList() {
const { t } = useTranslation();
const [testingVersions, setTestingVersions] = useState<TestingVersion[]>([]);
useEffect(() => {
(async () => {
setTestingVersions((await callUpdaterMethod('get_testing_versions')).result);
})();
}, []);
if (testingVersions.length === 0) {
return (
<div>
<p>No open PRs found</p>
</div>
);
}
return (
<DialogBody>
<DialogControlsSection>
<ul style={{ listStyleType: 'none', padding: '0' }}>
{testingVersions.map((version) => {
return (
<li style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', paddingBottom: '10px' }}>
<span>
{version.name} <span style={{ opacity: '50%' }}>{'#' + version.id}</span>
</span>
<Focusable style={{ height: '40px', marginLeft: 'auto', display: 'flex' }}>
<DialogButton
style={{ height: '40px', minWidth: '60px', marginRight: '10px' }}
onClick={() => {
callUpdaterMethod('download_testing_version', { pr_id: version.id, sha_id: version.head_sha });
setSetting('branch', UpdateBranch.Testing);
}}
>
<div
style={{
display: 'flex',
minWidth: '150px',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
{t('Testing.download')}
<FaDownload style={{ paddingLeft: '1rem' }} />
</div>
</DialogButton>
<DialogButton
style={{
height: '40px',
width: '40px',
padding: '10px 12px',
minWidth: '40px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
onClick={() => Navigation.NavigateToExternalWeb(version.link)}
>
<FaInfo />
</DialogButton>
</Focusable>
</li>
);
})}
</ul>
</DialogControlsSection>
</DialogBody>
);
}
+72 -65
View File
@@ -8,20 +8,19 @@ import {
TextField,
findModule,
} from 'decky-frontend-lib';
import { FC, useEffect, useMemo, useState } from 'react';
import { Dispatch, FC, SetStateAction, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import logo from '../../../assets/plugin_store.png';
import Logger from '../../logger';
import { Store, StorePlugin, getPluginList, getStore } from '../../store';
import { SortDirections, SortOptions, Store, StorePlugin, getPluginList, getStore } from '../../store';
import PluginCard from './PluginCard';
const logger = new Logger('Store');
const StorePage: FC<{}> = () => {
const [currentTabRoute, setCurrentTabRoute] = useState<string>('browse');
const [data, setData] = useState<StorePlugin[] | null>(null);
const [isTesting, setIsTesting] = useState<boolean>(false);
const [pluginCount, setPluginCount] = useState<number | null>(null);
const { TabCount } = findModule((m) => {
if (m?.TabCount && m?.TabTitle) return true;
return false;
@@ -29,17 +28,6 @@ const StorePage: FC<{}> = () => {
const { t } = useTranslation();
useEffect(() => {
(async () => {
const res = await getPluginList();
logger.log('got data!', res);
setData(res);
const storeRes = await getStore();
logger.log(`store is ${storeRes}, isTesting is ${storeRes === Store.Testing}`);
setIsTesting(storeRes === Store.Testing);
})();
}, []);
return (
<>
<div
@@ -49,52 +37,71 @@ const StorePage: FC<{}> = () => {
background: '#0005',
}}
>
{!data ? (
<div style={{ height: '100%' }}>
<SteamSpinner />
</div>
) : (
<Tabs
activeTab={currentTabRoute}
onShowTab={(tabId: string) => {
setCurrentTabRoute(tabId);
}}
tabs={[
{
title: t('Store.store_tabs.title'),
content: <BrowseTab children={{ data: data, isTesting: isTesting }} />,
id: 'browse',
renderTabAddon: () => <span className={TabCount}>{data.length}</span>,
},
{
title: t('Store.store_tabs.about'),
content: <AboutTab />,
id: 'about',
},
]}
/>
)}
<Tabs
activeTab={currentTabRoute}
onShowTab={(tabId: string) => {
setCurrentTabRoute(tabId);
}}
tabs={[
{
title: t('Store.store_tabs.title'),
content: <BrowseTab setPluginCount={setPluginCount} />,
id: 'browse',
renderTabAddon: () => <span className={TabCount}>{pluginCount}</span>,
},
{
title: t('Store.store_tabs.about'),
content: <AboutTab />,
id: 'about',
},
]}
/>
</div>
</>
);
};
const BrowseTab: FC<{ children: { data: StorePlugin[]; isTesting: boolean } }> = (data) => {
const BrowseTab: FC<{ setPluginCount: Dispatch<SetStateAction<number | null>> }> = ({ setPluginCount }) => {
const { t } = useTranslation();
const sortOptions = useMemo(
const dropdownSortOptions = useMemo(
(): DropdownOption[] => [
{ data: 1, label: t('Store.store_tabs.alph_desc') },
{ data: 2, label: t('Store.store_tabs.alph_asce') },
// ascending and descending order are the wrong way around for the alphabetical sort
// this is because it was initially done incorrectly for i18n and 'fixing' it would
// make all the translations incorrect
{ data: [SortOptions.name, SortDirections.ascending], label: t('Store.store_tabs.alph_desc') },
{ data: [SortOptions.name, SortDirections.descending], label: t('Store.store_tabs.alph_asce') },
{ data: [SortOptions.date, SortDirections.ascending], label: t('Store.store_tabs.date_asce') },
{ data: [SortOptions.date, SortDirections.descending], label: t('Store.store_tabs.date_desc') },
{ data: [SortOptions.downloads, SortDirections.descending], label: t('Store.store_tabs.downloads_desc') },
{ data: [SortOptions.downloads, SortDirections.ascending], label: t('Store.store_tabs.downloads_asce') },
],
[],
);
// const filterOptions = useMemo((): DropdownOption[] => [{ data: 1, label: 'All' }], []);
const [selectedSort, setSort] = useState<number>(sortOptions[0].data);
const [selectedSort, setSort] = useState<[SortOptions, SortDirections]>(dropdownSortOptions[0].data);
// const [selectedFilter, setFilter] = useState<number>(filterOptions[0].data);
const [searchFieldValue, setSearchValue] = useState<string>('');
const [pluginList, setPluginList] = useState<StorePlugin[] | null>(null);
const [isTesting, setIsTesting] = useState<boolean>(false);
useEffect(() => {
(async () => {
const res = await getPluginList(selectedSort[0], selectedSort[1]);
logger.log('got data!', res);
setPluginList(res);
setPluginCount(res.length);
})();
}, [selectedSort]);
useEffect(() => {
(async () => {
const storeRes = await getStore();
logger.log(`store is ${storeRes}, isTesting is ${storeRes === Store.Testing}`);
setIsTesting(storeRes === Store.Testing);
})();
}, []);
return (
<>
@@ -117,7 +124,7 @@ const BrowseTab: FC<{ children: { data: StorePlugin[]; isTesting: boolean } }> =
<span className="DialogLabel">{t("Store.store_sort.label")}</span>
<Dropdown
menuLabel={t("Store.store_sort.label") as string}
rgOptions={sortOptions}
rgOptions={dropdownSortOptions}
strDefaultLabel={t("Store.store_sort.label_def") as string}
selectedOption={selectedSort}
onChange={(e) => setSort(e.data)}
@@ -163,7 +170,7 @@ const BrowseTab: FC<{ children: { data: StorePlugin[]; isTesting: boolean } }> =
<span className="DialogLabel">{t('Store.store_sort.label')}</span>
<Dropdown
menuLabel={t('Store.store_sort.label') as string}
rgOptions={sortOptions}
rgOptions={dropdownSortOptions}
strDefaultLabel={t('Store.store_sort.label_def') as string}
selectedOption={selectedSort}
onChange={(e) => setSort(e.data)}
@@ -182,7 +189,7 @@ const BrowseTab: FC<{ children: { data: StorePlugin[]; isTesting: boolean } }> =
</div>
</Focusable>
</div>
{data.children.isTesting && (
{isTesting && (
<div
style={{
alignItems: 'center',
@@ -213,22 +220,22 @@ const BrowseTab: FC<{ children: { data: StorePlugin[]; isTesting: boolean } }> =
</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} />
))}
{!pluginList ? (
<div style={{ height: '100%' }}>
<SteamSpinner />
</div>
) : (
pluginList
.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()))
);
})
.map((plugin: StorePlugin) => <PluginCard plugin={plugin} />)
)}
</div>
</>
);
+49
View File
@@ -0,0 +1,49 @@
import { DeckyState } from './components/DeckyState';
import { PluginUpdateMapping } from './store';
import { getSetting, setSetting } from './utils/settings';
/**
* A Service class for managing the state and actions related to the frozen plugins feature.
*
* It's mostly responsible for sending setting updates to the server and keeping the local state in sync.
*/
export class FrozenPluginService {
constructor(private deckyState: DeckyState) {}
init() {
getSetting<string[]>('frozenPlugins', []).then((frozenPlugins) => {
this.deckyState.setFrozenPlugins(frozenPlugins);
});
}
/**
* Sends the new frozen plugins list to the server and persists it locally in the decky state
*
* @param frozenPlugins The new list of frozen plugins
*/
async update(frozenPlugins: string[]) {
await setSetting('frozenPlugins', frozenPlugins);
this.deckyState.setFrozenPlugins(frozenPlugins);
// Remove pending updates for frozen plugins
const updates = this.deckyState.publicState().updates;
if (updates) {
const filteredUpdates = new Map() as PluginUpdateMapping;
updates.forEach((v, k) => {
if (!frozenPlugins.includes(k)) {
filteredUpdates.set(k, v);
}
});
this.deckyState.setUpdates(filteredUpdates);
}
}
/**
* Refreshes the state of frozen plugins in the local state
*/
async invalidate() {
this.deckyState.setFrozenPlugins(await getSetting('frozenPlugins', []));
}
}
+6 -1
View File
@@ -22,6 +22,7 @@ import PluginUninstallModal from './components/modals/PluginUninstallModal';
import NotificationBadge from './components/NotificationBadge';
import PluginView from './components/PluginView';
import WithSuspense from './components/WithSuspense';
import { FrozenPluginService } from './frozen-plugins-service';
import { HiddenPluginsService } from './hidden-plugins-service';
import Logger from './logger';
import { NotificationService } from './notification-service';
@@ -49,6 +50,7 @@ class PluginLoader extends Logger {
public toaster: Toaster = new Toaster();
private deckyState: DeckyState = new DeckyState();
public frozenPluginsService = new FrozenPluginService(this.deckyState);
public hiddenPluginsService = new HiddenPluginsService(this.deckyState);
public notificationService = new NotificationService(this.deckyState);
@@ -144,7 +146,9 @@ class PluginLoader extends Logger {
}
public async checkPluginUpdates() {
const updates = await checkForUpdates(this.plugins);
const frozenPlugins = this.deckyState.publicState().frozenPlugins;
const updates = await checkForUpdates(this.plugins.filter((p) => !frozenPlugins.includes(p.name)));
this.deckyState.setUpdates(updates);
return updates;
}
@@ -224,6 +228,7 @@ class PluginLoader extends Logger {
this.deckyState.setPluginOrder(pluginOrder);
});
this.frozenPluginsService.init();
this.hiddenPluginsService.init();
this.notificationService.init();
}
+22 -2
View File
@@ -7,6 +7,17 @@ export enum Store {
Custom,
}
export enum SortOptions {
name = 'name',
date = 'date',
downloads = 'downloads',
}
export enum SortDirections {
ascending = 'asc',
descending = 'desc',
}
export interface StorePluginVersion {
name: string;
hash: string;
@@ -36,11 +47,20 @@ export async function getStore(): Promise<Store> {
return await getSetting<Store>('store', Store.Default);
}
export async function getPluginList(): Promise<StorePlugin[]> {
export async function getPluginList(
sort_by: SortOptions | null = null,
sort_direction: SortDirections | null = null,
): Promise<StorePlugin[]> {
let version = await window.DeckyPluginLoader.updateVersion();
let store = await getSetting<Store | null>('store', null);
let customURL = await getSetting<string>('store-url', 'https://plugins.deckbrew.xyz/plugins');
let query: URLSearchParams | string = new URLSearchParams();
sort_by && query.set('sort_by', sort_by);
sort_direction && query.set('sort_direction', sort_direction);
query = '?' + String(query);
let storeURL;
if (store === null) {
console.log('Could not get store, using Default.');
@@ -62,7 +82,7 @@ export async function getPluginList(): Promise<StorePlugin[]> {
storeURL = 'https://plugins.deckbrew.xyz/plugins';
break;
}
return fetch(storeURL, {
return fetch(storeURL + query, {
method: 'GET',
headers: {
'X-Decky-Version': version.current,
+2 -1
View File
@@ -40,7 +40,8 @@ class TabsHook extends Logger {
return null;
}
if (
typeof currentNode?.memoizedProps?.visible == 'boolean' &&
(typeof currentNode?.memoizedProps?.visible == 'boolean' ||
typeof currentNode?.memoizedProps?.active == 'boolean') &&
currentNode?.type?.toString()?.includes('QuickAccessMenuBrowserView')
) {
this.log(`QAM root was found in ${iters} recursion cycles`);
+4 -1
View File
@@ -81,7 +81,10 @@ class Toaster extends Logger {
instance = findToasterRoot(tree, 0);
}
this.node = instance.return;
this.rNode = this.node.return;
this.rNode = findInReactTree(
this.node.return.return,
(node) => node?.stateNode && node.type?.InstallErrorReportingStore,
);
let toast: any;
let renderedToast: ReactNode = null;
let innerPatched: any;