summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--backend/decky_loader/updater.py142
-rw-r--r--backend/locales/en-US.json8
-rw-r--r--frontend/package.json2
-rw-r--r--frontend/pnpm-lock.yaml16
-rw-r--r--frontend/src/components/DeckyToaster.tsx13
-rw-r--r--frontend/src/components/settings/index.tsx14
-rw-r--r--frontend/src/components/settings/pages/general/BranchSelect.tsx15
-rw-r--r--frontend/src/components/settings/pages/general/Updater.tsx4
-rw-r--r--frontend/src/components/settings/pages/testing/index.tsx89
9 files changed, 246 insertions, 57 deletions
diff --git a/backend/decky_loader/updater.py b/backend/decky_loader/updater.py
index 5ead49b4..a28f0c11 100644
--- a/backend/decky_loader/updater.py
+++ b/backend/decky_loader/updater.py
@@ -1,19 +1,24 @@
from __future__ import annotations
-import os
-import shutil
from asyncio import sleep
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.localplatform import chmod, service_restart, ON_LINUX, get_keep_systemd_service, get_selinux
+from .localplatform.localplatform import chmod, service_restart, ON_LINUX, ON_WINDOWS, get_keep_systemd_service, get_selinux
+import shutil
+from typing import List, TYPE_CHECKING, TypedDict
+import zipfile
from aiohttp import ClientSession
from . import helpers
from .injector import get_gamepadui_tab
from .settings import SettingsManager
+if TYPE_CHECKING:
+ from .main import PluginManager
+
logger = getLogger("Updater")
@@ -24,6 +29,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:
@@ -44,6 +55,8 @@ class Updater:
context.ws.add_route("updater/check_for_updates", self.check_for_updates);
context.ws.add_route("updater/do_restart", self.do_restart);
context.ws.add_route("updater/do_update", self.do_update);
+ context.ws.add_route("updater/get_testing_versions", self.get_testing_versions);
+ context.ws.add_route("updater/download_testing_version", self.download_testing_version);
context.loop.create_task(self.version_reloader())
def get_branch(self, manager: SettingsManager):
@@ -126,6 +139,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(self.context.ws.emit("frontend/update_download_percentage", new_progress))
+ 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 self.context.ws.emit("frontend/finish_download")
+ await self.do_restart()
+ await tab.close_websocket()
+
async def do_update(self):
logger.debug("Starting update.")
try:
@@ -134,9 +194,9 @@ class Updater:
logger.error("Unable to update as remoteVer is missing")
return
+ 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:
@@ -149,8 +209,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")
@@ -178,33 +236,51 @@ 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(self.context.ws.emit("frontend/update_download_percentage", new_progress))
- progress = new_progress
-
- 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 self.context.ws.emit("frontend/finish_download")
- 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)
+ else:
+ logger.error("workflow run not found", str(works))
diff --git a/backend/locales/en-US.json b/backend/locales/en-US.json
index aaa7659f..ea0542ca 100644
--- a/backend/locales/en-US.json
+++ b/backend/locales/en-US.json
@@ -192,7 +192,8 @@
"SettingsIndex": {
"developer_title": "Developer",
"general_title": "General",
- "plugins_title": "Plugins"
+ "plugins_title": "Plugins",
+ "testing_title": "Testing"
},
"Store": {
"store_contrib": {
@@ -260,5 +261,8 @@
"reloading": "Reloading",
"updating": "Updating"
}
- }
+ },
+ "Testing": {
+ "download": "Download"
+ }
}
diff --git a/frontend/package.json b/frontend/package.json
index 3f57c078..ad48ba77 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 1fa185de..705f935b 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -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
@@ -3104,7 +3104,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'
@@ -3113,14 +3113,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
diff --git a/frontend/src/components/DeckyToaster.tsx b/frontend/src/components/DeckyToaster.tsx
index eaee75eb..6faece09 100644
--- a/frontend/src/components/DeckyToaster.tsx
+++ b/frontend/src/components/DeckyToaster.tsx
@@ -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 () => {
diff --git a/frontend/src/components/settings/index.tsx b/frontend/src/components/settings/index.tsx
index f3a76407..568a0a49 100644
--- a/frontend/src/components/settings/index.tsx
+++ b/frontend/src/components/settings/index.tsx
@@ -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);
@@ -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} />;
diff --git a/frontend/src/components/settings/pages/general/BranchSelect.tsx b/frontend/src/components/settings/pages/general/BranchSelect.tsx
index 6e69208a..d51f1db8 100644
--- a/frontend/src/components/settings/pages/general/BranchSelect.tsx
+++ b/frontend/src/components/settings/pages/general/BranchSelect.tsx
@@ -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],
diff --git a/frontend/src/components/settings/pages/general/Updater.tsx b/frontend/src/components/settings/pages/general/Updater.tsx
index 01aeb4c3..45b026ca 100644
--- a/frontend/src/components/settings/pages/general/Updater.tsx
+++ b/frontend/src/components/settings/pages/general/Updater.tsx
@@ -136,8 +136,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
diff --git a/frontend/src/components/settings/pages/testing/index.tsx b/frontend/src/components/settings/pages/testing/index.tsx
new file mode 100644
index 00000000..72267295
--- /dev/null
+++ b/frontend/src/components/settings/pages/testing/index.tsx
@@ -0,0 +1,89 @@
+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 { setSetting } from '../../../../utils/settings';
+import { UpdateBranch } from '../general/BranchSelect';
+
+interface TestingVersion {
+ id: number;
+ name: string;
+ link: string;
+ head_sha: string;
+}
+
+const getTestingVersions = DeckyBackend.callable<[], TestingVersion[]>('updater/get_testing_versions');
+const downloadTestingVersion = DeckyBackend.callable<[pr_id: number, sha: string]>('updater/download_testing_version');
+
+export default function TestingVersionList() {
+ const { t } = useTranslation();
+ const [testingVersions, setTestingVersions] = useState<TestingVersion[]>([]);
+
+ useEffect(() => {
+ (async () => {
+ setTestingVersions(await getTestingVersions());
+ })();
+ }, []);
+
+ 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={() => {
+ downloadTestingVersion(version.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>
+ );
+}