Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ No setup, no configuration — the curated list shows up in Discover the moment

### Controller buttons

DeckyHub uses the standard Steam Deck controls, plus two extra shortcuts on every **Install** / **Update** button (on Discover and Updates cards, in the [Manage](#manage) window, and for DeckyHub's own update):
DeckyHub uses the standard Steam Deck controls, plus two extra shortcuts on every **Install** / **Update** button (on Discover and Updates cards, on **Search** results, in the [Manage](#manage) window, and for DeckyHub's own update):

| Button | What it does |
| --- | --- |
Expand Down
27 changes: 27 additions & 0 deletions devtools/decky-mock/e2e/deckyhub.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,7 @@ test("Search installs a plugin straight from its GitHub release", async ({ page
await page.goto("/?bridge=http://127.0.0.1:8643");
const app = mock(page);
await app.locator(".qam-content").getByRole("button", { name: "Search", exact: true }).click();
await expect(app.getByRole("heading", { name: "Search & Install", exact: true })).toBeVisible();
await app.getByLabel("Search GitHub").fill("mako");
await app.getByRole("button", { name: "Search", exact: true }).click();
// Already in the curated list, so Add shows as done; Install still works.
Expand All @@ -815,6 +816,32 @@ test("Search installs a plugin straight from its GitHub release", async ({ page
]);
});

test("Y and X on a search result's Install show the release notes and download the ZIP", async ({ page }) => {
await mockMakoReleases(page, [{ ...makoRelease("plugin-v1.2.3", "e".repeat(64)), body: "Faster frame pacing." }]);
await page.route("https://api.github.com/search/repositories**", (route) =>
route.fulfill({ json: { items: [{ full_name: "eugeniosegala/MAKO", description: "Frame generation", stargazers_count: 1 }] } }),
);
await recordInstalls(page);
await page.goto("/?preview=/deckyhub/search&bridge=http://127.0.0.1:8643");
const app = mock(page);
await app.getByLabel("Search GitHub").fill("mako");
await app.getByRole("button", { name: "Search", exact: true }).click();
const install = app.getByRole("button", { name: "Install", exact: true });
await install.focus();
await expect(app.locator(".steam-legend")).toContainText("Ⓨ Release Info");
await expect(app.locator(".steam-legend")).toContainText("Ⓧ Download ZIP");

await install.press("y");
await expect(app.locator(".steam-modal", { hasText: "MAKO Decky v1.2.3" }).getByText("Faster frame pacing.")).toBeVisible();
await page.reload();
await app.getByLabel("Search GitHub").fill("mako");
await app.getByRole("button", { name: "Search", exact: true }).click();
await install.press("x");
await expect(app.locator(".steam-modal", { hasText: "mako-decky.zip" })).toBeVisible();
// Neither key installs anything.
expect(await pluginFrame(page).evaluate(() => (window as unknown as { __installCalls: unknown[][] }).__installCalls)).toEqual([]);
});

test("The download window spins until Decky's own install dialog is up", async ({ page }) => {
await mockMakoReleases(page, [makoRelease("plugin-v1.2.3", "a".repeat(64))]);
await recordInstalls(page);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "deckyhub",
"version": "2.6.0-rc.1",
"version": "2.6.0-rc.2",
"description": "Discover, track, and safely download GitHub releases for Steam Deck tools from Gaming Mode.",
"author": "Andrii Shevchuk-Yuhanets",
"private": true,
Expand Down
1 change: 1 addition & 0 deletions src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const de: Record<MessageKey, string> = {
"view.discoverDescription": "Unterstützte GitHub-Projekte und ihre ZIP-Releases durchsuchen.",
"view.discoverEmpty": "Keine Repositories verfügbar.",
"view.searchDescription": "GitHub nach Decky-Plugins durchsuchen. Direkt installieren oder hinzufügen, um Updates zu verfolgen.",
"view.searchTitle": "Suchen & Installieren",
"search.warning": "Suchergebnisse kommen direkt von GitHub. Niemand hat sie geprüft: weder DeckyHub noch der Decky-Store. Ein Plugin kann alles auf deinem Deck tun, installiere also nur Plugins von Autoren, denen du vertraust.",

"filter.search": "Suche",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const en = {
"view.discoverDescription": "Browse supported GitHub projects and their ZIP releases.",
"view.discoverEmpty": "No repositories are available.",
"view.searchDescription": "Search GitHub for Decky plugins. Install one straight away, or add it to track its updates.",
"view.searchTitle": "Search & Install",
"search.warning": "Search results come straight from GitHub. Nobody has reviewed them: not DeckyHub, not the Decky store. A plugin can do anything on your Deck, so only install plugins from authors you trust.",

"filter.search": "Search",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const es: Record<MessageKey, string> = {
"view.discoverDescription": "Explora proyectos de GitHub compatibles y sus versiones ZIP.",
"view.discoverEmpty": "No hay repositorios disponibles.",
"view.searchDescription": "Busca plugins de Decky en GitHub. Instálalo al momento o añádelo para seguir sus actualizaciones.",
"view.searchTitle": "Buscar e instalar",
"search.warning": "Los resultados vienen directamente de GitHub. Nadie los ha revisado: ni DeckyHub ni la tienda de Decky. Un plugin puede hacer cualquier cosa en tu Deck, así que instala solo plugins de autores en los que confíes.",

"filter.search": "Buscar",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const fr: Record<MessageKey, string> = {
"view.discoverDescription": "Parcourez les projets GitHub pris en charge et leurs versions ZIP.",
"view.discoverEmpty": "Aucun dépôt disponible.",
"view.searchDescription": "Cherchez des plugins Decky sur GitHub. Installez-les tout de suite ou ajoutez-les pour suivre leurs mises à jour.",
"view.searchTitle": "Rechercher et installer",
"search.warning": "Les résultats viennent directement de GitHub. Personne ne les a vérifiés : ni DeckyHub, ni la boutique Decky. Un plugin peut tout faire sur votre Deck, n'installez donc que des plugins d'auteurs de confiance.",

"filter.search": "Rechercher",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const ja: Record<MessageKey, string> = {
"view.discoverDescription": "対応しているGitHubプロジェクトとそのZIPリリースを見つけます。",
"view.discoverEmpty": "利用できるリポジトリがありません。",
"view.searchDescription": "GitHub で Decky プラグインを検索します。すぐにインストールするか、追加して更新を追跡できます。",
"view.searchTitle": "検索とインストール",
"search.warning": "検索結果は GitHub から直接取得されます。DeckyHub も Decky ストアも審査していません。プラグインは Deck 上で何でもできるため、信頼できる作者のプラグインだけをインストールしてください。",

"filter.search": "検索",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const ru: Record<MessageKey, string> = {
"view.discoverDescription": "Просмотр поддерживаемых проектов GitHub и их ZIP-релизов.",
"view.discoverEmpty": "Нет доступных репозиториев.",
"view.searchDescription": "Ищите плагины Decky на GitHub. Установите сразу или добавьте, чтобы следить за обновлениями.",
"view.searchTitle": "Поиск и установка",
"search.warning": "Результаты поиска приходят прямо с GitHub. Их никто не проверял: ни DeckyHub, ни магазин Decky. Плагин может сделать на вашем Deck что угодно, поэтому устанавливайте только плагины авторов, которым доверяете.",

"filter.search": "Поиск",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/uk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const uk: Record<MessageKey, string> = {
"view.discoverDescription": "Перегляд підтримуваних проєктів GitHub та їхніх ZIP-релізів.",
"view.discoverEmpty": "Немає доступних репозиторіїв.",
"view.searchDescription": "Шукайте плагіни Decky на GitHub. Встановіть одразу або додайте, щоб стежити за оновленнями.",
"view.searchTitle": "Пошук і встановлення",
"search.warning": "Результати пошуку надходять просто з GitHub. Їх ніхто не перевіряв: ні DeckyHub, ні магазин Decky. Плагін може зробити на вашому Deck будь-що, тож встановлюйте лише плагіни авторів, яким довіряєте.",

"filter.search": "Пошук",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const zh: Record<MessageKey, string> = {
"view.discoverDescription": "浏览受支持的 GitHub 项目及其 ZIP 发行版。",
"view.discoverEmpty": "没有可用的仓库。",
"view.searchDescription": "在 GitHub 上搜索 Decky 插件。可直接安装,或添加以跟踪更新。",
"view.searchTitle": "搜索与安装",
"search.warning": "搜索结果直接来自 GitHub,未经任何审核:DeckyHub 和 Decky 商店都没有审核过。插件可以在你的 Deck 上做任何事,请只安装你信任的作者的插件。",

"filter.search": "搜索",
Expand Down
56 changes: 45 additions & 11 deletions src/pages/SearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import { Card, PageHeader } from "../components/Card";
import { Pager } from "../components/Pager";
import { RateLimitBanner } from "../components/RateLimitBanner";
import { startPluginInstall } from "../components/AppDetailsModal";
import { showReleaseInfo } from "../components/AppCard";
import { showDownloadModal } from "../components/DownloadProgress";
import { useEffect, useState } from "react";
import { FaCheck, FaDownload, FaPlus, FaSearch, FaTimes } from "react-icons/fa";
import { addCustomRepo, getApps, REGISTRY_UPDATED } from "../api";
import { addCustomRepo, downloadAsset, getApps, getSettings, REGISTRY_UPDATED } from "../api";
import { useT } from "../i18n";
import type { SearchRepo } from "../types";
import type { HideableButton, SearchRepo } from "../types";
import { displayVersion } from "../version";
import { compactButtonStyle, disclaimerStyle, githubFetch, githubResponseError, hydrate, installAction, installTypeLabel, log, surfaceStyle } from "../utils";

Expand All @@ -35,9 +37,12 @@ export function SearchPage() {
// The repo whose install is being prepared, so a second tap can't start another.
const [installing, setInstalling] = useState<string | null>(null);

const refreshRepos = () => void getApps().then((result) => setExistingRepos(new Set(result.apps.map((app) => app.repo.toLowerCase()))));
const [hiddenButtons, setHiddenButtons] = useState<HideableButton[]>([]);

useEffect(refreshRepos, []);
useEffect(() => {
void getApps().then((result) => setExistingRepos(new Set(result.apps.map((app) => app.repo.toLowerCase()))));
void getSettings().then((settings) => setHiddenButtons(settings.hiddenButtons ?? []));
}, []);

const search = async () => {
if (!query.trim() || searching) return;
Expand Down Expand Up @@ -80,16 +85,37 @@ export function SearchPage() {
toaster.toast({ title: "DeckyHub", body: t(added ? "repos.addedToast" : "repos.alreadyAdded", { repo }) });
};

// The repo's latest stable release, looked up the way a Discover card does. A repo
// that isn't listed gets the entry the backend makes for an added one (_custom_apps).
const latestRelease = async (repo: string) => {
const listed = (await getApps()).apps.find((app) => app.repo.toLowerCase() === repo.toLowerCase());
const app = await hydrate(listed ?? { id: `custom-${repo.replace("/", "-")}`, name: repo, repo, category: "Custom", detect: { type: "decky-plugin" }, asset: { include: [], exclude: ["source"] }, assets: [] }, false);
if (!app.latestVersion || !app.assets[0]) throw new Error(app.error ?? t("release.noMatch"));
return app as typeof app & { latestVersion: string };
};

// Y: that release's notes, without adding or installing anything.
const releaseInfo = async (repo: string) => {
const app = await latestRelease(repo);
showReleaseInfo(t, `${app.name} ${displayVersion(app.latestVersion)}`, app.publishedAt, app.releaseNotes);
};

// X: that release's ZIP to the download folder instead of installing it.
const downloadZip = async (repo: string) => {
const asset = (await latestRelease(repo)).assets[0];
const result = await downloadAsset(asset, repo);
if (result.error) throw new Error(result.error);
if (result.jobId) showDownloadModal(t, result.jobId, { state: "queued", filename: asset.name, total: asset.size }, () => {});
};

// Adds the repo, then installs its latest stable release the way a Discover card does.
const install = async (repo: string) => {
setInstalling(repo);
try {
await track(repo);
const listed = (await getApps()).apps.find((app) => app.repo.toLowerCase() === repo.toLowerCase());
if (!listed) throw new Error(t("release.noMatch"));
const app = await hydrate(listed, false);
const action = app.latestVersion ? installAction(app, { tag: app.latestVersion, assets: app.assets }) : null;
if (!action || !app.latestVersion) throw new Error(app.error ?? t("release.noMatch"));
const app = await latestRelease(repo);
const action = installAction(app, { tag: app.latestVersion, assets: app.assets });
if (!action) throw new Error(t("release.noMatch"));
const label = installTypeLabel(t, action.type, displayVersion(app.latestVersion).replace(/^v/, ""));
// Decky's own dialog takes over, so leave the page like a card's Install does.
await startPluginInstall(t, app.name, app.repo, app.assets[0], app.latestVersion, action, label, () => Navigation.NavigateBack());
Expand All @@ -101,7 +127,7 @@ export function SearchPage() {
return (
<>
<RateLimitBanner />
<PageHeader icon={<FaSearch />} title={t("nav.search")} description={t("view.searchDescription")} />
<PageHeader icon={<FaSearch />} title={t("view.searchTitle")} description={t("view.searchDescription")} />
<div className="deckyhub-search-warning" style={disclaimerStyle}>{t("search.warning")}</div>
<Card>
<PanelSection>
Expand Down Expand Up @@ -153,7 +179,15 @@ export function SearchPage() {
<PanelSectionRow>
<Focusable flow-children="right" style={{ display: "flex", gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<Button style={compactButtonStyle} disabled={installing !== null} onClick={() => void install(repo.full_name).catch(showError)}>
<Button
style={compactButtonStyle}
disabled={installing !== null}
onSecondaryButton={() => void releaseInfo(repo.full_name).catch(showError)}
onSecondaryActionDescription={t("appcard.releaseInfo")}
onOptionsButton={hiddenButtons.includes("downloadZip") ? undefined : () => void downloadZip(repo.full_name).catch(showError)}
onOptionsActionDescription={t("settings.downloadUpdate")}
onClick={() => void install(repo.full_name).catch(showError)}
>
<FaDownload /> {t("appcard.install")}
</Button>
</div>
Expand Down
Loading