diff options
| author | Party Wumpus <48649272+PartyWumpus@users.noreply.github.com> | 2024-02-15 02:32:58 +0000 |
|---|---|---|
| committer | Party Wumpus <48649272+PartyWumpus@users.noreply.github.com> | 2024-02-20 21:33:41 +0000 |
| commit | 35f6f041c1e800a8df9c222a17c00663ad3c9090 (patch) | |
| tree | a53d23151458a140f9eb76f728eb5c5c552370fe | |
| parent | 7e3f9edacf6a96e821a7e1850b598b6e303fae1c (diff) | |
| download | decky-loader-35f6f041c1e800a8df9c222a17c00663ad3c9090.tar.gz decky-loader-35f6f041c1e800a8df9c222a17c00663ad3c9090.zip | |
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>
| -rw-r--r-- | backend/decky_loader/updater.py | 142 | ||||
| -rw-r--r-- | backend/locales/en-US.json | 8 | ||||
| -rw-r--r-- | frontend/package.json | 2 | ||||
| -rw-r--r-- | frontend/pnpm-lock.yaml | 16 | ||||
| -rw-r--r-- | frontend/src/components/DeckyToaster.tsx | 13 | ||||
| -rw-r--r-- | frontend/src/components/settings/index.tsx | 14 | ||||
| -rw-r--r-- | frontend/src/components/settings/pages/general/BranchSelect.tsx | 15 | ||||
| -rw-r--r-- | frontend/src/components/settings/pages/general/Updater.tsx | 4 | ||||
| -rw-r--r-- | frontend/src/components/settings/pages/testing/index.tsx | 89 |
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> + ); +} |
