mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-06-13 12:15:09 +03:00
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+114
-40
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+8
-8
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user