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>
This commit is contained in:
Party Wumpus
2024-02-15 02:32:58 +00:00
committed by GitHub
parent 435dfa7884
commit 9503d5cee0
9 changed files with 249 additions and 64 deletions
+6 -2
View File
@@ -192,7 +192,8 @@
"SettingsIndex": { "SettingsIndex": {
"developer_title": "Developer", "developer_title": "Developer",
"general_title": "General", "general_title": "General",
"plugins_title": "Plugins" "plugins_title": "Plugins",
"testing_title": "Testing"
}, },
"Store": { "Store": {
"store_contrib": { "store_contrib": {
@@ -260,5 +261,8 @@
"reloading": "Reloading", "reloading": "Reloading",
"updating": "Updating" "updating": "Updating"
} }
} },
"Testing": {
"download": "Download"
}
} }
+114 -40
View File
@@ -1,20 +1,29 @@
from __future__ import annotations from __future__ import annotations
import os
import shutil
from asyncio import sleep from asyncio import sleep
from json.decoder import JSONDecodeError from json.decoder import JSONDecodeError
from logging import getLogger from logging import getLogger
import os
from os import getcwd, path, remove from os import getcwd, path, remove
from typing import TYPE_CHECKING, List, TypedDict import shutil
if TYPE_CHECKING: from typing import List, TYPE_CHECKING, TypedDict
from .main import PluginManager import zipfile
from .localplatform import chmod, service_restart, ON_LINUX, get_keep_systemd_service, get_selinux
from aiohttp import ClientSession, web from aiohttp import ClientSession, web
from . import helpers from . import helpers
from .injector import get_gamepadui_tab 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 from .settings import SettingsManager
if TYPE_CHECKING:
from .main import PluginManager
logger = getLogger("Updater") logger = getLogger("Updater")
@@ -25,6 +34,12 @@ class RemoteVer(TypedDict):
tag_name: str tag_name: str
prerelease: bool prerelease: bool
assets: List[RemoteVerAsset] assets: List[RemoteVerAsset]
class TestingVersion(TypedDict):
id: int
name: str
link: str
head_sha: str
class Updater: class Updater:
def __init__(self, context: PluginManager) -> None: def __init__(self, context: PluginManager) -> None:
@@ -36,7 +51,9 @@ class Updater:
"get_version": self.get_version, "get_version": self.get_version,
"do_update": self.do_update, "do_update": self.do_update,
"do_restart": self.do_restart, "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.remoteVer: RemoteVer | None = None
self.allRemoteVers: List[RemoteVer] = [] self.allRemoteVers: List[RemoteVer] = []
@@ -150,6 +167,53 @@ class Updater:
pass pass
await sleep(60 * 60 * 6) # 6 hours 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): async def do_update(self):
logger.debug("Starting update.") logger.debug("Starting update.")
try: try:
@@ -161,7 +225,6 @@ class Updater:
version = self.remoteVer["tag_name"] version = self.remoteVer["tag_name"]
download_url = None download_url = None
download_filename = "PluginLoader" if ON_LINUX else "PluginLoader.exe" download_filename = "PluginLoader" if ON_LINUX else "PluginLoader.exe"
download_temp_filename = download_filename + ".new"
for x in self.remoteVer["assets"]: for x in self.remoteVer["assets"]:
if x["name"] == download_filename: if x["name"] == download_filename:
@@ -174,8 +237,6 @@ class Updater:
service_url = self.get_service_url() service_url = self.get_service_url()
logger.debug("Retrieved service URL") logger.debug("Retrieved service URL")
tab = await get_gamepadui_tab()
await tab.open_websocket()
async with ClientSession() as web: async with ClientSession() as web:
if ON_LINUX and not get_keep_systemd_service(): if ON_LINUX and not get_keep_systemd_service():
logger.debug("Downloading systemd service") logger.debug("Downloading systemd service")
@@ -203,36 +264,49 @@ class Updater:
os.mkdir(path.join(getcwd(), ".systemd")) os.mkdir(path.join(getcwd(), ".systemd"))
shutil.move(service_file_path, path.join(getcwd(), ".systemd")+"/plugin_loader.service") shutil.move(service_file_path, path.join(getcwd(), ".systemd")+"/plugin_loader.service")
logger.debug("Downloading binary") await self.download_decky_binary(download_url, version)
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()
async def do_restart(self): async def do_restart(self):
await service_restart("plugin_loader") 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
@@ -25,7 +25,7 @@
"i18next-parser": "^8.0.0", "i18next-parser": "^8.0.0",
"import-sort-style-module": "^6.0.0", "import-sort-style-module": "^6.0.0",
"inquirer": "^8.2.5", "inquirer": "^8.2.5",
"prettier": "^2.8.8", "prettier": "^3.2.5",
"prettier-plugin-import-sort": "^0.0.7", "prettier-plugin-import-sort": "^0.0.7",
"react": "16.14.0", "react": "16.14.0",
"react-dom": "16.14.0", "react-dom": "16.14.0",
+8 -8
View File
@@ -77,11 +77,11 @@ devDependencies:
specifier: ^8.2.5 specifier: ^8.2.5
version: 8.2.5 version: 8.2.5
prettier: prettier:
specifier: ^2.8.8 specifier: ^3.2.5
version: 2.8.8 version: 3.2.5
prettier-plugin-import-sort: prettier-plugin-import-sort:
specifier: ^0.0.7 specifier: ^0.0.7
version: 0.0.7(prettier@2.8.8) version: 0.0.7(prettier@3.2.5)
react: react:
specifier: 16.14.0 specifier: 16.14.0
version: 16.14.0 version: 16.14.0
@@ -3108,7 +3108,7 @@ packages:
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
dev: true 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==} resolution: {integrity: sha512-O0KlUSq+lwvh+UiN3wZDT6wWkf7TNxTVv2/XXE5KqpRNbFJq3nRg2ftzBYFFO8QGpdWIrOB0uCTCtFjIxmVKQw==}
peerDependencies: peerDependencies:
prettier: '>= 2.0' prettier: '>= 2.0'
@@ -3117,14 +3117,14 @@ packages:
import-sort-config: 6.0.0 import-sort-config: 6.0.0
import-sort-parser-babylon: 6.0.0 import-sort-parser-babylon: 6.0.0
import-sort-parser-typescript: 6.0.0 import-sort-parser-typescript: 6.0.0
prettier: 2.8.8 prettier: 3.2.5
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
dev: true dev: true
/prettier@2.8.8: /prettier@3.2.5:
resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} resolution: {integrity: sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==}
engines: {node: '>=10.13.0'} engines: {node: '>=14'}
hasBin: true hasBin: true
dev: true dev: true
+8 -5
View File
@@ -30,11 +30,14 @@ const DeckyToaster: FC<DeckyToasterProps> = () => {
// not actually node but TS is shit // not actually node but TS is shit
let interval: NodeJS.Timer | null; let interval: NodeJS.Timer | null;
if (renderedToast) { if (renderedToast) {
interval = setTimeout(() => { interval = setTimeout(
interval = null; () => {
console.log('clear toast', renderedToast.data); interval = null;
removeToast(renderedToast.data); console.log('clear toast', renderedToast.data);
}, (renderedToast.data.duration || 5e3) + 1000); removeToast(renderedToast.data);
},
(renderedToast.data.duration || 5e3) + 1000,
);
console.log('set int', interval); console.log('set int', interval);
} }
return () => { return () => {
+13 -1
View File
@@ -1,7 +1,7 @@
import { SidebarNavigation } from 'decky-frontend-lib'; import { SidebarNavigation } from 'decky-frontend-lib';
import { lazy } from 'react'; import { lazy } from 'react';
import { useTranslation } from 'react-i18next'; 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 { useSetting } from '../../utils/hooks/useSetting';
import DeckyIcon from '../DeckyIcon'; import DeckyIcon from '../DeckyIcon';
@@ -10,6 +10,7 @@ import GeneralSettings from './pages/general';
import PluginList from './pages/plugin_list'; import PluginList from './pages/plugin_list';
const DeveloperSettings = lazy(() => import('./pages/developer')); const DeveloperSettings = lazy(() => import('./pages/developer'));
const TestingMenu = lazy(() => import('./pages/testing'));
export default function SettingsPage() { export default function SettingsPage() {
const [isDeveloper, setIsDeveloper] = useSetting<boolean>('developer.enabled', false); const [isDeveloper, setIsDeveloper] = useSetting<boolean>('developer.enabled', false);
@@ -39,6 +40,17 @@ export default function SettingsPage() {
icon: <FaCode />, icon: <FaCode />,
visible: isDeveloper, visible: isDeveloper,
}, },
{
title: t('SettingsIndex.testing_title'),
content: (
<WithSuspense>
<TestingMenu />
</WithSuspense>
),
route: '/decky/settings/testing',
icon: <FaFlask />,
visible: isDeveloper,
},
]; ];
return <SidebarNavigation pages={pages} />; return <SidebarNavigation pages={pages} />;
@@ -8,10 +8,15 @@ import { useSetting } from '../../../../utils/hooks/useSetting';
const logger = new Logger('BranchSelect'); const logger = new Logger('BranchSelect');
enum UpdateBranch { export enum UpdateBranch {
Stable,
Prerelease,
Testing,
}
enum LessUpdateBranch {
Stable, Stable,
Prerelease, Prerelease,
// Testing,
} }
const BranchSelect: FunctionComponent<{}> = () => { const BranchSelect: FunctionComponent<{}> = () => {
@@ -24,11 +29,11 @@ const BranchSelect: FunctionComponent<{}> = () => {
const [selectedBranch, setSelectedBranch] = useSetting<UpdateBranch>('branch', UpdateBranch.Stable); const [selectedBranch, setSelectedBranch] = useSetting<UpdateBranch>('branch', UpdateBranch.Stable);
return ( return (
// Returns numerical values from 0 to 2 (with current branch setup as of 8/28/22) // 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 nightly // 0 being stable, 1 being pre-release and 2 being testing (not a branch!)
<Field label={t('BranchSelect.update_channel.label')} childrenContainerWidth={'fixed'}> <Field label={t('BranchSelect.update_channel.label')} childrenContainerWidth={'fixed'}>
<Dropdown <Dropdown
rgOptions={Object.values(UpdateBranch) rgOptions={Object.values(selectedBranch == UpdateBranch.Testing ? UpdateBranch : LessUpdateBranch)
.filter((branch) => typeof branch == 'number') .filter((branch) => typeof branch == 'number')
.map((branch) => ({ .map((branch) => ({
label: tBranches[branch as number], label: tBranches[branch as number],
@@ -135,8 +135,8 @@ export default function UpdaterSettings() {
{checkingForUpdates {checkingForUpdates
? t('Updater.updates.checking') ? t('Updater.updates.checking')
: !versionInfo?.remote || versionInfo?.remote?.tag_name == versionInfo?.current : !versionInfo?.remote || versionInfo?.remote?.tag_name == versionInfo?.current
? t('Updater.updates.check_button') ? t('Updater.updates.check_button')
: t('Updater.updates.install_button')} : t('Updater.updates.install_button')}
</DialogButton> </DialogButton>
) : ( ) : (
<ProgressBarWithInfo <ProgressBarWithInfo
@@ -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>
);
}