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
parent 7e3f9edacf
commit 35f6f041c1
9 changed files with 246 additions and 57 deletions
+109 -33
View File
@@ -1,19 +1,24 @@
from __future__ import annotations from __future__ import annotations
import os
import shutil
from asyncio import sleep from asyncio import sleep
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 from typing import TYPE_CHECKING, List, TypedDict
if TYPE_CHECKING: if TYPE_CHECKING:
from .main import PluginManager 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 aiohttp import ClientSession
from . import helpers from . import helpers
from .injector import get_gamepadui_tab from .injector import get_gamepadui_tab
from .settings import SettingsManager from .settings import SettingsManager
if TYPE_CHECKING:
from .main import PluginManager
logger = getLogger("Updater") logger = getLogger("Updater")
@@ -24,6 +29,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:
@@ -44,6 +55,8 @@ class Updater:
context.ws.add_route("updater/check_for_updates", self.check_for_updates); 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_restart", self.do_restart);
context.ws.add_route("updater/do_update", self.do_update); 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()) context.loop.create_task(self.version_reloader())
def get_branch(self, manager: SettingsManager): def get_branch(self, manager: SettingsManager):
@@ -126,6 +139,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(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): async def do_update(self):
logger.debug("Starting update.") logger.debug("Starting update.")
try: try:
@@ -134,9 +194,9 @@ class Updater:
logger.error("Unable to update as remoteVer is missing") logger.error("Unable to update as remoteVer is missing")
return return
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:
@@ -149,8 +209,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")
@@ -178,33 +236,51 @@ 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(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()
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)
else:
logger.error("workflow run not found", str(works))
+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"
}
} }
+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
@@ -3104,7 +3104,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'
@@ -3113,14 +3113,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],
@@ -136,8 +136,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,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>
);
}