From 6f99dbbef3cc36a87e9d03bfb5e8bebe221b674f Mon Sep 17 00:00:00 2001 From: Andrii Shevchuk-Yuhanets Date: Sat, 24 Oct 2026 15:40:49 -0400 Subject: [PATCH 01/15] fix: download install ZIPs in DeckyHub with progress, hand Decky a file:// path (2.1.1-rc.21) (#70) Co-authored-by: Claude Opus 5.5 --- AGENTS.md | 6 ++-- devtools/decky-mock/dev-server.py | 12 +++++++ devtools/decky-mock/e2e/deckyhub.spec.ts | 28 ++++++++------- main.py | 36 +++++++++++++++++++ package.json | 2 +- src/api.ts | 1 + src/components/AppCard.tsx | 2 +- src/components/AppDetailsModal.tsx | 6 ++-- src/components/DeckyHubUpdate.tsx | 2 +- src/components/DeckyHubUpdateModal.tsx | 2 +- src/components/DownloadProgress.tsx | 12 ++++++- src/pages/Content.tsx | 2 +- src/utils.ts | 32 ++++++++++++----- tests/test_plugin_status.py | 45 ++++++++++++++++++++++++ 14 files changed, 156 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7708e39..1d275e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ Run a single backend test: `python -m unittest tests.test_plugin_status.PluginSt - Keep the backend dependency-free; it uses only the Python standard library. - Never work directly on `main`; branch naming and the pre-commit guard are in [CONTRIBUTING.md](CONTRIBUTING.md#releases). - Any new `@decky/ui` import used in `src/` needs a matching stand-in added to `devtools/decky-mock/ui.tsx`, or the mock's build fails with "No matching export in ui.tsx". -- Download progress and install instructions are one shared modal (`showDownloadModal()` in `src/components/DownloadProgress.tsx`), used by every download-to-`/home/deck/Downloads`-style entry point (Updates/Discover, DeckyHub's "Download ZIP" fallback in `DeckyHubUpdateModal.tsx`, and the dev-mock preview buttons). Don't hand-roll a separate copy for previews or a new call site — it silently drifts from the real component (this happened once already). The exception is DeckyHub's own self-update install button (see below), which hands off to Decky Loader's own installer (and its native progress dialog) and has no `jobId`/`get_download` to poll. +- Download progress and install instructions are one shared modal (`showDownloadModal()` in `src/components/DownloadProgress.tsx`), used by every download-to-`/home/deck/Downloads`-style entry point (Updates/Discover, DeckyHub's "Download ZIP" fallback in `DeckyHubUpdateModal.tsx`, and the dev-mock preview buttons). Don't hand-roll a separate copy for previews or a new call site — it silently drifts from the real component (this happened once already). Install / Update / Downgrade buttons (every `installDeckyPlugin()`/`updateDeckyPlugins()` call) reuse the same modal via `showInstallDownloadModal()`, which polls `stage_install`'s frontend-chosen job id and closes itself once the ZIP is staged, before Decky Loader's own dialog takes over. - A component rendered via `showModal()` cannot call `useT()` (or read any other React context) — `showModal()` mounts onto a separate root from the caller's tree (Decky's own MAIN-window modal manager in production; a fresh React root in the local mock), so `I18nProvider` never reaches it. Resolve `t` in the component that still has context and pass it down as a prop (see `DownloadProgress.tsx`'s `DownloadModal`, `DeckyHubUpdateModal.tsx`) — calling `useT()` inside a modal component throws `"useT must be used within I18nProvider"` the instant it mounts. - Preserve Steam Deck controller navigation: custom layouts need `Focusable` with valid `flow-children` values. Test controller behavior on real hardware before release; the browser mock cannot validate it. - `docs/` is the GitHub Pages site (promo `index.html`, registry editor `editor.html`, `404.html`), assembled by `devtools/build-site.py` in `.github/workflows/pages.yml`: it renders the catalog from `registry/apps.json` into `index.html` and generates the WebP screenshots, so adding a registry app or replacing a screenshot needs no page edit. When the registry changes, update the README's **Bundled repositories** table too. @@ -70,8 +70,8 @@ Release steps (version bump, `v*` tag) are in [CONTRIBUTING.md](CONTRIBUTING.md# - **i18n** (`src/i18n/`) is a from-scratch key/template system, not a library: `en.ts` is the canonical `MessageKey` type every other locale file must satisfy, `useT()` looks up the current locale's string (falling back to English) and does `{var}` substitution. Plain functions in `utils.ts` that build user-facing text with no component tree (GitHub errors, the updates toast) use `tr`, which `I18nProvider` points at the current locale via `setTranslator()` — don't hard-code English there. - **Gamepad/controller navigation** on Steam Deck depends on wrapping any custom-layout container (e.g. a CSS grid) in `` from `@decky/ui` with the correct `flow-children` value — these are literal Panorama/SteamUI focus-flow keywords (`"right-wrap"` for a wrapping left-to-right grid, etc.), not arbitrary strings; an unrecognized value silently falls back to plain vertical flow with no error. `PanelSection`/`ButtonItem`/`PanelSectionRow` handle focus automatically; a raw `
` does not. - **DeckyHub's own self-update is split across two components**, mirroring the pattern in unifideck (a similarly-shaped Decky plugin): `DeckyHubUpdate.tsx` is deliberately thin — just a `PanelSection` titled `"Current - vX.Y.Z"`, laid out like an app card: `"Update to vX.Y.Z"` once a newer release is known (it hands that release straight to `installDeckyPlugin()`, hidden by Display Settings' "Update" like the cards' button) and **Manage**, which opens `DeckyHubUpdateModal.tsx` via `showModal()`. The modal owns everything else: a stable/pre-release channel `DropdownItem`, a version `DropdownItem` listing recent releases for that channel (fetched via `listDeckyHubReleases()` in `src/utils.ts`, which hits `GET /releases?per_page=20` — the same cached list `latestDeckyHubRelease()` picks the panel's background check-and-toast release from), and the install actions for whichever release is selected. The primary button's label/action (`resolveDeckyHubInstallType()` in `utils.ts`) is one of **Update to vX** / **Reinstall vX** / **Downgrade to vX** depending on how the selected version numerically compares to the installed one — reinstalling or downgrading are first-class, not hidden. -- **The primary button bypasses this plugin's own backend entirely.** `@decky/api`'s `call`/`callable` are scoped to this plugin's own RPC connection and cannot reach Decky Loader's global routes, so `getDeckyBackend()` (in `utils.ts`) goes through `window.DeckyBackend` (falling back to `window.opener?.DeckyBackend` for the Quick Access Menu's popup window in Gaming Mode) and calls the loader-global `utilities/install_plugin` route directly (via `installDeckyPlugin()`, shared with the Manage window's **Install** / **Update**, which uses the `pluginName` `get_apps` reports for decky-plugin detections) — the same one the Decky Store's own install button uses. That route pops Decky's own native confirm/progress dialog and does the download+verify+install itself, so the modal closes as soon as it hands the release over. Once the install finishes Decky reloads DeckyHub's backend and frontend itself (`import_plugin` → `loader/import_plugin`); as a fallback, `reloadIfStillOld()` in `utils.ts` waits Decky's 15 s load timeout and, if the old copy was never dismounted (`markUnloaded()` in `onDismount`), calls `window.DeckyPluginLoader.importPlugin("DeckyHub")`. Both version windows (this one and a plugin's Manage window) close on every action except **Check for Updates** and **Download ZIP**, which keeps the window open beneath the shared download-progress window. The **"Download ZIP"** button next to it is the older, always-available fallback for the selected release, going through the same `download_asset`/download-queue path as every other download and the shared `showDownloadModal()`. +- **The primary button hands the install to Decky Loader, not this plugin's backend.** `@decky/api`'s `call`/`callable` are scoped to this plugin's own RPC connection and cannot reach Decky Loader's global routes, so `getDeckyBackend()` (in `utils.ts`) goes through `window.DeckyBackend` (falling back to `window.opener?.DeckyBackend` for the Quick Access Menu's popup window in Gaming Mode) and calls the loader-global `utilities/install_plugin` route directly (via `installDeckyPlugin()`, shared with the Manage window's **Install** / **Update**, which uses the `pluginName` `get_apps` reports for decky-plugin detections) — the same one the Decky Store's own install button uses. That route pops Decky's own native confirm/progress dialog and does the verify+install itself, so the modal closes as soon as it hands the release over. The download is the one step DeckyHub keeps: `installDeckyPlugin()`/`updateDeckyPlugins()` first call `stage_install` (`main.py`), which downloads the ZIP with DeckyHub's own downloader into `$DECKY_PLUGIN_RUNTIME_DIR/installs/` (same release-asset check and SHA-256 verification as `download_asset`), and hand Decky a `file://` path with the same hash. Decky Loader 3.3.0's own fetch of a GitHub release URL was seen to hang for good on a real Deck (no socket, no timeout), while a `file://` artifact is read straight from disk. Staging happens before a fresh-install plugin's uninstall, so a failed download leaves it installed; files older than a day are swept on the next stage, since Decky reads one only once its dialog is accepted. The decky-mock bridge replaces `stage_install` with a no-network stand-in, so E2E sees `file:///deckyhub-installs/`. Once the install finishes Decky reloads DeckyHub's backend and frontend itself (`import_plugin` → `loader/import_plugin`); as a fallback, `reloadIfStillOld()` in `utils.ts` waits Decky's 15 s load timeout and, if the old copy was never dismounted (`markUnloaded()` in `onDismount`), calls `window.DeckyPluginLoader.importPlugin("DeckyHub")`. Both version windows (this one and a plugin's Manage window) close on every action except **Check for Updates** and **Download ZIP**, which keeps the window open beneath the shared download-progress window. The **"Download ZIP"** button next to it is the older, always-available fallback for the selected release, going through the same `download_asset`/download-queue path as every other download and the shared `showDownloadModal()`. ## Security boundaries -The full policy is in [CONTRIBUTING.md](CONTRIBUTING.md#security-boundaries). Implementation points: `download_asset` raises on non-HTTPS URLs and reduces filenames to a basename (path traversal guard), and `import_custom_repos` restricts the source file to a `.json` under `/home/deck`. Never disable certificate verification to work around a trust-store problem — `verifying_ssl_context()` fixes *where* the roots are read from while leaving `CERT_REQUIRED` and hostname checking on; a registry that can't be verified must fail, not be fetched anyway. A user-added registry is fetched only over HTTPS (enforced by `validate_registry_url()`, and `fetch_remote_registry()` rejects a redirect off HTTPS), the response is capped at `MAX_REMOTE_REGISTRY_BYTES`, and its entries only ever name GitHub release assets of the repo they declare — the same `download_asset` check applies, so an added registry can't widen what the plugin is willing to download. +The full policy is in [CONTRIBUTING.md](CONTRIBUTING.md#security-boundaries). Implementation points: `download_asset` (and `stage_install`, through the same `_validate_download_asset`) raises on non-HTTPS URLs and reduces filenames to a basename (path traversal guard), and `import_custom_repos` restricts the source file to a `.json` under `/home/deck`. Never disable certificate verification to work around a trust-store problem — `verifying_ssl_context()` fixes *where* the roots are read from while leaving `CERT_REQUIRED` and hostname checking on; a registry that can't be verified must fail, not be fetched anyway. A user-added registry is fetched only over HTTPS (enforced by `validate_registry_url()`, and `fetch_remote_registry()` rejects a redirect off HTTPS), the response is capped at `MAX_REMOTE_REGISTRY_BYTES`, and its entries only ever name GitHub release assets of the repo they declare — the same `download_asset` check applies, so an added registry can't widen what the plugin is willing to download. diff --git a/devtools/decky-mock/dev-server.py b/devtools/decky-mock/dev-server.py index 565e626..29cbf19 100644 --- a/devtools/decky-mock/dev-server.py +++ b/devtools/decky-mock/dev-server.py @@ -93,6 +93,18 @@ def _run_loop(): asyncio.run_coroutine_threadsafe(plugin._main(), loop).result() print(f"[decky-mock] Plugin ready. Fake DECKY_HOME: {fake_decky.DECKY_HOME}") +# Installs first download their ZIP (Plugin.stage_install), but E2E release +# assets are made up, so check them the same way and hand back a path without +# the network; tests/test_plugin_status.py covers the real download. +async def _stage_install(asset, repo=None, job_id=None): + try: + return {"path": f"/deckyhub-installs/{Plugin._validate_download_asset(asset, repo)}"} + except ValueError as error: + return {"error": str(error)} + + +plugin.stage_install = _stage_install + # Fixed, never-completing job for the DevShell's "Download progress modal" preview button. plugin.downloads["preview-job"] = {"state": "downloading", "filename": "Decky-Framegen.zip", "received": 60_100_000, "total": 199_600_000, "path": None, "error": None} diff --git a/devtools/decky-mock/e2e/deckyhub.spec.ts b/devtools/decky-mock/e2e/deckyhub.spec.ts index 41c0aab..d3def6e 100644 --- a/devtools/decky-mock/e2e/deckyhub.spec.ts +++ b/devtools/decky-mock/e2e/deckyhub.spec.ts @@ -40,6 +40,8 @@ const deckyHubRelease = (tag: string, { sha = "a", prerelease = false, published tag_name: tag, prerelease, draft: false, ...(publishedAt && { published_at: publishedAt }), html_url: `${DECKYHUB_RELEASES}/tag/${tag}`, assets: [{ name: `DeckyHub-${tag}.zip`, browser_download_url: deckyHubZipUrl(tag), digest: `sha256:${sha.repeat(64)}` }], }); +// What Decky receives once DeckyHub has staged a release ZIP (see dev-server.py's stage_install). +const localZip = (url: string) => `file:///deckyhub-installs/${url.split("/").pop()}`; const mockDeckyHubReleases = (page: Page, releases: unknown[]) => page.route("https://api.github.com/repos/mazillka/deckyhub-plugin/releases?per_page=20", (route) => route.fulfill({ json: releases })); @@ -282,7 +284,7 @@ test("DeckyHub's panel Update hands the newer release straight to Decky's instal await expect(app.locator(".steam-modal")).toHaveCount(0); const frame = page.frames().find((candidate) => candidate !== page.mainFrame())!; await expect.poll(() => frame.evaluate(() => (window as unknown as { __installCalls: unknown[][] }).__installCalls)).toEqual([ - ["utilities/install_plugin", deckyHubZipUrl("v9.9.9"), "DeckyHub", "9.9.9", "b".repeat(64), 2], + ["utilities/install_plugin", localZip(deckyHubZipUrl("v9.9.9")), "DeckyHub", "9.9.9", "b".repeat(64), 2], ]); }); @@ -415,7 +417,7 @@ test("DeckyHub's Reinstall closes its window and hands the release to Decky's in await expect(app.locator(".steam-modal")).toHaveCount(0); const frame = page.frames().find((candidate) => candidate !== page.mainFrame())!; await expect.poll(() => frame.evaluate(() => (window as unknown as { __installCalls: unknown[][] }).__installCalls)).toEqual([ - ["utilities/install_plugin", deckyHubZipUrl(deckyHubTag), "DeckyHub", deckyHubVersion, "a".repeat(64), 1], + ["utilities/install_plugin", localZip(deckyHubZipUrl(deckyHubTag)), "DeckyHub", deckyHubVersion, "a".repeat(64), 1], ]); }); @@ -675,7 +677,7 @@ test("An outdated Decky plugin's card offers Update and hands it to Decky's inst const frame = page.frames().find((candidate) => candidate !== page.mainFrame())!; await expect.poll(() => frame.evaluate(() => (window as unknown as { __installCalls: unknown[][] }).__installCalls)).toEqual([ - ["utilities/install_plugin", url, "MAKO - Frame Generation", "1.2.3", sha256, 2], + ["utilities/install_plugin", localZip(url), "MAKO - Frame Generation", "1.2.3", sha256, 2], ]); } finally { // Retries: on Windows the bridge's plugin scan can briefly lock the folder. @@ -742,7 +744,7 @@ test("The Manage window offers Install for a Decky plugin that isn't installed y // plugin's own manifest name, with Decky's INSTALL (0) type. const frame = page.frames().find((candidate) => candidate !== page.mainFrame())!; await expect.poll(() => frame.evaluate(() => (window as unknown as { __installCalls: unknown[][] }).__installCalls)).toEqual([ - ["utilities/install_plugin", url, "MAKO - Frame Generation", "1.2.3", sha256, 0], + ["utilities/install_plugin", localZip(url), "MAKO - Frame Generation", "1.2.3", sha256, 0], ]); }); @@ -760,7 +762,7 @@ test("Discover's card installs the latest release of a Decky plugin that isn't i const frame = page.frames().find((candidate) => candidate !== page.mainFrame())!; await expect.poll(() => frame.evaluate(() => (window as unknown as { __installCalls: unknown[][] }).__installCalls)).toEqual([ - ["utilities/install_plugin", url, "MAKO - Frame Generation", "1.2.3", sha256, 0], + ["utilities/install_plugin", localZip(url), "MAKO - Frame Generation", "1.2.3", sha256, 0], ]); }); @@ -777,7 +779,7 @@ test("A release without a GitHub checksum installs with an empty hash", async ({ // Decky Loader skips its SHA-256 check when the hash is empty. const frame = page.frames().find((candidate) => candidate !== page.mainFrame())!; await expect.poll(() => frame.evaluate(() => (window as unknown as { __installCalls: unknown[][] }).__installCalls)).toEqual([ - ["utilities/install_plugin", url, "MAKO - Frame Generation", "1.2.3", "", 0], + ["utilities/install_plugin", localZip(url), "MAKO - Frame Generation", "1.2.3", "", 0], ]); }); @@ -808,7 +810,7 @@ test("The Manage window reinstalls or downgrades an installed Decky plugin to th const frame = page.frames().find((candidate) => candidate !== page.mainFrame())!; await expect.poll(() => frame.evaluate(() => (window as unknown as { __installCalls: unknown[][] }).__installCalls)).toEqual([ - ["utilities/install_plugin", "https://github.com/eugeniosegala/MAKO/releases/download/plugin-v1.0.0/mako-decky.zip", "MAKO - Frame Generation", "1.0.0", sha, 3], + ["utilities/install_plugin", localZip("https://github.com/eugeniosegala/MAKO/releases/download/plugin-v1.0.0/mako-decky.zip"), "MAKO - Frame Generation", "1.0.0", sha, 3], ]); } finally { rmSync(pluginDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); @@ -844,7 +846,7 @@ test("Unifideck updates by uninstalling first and installing fresh, after a warn await expect.poll(calls).toEqual([ ["utilities/uninstall_plugin", "Unifideck"], - ["utilities/install_plugin", url, "Unifideck", "0.7.6", sha, 0], + ["utilities/install_plugin", localZip(url), "Unifideck", "0.7.6", sha, 0], ]); } finally { rmSync(pluginDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); @@ -881,11 +883,11 @@ test("Update All sends every outdated Decky plugin to Decky's installer in one r const frame = page.frames().find((candidate) => candidate !== page.mainFrame())!; const calls = () => frame.evaluate(() => (window as unknown as { __installCalls: unknown[][] }).__installCalls); await expect.poll(calls).toHaveLength(1); - const [[route, requests]] = (await calls()) as [string, { name: string; version: string; install_type: number }[]][]; + const [[route, requests]] = (await calls()) as [string, { artifact: string; name: string; version: string; install_type: number }[]][]; expect(route).toBe("utilities/install_plugins"); - expect(requests.map(({ name, version, install_type }) => [name, version, install_type]).sort()).toEqual([ - ["Decky LSFG-VK", "0.2.0", 2], - ["MAKO - Frame Generation", "1.2.3", 2], + expect(requests.map(({ artifact, name, version, install_type }) => [name, version, install_type, artifact]).sort()).toEqual([ + ["Decky LSFG-VK", "0.2.0", 2, localZip("decky-lsfg-vk.zip")], + ["MAKO - Frame Generation", "1.2.3", 2, localZip("mako-decky.zip")], ]); } finally { for (const pluginDir of dirs) rmSync(pluginDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); @@ -1585,6 +1587,8 @@ test("DeckyHub asks Decky to load its new version when Decky doesn't reload it a await app.locator(".qam-content").getByRole("button", { name: "Update to v9.9.9", exact: true }).click(); const imports = () => pluginFrame(page).evaluate(() => (window as unknown as { __imports: string[] }).__imports); + // Decky can only finish an install it has been handed, after the ZIP is staged. + await expect.poll(() => pluginFrame(page).evaluate(() => (window as unknown as { __installCalls: unknown[][] }).__installCalls.length)).toBe(1); await emit(page, "loader/plugin_download_finish", "DeckyHub"); // Decky gets its own 15 s to reload DeckyHub first. await page.clock.fastForward(10_000); diff --git a/main.py b/main.py index 9734c2b..83af12e 100644 --- a/main.py +++ b/main.py @@ -659,6 +659,42 @@ async def download_asset(self, asset: dict, repo: str | None = None): decky.logger.error("Can't start %s from %s: %s", name, repo, error) return {"error": f"Can't start {name}: {error}"} + async def stage_install(self, asset: dict, repo: str | None = None, job_id: str | None = None): + """Downloads a release ZIP for Decky Loader's installer to read as file://. + + Decky Loader 3.3.0's own download of a GitHub release URL was seen to hang + for good on a real Deck (its request never opened a socket, no timeout + fired) while this downloader fetched the same file in seconds. Decky reads + a file:// ZIP straight from disk and still checks the SHA-256 it's given. + The frontend may pass job_id to poll progress (get_download) and cancel + it (cancel_download) while this runs; it never names the file. + """ + target_id, registered = f"install-{time.monotonic_ns()}", None + try: + name = self._validate_download_asset(asset, repo) + registered = job_id if isinstance(job_id, str) and job_id and job_id not in self.downloads else target_id + job_id = registered + # Registered before any disk work so the first poll finds it. + self.downloads[job_id] = {"state": "queued", "filename": name, "received": 0, "total": asset.get("size") or 0, "path": None, "error": None} + directory = Path(decky.DECKY_PLUGIN_RUNTIME_DIR) / "installs" + directory.mkdir(parents=True, exist_ok=True) + # Decky reads the file only once its confirm dialog is accepted, which + # may be after DeckyHub reloads, so old ones are swept here instead. + for old in directory.iterdir(): + if old.is_file() and old.stat().st_mtime < time.time() - 24 * 3600: + old.unlink(missing_ok=True) + target = directory / f"{target_id}-{name}" + await asyncio.to_thread(self._download, job_id, asset, target) + job = self.downloads.pop(job_id) + if job["state"] != "complete": + return {"error": job["error"] or f"Download {job['state']}"} + return {"path": str(target)} + except (OSError, ValueError) as error: + if registered: + self.downloads.pop(registered, None) + decky.logger.error("Can't stage %s for install: %s", asset.get("url") if isinstance(asset, dict) else asset, error) + return {"error": str(error)} + @staticmethod def _validate_download_asset(asset: dict, repo: str | None) -> str: prefix = f"https://github.com/{repo}/releases/download/" if isinstance(repo, str) and REPOSITORY_NAME.fullmatch(repo) else "" diff --git a/package.json b/package.json index 700d5ae..7c2e471 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "deckyhub", - "version": "2.1.1-rc.19", + "version": "2.1.1-rc.21", "description": "Discover, track, and safely download GitHub releases for Steam Deck tools from Gaming Mode.", "author": "Andrii Shevchuk-Yuhanets", "private": true, diff --git a/src/api.ts b/src/api.ts index a798e58..0d1c003 100644 --- a/src/api.ts +++ b/src/api.ts @@ -5,6 +5,7 @@ export const getApps = callable<[], { apps: App[] }>("get_apps"); export const getSettings = callable<[], Settings>("get_settings"); export const saveSettings = callable<[settings: Settings], Settings>("save_settings"); export const downloadAsset = callable<[asset: Asset, repo?: string], { jobId?: string; error?: string }>("download_asset"); +export const stageInstall = callable<[asset: Asset, repo: string, jobId: string], { path?: string; error?: string }>("stage_install"); export const getDownload = callable<[jobId: string], Download>("get_download"); export const cancelDownload = callable<[jobId: string], { ok: boolean }>("cancel_download"); export const getDeckyHubInfo = callable<[], DeckyHubInfo>("get_deckyhub_info"); diff --git a/src/components/AppCard.tsx b/src/components/AppCard.tsx index e64fb48..669f3dd 100644 --- a/src/components/AppCard.tsx +++ b/src/components/AppCard.tsx @@ -105,7 +105,7 @@ export function AppCard({ onOptionsActionDescription={t("settings.downloadUpdate")} onClick={() => { setInstalling(true); - void startPluginInstall(t, app.name, app.assets[0], app.latestVersion!, install, quickLabel, leavePage).finally(() => setInstalling(false)); + void startPluginInstall(t, app.name, app.repo, app.assets[0], app.latestVersion!, install, quickLabel, leavePage).finally(() => setInstalling(false)); }} > {isUpdate ? : } {quickLabel} diff --git a/src/components/AppDetailsModal.tsx b/src/components/AppDetailsModal.tsx index 2910f02..90ecd18 100644 --- a/src/components/AppDetailsModal.tsx +++ b/src/components/AppDetailsModal.tsx @@ -30,14 +30,14 @@ export function confirmPluginInstall(t: TFunc, title: string, okLabel: string, n // card's quick Install/Update. installDeckyPlugin() uninstalls fresh-install // plugins first, before Decky's own dialog appears, so cancelling that dialog // would leave the plugin uninstalled — say so before starting. -export function startPluginInstall(t: TFunc, appName: string, asset: Asset, tag: string, action: { name: string; type: 0 | 1 | 2 | 3 }, label: string, onStarted?: () => void): Promise { +export function startPluginInstall(t: TFunc, appName: string, repo: string, asset: Asset, tag: string, action: { name: string; type: 0 | 1 | 2 | 3 }, label: string, onStarted?: () => void): Promise { if (!getDeckyBackend()) { toaster.toast({ title: "DeckyHub", body: t("settings.selfUpdateUnavailable") }); return Promise.resolve(); } // Settles once Decky has the request (its own dialog is up) or it failed. const start = () => { - const request = installDeckyPlugin(asset, action.name, tag, action.type).then( + const request = installDeckyPlugin(asset, repo, action.name, tag, action.type).then( () => undefined, (error: unknown) => void toaster.toast({ title: "DeckyHub", body: String(error) }), ); @@ -100,7 +100,7 @@ export function AppDetailsModal({ const runInstall = (release: AppReleaseOption, action: NonNullable) => // Decky's own confirm/progress dialog would otherwise open behind this // window; the Updates/Discover page behind it is left too. - void startPluginInstall(t, app.name, release.assets[0], release.tag, action, installLabel, () => { + void startPluginInstall(t, app.name, app.repo, release.assets[0], release.tag, action, installLabel, () => { closeModal?.(); Navigation.NavigateBack(); }); diff --git a/src/components/DeckyHubUpdate.tsx b/src/components/DeckyHubUpdate.tsx index 3e9268b..241992f 100644 --- a/src/components/DeckyHubUpdate.tsx +++ b/src/components/DeckyHubUpdate.tsx @@ -68,7 +68,7 @@ export function DeckyHubUpdate() { toaster.toast({ title: "DeckyHub", body: t("settings.selfUpdateUnavailable") }); return; } - installDeckyPlugin(release.asset!, "DeckyHub", release.version!, PLUGIN_INSTALL_TYPE.UPDATE).catch((error: unknown) => + installDeckyPlugin(release.asset!, "mazillka/deckyhub-plugin", "DeckyHub", release.version!, PLUGIN_INSTALL_TYPE.UPDATE).catch((error: unknown) => toaster.toast({ title: "DeckyHub", body: String(error) }), ); }; diff --git a/src/components/DeckyHubUpdateModal.tsx b/src/components/DeckyHubUpdateModal.tsx index 4786da6..228f041 100644 --- a/src/components/DeckyHubUpdateModal.tsx +++ b/src/components/DeckyHubUpdateModal.tsx @@ -77,7 +77,7 @@ export function DeckyHubUpdateModal({ return; } closeModal?.(); - installDeckyPlugin(release.asset, PLUGIN_NAME, release.tag, resolveDeckyHubInstallType(release.tag, info.version)).catch((error: unknown) => + installDeckyPlugin(release.asset, "mazillka/deckyhub-plugin", PLUGIN_NAME, release.tag, resolveDeckyHubInstallType(release.tag, info.version)).catch((error: unknown) => toaster.toast({ title: "DeckyHub", body: String(error) }), ); }; diff --git a/src/components/DownloadProgress.tsx b/src/components/DownloadProgress.tsx index 32d49e6..1032122 100644 --- a/src/components/DownloadProgress.tsx +++ b/src/components/DownloadProgress.tsx @@ -14,12 +14,14 @@ function DownloadModal({ jobId, initial, onSettled, + closeWhenDone, closeModal, }: { t: TFunc; jobId: string; initial: Download; onSettled?: (state: Download) => void; + closeWhenDone?: boolean; closeModal?: () => void; }) { const [state, setState] = useState(initial); @@ -46,7 +48,9 @@ function DownloadModal({ }; useEffect(() => { - if (!active) settle(); + if (active) return; + settle(); + if (closeWhenDone) closeModal?.(); }, [active]); useEffect(() => settle, []); @@ -100,3 +104,9 @@ function DownloadModal({ export function showDownloadModal(t: TFunc, jobId: string, initial: Download, onSettled?: (state: Download) => void) { showModal(); } + +// Progress for an install's ZIP (stage_install), closed as soon as the job +// settles: Decky's own dialog follows, and a failure is toasted by the caller. +export function showInstallDownloadModal(t: TFunc, jobId: string, filename: string, total?: number) { + showModal(); +} diff --git a/src/pages/Content.tsx b/src/pages/Content.tsx index 5add086..5b85d41 100644 --- a/src/pages/Content.tsx +++ b/src/pages/Content.tsx @@ -214,7 +214,7 @@ export function Content({ fullPage }: { fullPage?: View }) { const batchUpdates = view === "updates" && !hiddenButtons.includes("update") ? visibleApps.flatMap((app) => { const action = app.latestVersion ? installAction(app, { tag: app.latestVersion, assets: app.assets }) : null; - return app.updateAvailable === true && action?.type === PLUGIN_INSTALL_TYPE.UPDATE && !needsFreshInstall(action.name, action.type) ? [{ asset: app.assets[0], name: action.name, version: app.latestVersion! }] : []; + return app.updateAvailable === true && action?.type === PLUGIN_INSTALL_TYPE.UPDATE && !needsFreshInstall(action.name, action.type) ? [{ asset: app.assets[0], repo: app.repo, name: action.name, version: app.latestVersion! }] : []; }) : []; // Decky's own dialog takes over, so leave the page once the request is sent. diff --git a/src/utils.ts b/src/utils.ts index d532cd2..a6374de 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,6 @@ import { fetchNoCors, toaster } from "@decky/api"; -import { getSettings, logFrontend } from "./api"; +import { getSettings, logFrontend, stageInstall } from "./api"; +import { showInstallDownloadModal } from "./components/DownloadProgress"; import { en, type MessageKey, type TFunc } from "./i18n/en"; import type { App, AppReleaseOption, Asset, DeckyHubRelease, DeckyHubReleaseOption, RepoPreference, UpdateChannel } from "./types"; import { compareVersions, displayVersion, inChannel, normalizeVersion } from "./version"; @@ -272,9 +273,11 @@ function reloadIfStillOld() { }, 15_000); } -// Hands a verified release ZIP to Decky Loader's own installer — the route the -// Decky Store uses. The loader shows its native confirm/progress dialog and -// does the download, SHA-256 check and install itself. +// Hands a release ZIP to Decky Loader's own installer — the route the Decky +// Store uses. The loader shows its native confirm/progress dialog and does the +// SHA-256 check and install itself. DeckyHub's backend downloads the ZIP first +// (stage_install in main.py: Decky's own download of a GitHub URL can hang) and +// Decky reads it as file://. // // Plugins that are uninstalled first and then installed fresh instead of // updated in place. Decky Loader's in-place update uninstalls the old version @@ -286,14 +289,24 @@ const FRESH_INSTALL_PLUGINS = ["Unifideck"]; export const needsFreshInstall = (pluginName: string, installType: number) => installType !== PLUGIN_INSTALL_TYPE.INSTALL && FRESH_INSTALL_PLUGINS.includes(pluginName); -export async function installDeckyPlugin(asset: Asset, pluginName: string, version: string, installType: 0 | 1 | 2 | 3) { +async function localZip(asset: Asset, repo: string) { + const jobId = `install-${Date.now()}-${Math.random().toString(36).slice(2)}`; + showInstallDownloadModal(tr, jobId, asset.name, asset.size); + const { path, error } = await stageInstall(asset, repo, jobId); + if (!path) throw new Error(error); + return `file://${path}`; +} + +export async function installDeckyPlugin(asset: Asset, repo: string, pluginName: string, version: string, installType: 0 | 1 | 2 | 3) { + // Before any uninstall, so a failed download leaves the plugin as it was. + const artifact = await localZip(asset, repo); if (needsFreshInstall(pluginName, installType)) { await uninstallDeckyPlugin(pluginName); log(`Decky Loader uninstalled ${pluginName} before a fresh install`); installType = PLUGIN_INSTALL_TYPE.INSTALL; } traceDeckyInstall(pluginName); - return callDeckyLoader("utilities/install_plugin", asset.url, pluginName, normalizeVersion(displayVersion(version)), asset.sha256 ?? "", installType).then((result: unknown) => { + return callDeckyLoader("utilities/install_plugin", artifact, pluginName, normalizeVersion(displayVersion(version)), asset.sha256 ?? "", installType).then((result: unknown) => { log(`Decky Loader install_plugin ${pluginName} accepted, waiting for confirm/progress`); return result; }); @@ -302,10 +315,13 @@ export async function installDeckyPlugin(asset: Asset, pluginName: string, versi // Several updates in one go: Decky Loader shows one confirm dialog listing them // all and installs them in order. Fresh-install plugins need their uninstall // first, so callers leave them out. -export function updateDeckyPlugins(updates: { asset: Asset; name: string; version: string }[]) { +export async function updateDeckyPlugins(updates: { asset: Asset; repo: string; name: string; version: string }[]) { + // One at a time, so a single progress window shows at once. + const artifacts: string[] = []; + for (const { asset, repo } of updates) artifacts.push(await localZip(asset, repo)); return callDeckyLoader( "utilities/install_plugins", - updates.map(({ asset, name, version }) => ({ artifact: asset.url, name, version: normalizeVersion(displayVersion(version)), hash: asset.sha256 ?? "", install_type: PLUGIN_INSTALL_TYPE.UPDATE })), + updates.map(({ asset, name, version }, index) => ({ artifact: artifacts[index], name, version: normalizeVersion(displayVersion(version)), hash: asset.sha256 ?? "", install_type: PLUGIN_INSTALL_TYPE.UPDATE })), ); } diff --git a/tests/test_plugin_status.py b/tests/test_plugin_status.py index 77c223d..bfcac74 100644 --- a/tests/test_plugin_status.py +++ b/tests/test_plugin_status.py @@ -2,6 +2,7 @@ import hashlib import json import logging +import os import sys import tempfile import types @@ -452,6 +453,50 @@ def test_download_asset_reuses_an_active_matching_job(self): self.assertEqual(second["jobId"], first["jobId"]) self.assertEqual(len(plugin.downloads), 1) + def test_stage_install_downloads_a_verified_zip_for_decky_to_read(self): + with tempfile.TemporaryDirectory() as home, patch.object(sys.modules["decky"], "DECKY_PLUGIN_RUNTIME_DIR", home, create=True): + plugin = Plugin() + plugin.downloads = {} + plugin.cancelled = set() + stale = Path(home) / "installs" / "old.zip" + stale.parent.mkdir() + stale.write_bytes(b"old") + os.utime(stale, (0, 0)) + asset = {"name": "../a.zip", "url": "https://github.com/owner/repo/releases/download/v1/a.zip", "sha256": hashlib.sha256(b"data").hexdigest()} + + seen = [] + + def fake_download(job_id, asset, temp): + seen.append(asyncio.run(plugin.get_download("poll-me"))["state"]) + temp.write_bytes(b"data") + + with patch.object(plugin, "_download_with_urllib", side_effect=fake_download): + result = asyncio.run(plugin.stage_install(asset, "owner/repo", "poll-me")) + + self.assertEqual(seen, ["downloading"]) + path = Path(result["path"]) + self.assertNotIn("poll-me", path.name) + self.assertEqual(path.parent, Path(home) / "installs") + self.assertTrue(path.name.endswith("-a.zip")) + self.assertEqual(path.read_bytes(), b"data") + self.assertFalse(stale.exists()) + self.assertEqual(plugin.downloads, {}) + + def test_stage_install_refuses_other_repos_and_bad_checksums(self): + with tempfile.TemporaryDirectory() as home, patch.object(sys.modules["decky"], "DECKY_PLUGIN_RUNTIME_DIR", home, create=True): + plugin = Plugin() + plugin.downloads = {} + plugin.cancelled = set() + asset = {"name": "a.zip", "url": "https://github.com/owner/repo/releases/download/v1/a.zip", "sha256": "0" * 64} + + with patch.object(plugin, "_download_with_urllib", side_effect=lambda job_id, asset, temp: temp.write_bytes(b"data")) as download: + self.assertEqual(asyncio.run(plugin.stage_install(asset, "other/repo")), {"error": "Only GitHub release assets for the selected repository can be downloaded"}) + download.assert_not_called() + self.assertIn("SHA256", asyncio.run(plugin.stage_install(asset, "owner/repo"))["error"]) + + self.assertEqual(list((Path(home) / "installs").iterdir()), []) + self.assertEqual(plugin.downloads, {}) + def test_download_marks_error_on_checksum_mismatch(self): with tempfile.TemporaryDirectory() as home: plugin = Plugin() From f1475aecc84968fbd4081301db3f609c804f677a Mon Sep 17 00:00:00 2001 From: Andrii Shevchuk-Yuhanets Date: Sat, 24 Oct 2026 15:41:49 -0400 Subject: [PATCH 02/15] docs: develop is the integration branch, main is stable releases only (#71) Co-authored-by: Claude Opus 5.5 --- .claude/skills/deckyhub-registry/SKILL.md | 4 ++-- .claude/skills/update-dependencies/SKILL.md | 2 +- .githooks/pre-commit | 2 +- .github/dependabot.yml | 3 +++ .github/workflows/ci.yml | 2 +- .github/workflows/codeql.yml | 4 ++-- AGENTS.md | 2 +- CONTRIBUTING.md | 6 +++--- SKILLS.md | 6 ++++++ devtools/sync-registry.py | 12 ++++++------ 10 files changed, 26 insertions(+), 17 deletions(-) diff --git a/.claude/skills/deckyhub-registry/SKILL.md b/.claude/skills/deckyhub-registry/SKILL.md index 89c0deb..bba54ae 100644 --- a/.claude/skills/deckyhub-registry/SKILL.md +++ b/.claude/skills/deckyhub-registry/SKILL.md @@ -14,7 +14,7 @@ The curated list lives in two places that must stay byte-identical: Edit the registry repo first, then sync the plugin repo. Never edit the plugin's copy by hand. -Local checkouts sit side by side: `../deckyhub-registry` next to this repo. Clone it with `gh repo clone mazillka/deckyhub-registry` if it's missing. Both repos follow [CONTRIBUTING.md](../../../CONTRIBUTING.md#releases): work on a short kebab-case branch, never commit to `main`, one topic per PR, squash-merge. +Local checkouts sit side by side: `../deckyhub-registry` next to this repo. Clone it with `gh repo clone mazillka/deckyhub-registry` if it's missing. Both repos follow [CONTRIBUTING.md](../../../CONTRIBUTING.md#releases): work on a short kebab-case branch, never commit to `main` (in this repo, branch from and target `develop`), one topic per PR, squash-merge. ## Adding, removing or editing an app @@ -43,7 +43,7 @@ Local checkouts sit side by side: `../deckyhub-registry` next to this repo. Clon python devtools/sync-registry.py --pr ``` - It branches from `origin/main`, copies the merged registry into `registry/apps.json`, regenerates README's **Bundled repositories** table, bumps `package.json` to the next pre-release (`2.1.1-rc.N` -> `rc.N+1`; a stable `X.Y.Z` -> `X.Y.(Z+1)-rc.1`), runs the backend tests, commits, pushes and runs `gh pr create`, titled like `feat: add to the registry ()`. If nothing changed it says so and leaves no branch. + It branches from `origin/develop`, copies the merged registry into `registry/apps.json`, regenerates README's **Bundled repositories** table, bumps `package.json` to the next pre-release (`2.1.1-rc.N` -> `rc.N+1`; a stable `X.Y.Z` -> `X.Y.(Z+1)-rc.1`), runs the backend tests, commits, pushes and runs `gh pr create`, titled like `feat: add to the registry ()`. If nothing changed it says so and leaves no branch. Without `--pr` it only rewrites the two files, for doing the rest by hand; pass a path to sync from an unmerged registry branch: `python devtools/sync-registry.py ../deckyhub-registry/registry/apps.json`. 5. Check the PR's CI and merge it. diff --git a/.claude/skills/update-dependencies/SKILL.md b/.claude/skills/update-dependencies/SKILL.md index 15e1d30..d0b4f55 100644 --- a/.claude/skills/update-dependencies/SKILL.md +++ b/.claude/skills/update-dependencies/SKILL.md @@ -17,7 +17,7 @@ Dependabot (`.github/dependabot.yml`) opens weekly PRs for the same three. When ## Steps -1. Branch from `main` (`chore-deps-`); never update on `main` or on a feature branch. +1. Branch from `origin/develop` (`chore-deps-`) and open the PR against `develop`; never update on `develop`, `main` or a feature branch. 2. Run the three checks above. If nothing is outdated, stop and say so - don't open an empty PR. 3. Update. Rules: - `react` and `react-dom` in the mock must stay on the exact same version (Dependabot groups them for this reason). diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 3917a93..7cf895d 100644 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -2,7 +2,7 @@ branch=$(git branch --show-current) case "$branch" in - main|staging) + main|develop|staging) echo "Direct commits to $branch are blocked. Create a branch first." >&2 exit 1 ;; diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a72ede9..e8eecbe 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,9 +3,11 @@ updates: - package-ecosystem: npm directory: / schedule: { interval: weekly } + target-branch: develop - package-ecosystem: npm directory: /devtools/decky-mock schedule: { interval: weekly } + target-branch: develop groups: # react and react-dom must stay on the exact same version. react: @@ -13,3 +15,4 @@ updates: - package-ecosystem: github-actions directory: / schedule: { interval: weekly } + target-branch: develop diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0082b1..cf8e978 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, develop] pull_request: workflow_dispatch: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9f2dc82..9b09421 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,9 +2,9 @@ name: CodeQL on: push: - branches: [main] + branches: [main, develop] pull_request: - branches: [main] + branches: [main, develop] schedule: - cron: "17 4 * * 1" diff --git a/AGENTS.md b/AGENTS.md index 1d275e0..c7ec8a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ Run a single backend test: `python -m unittest tests.test_plugin_status.PluginSt ## Development rules - Keep the backend dependency-free; it uses only the Python standard library. -- Never work directly on `main`; branch naming and the pre-commit guard are in [CONTRIBUTING.md](CONTRIBUTING.md#releases). +- Branch all new work from `develop` and open PRs against `develop`; `main` is for stable releases only. Never work directly on either; branch naming and the pre-commit guard are in [CONTRIBUTING.md](CONTRIBUTING.md#releases). - Any new `@decky/ui` import used in `src/` needs a matching stand-in added to `devtools/decky-mock/ui.tsx`, or the mock's build fails with "No matching export in ui.tsx". - Download progress and install instructions are one shared modal (`showDownloadModal()` in `src/components/DownloadProgress.tsx`), used by every download-to-`/home/deck/Downloads`-style entry point (Updates/Discover, DeckyHub's "Download ZIP" fallback in `DeckyHubUpdateModal.tsx`, and the dev-mock preview buttons). Don't hand-roll a separate copy for previews or a new call site — it silently drifts from the real component (this happened once already). Install / Update / Downgrade buttons (every `installDeckyPlugin()`/`updateDeckyPlugins()` call) reuse the same modal via `showInstallDownloadModal()`, which polls `stage_install`'s frontend-chosen job id and closes itself once the ZIP is staged, before Decky Loader's own dialog takes over. - A component rendered via `showModal()` cannot call `useT()` (or read any other React context) — `showModal()` mounts onto a separate root from the caller's tree (Decky's own MAIN-window modal manager in production; a fresh React root in the local mock), so `I18nProvider` never reaches it. Resolve `t` in the component that still has context and pass it down as a prop (see `DownloadProgress.tsx`'s `DownloadModal`, `DeckyHubUpdateModal.tsx`) — calling `useT()` inside a modal component throws `"useT must be used within I18nProvider"` the instant it mounts. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b9b50f0..8bb8479 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,11 +48,11 @@ Users can point DeckyHub at their own registries too (**Repositories** → **Cus ## Releases -Create work branches as ``; add an ISO date suffix only when needed for uniqueness. Do not work directly on `main`. +`develop` is where all new work lands; `main` holds stable releases only. Create work branches from `origin/develop` as `` (add an ISO date suffix only when needed for uniqueness) and open their PRs against `develop`. Do not work directly on `develop` or `main`. A stable release is a PR from `develop` into `main`; pre-releases (`vX.Y.Z-rc.N`) are tagged on `develop`, stable releases (`vX.Y.Z`) on `main`. -Merge pull requests with **Squash and merge** so `main` keeps one commit per PR; keep each PR to a single topic and use its title as the commit message. +Merge feature pull requests into `develop` with **Squash and merge** so it keeps one commit per PR; keep each PR to a single topic and use its title as the commit message. -The repository's `.githooks/pre-commit` hook blocks commits on `main`. Activate it once per checkout with `git config core.hooksPath .githooks`; protected branches on GitHub should enforce the same rule remotely. +The repository's `.githooks/pre-commit` hook blocks commits on `develop` and `main`. Activate it once per checkout with `git config core.hooksPath .githooks`; protected branches on GitHub should enforce the same rule remotely. To release, bump `version` in `package.json` (the backend reads it at runtime; there is no second copy), then push a `v*` Git tag. The tag starts the GitHub Actions release workflow. It fails unless the tag is `v` plus the `package.json` version. It installs the locked packages, typechecks, builds, runs the frontend and backend tests and the browser E2E suite in `devtools/decky-mock/`, and packages the Decky plugin with a read-only token; a separate publish job then attaches `DeckyHub-v*.zip` to the GitHub release. Release-notes conventions are in the Release section of [AGENTS.md](AGENTS.md). diff --git a/SKILLS.md b/SKILLS.md index 7437309..b73bb9a 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -2,6 +2,12 @@ Use these project skills when working in this repository. +## Branches + +- `develop` is the integration branch: branch every feature, fix and pre-release from `origin/develop` and open its PR against `develop`. +- `main` holds stable releases only. It changes only through a PR from `develop` when cutting a stable `vX.Y.Z`; never branch from it or target it with feature work. +- Pre-release tags (`vX.Y.Z-rc.N`) are cut from `develop`; stable tags from `main`. Details in [CONTRIBUTING.md](CONTRIBUTING.md#releases). + ## Steam Deck UI - Verify UI behavior in the mock and on real Steam Deck hardware before release. Controller-focus and modal rules are in [AGENTS.md](AGENTS.md#development-rules). diff --git a/devtools/sync-registry.py b/devtools/sync-registry.py index a057d96..d10dcf5 100644 --- a/devtools/sync-registry.py +++ b/devtools/sync-registry.py @@ -5,7 +5,7 @@ python devtools/sync-registry.py --pr # all of it as a pull request --pr does the whole routine after a registry PR merges: a fresh branch from -origin/main, the sync, the next pre-release version in package.json, the backend +origin/develop, the sync, the next pre-release version in package.json, the backend tests, a commit, a push and `gh pr create`. It needs a clean working tree and stops without a branch when there's nothing to sync. @@ -73,11 +73,11 @@ def git(*args: str) -> str: def pull_request() -> None: if git("status", "--porcelain", "--untracked-files=no"): - sys.exit("Commit or stash your changes first: --pr starts a new branch from origin/main") - start = git("branch", "--show-current") or "main" - git("fetch", "origin", "main") + sys.exit("Commit or stash your changes first: --pr starts a new branch from origin/develop") + start = git("branch", "--show-current") or "develop" + git("fetch", "origin", "develop") branch = f"sync-registry-{time.strftime('%Y-%m-%d-%H%M%S')}" - git("switch", "-c", branch, "origin/main") + git("switch", "-c", branch, "origin/develop") # Compared as text, not via git status: with core.autocrlf a rewritten file shows # as modified even when only its line endings differ. old_registry, old_readme = REGISTRY.read_text(encoding="utf-8"), README.read_text(encoding="utf-8") @@ -108,7 +108,7 @@ def pull_request() -> None: git("add", str(REGISTRY), str(README), str(PACKAGE)) git("commit", "-m", title) git("push", "-u", "origin", branch) - subprocess.run(["gh", "pr", "create", "--base", "main", "--head", branch, "--title", title, "--body", body], cwd=ROOT, check=True) + subprocess.run(["gh", "pr", "create", "--base", "develop", "--head", branch, "--title", title, "--body", body], cwd=ROOT, check=True) def main() -> None: From eb70b16b88f5a74c5dcd7e73fab43394900180e8 Mon Sep 17 00:00:00 2001 From: Andrii Shevchuk-Yuhanets Date: Sat, 24 Oct 2026 20:59:15 -0400 Subject: [PATCH 03/15] refactor: trim single-use helpers found by the ponytail audit (#72) Co-authored-by: Claude Opus 5.5 --- AGENTS.md | 4 ++-- main.py | 17 +++-------------- src/i18n/index.tsx | 12 ++++++------ src/pages/Content.tsx | 2 +- src/utils.ts | 9 ++------- 5 files changed, 14 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c7ec8a3..29d7a96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,10 +60,10 @@ Release steps (version bump, `v*` tag) are in [CONTRIBUTING.md](CONTRIBUTING.md# - **Users can add their own registries** from **Repositories** → **Custom Registries**. That page has one field: an `owner/repo` is expanded via `registryUrlForRepo()` (`src/utils.ts`) to the same `raw.githubusercontent.com/.../registry/apps.json` shape, and anything else is sent as a URL. The page sends the resolved URL; the backend only stores and validates it (`validate_registry_url()` — HTTPS, `.json` path, length cap) and caps the list at `MAX_CUSTOM_REGISTRIES`. `_load_registry()` loads the official source first, then each added one, **first source wins** on `repo`, `id` and `detect.names` (with `DeckyHub` reserved) so an added registry can never override an official entry's detect rules or asset filters, nor claim an installed plugin's name and offer an Update that installs its own repo's release over it. Each source caches independently (`registries/.json`, hashed from the URL so two lists can't clobber each other) and a source with neither a live copy nor a cache is skipped rather than failing the load. `add_custom_registry()` fetches once before saving, so a dead URL fails in the UI instead of being silently dropped at every startup, and refuses (`nothing-new`) a list whose every entry is already shown; the decky-mock bridge serves fixture registries from `https://registry.test/` so E2E can exercise this without the network; add/remove reload `self.apps` from caches (no extra downloads; remove also deletes that source's cache), `refresh_registry()` re-downloads every source, and the page dispatches `REGISTRY_UPDATED`. - **All HTTPS goes through `verifying_ssl_context()` in `main.py`**, wired into the shared `_OPENER` (alongside its HTTPS-only redirect handler), never a bare `urlopen`. Decky Loader runs plugins under a PyInstaller-bundled Python whose OpenSSL was compiled against Debian's certificate layout and looks under `/usr/lib/ssl` — a path that does not exist on SteamOS, which is Arch-based and keeps its CAs in `/etc/ssl`. The result is a default context that trusts *nobody*, so every fetch fails with `CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate`, the official registry silently falls back to the bundled copy, and only explicit actions like adding a registry surface an error. `verifying_ssl_context()` builds the normal verifying context and, **only when it comes up with zero certificates**, loads a CA bundle from `CA_BUNDLE_PATHS`. Verification stays on: a self-signed, expired or hostname-mismatched certificate still fails exactly as before. - **Downloads are queued, not fire-and-forget**: `download_asset` pushes onto `self.download_queue`, drained one at a time by `_run_download_queue()` → `_download()`. Progress/state lives in `self.downloads[job_id]`, polled by the frontend via `get_download`. Cancellation is cooperative: `cancel_download` adds an active (queued or downloading) job_id to `self.cancelled`, and `_download` checks that set before it starts and while it runs; `clear_downloads` skips the files of active jobs. `_download` tries `urllib` first, falls back to system `curl` on `URLError` (some CDNs 403 Python's default UA/TLS stack); both refuse redirects off HTTPS. SHA-256 verification is opt-out for normal downloads, mandatory for DeckyHub self-updates. -- **Version comparison** happens entirely client-side in `src/version.ts` (`compareVersions`, used by both `isUpdate` and `resolveDeckyHubInstallType` in `src/utils.ts`) — the backend never decides "update available"; it just supplies `installedVersion`, and the frontend fetches `latestVersion`/`publishedAt` directly from the GitHub API and compares the dotted version number plus any pre-release suffix, piece by piece (`1.0.3-alpha.5` < `1.0.3-beta.1` < `1.0.3-rc.2` < `1.0.3-rc.10` < `1.0.3`). +- **Version comparison** happens entirely client-side in `src/version.ts` (`compareVersions`, used by both `hydrate` and `resolveDeckyHubInstallType` in `src/utils.ts`) — the backend never decides "update available"; it just supplies `installedVersion`, and the frontend fetches `latestVersion`/`publishedAt` directly from the GitHub API and compares the dotted version number plus any pre-release suffix, piece by piece (`1.0.3-alpha.5` < `1.0.3-beta.1` < `1.0.3-rc.2` < `1.0.3-rc.10` < `1.0.3`). **Frontend (`src/`) talks to GitHub directly from the browser**, not through the Python backend — every release lookup (`hydrate()`, `listAppReleases()`, `listDeckyHubReleases()`, `latestDeckyHubRelease()` in `src/utils.ts`) goes through one `fetchReleases(repo)`, which calls `fetchNoCors` from `@decky/api` against `api.github.com`. This means release/version data is rate-limited by GitHub per-device — 60 req/hr (10/min search) unauthenticated, 5,000/hr (30/min search) once a user sets a personal access token in Settings. A 403/429 there is virtually always that limit, not a real access error, so `githubResponseError()` turns it into a message naming the rate limit and, when the response's `retry-after`/`x-ratelimit-reset` headers say so, roughly how long to wait (plus a hint to add a token, if none is set), instead of a bare status code — and, when no token is set, also calls `reportRateLimit()`, which raises `RateLimitBanner` (a dismissible notice with a link to Settings) at the top of every screen that calls GitHub until the limit resets or a token is saved. `fetchReleases()` caches each repo's raw release list in `localStorage` (`readCache`/`writeCache`, `CACHE_TTL` = 30 minutes, keyed per repo) and callers apply their own channel/tag/asset filters on top, so the cards and both version pickers share one fetch; the cache is bypassed with `force=true` — wired to every explicit "Refresh"/"Check for Updates" action, never to the automatic loads that happen on mount, channel switch or the 15-minute background refresh. Unauthenticated GitHub calls send **no request headers at all** (no `Accept`, no `If-None-Match`/ETag re-check): any header turns `fetchNoCors` into a CORS preflight to Decky Loader, and on a real Deck (Decky 3.2.10) those preflights were seen to stall indefinitely once Chrome's 6-socket pool for them filled with ones the loader never read, hanging every GitHub call for its full 15 s timeout while header-less requests still worked; with a token set the `Authorization` header makes the preflight unavoidable. If a fetch fails and an older entry exists, that entry is returned instead of an error (the rate-limit banner still shows). An empty release list is treated with suspicion too: GitHub sometimes answers `[]` to an authenticated request for a repo that has releases, so with a token set `requestReleases()` asks once more without it, and an empty answer never replaces a cached non-empty list. GitHub's `GET .../releases` list endpoint has been observed **not** reliably sorted newest-first (a just-published release returned mid-array instead of at index 0), so `fetchReleases()` sorts by `published_at` itself rather than trusting response order for "latest". "Latest" for a channel is the newest matching release among the last 20: the stable channel sees only stable releases, the pre-release channel sees every release (`inChannel()` in `src/version.ts`), so a stable release newer than the last pre-release still reaches people on pre-releases. -- **GitHub token**: stored server-side as `settings.githubToken` (validated/trimmed by `_coerce_github_token()` in `main.py`; the backend itself never uses it — its own network calls hit `raw.githubusercontent.com` and release-asset CDN URLs, a different, effectively unlimited bucket). On the frontend it lives in a module-level variable in `utils.ts` (`setGithubToken()`/`githubHeaders()`), not React state, since it's read from plain async functions outside any component tree. Every route mounts its own `I18nProvider` (see below), and that provider's settings-loading effect is also what primes the token on mount and re-primes it whenever `SettingsPage` saves a new one (via the same `LOCALE_CHANGED` event used for locale changes) — so there's no dedicated "token changed" event of its own. Because React runs a child's effects before its parent's, a route's first GitHub call can fire before that provider has loaded the token, so every api.github.com call goes through `githubFetch()`, which waits once for the saved token (`getSettings()`) before its first request. +- **GitHub token**: stored server-side as `settings.githubToken` (validated/trimmed by `_coerce_settings()` in `main.py`; the backend itself never uses it — its own network calls hit `raw.githubusercontent.com` and release-asset CDN URLs, a different, effectively unlimited bucket). On the frontend it lives in a module-level variable in `utils.ts` (`setGithubToken()`/`githubHeaders()`), not React state, since it's read from plain async functions outside any component tree. Every route mounts its own `I18nProvider` (see below), and that provider's settings-loading effect is also what primes the token on mount and re-primes it whenever `SettingsPage` saves a new one (via the same `LOCALE_CHANGED` event used for locale changes) — so there's no dedicated "token changed" event of its own. Because React runs a child's effects before its parent's, a route's first GitHub call can fire before that provider has loaded the token, so every api.github.com call goes through `githubFetch()`, which waits once for the saved token (`getSettings()`) before its first request. - **`Content.tsx`** is the shared list view for both the Updates and Discover routes (`fullPage` prop selects which), including the app grid and search/status filters. **`AppCard.tsx`** renders one app's status/version and its download buttons. **`DownloadProgress.tsx`**'s `showDownloadModal()` is the single download UI used by `Content.tsx`, `DeckyHubUpdateModal.tsx`, and the dev-mock preview: it opens a modal that self-polls `get_download`, shows progress with a Cancel button while active, and swaps in place to install instructions (or an error) once the job settles — callers just disable their download buttons via an `onSettled` callback. **`ManageRepositoriesPage.tsx`** and **`SettingsPage.tsx`** are separate routes registered in `src/index.tsx` via `routerHook.addRoute`. - **Custom-events-as-pub/sub**: cross-component signals (`REGISTRY_UPDATED` in `api.ts`, `LOCALE_CHANGED` in `i18n/index.tsx`) are plain `window.dispatchEvent`/`addEventListener` `CustomEvent`s, not React context — grep for these constants before assuming a settings change needs prop drilling. diff --git a/main.py b/main.py index 83af12e..1020649 100644 --- a/main.py +++ b/main.py @@ -78,18 +78,6 @@ def verifying_ssl_context() -> ssl.SSLContext: _OPENER = build_opener(_HttpsOnlyRedirects, HTTPSHandler(context=verifying_ssl_context())) -def _coerce_columns_per_row(value) -> int: - return value if type(value) is int and value in (1, 2, 3) else DEFAULT_COLUMNS_PER_ROW - - -# The frontend sends this straight to api.github.com's Authorization header -# (see githubHeaders() in src/utils.ts) — it never touches the backend's own -# network calls, which only hit raw.githubusercontent.com/release-asset CDN -# URLs and aren't subject to the api.github.com rate limit this is for. -def _coerce_github_token(value) -> str: - return value.strip()[:255] if isinstance(value, str) else "" - - # User-added registry URLs, normalized, de-duplicated and capped. Invalid entries # are dropped rather than raised on: a settings file edited by hand shouldn't stop # the plugin from starting, and add_custom_registry() is where typos get reported. @@ -132,8 +120,9 @@ def _coerce_settings(settings: dict) -> dict: "overwriteExisting": bool(settings.get("overwriteExisting", True)), "updateChannel": "prerelease" if settings.get("updateChannel") == "prerelease" else "stable", "language": settings.get("language") if settings.get("language") in SUPPORTED_LANGUAGES else "auto", - "columnsPerRow": _coerce_columns_per_row(settings.get("columnsPerRow")), - "githubToken": _coerce_github_token(settings.get("githubToken")), + "columnsPerRow": settings.get("columnsPerRow") if type(settings.get("columnsPerRow")) is int and settings["columnsPerRow"] in (1, 2, 3) else DEFAULT_COLUMNS_PER_ROW, + # Only the frontend's api.github.com calls send it (githubHeaders() in src/utils.ts). + "githubToken": settings["githubToken"].strip()[:255] if isinstance(settings.get("githubToken"), str) else "", "density": "compact" if settings.get("density") == "compact" else "default", # Manage-window buttons the user chose to hide (Display Settings). "hiddenButtons": [name for name in HIDEABLE_BUTTONS if isinstance(settings.get("hiddenButtons"), list) and name in settings["hiddenButtons"]], diff --git a/src/i18n/index.tsx b/src/i18n/index.tsx index 386096c..e82412c 100644 --- a/src/i18n/index.tsx +++ b/src/i18n/index.tsx @@ -10,7 +10,7 @@ import { fr } from "./fr"; import { ja } from "./ja"; import { zh } from "./zh"; -export type Locale = "en" | "uk" | "ru" | "es" | "de" | "fr" | "ja" | "zh"; +type Locale = "en" | "uk" | "ru" | "es" | "de" | "fr" | "ja" | "zh"; export const LOCALES: { code: Locale; label: string }[] = [ { code: "en", label: "English" }, @@ -39,7 +39,7 @@ function detectLocale(): Locale { return "en"; } -const I18nContext = createContext<{ locale: Locale; t: TFunc } | null>(null); +const I18nContext = createContext(null); export function I18nProvider({ children }: { children: ReactNode }) { const [preference, setPreference] = useState("auto"); @@ -66,11 +66,11 @@ export function I18nProvider({ children }: { children: ReactNode }) { useEffect(() => setTranslator(t), [locale]); - return {children}; + return {children}; } export function useT(): TFunc { - const ctx = useContext(I18nContext); - if (!ctx) throw new Error("useT must be used within I18nProvider"); - return ctx.t; + const t = useContext(I18nContext); + if (!t) throw new Error("useT must be used within I18nProvider"); + return t; } diff --git a/src/pages/Content.tsx b/src/pages/Content.tsx index 5b85d41..04262a6 100644 --- a/src/pages/Content.tsx +++ b/src/pages/Content.tsx @@ -63,7 +63,7 @@ export function Content({ fullPage }: { fullPage?: View }) { const local = appData.apps; const preferences = settings.repoSettings || {}; setRepoPreferences(preferences); - setColumnsPerRow(settings.columnsPerRow || DEFAULT_COLUMNS_PER_ROW); + setColumnsPerRow(settings.columnsPerRow); setHiddenButtons(settings.hiddenButtons ?? []); const tracked = view === "updates" ? local.filter((app) => app.installedVersion) : local; setCardPage(0); diff --git a/src/utils.ts b/src/utils.ts index a6374de..fb02f2b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -406,12 +406,6 @@ export function installAction(app: App, release: { tag: string; assets: Asset[] return null; } -function isUpdate(app: App, latest: string | null) { - if (!app.installedVersion || !latest) return null; - const cmp = compareVersions(latest, app.installedVersion); - return cmp === null ? null : cmp > 0; -} - export function installTypeLabel(t: TFunc, type: number, version: string, updateLabel = t("settings.updateTo", { version })) { return type === PLUGIN_INSTALL_TYPE.INSTALL ? t("appcard.installVersion", { version }) @@ -553,6 +547,7 @@ export async function hydrate(app: App, force: boolean, preference?: RepoPrefere const { items, error } = await listAppReleases(app, preference, force); const release = items.find((item) => inChannel(item, channel)); if (!release) return { ...app, channel, error: error ?? tr("release.noMatch") }; + const cmp = app.installedVersion ? compareVersions(release.tag, app.installedVersion) : null; return { ...app, channel, @@ -561,6 +556,6 @@ export async function hydrate(app: App, force: boolean, preference?: RepoPrefere releaseUrl: release.url, releaseNotes: release.notes, assets: release.assets, - updateAvailable: isUpdate(app, release.tag), + updateAvailable: cmp === null ? null : cmp > 0, }; } From 2126f911e2c423d7257927b66cf9ae6a1de9d592 Mon Sep 17 00:00:00 2001 From: Andrii Shevchuk-Yuhanets Date: Sat, 24 Oct 2026 20:59:23 -0400 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20spin=20"Preparing=20install?= =?UTF-8?q?=E2=80=A6"=20until=20Decky's=20install=20dialog=20is=20up=20(#7?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 5.5 --- AGENTS.md | 2 +- README.md | 4 +- devtools/decky-mock/e2e/deckyhub.spec.ts | 24 +++++++++- src/components/DownloadProgress.tsx | 53 +++++++++++++-------- src/i18n/de.ts | 1 + src/i18n/en.ts | 1 + src/i18n/es.ts | 1 + src/i18n/fr.ts | 1 + src/i18n/ja.ts | 1 + src/i18n/ru.ts | 1 + src/i18n/uk.ts | 1 + src/i18n/zh.ts | 1 + src/utils.ts | 60 ++++++++++++++++-------- 13 files changed, 107 insertions(+), 44 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 29d7a96..877b3b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ Run a single backend test: `python -m unittest tests.test_plugin_status.PluginSt - Keep the backend dependency-free; it uses only the Python standard library. - Branch all new work from `develop` and open PRs against `develop`; `main` is for stable releases only. Never work directly on either; branch naming and the pre-commit guard are in [CONTRIBUTING.md](CONTRIBUTING.md#releases). - Any new `@decky/ui` import used in `src/` needs a matching stand-in added to `devtools/decky-mock/ui.tsx`, or the mock's build fails with "No matching export in ui.tsx". -- Download progress and install instructions are one shared modal (`showDownloadModal()` in `src/components/DownloadProgress.tsx`), used by every download-to-`/home/deck/Downloads`-style entry point (Updates/Discover, DeckyHub's "Download ZIP" fallback in `DeckyHubUpdateModal.tsx`, and the dev-mock preview buttons). Don't hand-roll a separate copy for previews or a new call site — it silently drifts from the real component (this happened once already). Install / Update / Downgrade buttons (every `installDeckyPlugin()`/`updateDeckyPlugins()` call) reuse the same modal via `showInstallDownloadModal()`, which polls `stage_install`'s frontend-chosen job id and closes itself once the ZIP is staged, before Decky Loader's own dialog takes over. +- Download progress and install instructions are one shared modal (`showDownloadModal()` in `src/components/DownloadProgress.tsx`), used by every download-to-`/home/deck/Downloads`-style entry point (Updates/Discover, DeckyHub's "Download ZIP" fallback in `DeckyHubUpdateModal.tsx`, and the dev-mock preview buttons). Don't hand-roll a separate copy for previews or a new call site — it silently drifts from the real component (this happened once already). Install / Update / Downgrade buttons (every `installDeckyPlugin()`/`updateDeckyPlugins()` call) reuse the same modal via `showInstallDownloadModal()`, which polls `stage_install`'s frontend-chosen job id, then spins ("Preparing install…") until Decky Loader's `install_plugin` call returns (its own dialog is up) and closes. - A component rendered via `showModal()` cannot call `useT()` (or read any other React context) — `showModal()` mounts onto a separate root from the caller's tree (Decky's own MAIN-window modal manager in production; a fresh React root in the local mock), so `I18nProvider` never reaches it. Resolve `t` in the component that still has context and pass it down as a prop (see `DownloadProgress.tsx`'s `DownloadModal`, `DeckyHubUpdateModal.tsx`) — calling `useT()` inside a modal component throws `"useT must be used within I18nProvider"` the instant it mounts. - Preserve Steam Deck controller navigation: custom layouts need `Focusable` with valid `flow-children` values. Test controller behavior on real hardware before release; the browser mock cannot validate it. - `docs/` is the GitHub Pages site (promo `index.html`, registry editor `editor.html`, `404.html`), assembled by `devtools/build-site.py` in `.github/workflows/pages.yml`: it renders the catalog from `registry/apps.json` into `index.html` and generates the WebP screenshots, so adding a registry app or replacing a screenshot needs no page edit. When the registry changes, update the README's **Bundled repositories** table too. diff --git a/README.md b/README.md index 2884e22..d9e9b9d 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Tap **Manage** on any card in Discover or Updates to: - pick the release **channel** and a specific **version**. **Stable** shows only stable releases; **Pre-release** shows every release, newest first, so a stable release that comes out after the last pre-release is still offered; - **Download ZIP**, open the **Release Page**, or **Check for Updates** for that app; -- for a Decky plugin, **Install** (not installed yet) or **Update** (the selected version is newer than the installed one). This hands the release ZIP to Decky Loader's own installer, the same one the Decky Store uses. Decky asks you to confirm and installs it, verifying the SHA-256 checksum GitHub publishes for the ZIP. Older releases (files uploaded before mid-2025) have no checksum and Decky installs them without that check; +- for a Decky plugin, **Install** (not installed yet) or **Update** (the selected version is newer than the installed one). DeckyHub downloads the release ZIP (with a progress window, then a spinner until Decky is ready) and hands it to Decky Loader's own installer, the same one the Decky Store uses. Decky asks you to confirm and installs it, verifying the SHA-256 checksum GitHub publishes for the ZIP. Older releases (files uploaded before mid-2025) have no checksum and Decky installs them without that check; - for an installed Decky plugin, **Uninstall**, which asks you to confirm and then removes the plugin and its settings through Decky Loader. ### Downloads @@ -122,7 +122,7 @@ Under **Custom Registries**, add someone else's app list by its GitHub `owner/re The Quick Access Menu shows the installed version (**Current - vX.Y.Z**) and checks for a newer DeckyHub release in the background, with a notification the first time one appears; an **Update to vX.Y.Z** button then installs it straight away, like an app card's. **Manage** opens DeckyHub's update window, where you pick a channel (**Stable releases**, or **Pre-releases** for every release) and a version, then: -- **Update to / Reinstall / Downgrade to vX.Y.Z** — hands that release to Decky Loader's own installer (the same one the Decky Store uses), which downloads it, verifies its SHA-256 checksum, and installs it in place. Only releases with a published checksum are offered. +- **Update to / Reinstall / Downgrade to vX.Y.Z** — downloads that release (with a progress window) and hands it to Decky Loader's own installer (the same one the Decky Store uses), which verifies its SHA-256 checksum and installs it in place. Only releases with a published checksum are offered. - **Download ZIP** — the classic path: saves the release ZIP to `/home/deck/Downloads/deckyhub`, which you then install yourself via Decky's settings → Developer → Install Plugin from ZIP. If automatic update isn't available for some reason (e.g. running inside an older Decky Loader build), fall back to Download ZIP. diff --git a/devtools/decky-mock/e2e/deckyhub.spec.ts b/devtools/decky-mock/e2e/deckyhub.spec.ts index d3def6e..3ad3266 100644 --- a/devtools/decky-mock/e2e/deckyhub.spec.ts +++ b/devtools/decky-mock/e2e/deckyhub.spec.ts @@ -59,7 +59,11 @@ const recordInstalls = (page: Page) => __emit: (event: string, ...args: unknown[]) => [...(listeners[event] ?? [])].forEach((listener) => listener(...args)), DeckyPluginLoader: { importPlugin: async (name: string) => void imports.push(name) }, DeckyBackend: { - call: async (...args: unknown[]) => void calls.push(args), + // Settles once __holdInstall does, if a test set one (Decky's dialog still coming). + call: async (...args: unknown[]) => { + calls.push(args); + await (window as unknown as { __holdInstall?: Promise }).__holdInstall; + }, addEventListener: (event: string, listener: (...args: unknown[]) => void) => void (listeners[event] ??= []).push(listener), removeEventListener: (event: string, listener: (...args: unknown[]) => void) => void (listeners[event] = (listeners[event] ?? []).filter((item) => item !== listener)), }, @@ -748,6 +752,24 @@ test("The Manage window offers Install for a Decky plugin that isn't installed y ]); }); +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); + await page.addInitScript(() => { + const target = window as unknown as { __holdInstall: Promise; __releaseInstall: () => void }; + target.__holdInstall = new Promise((resolve) => (target.__releaseInstall = resolve)); + }); + await page.goto("/?preview=/deckyhub/discover&bridge=http://127.0.0.1:8643"); + const makoCard = mock(page).getByRole("heading", { name: "MAKO Decky" }).locator(".."); + await makoCard.getByRole("button", { name: "Install v1.2.3", exact: true }).click(); + await acceptDisclaimer(page, "Install v1.2.3"); + + const preparing = mock(page).getByText("Preparing install…"); + await expect(preparing).toBeVisible(); + await pluginFrame(page).evaluate(() => (window as unknown as { __releaseInstall: () => void }).__releaseInstall()); + await expect(preparing).toHaveCount(0); +}); + test("Discover's card installs the latest release of a Decky plugin that isn't installed yet", async ({ page }) => { const sha256 = "a".repeat(64); const url = "https://github.com/eugeniosegala/MAKO/releases/download/plugin-v1.2.3/mako-decky.zip"; diff --git a/src/components/DownloadProgress.tsx b/src/components/DownloadProgress.tsx index 1032122..baa34ba 100644 --- a/src/components/DownloadProgress.tsx +++ b/src/components/DownloadProgress.tsx @@ -14,14 +14,15 @@ function DownloadModal({ jobId, initial, onSettled, - closeWhenDone, + until, closeModal, }: { t: TFunc; jobId: string; initial: Download; onSettled?: (state: Download) => void; - closeWhenDone?: boolean; + // Install mode: stays open (spinning once the ZIP is in) until this settles. + until?: Promise; closeModal?: () => void; }) { const [state, setState] = useState(initial); @@ -48,33 +49,43 @@ function DownloadModal({ }; useEffect(() => { - if (active) return; - settle(); - if (closeWhenDone) closeModal?.(); + if (!active) settle(); }, [active]); - useEffect(() => settle, []); + useEffect(() => { + void until?.finally(() => closeModal?.()); + return settle; + }, []); const progress = state.total ? Math.min(100, (100 * (state.received ?? 0)) / state.total) : 0; + // Downloaded (or being checksummed) but Decky's own dialog isn't up yet. + const preparing = Boolean(until) && (!active || progress >= 100); return (
- {active && ( + {preparing && ( +
+ {state.filename} + + {t("dl.preparingInstall")} +
+ )} + {active && !preparing && (
{t("dl.downloading", { filename: state.filename ?? "" })} {state.total ? t("dl.progress", { percent: Math.round(progress), received: readableBytes(state.received), total: readableBytes(state.total) }) : t("dl.preparing")}
)} - {state.state === "missing" &&
{t("dl.missing")}
} - {state.state === "error" && ( + {!until && state.state === "missing" &&
{t("dl.missing")}
} + {!until && state.state === "error" && ( <> {t("dl.failedTitle")}
{state.error || t("dl.failedNoReason")}
)} - {state.state === "complete" && ( + {!until && state.state === "complete" && ( <>
✓ @@ -90,12 +101,14 @@ function DownloadModal({
)} - + {!preparing && ( + + )}
); @@ -105,8 +118,8 @@ export function showDownloadModal(t: TFunc, jobId: string, initial: Download, on showModal(); } -// Progress for an install's ZIP (stage_install), closed as soon as the job -// settles: Decky's own dialog follows, and a failure is toasted by the caller. -export function showInstallDownloadModal(t: TFunc, jobId: string, filename: string, total?: number) { - showModal(); +// Progress for an install's ZIP (stage_install), then a spinner until `until` +// settles: Decky's own dialog is up, or the caller toasts the failure. +export function showInstallDownloadModal(t: TFunc, jobId: string, filename: string, total: number | undefined, until: Promise) { + showModal(); } diff --git a/src/i18n/de.ts b/src/i18n/de.ts index abb0ec3..acb2ca2 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -50,6 +50,7 @@ export const de: Record = { "dl.downloading": "{filename} wird heruntergeladen", "dl.preparing": "Download wird vorbereitet…", + "dl.preparingInstall": "Installation wird vorbereitet…", "dl.savedTo": "Die ZIP-Datei wurde gespeichert unter:", "dl.selectedFolder": "dem ausgewählten Download-Ordner", "dl.installInDecky": "In Decky installieren:", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index d9cf1b8..018493f 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -49,6 +49,7 @@ export const en = { "dl.downloading": "Downloading {filename}", "dl.preparing": "Preparing download…", + "dl.preparingInstall": "Preparing install…", "dl.savedTo": "The ZIP was saved to:", "dl.selectedFolder": "the selected download folder", "dl.installInDecky": "Install it in Decky:", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 9f20c3f..1f4473b 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -50,6 +50,7 @@ export const es: Record = { "dl.downloading": "Descargando {filename}", "dl.preparing": "Preparando descarga…", + "dl.preparingInstall": "Preparando la instalación…", "dl.savedTo": "El ZIP se guardó en:", "dl.selectedFolder": "la carpeta de descargas seleccionada", "dl.installInDecky": "Instálalo en Decky:", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 89daec5..09c9fee 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -50,6 +50,7 @@ export const fr: Record = { "dl.downloading": "Téléchargement de {filename}", "dl.preparing": "Préparation du téléchargement…", + "dl.preparingInstall": "Préparation de l'installation…", "dl.savedTo": "Le fichier ZIP a été enregistré dans :", "dl.selectedFolder": "le dossier de téléchargement sélectionné", "dl.installInDecky": "Installez-le dans Decky :", diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index e3a9546..dc291ac 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -50,6 +50,7 @@ export const ja: Record = { "dl.downloading": "{filename} をダウンロード中", "dl.preparing": "ダウンロードを準備中…", + "dl.preparingInstall": "インストールを準備中…", "dl.savedTo": "ZIPの保存先:", "dl.selectedFolder": "選択したダウンロードフォルダ", "dl.installInDecky": "Deckyでインストール:", diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index 8b7b0b5..42cb5bb 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -50,6 +50,7 @@ export const ru: Record = { "dl.downloading": "Загрузка {filename}", "dl.preparing": "Подготовка загрузки…", + "dl.preparingInstall": "Подготовка к установке…", "dl.savedTo": "ZIP-файл сохранён в:", "dl.selectedFolder": "выбранную папку загрузок", "dl.installInDecky": "Установите в Decky:", diff --git a/src/i18n/uk.ts b/src/i18n/uk.ts index 2493ca4..d9405e1 100644 --- a/src/i18n/uk.ts +++ b/src/i18n/uk.ts @@ -50,6 +50,7 @@ export const uk: Record = { "dl.downloading": "Завантаження {filename}", "dl.preparing": "Підготовка завантаження…", + "dl.preparingInstall": "Підготовка до встановлення…", "dl.savedTo": "ZIP-файл збережено до:", "dl.selectedFolder": "вибраної папки завантажень", "dl.installInDecky": "Встановіть у Decky:", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 9f5ab36..b401b5b 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -50,6 +50,7 @@ export const zh: Record = { "dl.downloading": "正在下载 {filename}", "dl.preparing": "正在准备下载…", + "dl.preparingInstall": "正在准备安装…", "dl.savedTo": "ZIP 文件已保存至:", "dl.selectedFolder": "所选下载文件夹", "dl.installInDecky": "在 Decky 中安装:", diff --git a/src/utils.ts b/src/utils.ts index fb02f2b..67bf3fb 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -289,40 +289,60 @@ const FRESH_INSTALL_PLUGINS = ["Unifideck"]; export const needsFreshInstall = (pluginName: string, installType: number) => installType !== PLUGIN_INSTALL_TYPE.INSTALL && FRESH_INSTALL_PLUGINS.includes(pluginName); -async function localZip(asset: Asset, repo: string) { +// Its progress window stays open, spinning once the ZIP is in, until `handedOver` +// settles (Decky's dialog is up, or it failed); without one, until it's staged. +async function localZip(asset: Asset, repo: string, handedOver?: Promise) { const jobId = `install-${Date.now()}-${Math.random().toString(36).slice(2)}`; - showInstallDownloadModal(tr, jobId, asset.name, asset.size); - const { path, error } = await stageInstall(asset, repo, jobId); + const staged = stageInstall(asset, repo, jobId); + showInstallDownloadModal(tr, jobId, asset.name, asset.size, handedOver ?? staged); + const { path, error } = await staged; if (!path) throw new Error(error); return `file://${path}`; } +// A promise and the function that settles it, for localZip's handedOver. +function handOff() { + let done = () => {}; + const handedOver = new Promise((resolve) => (done = resolve)); + return { handedOver, done }; +} + export async function installDeckyPlugin(asset: Asset, repo: string, pluginName: string, version: string, installType: 0 | 1 | 2 | 3) { - // Before any uninstall, so a failed download leaves the plugin as it was. - const artifact = await localZip(asset, repo); - if (needsFreshInstall(pluginName, installType)) { - await uninstallDeckyPlugin(pluginName); - log(`Decky Loader uninstalled ${pluginName} before a fresh install`); - installType = PLUGIN_INSTALL_TYPE.INSTALL; - } - traceDeckyInstall(pluginName); - return callDeckyLoader("utilities/install_plugin", artifact, pluginName, normalizeVersion(displayVersion(version)), asset.sha256 ?? "", installType).then((result: unknown) => { + const { handedOver, done } = handOff(); + try { + // Before any uninstall, so a failed download leaves the plugin as it was. + const artifact = await localZip(asset, repo, handedOver); + if (needsFreshInstall(pluginName, installType)) { + await uninstallDeckyPlugin(pluginName); + log(`Decky Loader uninstalled ${pluginName} before a fresh install`); + installType = PLUGIN_INSTALL_TYPE.INSTALL; + } + traceDeckyInstall(pluginName); + const result = await callDeckyLoader("utilities/install_plugin", artifact, pluginName, normalizeVersion(displayVersion(version)), asset.sha256 ?? "", installType); log(`Decky Loader install_plugin ${pluginName} accepted, waiting for confirm/progress`); return result; - }); + } finally { + done(); + } } // Several updates in one go: Decky Loader shows one confirm dialog listing them // all and installs them in order. Fresh-install plugins need their uninstall // first, so callers leave them out. export async function updateDeckyPlugins(updates: { asset: Asset; repo: string; name: string; version: string }[]) { - // One at a time, so a single progress window shows at once. - const artifacts: string[] = []; - for (const { asset, repo } of updates) artifacts.push(await localZip(asset, repo)); - return callDeckyLoader( - "utilities/install_plugins", - updates.map(({ asset, name, version }, index) => ({ artifact: artifacts[index], name, version: normalizeVersion(displayVersion(version)), hash: asset.sha256 ?? "", install_type: PLUGIN_INSTALL_TYPE.UPDATE })), - ); + // One at a time, so a single progress window shows at once; the last one + // stays up until Decky's dialog is. + const { handedOver, done } = handOff(); + try { + const artifacts: string[] = []; + for (const [index, { asset, repo }] of updates.entries()) artifacts.push(await localZip(asset, repo, index === updates.length - 1 ? handedOver : undefined)); + return await callDeckyLoader( + "utilities/install_plugins", + updates.map(({ asset, name, version }, index) => ({ artifact: artifacts[index], name, version: normalizeVersion(displayVersion(version)), hash: asset.sha256 ?? "", install_type: PLUGIN_INSTALL_TYPE.UPDATE })), + ); + } finally { + done(); + } } // Logs every stage Decky Loader reports for this install, with elapsed time, From 81aa4a67bac6e6fde5f6953c9b6e1757dadebb6a Mon Sep 17 00:00:00 2001 From: Andrii Shevchuk-Yuhanets Date: Sat, 24 Oct 2026 21:13:41 -0400 Subject: [PATCH 05/15] feat: add PS5 Dashboard, Xbox Dashboard to the registry (2.1.1-rc.22) (#74) Co-authored-by: Claude Opus 5.5 --- README.md | 2 ++ devtools/decky-mock/e2e/deckyhub.spec.ts | 3 +++ package.json | 2 +- registry/apps.json | 18 ++++++++++++++++++ 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d9e9b9d..92f6688 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,8 @@ No setup, no configuration — these show up in Discover the moment you install | Game Glance | Customization | [michpalm/game-glance](https://github.com/michpalm/game-glance) | | Decky MultiFrameGen | Frame Generation | [MWGIH/DeckyMultiFrameGen](https://github.com/MWGIH/DeckyMultiFrameGen) | | Finally Achievement Plugin | Customization | [otisabiburada/finally-achievement-plugin](https://github.com/otisabiburada/finally-achievement-plugin) | +| PS5 Dashboard | Customization | [kromossomom-debug/decky-ps5-dashboard](https://github.com/kromossomom-debug/decky-ps5-dashboard) | +| Xbox Dashboard | Customization | [kromossomom-debug/decky-xbox-dashboard](https://github.com/kromossomom-debug/decky-xbox-dashboard) | The list lives in [mazillka/deckyhub-registry](https://github.com/mazillka/deckyhub-registry) and refreshes when DeckyHub starts, so new apps show up without a DeckyHub update. Know a plugin that belongs here? Open an issue or pull request there. diff --git a/devtools/decky-mock/e2e/deckyhub.spec.ts b/devtools/decky-mock/e2e/deckyhub.spec.ts index 3ad3266..26f92e4 100644 --- a/devtools/decky-mock/e2e/deckyhub.spec.ts +++ b/devtools/decky-mock/e2e/deckyhub.spec.ts @@ -1662,7 +1662,9 @@ test("A repository added from search shows on Discover until it's removed", asyn await expect(app.getByRole("heading", { name: repo }).locator("..").getByRole("button", { name: "Added", exact: true })).toBeDisabled(); await expect(app.getByRole("button", { name: `Remove ${repo}`, exact: true })).toBeVisible(); + // Searched for, since a full first page of registry apps pushes it to page 2. await app.getByRole("button", { name: "/deckyhub/discover" }).click(); + await app.getByLabel("Search").fill(repo); await expect(app.getByRole("heading", { name: repo })).toBeVisible(); await app.getByRole("button", { name: "/deckyhub/repositories" }).click(); @@ -1672,6 +1674,7 @@ test("A repository added from search shows on Discover until it's removed", asyn await app.getByRole("button", { name: "/deckyhub/discover" }).click(); await expect(app.getByRole("heading", { name: "MAKO Decky" })).toBeVisible(); + await app.getByLabel("Search").fill(repo); await expect(app.getByRole("heading", { name: repo })).toHaveCount(0); } finally { await bridgeCall(page, "remove_custom_repo", repo); diff --git a/package.json b/package.json index 7c2e471..843c325 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "deckyhub", - "version": "2.1.1-rc.21", + "version": "2.1.1-rc.22", "description": "Discover, track, and safely download GitHub releases for Steam Deck tools from Gaming Mode.", "author": "Andrii Shevchuk-Yuhanets", "private": true, diff --git a/registry/apps.json b/registry/apps.json index fd1274e..3df39d5 100644 --- a/registry/apps.json +++ b/registry/apps.json @@ -163,6 +163,24 @@ "category": "Customization", "detect": { "type": "decky-plugin", "names": ["Finally Achievement Plugin"] }, "asset": { "include": [".zip"], "exclude": ["source"] } + }, + { + "id": "decky-ps5-dashboard", + "name": "PS5 Dashboard", + "description": "Makes Game Mode look and feel like the PlayStation 5 home screen, with HowLongToBeat times and achievement cards.", + "repo": "kromossomom-debug/decky-ps5-dashboard", + "category": "Customization", + "detect": { "type": "decky-plugin", "names": ["PS5 Dashboard"] }, + "asset": { "include": [".zip"], "exclude": ["source"] } + }, + { + "id": "decky-xbox-dashboard", + "name": "Xbox Dashboard", + "description": "Gives Game Mode the Xbox Series X|S dashboard look: a game-art home screen, LB/RB tabs and Guide-style menus.", + "repo": "kromossomom-debug/decky-xbox-dashboard", + "category": "Customization", + "detect": { "type": "decky-plugin", "names": ["Xbox Dashboard"] }, + "asset": { "include": [".zip"], "exclude": ["source"] } } ] } From e84813e8e7a93f9adfcb7f705be1743232045c46 Mon Sep 17 00:00:00 2001 From: Andrii Shevchuk-Yuhanets Date: Sat, 24 Oct 2026 21:16:49 -0400 Subject: [PATCH 06/15] feat: add Loadout, Achievement Customizer to the registry (2.1.1-rc.23) (#75) --- README.md | 2 ++ package.json | 2 +- registry/apps.json | 18 ++++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 92f6688..6652875 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ No setup, no configuration — these show up in Discover the moment you install | Finally Achievement Plugin | Customization | [otisabiburada/finally-achievement-plugin](https://github.com/otisabiburada/finally-achievement-plugin) | | PS5 Dashboard | Customization | [kromossomom-debug/decky-ps5-dashboard](https://github.com/kromossomom-debug/decky-ps5-dashboard) | | Xbox Dashboard | Customization | [kromossomom-debug/decky-xbox-dashboard](https://github.com/kromossomom-debug/decky-xbox-dashboard) | +| Loadout | Performance | [cyruzin/loadout](https://github.com/cyruzin/loadout) | +| Achievement Customizer | Customization | [Hector2242/decky-plugin](https://github.com/Hector2242/decky-plugin) | The list lives in [mazillka/deckyhub-registry](https://github.com/mazillka/deckyhub-registry) and refreshes when DeckyHub starts, so new apps show up without a DeckyHub update. Know a plugin that belongs here? Open an issue or pull request there. diff --git a/package.json b/package.json index 843c325..0ae3e07 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "deckyhub", - "version": "2.1.1-rc.22", + "version": "2.1.1-rc.23", "description": "Discover, track, and safely download GitHub releases for Steam Deck tools from Gaming Mode.", "author": "Andrii Shevchuk-Yuhanets", "private": true, diff --git a/registry/apps.json b/registry/apps.json index 3df39d5..3ecf846 100644 --- a/registry/apps.json +++ b/registry/apps.json @@ -181,6 +181,24 @@ "category": "Customization", "detect": { "type": "decky-plugin", "names": ["Xbox Dashboard"] }, "asset": { "include": [".zip"], "exclude": ["source"] } + }, + { + "id": "loadout", + "name": "Loadout", + "description": "One-tap per-game Performance, Balanced, Quality and Battery profiles from community configs, with a safe return to Default.", + "repo": "cyruzin/loadout", + "category": "Performance", + "detect": { "type": "decky-plugin", "names": ["Loadout"] }, + "asset": { "include": [".zip"], "exclude": ["source"] } + }, + { + "id": "achievement-customizer", + "name": "Achievement Customizer", + "description": "Console-style looks and animations for achievement pop-ups: Xbox and PlayStation entrances, rarity glow, themes and colors.", + "repo": "Hector2242/decky-plugin", + "category": "Customization", + "detect": { "type": "decky-plugin", "names": ["Achievement Customizer"] }, + "asset": { "include": [".zip"], "exclude": ["source"] } } ] } From ab87ff572b29ad6de124e216a0dcd56d387d90c1 Mon Sep 17 00:00:00 2001 From: Andrii Shevchuk-Yuhanets Date: Sat, 24 Oct 2026 21:22:38 -0400 Subject: [PATCH 07/15] feat: sync the registry (2.1.1-rc.24) (#76) --- README.md | 2 +- package.json | 2 +- registry/apps.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6652875..dd59a69 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ No setup, no configuration — these show up in Discover the moment you install | Spindeck | Customization | [justinca92/spindeck](https://github.com/justinca92/spindeck) | | Game Glance | Customization | [michpalm/game-glance](https://github.com/michpalm/game-glance) | | Decky MultiFrameGen | Frame Generation | [MWGIH/DeckyMultiFrameGen](https://github.com/MWGIH/DeckyMultiFrameGen) | -| Finally Achievement Plugin | Customization | [otisabiburada/finally-achievement-plugin](https://github.com/otisabiburada/finally-achievement-plugin) | +| Finally Achievement Plugin | Customization | [otisabiburada/finally-decky-achievements](https://github.com/otisabiburada/finally-decky-achievements) | | PS5 Dashboard | Customization | [kromossomom-debug/decky-ps5-dashboard](https://github.com/kromossomom-debug/decky-ps5-dashboard) | | Xbox Dashboard | Customization | [kromossomom-debug/decky-xbox-dashboard](https://github.com/kromossomom-debug/decky-xbox-dashboard) | | Loadout | Performance | [cyruzin/loadout](https://github.com/cyruzin/loadout) | diff --git a/package.json b/package.json index 0ae3e07..a89cd45 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "deckyhub", - "version": "2.1.1-rc.23", + "version": "2.1.1-rc.24", "description": "Discover, track, and safely download GitHub releases for Steam Deck tools from Gaming Mode.", "author": "Andrii Shevchuk-Yuhanets", "private": true, diff --git a/registry/apps.json b/registry/apps.json index 3ecf846..43d86a1 100644 --- a/registry/apps.json +++ b/registry/apps.json @@ -159,7 +159,7 @@ "id": "finally-achievement-plugin", "name": "Finally Achievement Plugin", "description": "Xbox/PlayStation-style achievement pop-ups, plus an achievement browser and an on-screen tracker.", - "repo": "otisabiburada/finally-achievement-plugin", + "repo": "otisabiburada/finally-decky-achievements", "category": "Customization", "detect": { "type": "decky-plugin", "names": ["Finally Achievement Plugin"] }, "asset": { "include": [".zip"], "exclude": ["source"] } From e945e0d8c5f5918558be69755a689f9f60afe22e Mon Sep 17 00:00:00 2001 From: Andrii Shevchuk-Yuhanets Date: Sat, 24 Oct 2026 21:38:57 -0400 Subject: [PATCH 08/15] feat: sync the registry (2.1.1-rc.25) (#77) --- README.md | 1 - package.json | 2 +- registry/apps.json | 9 --------- 3 files changed, 1 insertion(+), 11 deletions(-) diff --git a/README.md b/README.md index dd59a69..29d1a64 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,6 @@ No setup, no configuration — these show up in Discover the moment you install | PS5 Dashboard | Customization | [kromossomom-debug/decky-ps5-dashboard](https://github.com/kromossomom-debug/decky-ps5-dashboard) | | Xbox Dashboard | Customization | [kromossomom-debug/decky-xbox-dashboard](https://github.com/kromossomom-debug/decky-xbox-dashboard) | | Loadout | Performance | [cyruzin/loadout](https://github.com/cyruzin/loadout) | -| Achievement Customizer | Customization | [Hector2242/decky-plugin](https://github.com/Hector2242/decky-plugin) | The list lives in [mazillka/deckyhub-registry](https://github.com/mazillka/deckyhub-registry) and refreshes when DeckyHub starts, so new apps show up without a DeckyHub update. Know a plugin that belongs here? Open an issue or pull request there. diff --git a/package.json b/package.json index a89cd45..7604e81 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "deckyhub", - "version": "2.1.1-rc.24", + "version": "2.1.1-rc.25", "description": "Discover, track, and safely download GitHub releases for Steam Deck tools from Gaming Mode.", "author": "Andrii Shevchuk-Yuhanets", "private": true, diff --git a/registry/apps.json b/registry/apps.json index 43d86a1..326aa42 100644 --- a/registry/apps.json +++ b/registry/apps.json @@ -190,15 +190,6 @@ "category": "Performance", "detect": { "type": "decky-plugin", "names": ["Loadout"] }, "asset": { "include": [".zip"], "exclude": ["source"] } - }, - { - "id": "achievement-customizer", - "name": "Achievement Customizer", - "description": "Console-style looks and animations for achievement pop-ups: Xbox and PlayStation entrances, rarity glow, themes and colors.", - "repo": "Hector2242/decky-plugin", - "category": "Customization", - "detect": { "type": "decky-plugin", "names": ["Achievement Customizer"] }, - "asset": { "include": [".zip"], "exclude": ["source"] } } ] } From 7615e6d395ec89b2d0d65a5229821012444ad8d3 Mon Sep 17 00:00:00 2001 From: Andrii Shevchuk-Yuhanets Date: Sat, 24 Oct 2026 22:26:16 -0400 Subject: [PATCH 09/15] feat: card-based restyle of the whole plugin, no Discover paging (2.1.1-rc.26) (#78) Co-authored-by: Claude Opus 5.5 --- devtools/decky-mock/e2e/deckyhub.spec.ts | 26 +++---- package.json | 2 +- src/components/AppCard.tsx | 95 ++++++++++++++---------- src/components/AppDetailsModal.tsx | 3 +- src/components/Card.tsx | 43 +++++++++++ src/components/CleanupSection.tsx | 11 +-- src/components/DeckyHubUpdate.tsx | 6 +- src/components/DeckyHubUpdateModal.tsx | 4 +- src/components/RateLimitBanner.tsx | 2 +- src/components/VersionPickerPanel.tsx | 13 +++- src/pages/Content.tsx | 71 ++++++++---------- src/pages/ManageRepositoriesPage.tsx | 24 +++--- src/pages/SettingsPage.tsx | 24 +++--- src/utils.ts | 9 +++ 14 files changed, 195 insertions(+), 138 deletions(-) create mode 100644 src/components/Card.tsx diff --git a/devtools/decky-mock/e2e/deckyhub.spec.ts b/devtools/decky-mock/e2e/deckyhub.spec.ts index 26f92e4..ff1b543 100644 --- a/devtools/decky-mock/e2e/deckyhub.spec.ts +++ b/devtools/decky-mock/e2e/deckyhub.spec.ts @@ -162,12 +162,16 @@ test("Discover keeps search and status on one row", async ({ page }) => { test("Discover keeps its refresh action beside the description", async ({ page }) => { await page.goto("/?preview=/deckyhub/discover&bridge=http://127.0.0.1:8643"); const app = mock(page); + const title = app.getByRole("heading", { name: "Discover", exact: true }); const description = app.getByText("Browse supported GitHub projects and their ZIP releases."); const refresh = app.getByRole("button", { name: "Refresh", exact: true }); - const [descriptionBox, refreshBox] = await Promise.all([description.boundingBox(), refresh.boundingBox()]); - expect(Math.abs(descriptionBox!.y - refreshBox!.y)).toBeLessThan(20); - expect(refreshBox!.x).toBeGreaterThan(descriptionBox!.x); + // On the header's row: its middle falls between the title's top and the description's bottom. + const [titleBox, descriptionBox, refreshBox] = await Promise.all([title.boundingBox(), description.boundingBox(), refresh.boundingBox()]); + const refreshMiddle = refreshBox!.y + refreshBox!.height / 2; + expect(refreshMiddle).toBeGreaterThan(titleBox!.y); + expect(refreshMiddle).toBeLessThan(descriptionBox!.y + descriptionBox!.height); + expect(refreshBox!.x).toBeGreaterThan(descriptionBox!.x + descriptionBox!.width); }); test("Repository search and action share one row", async ({ page }) => { @@ -1629,22 +1633,15 @@ test("A failed download shows its reason in the download window", async ({ page await expect(app.locator(".steam-modal")).toHaveCount(0); }); -test("Discover pages through more than 20 cards", async ({ page }) => { +test("Discover shows every card on one page", async ({ page }) => { const repos = Array.from({ length: 10 }, (_, index) => `e2e-paging/repo-${index}`); for (const repo of repos) await bridgeCall(page, "add_custom_repo", repo); try { await page.goto("/?preview=/deckyhub/discover&bridge=http://127.0.0.1:8643"); const app = mock(page); - const previous = app.getByRole("button", { name: "Previous", exact: true }); - const next = app.getByRole("button", { name: "Next", exact: true }); - await expect(app.getByText(/^Page 1 of 2$/)).toBeVisible(); - await expect(previous).toBeDisabled(); - await next.click(); - await expect(app.getByText(/^Page 2 of 2$/)).toBeVisible(); - await expect(next).toBeDisabled(); + await expect(app.getByRole("heading", { name: "MAKO Decky" })).toBeVisible(); await expect(app.getByRole("heading", { name: "e2e-paging/repo-9" })).toBeVisible(); - await previous.click(); - await expect(app.getByText(/^Page 1 of 2$/)).toBeVisible(); + await expect(app.getByRole("button", { name: "Next", exact: true })).toHaveCount(0); } finally { for (const repo of repos) await bridgeCall(page, "remove_custom_repo", repo); } @@ -1662,9 +1659,7 @@ test("A repository added from search shows on Discover until it's removed", asyn await expect(app.getByRole("heading", { name: repo }).locator("..").getByRole("button", { name: "Added", exact: true })).toBeDisabled(); await expect(app.getByRole("button", { name: `Remove ${repo}`, exact: true })).toBeVisible(); - // Searched for, since a full first page of registry apps pushes it to page 2. await app.getByRole("button", { name: "/deckyhub/discover" }).click(); - await app.getByLabel("Search").fill(repo); await expect(app.getByRole("heading", { name: repo })).toBeVisible(); await app.getByRole("button", { name: "/deckyhub/repositories" }).click(); @@ -1674,7 +1669,6 @@ test("A repository added from search shows on Discover until it's removed", asyn await app.getByRole("button", { name: "/deckyhub/discover" }).click(); await expect(app.getByRole("heading", { name: "MAKO Decky" })).toBeVisible(); - await app.getByLabel("Search").fill(repo); await expect(app.getByRole("heading", { name: repo })).toHaveCount(0); } finally { await bridgeCall(page, "remove_custom_repo", repo); diff --git a/package.json b/package.json index 7604e81..9df3671 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "deckyhub", - "version": "2.1.1-rc.25", + "version": "2.1.1-rc.26", "description": "Discover, track, and safely download GitHub releases for Steam Deck tools from Gaming Mode.", "author": "Andrii Shevchuk-Yuhanets", "private": true, diff --git a/src/components/AppCard.tsx b/src/components/AppCard.tsx index 669f3dd..7bf9618 100644 --- a/src/components/AppCard.tsx +++ b/src/components/AppCard.tsx @@ -1,9 +1,9 @@ -import { DialogButtonPrimary as Button, ConfirmModal, Navigation, PanelSection, PanelSectionRow, showModal } from "@decky/ui"; -import { useState } from "react"; +import { DialogButtonPrimary as Button, ConfirmModal, Navigation, showModal } from "@decky/ui"; +import { useState, type CSSProperties } from "react"; import { FaArrowUp, FaCog, FaPlus } from "react-icons/fa"; import { useT } from "../i18n"; import type { App, Asset, HideableButton, RepoPreference, UpdateChannel } from "../types"; -import { compactButtonStyle, cardDescriptionStyle, installAction, PLUGIN_INSTALL_TYPE, statusKey, statusColor } from "../utils"; +import { compactButtonStyle, cardDescriptionStyle, installAction, PLUGIN_INSTALL_TYPE, statusKey, statusColor, surfaceStyle } from "../utils"; import { displayVersion } from "../version"; import type { TFunc } from "../i18n/en"; import { AppDetailsModal, startPluginInstall } from "./AppDetailsModal"; @@ -33,6 +33,35 @@ export const showReleaseInfo = (t: TFunc, title: string, publishedAt?: string | />, ); +// Card chrome. The top border takes the status color (see statusColor()). +const cardStyle = { + display: "grid", + gridTemplateColumns: "40px minmax(0, 1fr)", + gridTemplateRows: "auto auto", + alignContent: "start", + columnGap: 10, + rowGap: 8, + ...surfaceStyle, + height: "100%", + padding: 12, + borderTop: "3px solid", +}; +const fullRow = { gridColumn: "1 / -1" }; +const monogramStyle = { display: "flex", alignItems: "center", justifyContent: "center", width: 40, height: 40, borderRadius: 8, color: "#fff", fontWeight: 700, letterSpacing: "0.02em" }; +const cardTitleStyle = { margin: 0, overflow: "hidden", color: "#fff", fontSize: "1.05em", fontWeight: 700, lineHeight: 1.2, textOverflow: "ellipsis", whiteSpace: "nowrap" as const }; +const categoryStyle = { color: "#8fcef4", fontSize: "0.78em", letterSpacing: "0.04em", textTransform: "uppercase" as const }; +const pillStyle = { display: "inline-block", marginRight: 6, padding: "1px 8px", border: "1px solid", borderRadius: 999, fontSize: "0.78em", fontWeight: 600 }; + +// Up to two initials, e.g. "Decky LSFG-VK" -> "DL". +const monogram = (name: string) => name.split(/[\s-]+/).filter(Boolean).slice(0, 2).map((word) => word[0].toUpperCase()).join(""); +// A stable hue per category, so every card in a category shares its color. +const categoryHue = (category: string) => [...category].reduce((hash, char) => (hash * 31 + char.charCodeAt(0)) % 360, 7); + +// The app's initials on its category's color, on its card and in its Manage window. +export function AppMonogram({ app, style }: { app: App; style?: CSSProperties }) { + return
{monogram(app.name)}
; +} + export function AppCard({ app, preference, @@ -63,38 +92,30 @@ export function AppCard({ const [installing, setInstalling] = useState(false); const latest = app.latestVersion ? displayVersion(app.latestVersion).replace(/^v/, "") : ""; const quickLabel = t(isUpdate ? "settings.updateTo" : "appcard.installVersion", { version: latest }); + const accent = statusColor(app) ?? "#8fcef4"; return ( - - -
- {app.description || " "} - {t(statusKey(app))} - {app.installedVersion && ( - <> -
- {t("settings.installedVersion", { version: displayVersion(app.installedVersion) })} - - )} -
- {/* No release and no error yet: its GitHub check is still running. */} - {t("settings.latestVersion", { version: app.latestVersion ? displayVersion(app.latestVersion) : app.error ? "—" : t("settings.checkingUpdate") })} - {/* Stable is the default, so only a pre-release override earns a line. */} - {app.channel === "prerelease" && ( - <> -
- {t("appcard.channel", { channel: t("appcard.channelPrerelease") })} - - )} - {app.error && ( - <> -
- {app.error} - - )} -
-
+ // The name stays a direct child of the card: tests (and screen readers) + // find a card's buttons from its heading. +
+ +

{app.name}

+ {app.category} +
+ {t(statusKey(app))} + {/* Stable is the default, so only a pre-release override earns a pill. */} + {app.channel === "prerelease" && ( + {t("appcard.channel", { channel: t("appcard.channelPrerelease") })} + )} +
+ {app.description || " "} +
+ {app.installedVersion && {t("settings.installedVersion", { version: displayVersion(app.installedVersion) })}} + {/* No release and no error yet: its GitHub check is still running. */} + {t("settings.latestVersion", { version: app.latestVersion ? displayVersion(app.latestVersion) : app.error ? "—" : t("settings.checkingUpdate") })} +
+ {app.error && {app.error}} +
{install && quickAction && ( - - )} - - - +
+
); } diff --git a/src/components/AppDetailsModal.tsx b/src/components/AppDetailsModal.tsx index 90ecd18..989dabc 100644 --- a/src/components/AppDetailsModal.tsx +++ b/src/components/AppDetailsModal.tsx @@ -7,7 +7,7 @@ import type { TFunc } from "../i18n/en"; import type { App, AppReleaseOption, Asset, HideableButton, RepoPreference, UpdateChannel } from "../types"; import { buildVersionOptions, getDeckyBackend, installAction, installDeckyPlugin, installTypeLabel, listAppReleases, modalButtonStyle, needsFreshInstall, PLUGIN_INSTALL_TYPE, readableBytes, sectionDividerStyle, uninstallDeckyPlugin, windowGap } from "../utils"; import { displayVersion } from "../version"; -import { showReleaseInfo } from "./AppCard"; +import { AppMonogram, showReleaseInfo } from "./AppCard"; import { ReleaseActions, useReleasePicker, VersionPickerPanel } from "./VersionPickerPanel"; // Every install, update or reinstall of a third-party plugin first says @@ -133,6 +133,7 @@ export function AppDetailsModal({
} title={app.name} subtitle={t("settings.installedVersion", { version: app.installedVersion ? displayVersion(app.installedVersion) : "—" })} channel={channel} diff --git a/src/components/Card.tsx b/src/components/Card.tsx new file mode 100644 index 0000000..d040cc8 --- /dev/null +++ b/src/components/Card.tsx @@ -0,0 +1,43 @@ +import { Focusable } from "@decky/ui"; +import type { CSSProperties, ReactNode } from "react"; +import { surfaceStyle } from "../utils"; +import { keepInView } from "./KeepInView"; + +// One section of a page, drawn as a tile. Like KeepInView, it scrolls itself +// into view when the D-pad moves focus into it. +export function Card({ children, accent, style }: { children: ReactNode; accent?: string; style?: CSSProperties }) { + return ( +
+ {children} +
+ ); +} + +export const iconTileStyle = { + display: "flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + width: 44, + height: 44, + borderRadius: 10, + color: "#fff", + fontSize: 20, + background: "linear-gradient(140deg, #1a9fff, #2156a8)", + boxShadow: "0 4px 14px rgba(26, 159, 255, 0.25)", +}; + +// A page's title row: icon tile, title and description, then any actions. +// Without actions it's a focus stop of its own, so the D-pad can reach it. +export function PageHeader({ icon, title, description, actions }: { icon: ReactNode; title: string; description?: string; actions?: ReactNode }) { + return ( + {}} onFocus={keepInView} style={{ display: "flex", alignItems: "center", gap: 14, marginBottom: "var(--dh-card-gap, 14px)" }}> +
{icon}
+
+

{title}

+ {description &&
{description}
} +
+ {actions} +
+ ); +} diff --git a/src/components/CleanupSection.tsx b/src/components/CleanupSection.tsx index 8a2f22a..7af7933 100644 --- a/src/components/CleanupSection.tsx +++ b/src/components/CleanupSection.tsx @@ -5,8 +5,8 @@ import { FaExclamationTriangle, FaTimes, FaTrash } from "react-icons/fa"; import { clearDownloads, listDownloads } from "../api"; import { useT } from "../i18n"; import type { TFunc } from "../i18n/en"; -import { compactButtonStyle, modalButtonStyle, sectionDividerStyle, windowGap } from "../utils"; -import { KeepInView } from "./KeepInView"; +import { compactButtonStyle, modalButtonStyle, windowGap } from "../utils"; +import { Card } from "./Card"; const CLEAR_LIST_LIMIT = 50; @@ -78,9 +78,7 @@ export function CleanupSection() { if (!downloads.length) return null; return ( - <> -
- + - - + ); } diff --git a/src/components/DeckyHubUpdate.tsx b/src/components/DeckyHubUpdate.tsx index 241992f..d894d04 100644 --- a/src/components/DeckyHubUpdate.tsx +++ b/src/components/DeckyHubUpdate.tsx @@ -9,7 +9,7 @@ import { compactButtonStyle, getDeckyBackend, installDeckyPlugin, latestDeckyHub import { normalizeVersion } from "../version"; import { showReleaseInfo } from "./AppCard"; import { DeckyHubUpdateModal, downloadDeckyHubZip } from "./DeckyHubUpdateModal"; -import { KeepInView } from "./KeepInView"; +import { Card } from "./Card"; const SELF_UPDATE_NOTICE_KEY = "deckyhub-self-update-notice"; @@ -74,7 +74,7 @@ export function DeckyHubUpdate() { }; return ( - + {hasUpdate && ( @@ -104,6 +104,6 @@ export function DeckyHubUpdate() { - + ); } diff --git a/src/components/DeckyHubUpdateModal.tsx b/src/components/DeckyHubUpdateModal.tsx index 228f041..161ee4a 100644 --- a/src/components/DeckyHubUpdateModal.tsx +++ b/src/components/DeckyHubUpdateModal.tsx @@ -1,13 +1,14 @@ import { toaster } from "@decky/api"; import { DialogButtonPrimary as Button, ModalRoot } from "@decky/ui"; import { useState } from "react"; -import { FaDownload, FaFileArchive, FaTimes } from "react-icons/fa"; +import { FaDownload, FaFileArchive, FaGithub, FaTimes } from "react-icons/fa"; import { downloadAsset, getSettings, saveSettings } from "../api"; import type { TFunc } from "../i18n/en"; import type { Asset, DeckyHubInfo, DeckyHubReleaseOption, UpdateChannel, HideableButton } from "../types"; import { PLUGIN_INSTALL_TYPE, buildVersionOptions, getDeckyBackend, installDeckyPlugin, installTypeLabel, listDeckyHubReleases, modalButtonStyle, resolveDeckyHubInstallType, sectionDividerStyle, windowGap } from "../utils"; import { normalizeVersion } from "../version"; import { showReleaseInfo } from "./AppCard"; +import { iconTileStyle } from "./Card"; import { showDownloadModal } from "./DownloadProgress"; import { ReleaseActions, useReleasePicker, VersionPickerPanel } from "./VersionPickerPanel"; @@ -103,6 +104,7 @@ export function DeckyHubUpdateModal({
} title={t("settings.deckyhubUpdate")} subtitle={`${t("settings.current")} - v${info.version}`} channel={channel} diff --git a/src/components/RateLimitBanner.tsx b/src/components/RateLimitBanner.tsx index 19c4b45..baecc2c 100644 --- a/src/components/RateLimitBanner.tsx +++ b/src/components/RateLimitBanner.tsx @@ -29,7 +29,7 @@ export function RateLimitBanner() { const minutes = Math.max(1, Math.ceil(remaining / 60_000)); return ( -
+
{t("rateLimit.title")} {limit.minutesKnown ? t("rateLimit.retryIn", { minutes }) : t("rateLimit.retryLater")} {t("rateLimit.howToAvoid")} diff --git a/src/components/VersionPickerPanel.tsx b/src/components/VersionPickerPanel.tsx index c5e9a18..f03d682 100644 --- a/src/components/VersionPickerPanel.tsx +++ b/src/components/VersionPickerPanel.tsx @@ -1,5 +1,5 @@ import { DialogButtonPrimary as Button, DropdownItem, Focusable, Navigation } from "@decky/ui"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { FaExternalLinkAlt, FaSync } from "react-icons/fa"; import type { TFunc } from "../i18n/en"; import type { UpdateChannel } from "../types"; @@ -66,6 +66,7 @@ export function ReleaseActions({ t, url, hideReleasePage, checking, disabled, on // (the primary action buttons) actually differs between them. export function VersionPickerPanel({ t, + icon, title, subtitle, channel, @@ -78,6 +79,7 @@ export function VersionPickerPanel({ isEmpty, }: { t: TFunc; + icon: ReactNode; title: string; subtitle: string; channel: UpdateChannel; @@ -92,8 +94,13 @@ export function VersionPickerPanel({ return ( <>
- {title} -
{subtitle}
+
+ {icon} +
+ {title} +
{subtitle}
+
+
{/* Inline label + no separator: Steam's dropdown height is fixed, so diff --git a/src/pages/Content.tsx b/src/pages/Content.tsx index 04262a6..44124fd 100644 --- a/src/pages/Content.tsx +++ b/src/pages/Content.tsx @@ -5,26 +5,25 @@ import { FaArrowCircleUp, FaCog, FaFileExport, FaListUl, FaSearch, FaSync, FaTim import { downloadAsset, exportLogs, getApps, getSettings, REGISTRY_UPDATED } from "../api"; import { useT } from "../i18n"; import type { App, Asset, HideableButton, RepoPreference, UpdateChannel, View } from "../types"; -import { compactButtonStyle, DEFAULT_COLUMNS_PER_ROW, getDeckyBackend, hydrate, installAction, log, needsFreshInstall, notifyUpdates, PLUGIN_INSTALL_TYPE, sectionDividerStyle, updateDeckyPlugins } from "../utils"; +import { compactButtonStyle, DEFAULT_COLUMNS_PER_ROW, getDeckyBackend, hydrate, installAction, log, needsFreshInstall, notifyUpdates, PLUGIN_INSTALL_TYPE, surfaceStyle, updateDeckyPlugins } from "../utils"; import { AppCard } from "../components/AppCard"; import { confirmPluginInstall } from "../components/AppDetailsModal"; import { CleanupSection } from "../components/CleanupSection"; import { showDownloadModal } from "../components/DownloadProgress"; import { FocusableGrid } from "../components/FocusableGrid"; +import { Card, PageHeader } from "../components/Card"; import { KeepInView, keepInView } from "../components/KeepInView"; -import { Pager } from "../components/Pager"; import { RateLimitBanner } from "../components/RateLimitBanner"; import { DeckyHubUpdate } from "../components/DeckyHubUpdate"; // ponytail: fixed pool of 8 GitHub requests in flight; lower it if the registry grows // big enough to trip GitHub's secondary (burst) rate limit. const RELEASE_CONCURRENCY = 8; -const CARDS_PER_PAGE = 20; const NAV_LINKS = [["updates", FaArrowCircleUp], ["discover", FaSearch], ["repositories", FaListUl], ["settings", FaCog]] as const; // Same no-responsibility note the install confirm shows, kept in view on the Updates and Discover pages. -const disclaimerStyle = { color: "#ff6b6b", fontSize: "0.85em" }; +const disclaimerStyle = { marginBottom: "var(--dh-card-gap, 14px)", padding: "8px 12px", color: "#ffb3b3", fontSize: "0.85em", background: "rgba(255, 107, 107, 0.08)", borderLeft: "3px solid #ff6b6b", borderRadius: 6 }; const countBadgeStyle = { minWidth: "1.5em", padding: "0 6px", borderRadius: 999, background: "#f0c33c", color: "#1a1a1a", fontSize: "0.8em", fontWeight: 700, lineHeight: "1.5em", textAlign: "center" as const }; @@ -39,7 +38,6 @@ export function Content({ fullPage }: { fullPage?: View }) { const [installedFilter, setInstalledFilter] = useState("All"); // "" means every category. const [categoryFilter, setCategoryFilter] = useState(""); - const [cardPage, setCardPage] = useState(0); const [columnsPerRow, setColumnsPerRow] = useState(DEFAULT_COLUMNS_PER_ROW); const [repoPreferences, setRepoPreferences] = useState>({}); const [hiddenButtons, setHiddenButtons] = useState([]); @@ -66,7 +64,6 @@ export function Content({ fullPage }: { fullPage?: View }) { setColumnsPerRow(settings.columnsPerRow); setHiddenButtons(settings.hiddenButtons ?? []); const tracked = view === "updates" ? local.filter((app) => app.installedVersion) : local; - setCardPage(0); setApps(tracked); setLoadError(null); // Discover shows every card straight away and fills each in as its @@ -164,11 +161,12 @@ export function Content({ fullPage }: { fullPage?: View }) { const activeCategory = categories.includes(categoryFilter) ? categoryFilter : ""; const discoverFilters = view === "discover" && ( +
- { setQuery(event.currentTarget.value); setCardPage(0); }} /> + setQuery(event.currentTarget.value)} />
@@ -182,7 +180,7 @@ export function Content({ fullPage }: { fullPage?: View }) { { label: t("filter.notInstalled"), data: "Not Installed" }, ]} selectedOption={installedFilter} - onChange={({ data }) => { setInstalledFilter(data); setCardPage(0); }} + onChange={({ data }) => setInstalledFilter(data)} />
@@ -194,13 +192,14 @@ export function Content({ fullPage }: { fullPage?: View }) { menuLabel={t("filter.category")} rgOptions={[{ label: t("filter.all"), data: "" }, ...categories.map((category) => ({ label: category, data: category }))]} selectedOption={activeCategory} - onChange={({ data }) => { setCategoryFilter(data); setCardPage(0); }} + onChange={({ data }) => setCategoryFilter(data)} />
+ ); const visibleApps = apps.filter((app) => @@ -227,9 +226,6 @@ export function Content({ fullPage }: { fullPage?: View }) { .finally(() => setUpdatingAll(false)); Navigation.NavigateBack(); }; - const pageCount = Math.max(1, Math.ceil(visibleApps.length / CARDS_PER_PAGE)); - const currentPage = Math.min(cardPage, pageCount - 1); - const displayedApps = visibleApps.slice(currentPage * CARDS_PER_PAGE, (currentPage + 1) * CARDS_PER_PAGE); const page = ( <> {loading && ( @@ -243,7 +239,7 @@ export function Content({ fullPage }: { fullPage?: View }) { )} {loadError && ( - + {loadError} @@ -252,39 +248,37 @@ export function Content({ fullPage }: { fullPage?: View }) { - + )} {!loading && !loadError && ( <> - - - -
{info.description}
+ : } + title={info.title} + description={info.description} + actions={ + <>
-
{batchUpdates.length > 1 && ( -
-
)} -
-
- -
{t("install.disclaimer")}
-
-
+ + } + /> +
{t("install.disclaimer")}
-
{discoverFilters} - {discoverFilters &&
} app.id} > @@ -300,15 +294,8 @@ export function Content({ fullPage }: { fullPage?: View }) { /> )} - {visibleApps.length > CARDS_PER_PAGE && ( - - - - - - )} {!visibleApps.length && ( -
+
{/* Decky's section title is a left-aligned flex row; center it too. */} @@ -337,6 +324,7 @@ export function Content({ fullPage }: { fullPage?: View }) { const updateCount = apps.filter((app) => app.updateAvailable).length; const navigation = ( <> + {NAV_LINKS.map(([page, Icon]) => ( @@ -347,11 +335,10 @@ export function Content({ fullPage }: { fullPage?: View }) { ))} -
+ -
- + +
); }} )} -
- + @@ -278,11 +279,10 @@ export function ManageRepositoriesPage() { ))} {!customRepos.length && {t("repos.noCustomRepos")}} - + -
- + @@ -325,7 +325,7 @@ export function ManageRepositoriesPage() { ))} {!registries.length && {t("repos.noCustomRegistries")}} - + ); } diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 3197dac..22c4cff 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,8 +1,8 @@ import { DialogButtonPrimary as Button, DropdownItem, Focusable, Navigation, PanelSection, PanelSectionRow, TextField, ToggleField } from "@decky/ui"; import { useEffect, useState, type ReactNode } from "react"; -import { FaExternalLinkAlt, FaSave, FaFolderOpen, FaInfoCircle, FaLock } from "react-icons/fa"; +import { FaCog, FaExternalLinkAlt, FaSave, FaFolderOpen, FaInfoCircle, FaLock } from "react-icons/fa"; import { getSettings, saveSettings } from "../api"; -import { KeepInView } from "../components/KeepInView"; +import { Card, PageHeader } from "../components/Card"; import { LOCALE_CHANGED, LOCALES, useT } from "../i18n"; import type { MessageKey } from "../i18n/en"; import type { HideableButton, Settings } from "../types"; @@ -70,7 +70,8 @@ export function SettingsPage() { return ( <> - + } title={t("nav.settings")} /> +
@@ -109,11 +110,10 @@ export function SettingsPage() { - + -
- +
@@ -129,11 +129,10 @@ export function SettingsPage() { update({ overwriteExisting: checked })} /> - + -
- + - + -
- + ))} - + ); } diff --git a/src/utils.ts b/src/utils.ts index 67bf3fb..30b7eba 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -23,6 +23,13 @@ export const modalButtonStyle = { ...compactButtonStyle, marginBottom: 0 }; export const sectionDividerStyle = { borderTop: "1px solid rgba(255, 255, 255, 0.14)", margin: "var(--dh-divider-margin, 16px) 0" }; export const windowGap = "var(--dh-window-gap, 10px)"; export const gridGap = "var(--dh-grid-gap, 12px)"; +// The tile every card and section is drawn on (see Card and AppCard). +export const surfaceStyle = { + background: "linear-gradient(160deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.02))", + border: "1px solid rgba(255, 255, 255, 0.09)", + borderRadius: 8, + boxSizing: "border-box" as const, +}; const COMPACT_SIZES: Record = { "--dh-button-height": "28px", @@ -31,6 +38,8 @@ const COMPACT_SIZES: Record = { "--dh-divider-margin": "8px", "--dh-window-gap": "6px", "--dh-grid-gap": "8px", + "--dh-card-gap": "8px", + "--dh-card-padding": "0 10px", }; // Applies Display Settings' density to this window and, in Gaming Mode, to the From dc78586bd4b70385d8885684b89a2ae8553fbfcc Mon Sep 17 00:00:00 2001 From: Andrii Shevchuk-Yuhanets Date: Sun, 25 Oct 2026 01:02:02 -0400 Subject: [PATCH 10/15] fix: send the allowed-origin constant in the mock bridge's CORS header (#79) Co-authored-by: Claude Opus 5.5 --- devtools/decky-mock/dev-server.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/devtools/decky-mock/dev-server.py b/devtools/decky-mock/dev-server.py index 29cbf19..e3fcf77 100644 --- a/devtools/decky-mock/dev-server.py +++ b/devtools/decky-mock/dev-server.py @@ -125,9 +125,11 @@ def call_route(route: str, args: list): class Handler(BaseHTTPRequestHandler): def _cors(self): origin = self.headers.get("Origin") - if origin in ALLOWED_ORIGINS: - self.send_header("Access-Control-Allow-Origin", origin) - self.send_header("Vary", "Origin") + # Echo our own constant, never the request's value (CodeQL py/http-response-splitting). + for allowed in ALLOWED_ORIGINS: + if origin == allowed: + self.send_header("Access-Control-Allow-Origin", allowed) + self.send_header("Vary", "Origin") self.send_header("Access-Control-Allow-Headers", "Content-Type") self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") From f67c298f36af0e71de18e432c27d09949b4f79c6 Mon Sep 17 00:00:00 2001 From: Andrii Shevchuk-Yuhanets Date: Sun, 25 Oct 2026 01:26:37 -0400 Subject: [PATCH 11/15] feat: gear Manage button beside Install/Update, "DeckyHub vX" title (2.1.1-rc.27) (#80) Co-authored-by: Claude Opus 5.5 --- AGENTS.md | 2 +- README.md | 2 +- package.json | 2 +- src/components/AppCard.tsx | 20 +++++++++++++------- src/components/DeckyHubUpdate.tsx | 23 ++++++++++++++--------- src/components/DeckyHubUpdateModal.tsx | 2 +- src/i18n/de.ts | 1 - src/i18n/en.ts | 1 - src/i18n/es.ts | 1 - src/i18n/fr.ts | 1 - src/i18n/ja.ts | 1 - src/i18n/ru.ts | 1 - src/i18n/uk.ts | 1 - src/i18n/zh.ts | 1 - src/utils.ts | 2 ++ 15 files changed, 33 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 877b3b4..02edb01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ Release steps (version bump, `v*` tag) are in [CONTRIBUTING.md](CONTRIBUTING.md# - **Custom-events-as-pub/sub**: cross-component signals (`REGISTRY_UPDATED` in `api.ts`, `LOCALE_CHANGED` in `i18n/index.tsx`) are plain `window.dispatchEvent`/`addEventListener` `CustomEvent`s, not React context — grep for these constants before assuming a settings change needs prop drilling. - **i18n** (`src/i18n/`) is a from-scratch key/template system, not a library: `en.ts` is the canonical `MessageKey` type every other locale file must satisfy, `useT()` looks up the current locale's string (falling back to English) and does `{var}` substitution. Plain functions in `utils.ts` that build user-facing text with no component tree (GitHub errors, the updates toast) use `tr`, which `I18nProvider` points at the current locale via `setTranslator()` — don't hard-code English there. - **Gamepad/controller navigation** on Steam Deck depends on wrapping any custom-layout container (e.g. a CSS grid) in `` from `@decky/ui` with the correct `flow-children` value — these are literal Panorama/SteamUI focus-flow keywords (`"right-wrap"` for a wrapping left-to-right grid, etc.), not arbitrary strings; an unrecognized value silently falls back to plain vertical flow with no error. `PanelSection`/`ButtonItem`/`PanelSectionRow` handle focus automatically; a raw `
` does not. -- **DeckyHub's own self-update is split across two components**, mirroring the pattern in unifideck (a similarly-shaped Decky plugin): `DeckyHubUpdate.tsx` is deliberately thin — just a `PanelSection` titled `"Current - vX.Y.Z"`, laid out like an app card: `"Update to vX.Y.Z"` once a newer release is known (it hands that release straight to `installDeckyPlugin()`, hidden by Display Settings' "Update" like the cards' button) and **Manage**, which opens `DeckyHubUpdateModal.tsx` via `showModal()`. The modal owns everything else: a stable/pre-release channel `DropdownItem`, a version `DropdownItem` listing recent releases for that channel (fetched via `listDeckyHubReleases()` in `src/utils.ts`, which hits `GET /releases?per_page=20` — the same cached list `latestDeckyHubRelease()` picks the panel's background check-and-toast release from), and the install actions for whichever release is selected. The primary button's label/action (`resolveDeckyHubInstallType()` in `utils.ts`) is one of **Update to vX** / **Reinstall vX** / **Downgrade to vX** depending on how the selected version numerically compares to the installed one — reinstalling or downgrading are first-class, not hidden. +- **DeckyHub's own self-update is split across two components**, mirroring the pattern in unifideck (a similarly-shaped Decky plugin): `DeckyHubUpdate.tsx` is deliberately thin — just a `PanelSection` titled `"DeckyHub vX.Y.Z"`, laid out like an app card: `"Update to vX.Y.Z"` once a newer release is known (it hands that release straight to `installDeckyPlugin()`, hidden by Display Settings' "Update" like the cards' button) and **Manage**, which opens `DeckyHubUpdateModal.tsx` via `showModal()`. The modal owns everything else: a stable/pre-release channel `DropdownItem`, a version `DropdownItem` listing recent releases for that channel (fetched via `listDeckyHubReleases()` in `src/utils.ts`, which hits `GET /releases?per_page=20` — the same cached list `latestDeckyHubRelease()` picks the panel's background check-and-toast release from), and the install actions for whichever release is selected. The primary button's label/action (`resolveDeckyHubInstallType()` in `utils.ts`) is one of **Update to vX** / **Reinstall vX** / **Downgrade to vX** depending on how the selected version numerically compares to the installed one — reinstalling or downgrading are first-class, not hidden. - **The primary button hands the install to Decky Loader, not this plugin's backend.** `@decky/api`'s `call`/`callable` are scoped to this plugin's own RPC connection and cannot reach Decky Loader's global routes, so `getDeckyBackend()` (in `utils.ts`) goes through `window.DeckyBackend` (falling back to `window.opener?.DeckyBackend` for the Quick Access Menu's popup window in Gaming Mode) and calls the loader-global `utilities/install_plugin` route directly (via `installDeckyPlugin()`, shared with the Manage window's **Install** / **Update**, which uses the `pluginName` `get_apps` reports for decky-plugin detections) — the same one the Decky Store's own install button uses. That route pops Decky's own native confirm/progress dialog and does the verify+install itself, so the modal closes as soon as it hands the release over. The download is the one step DeckyHub keeps: `installDeckyPlugin()`/`updateDeckyPlugins()` first call `stage_install` (`main.py`), which downloads the ZIP with DeckyHub's own downloader into `$DECKY_PLUGIN_RUNTIME_DIR/installs/` (same release-asset check and SHA-256 verification as `download_asset`), and hand Decky a `file://` path with the same hash. Decky Loader 3.3.0's own fetch of a GitHub release URL was seen to hang for good on a real Deck (no socket, no timeout), while a `file://` artifact is read straight from disk. Staging happens before a fresh-install plugin's uninstall, so a failed download leaves it installed; files older than a day are swept on the next stage, since Decky reads one only once its dialog is accepted. The decky-mock bridge replaces `stage_install` with a no-network stand-in, so E2E sees `file:///deckyhub-installs/`. Once the install finishes Decky reloads DeckyHub's backend and frontend itself (`import_plugin` → `loader/import_plugin`); as a fallback, `reloadIfStillOld()` in `utils.ts` waits Decky's 15 s load timeout and, if the old copy was never dismounted (`markUnloaded()` in `onDismount`), calls `window.DeckyPluginLoader.importPlugin("DeckyHub")`. Both version windows (this one and a plugin's Manage window) close on every action except **Check for Updates** and **Download ZIP**, which keeps the window open beneath the shared download-progress window. The **"Download ZIP"** button next to it is the older, always-available fallback for the selected release, going through the same `download_asset`/download-queue path as every other download and the shared `showDownloadModal()`. ## Security boundaries diff --git a/README.md b/README.md index 29d1a64..8c8185f 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ Under **Custom Registries**, add someone else's app list by its GitHub `owner/re ### Keeping DeckyHub itself updated -The Quick Access Menu shows the installed version (**Current - vX.Y.Z**) and checks for a newer DeckyHub release in the background, with a notification the first time one appears; an **Update to vX.Y.Z** button then installs it straight away, like an app card's. **Manage** opens DeckyHub's update window, where you pick a channel (**Stable releases**, or **Pre-releases** for every release) and a version, then: +The Quick Access Menu shows the installed version (**DeckyHub vX.Y.Z**) and checks for a newer DeckyHub release in the background, with a notification the first time one appears; an **Update to vX.Y.Z** button then installs it straight away, like an app card's. **Manage** opens DeckyHub's update window, where you pick a channel (**Stable releases**, or **Pre-releases** for every release) and a version, then: - **Update to / Reinstall / Downgrade to vX.Y.Z** — downloads that release (with a progress window) and hands it to Decky Loader's own installer (the same one the Decky Store uses), which verifies its SHA-256 checksum and installs it in place. Only releases with a published checksum are offered. - **Download ZIP** — the classic path: saves the release ZIP to `/home/deck/Downloads/deckyhub`, which you then install yourself via Decky's settings → Developer → Install Plugin from ZIP. diff --git a/package.json b/package.json index 9df3671..6ed007f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "deckyhub", - "version": "2.1.1-rc.26", + "version": "2.1.1-rc.27", "description": "Discover, track, and safely download GitHub releases for Steam Deck tools from Gaming Mode.", "author": "Andrii Shevchuk-Yuhanets", "private": true, diff --git a/src/components/AppCard.tsx b/src/components/AppCard.tsx index 7bf9618..c51b9d3 100644 --- a/src/components/AppCard.tsx +++ b/src/components/AppCard.tsx @@ -1,9 +1,9 @@ -import { DialogButtonPrimary as Button, ConfirmModal, Navigation, showModal } from "@decky/ui"; +import { DialogButtonPrimary as Button, ConfirmModal, Focusable, Navigation, showModal } from "@decky/ui"; import { useState, type CSSProperties } from "react"; import { FaArrowUp, FaCog, FaPlus } from "react-icons/fa"; import { useT } from "../i18n"; import type { App, Asset, HideableButton, RepoPreference, UpdateChannel } from "../types"; -import { compactButtonStyle, cardDescriptionStyle, installAction, PLUGIN_INSTALL_TYPE, statusKey, statusColor, surfaceStyle } from "../utils"; +import { compactButtonStyle, cardDescriptionStyle, installAction, PLUGIN_INSTALL_TYPE, statusKey, statusColor, surfaceStyle, visuallyHidden } from "../utils"; import { displayVersion } from "../version"; import type { TFunc } from "../i18n/en"; import { AppDetailsModal, startPluginInstall } from "./AppDetailsModal"; @@ -114,10 +114,12 @@ export function AppCard({ {t("settings.latestVersion", { version: app.latestVersion ? displayVersion(app.latestVersion) : app.error ? "—" : t("settings.checkingUpdate") })}
{app.error && {app.error}} -
+ {/* Install/Update, with Manage as a gear beside it; Manage alone takes the full row. */} + {install && quickAction && ( +
+
)} +
-
+
+
); } diff --git a/src/components/DeckyHubUpdate.tsx b/src/components/DeckyHubUpdate.tsx index d894d04..1ae8258 100644 --- a/src/components/DeckyHubUpdate.tsx +++ b/src/components/DeckyHubUpdate.tsx @@ -1,11 +1,11 @@ import { toaster } from "@decky/api"; -import { DialogButtonPrimary as Button, PanelSection, PanelSectionRow, showModal } from "@decky/ui"; +import { DialogButtonPrimary as Button, Focusable, PanelSection, PanelSectionRow, showModal } from "@decky/ui"; import { useEffect, useRef, useState } from "react"; import { FaArrowUp, FaCog } from "react-icons/fa"; import { getDeckyHubInfo, getSettings } from "../api"; import { useT } from "../i18n"; import type { DeckyHubInfo, DeckyHubRelease, HideableButton, UpdateChannel } from "../types"; -import { compactButtonStyle, getDeckyBackend, installDeckyPlugin, latestDeckyHubRelease, log, seenOnce, PLUGIN_INSTALL_TYPE, resolveDeckyHubInstallType } from "../utils"; +import { compactButtonStyle, getDeckyBackend, installDeckyPlugin, latestDeckyHubRelease, log, seenOnce, PLUGIN_INSTALL_TYPE, resolveDeckyHubInstallType, visuallyHidden } from "../utils"; import { normalizeVersion } from "../version"; import { showReleaseInfo } from "./AppCard"; import { DeckyHubUpdateModal, downloadDeckyHubZip } from "./DeckyHubUpdateModal"; @@ -58,7 +58,7 @@ export function DeckyHubUpdate() { }, [channel, info.version]); const loading = info.version === "unknown"; - const title = loading ? `${t("settings.checkingVersion")}…` : `${t("settings.current")} - v${info.version}`; + const title = loading ? `${t("settings.checkingVersion")}…` : `DeckyHub v${info.version}`; const hasUpdate = Boolean(release.asset && release.version && isNewer(release.version)) && !hiddenButtons.includes("update"); // Same hand-off as the update window's own Update button: Decky's @@ -76,10 +76,13 @@ export function DeckyHubUpdate() { return ( + {/* Update, with Manage as a gear beside it; Manage alone takes the full row. */} + + {hasUpdate && ( - +
- +
)} - +
+
+
diff --git a/src/components/DeckyHubUpdateModal.tsx b/src/components/DeckyHubUpdateModal.tsx index 161ee4a..8049221 100644 --- a/src/components/DeckyHubUpdateModal.tsx +++ b/src/components/DeckyHubUpdateModal.tsx @@ -106,7 +106,7 @@ export function DeckyHubUpdateModal({ t={t} icon={
} title={t("settings.deckyhubUpdate")} - subtitle={`${t("settings.current")} - v${info.version}`} + subtitle={t("settings.installedVersion", { version: `v${info.version}` })} channel={channel} onChannelChange={(next) => void changeChannel(next)} versionOptions={versionOptions} diff --git a/src/i18n/de.ts b/src/i18n/de.ts index acb2ca2..20dba91 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -58,7 +58,6 @@ export const de: Record = { "dl.ok": "OK", "settings.deckyhubUpdate": "DeckyHub-Update", - "settings.current": "Aktuell", "settings.checkingVersion": "Version wird geprüft", "settings.installedVersion": "Installiert: {version}", "settings.latestVersion": "Neueste: {version}", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 018493f..7a9f3aa 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -57,7 +57,6 @@ export const en = { "dl.ok": "OK", "settings.deckyhubUpdate": "DeckyHub Update", - "settings.current": "Current", "settings.checkingVersion": "Checking version", "settings.installedVersion": "Installed: {version}", "settings.latestVersion": "Latest: {version}", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 1f4473b..7ed5a32 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -58,7 +58,6 @@ export const es: Record = { "dl.ok": "Aceptar", "settings.deckyhubUpdate": "Actualización de DeckyHub", - "settings.current": "Actual", "settings.checkingVersion": "Comprobando versión", "settings.installedVersion": "Instalado: {version}", "settings.latestVersion": "Última: {version}", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 09c9fee..de45818 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -58,7 +58,6 @@ export const fr: Record = { "dl.ok": "OK", "settings.deckyhubUpdate": "Mise à jour de DeckyHub", - "settings.current": "Actuelle", "settings.checkingVersion": "Vérification de la version", "settings.installedVersion": "Installé : {version}", "settings.latestVersion": "Dernière : {version}", diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index dc291ac..3a35f6d 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -58,7 +58,6 @@ export const ja: Record = { "dl.ok": "OK", "settings.deckyhubUpdate": "DeckyHubのアップデート", - "settings.current": "現在", "settings.checkingVersion": "バージョンを確認中", "settings.installedVersion": "インストール済み: {version}", "settings.latestVersion": "最新: {version}", diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index 42cb5bb..ae2d3ba 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -58,7 +58,6 @@ export const ru: Record = { "dl.ok": "ОК", "settings.deckyhubUpdate": "Обновление DeckyHub", - "settings.current": "Текущая", "settings.checkingVersion": "Проверка версии", "settings.installedVersion": "Установлено: {version}", "settings.latestVersion": "Последняя: {version}", diff --git a/src/i18n/uk.ts b/src/i18n/uk.ts index d9405e1..30a4d47 100644 --- a/src/i18n/uk.ts +++ b/src/i18n/uk.ts @@ -58,7 +58,6 @@ export const uk: Record = { "dl.ok": "ОК", "settings.deckyhubUpdate": "Оновлення DeckyHub", - "settings.current": "Поточна", "settings.checkingVersion": "Перевірка версії", "settings.installedVersion": "Встановлено: {version}", "settings.latestVersion": "Остання: {version}", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index b401b5b..7754953 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -58,7 +58,6 @@ export const zh: Record = { "dl.ok": "确定", "settings.deckyhubUpdate": "DeckyHub 更新", - "settings.current": "当前", "settings.checkingVersion": "正在检查版本", "settings.installedVersion": "已安装:{version}", "settings.latestVersion": "最新:{version}", diff --git a/src/utils.ts b/src/utils.ts index 30b7eba..c308b97 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -21,6 +21,8 @@ export const iconRow = { display: "flex", alignItems: "center", justifyContent: export const compactButtonStyle = { ...iconRow, width: "100%", minHeight: "var(--dh-button-height, 36px)", marginBottom: "var(--dh-button-gap, 8px)", padding: "var(--dh-button-padding, 6px 10px)" }; export const modalButtonStyle = { ...compactButtonStyle, marginBottom: 0 }; export const sectionDividerStyle = { borderTop: "1px solid rgba(255, 255, 255, 0.14)", margin: "var(--dh-divider-margin, 16px) 0" }; +// Text screen readers (and tests) still read, e.g. an icon-only button's label. +export const visuallyHidden = { position: "absolute" as const, width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)", whiteSpace: "nowrap" as const }; export const windowGap = "var(--dh-window-gap, 10px)"; export const gridGap = "var(--dh-grid-gap, 12px)"; // The tile every card and section is drawn on (see Card and AppCard). From 97751013de7301a71305668800a54d8695d2956c Mon Sep 17 00:00:00 2001 From: Andrii Shevchuk-Yuhanets Date: Sun, 25 Oct 2026 01:41:23 -0400 Subject: [PATCH 12/15] fix: let buttons shrink so the Manage gear fits beside Install (2.1.1-rc.28) (#81) Co-authored-by: Claude Opus 5.5 --- package.json | 2 +- src/utils.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 6ed007f..d2aa706 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "deckyhub", - "version": "2.1.1-rc.27", + "version": "2.1.1-rc.28", "description": "Discover, track, and safely download GitHub releases for Steam Deck tools from Gaming Mode.", "author": "Andrii Shevchuk-Yuhanets", "private": true, diff --git a/src/utils.ts b/src/utils.ts index c308b97..65bd612 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -18,7 +18,9 @@ export const pageStyle = { boxSizing: "border-box" as const, height: "100%", ove // setting overrides them on the page root (see applyDensity). // Centers an icon and its label on one line, whatever the button's width. export const iconRow = { display: "flex", alignItems: "center", justifyContent: "center", gap: 8 } as const; -export const compactButtonStyle = { ...iconRow, width: "100%", minHeight: "var(--dh-button-height, 36px)", marginBottom: "var(--dh-button-gap, 8px)", padding: "var(--dh-button-padding, 6px 10px)" }; +// minWidth 0: Steam's DialogButton has a minimum width of its own, which pushed a +// narrow button (the Manage gear) over its neighbour in side-by-side rows on a Deck. +export const compactButtonStyle = { ...iconRow, width: "100%", minWidth: 0, minHeight: "var(--dh-button-height, 36px)", marginBottom: "var(--dh-button-gap, 8px)", padding: "var(--dh-button-padding, 6px 10px)" }; export const modalButtonStyle = { ...compactButtonStyle, marginBottom: 0 }; export const sectionDividerStyle = { borderTop: "1px solid rgba(255, 255, 255, 0.14)", margin: "var(--dh-divider-margin, 16px) 0" }; // Text screen readers (and tests) still read, e.g. an icon-only button's label. From 54b32fce43e1087dace523d242a108a748effc39 Mon Sep 17 00:00:00 2001 From: Andrii Shevchuk-Yuhanets Date: Sun, 25 Oct 2026 15:03:17 -0400 Subject: [PATCH 13/15] feat: Updates count badge and menu item only when needed, Repositories in Settings (2.1.1-rc.29) (#82) Co-authored-by: Claude Opus 5.5 --- devtools/decky-mock/e2e/deckyhub.spec.ts | 40 +++++++++++++++++------- package.json | 2 +- src/components/Card.tsx | 7 +++-- src/pages/Content.tsx | 29 ++++++++++------- src/pages/SettingsPage.tsx | 13 ++++++-- 5 files changed, 62 insertions(+), 29 deletions(-) diff --git a/devtools/decky-mock/e2e/deckyhub.spec.ts b/devtools/decky-mock/e2e/deckyhub.spec.ts index ff1b543..fb090f7 100644 --- a/devtools/decky-mock/e2e/deckyhub.spec.ts +++ b/devtools/decky-mock/e2e/deckyhub.spec.ts @@ -104,11 +104,11 @@ test.beforeEach(async ({ page }) => { test("Quick Access navigation opens Discover", async ({ page }) => { const app = mock(page); - const [updates, discover] = await Promise.all([ - app.getByRole("button", { name: "Updates", exact: true }).boundingBox(), + const [discover, settings] = await Promise.all([ app.getByRole("button", { name: "Discover", exact: true }).boundingBox(), + app.getByRole("button", { name: "Settings", exact: true }).boundingBox(), ]); - expect(discover!.y - (updates!.y + updates!.height)).toBeGreaterThanOrEqual(6); + expect(settings!.y - (discover!.y + discover!.height)).toBeGreaterThanOrEqual(6); await app.getByRole("button", { name: "Discover", exact: true }).click(); await expect(app.getByRole("heading", { name: "Discover", exact: true })).toBeVisible(); @@ -224,7 +224,7 @@ test("Cleanup shows only when the downloads folder has files, and clearing requi rmSync(downloads, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); const app = mock(page); await page.goto("/?bridge=http://127.0.0.1:8643"); - await expect(app.getByRole("button", { name: "Updates", exact: true })).toBeVisible(); + await expect(app.getByRole("button", { name: "Discover", exact: true })).toBeVisible(); await expect(app.getByText("Cleanup", { exact: true })).toHaveCount(0); mkdirSync(downloads, { recursive: true }); @@ -1477,7 +1477,7 @@ test("Every row of side-by-side controls lets the D-pad move left and right", as const app = mock(page); // Quick Access panel, with the rate-limit banner's two buttons. - await expect(app.getByRole("button", { name: "Updates", exact: true })).toBeVisible(); + await expect(app.getByRole("button", { name: "Discover", exact: true })).toBeVisible(); await app.getByRole("button", { name: "Rate limit banner", exact: true }).click(); expect(await sideBySideWithoutRightFlow(page)).toEqual([]); @@ -1509,7 +1509,7 @@ test("Every row of side-by-side controls lets the D-pad move left and right", as expect(await sideBySideWithoutRightFlow(page)).toEqual([]); }); -test("Quick Access lists its pages in order and badges the number of updates", async ({ page }) => { +test("Quick Access lists its pages in order, showing Updates only while there are some", async ({ page }) => { const removePlugin = seedPlugin("e2e-mako", "MAKO - Frame Generation", "1.0.0"); await mockMakoReleases(page, [makoRelease("plugin-v1.2.3", "c".repeat(64))]); try { @@ -1519,17 +1519,22 @@ test("Quick Access lists its pages in order and badges the number of updates", a await expect(nav.nth(0).locator("span")).toHaveText("1"); await expect(nav.nth(0)).toContainText("Updates"); await expect(nav.nth(1)).toHaveText("Discover"); - await expect(nav.nth(2)).toHaveText("Repositories"); - await expect(nav.nth(3)).toHaveText("Settings"); + await expect(nav.nth(2)).toHaveText("Settings"); await expect(app.getByText("Browse", { exact: true })).toHaveCount(0); } finally { removePlugin(); } await page.reload(); - const updates = mock(page).locator(".qam-content").getByRole("button").first(); - await expect(updates).toHaveText("Updates"); - await expect(updates.locator("span")).toHaveCount(0); + await expect(mock(page).locator(".qam-content").getByRole("button").first()).toHaveText("Discover"); + await expect(mock(page).getByRole("button", { name: "Updates", exact: true })).toHaveCount(0); +}); + +test("Settings opens Repositories", async ({ page }) => { + await page.goto("/?preview=/deckyhub/settings&bridge=http://127.0.0.1:8643"); + const app = mock(page); + await app.getByRole("button", { name: "Repositories", exact: true }).click(); + await expect(app.getByRole("heading", { name: "Repositories", exact: true })).toBeVisible(); }); test("An up-to-date Decky plugin's card offers only Manage", async ({ page }) => { @@ -1570,6 +1575,19 @@ test("Check for Updates and Download ZIP keep the Manage window open", async ({ await expect(app.locator(".steam-modal")).toHaveCount(1); }); +test("Updates counts its plugins and Discover lists them first", async ({ page }) => { + const removePlugin = seedPlugin("e2e-nexus", "Nexus Mods", "1.0.0"); + try { + await page.goto("/?preview=/deckyhub/updates&bridge=http://127.0.0.1:8643"); + const app = mock(page); + await expect(app.locator(".deckyhub-update-count")).toHaveText("1"); + await page.goto("/?preview=/deckyhub/discover&bridge=http://127.0.0.1:8643"); + await expect(mock(page).getByRole("heading", { level: 3 }).first()).toHaveText("Nexus Mods"); + } finally { + removePlugin(); + } +}); + test("The Updates page offers Discover when everything is up to date", async ({ page }) => { await page.goto("/?preview=/deckyhub/updates&bridge=http://127.0.0.1:8643"); const app = mock(page); diff --git a/package.json b/package.json index d2aa706..473a865 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "deckyhub", - "version": "2.1.1-rc.28", + "version": "2.1.1-rc.29", "description": "Discover, track, and safely download GitHub releases for Steam Deck tools from Gaming Mode.", "author": "Andrii Shevchuk-Yuhanets", "private": true, diff --git a/src/components/Card.tsx b/src/components/Card.tsx index d040cc8..7cb49d2 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -29,12 +29,15 @@ export const iconTileStyle = { // A page's title row: icon tile, title and description, then any actions. // Without actions it's a focus stop of its own, so the D-pad can reach it. -export function PageHeader({ icon, title, description, actions }: { icon: ReactNode; title: string; description?: string; actions?: ReactNode }) { +export function PageHeader({ icon, title, badge, description, actions }: { icon: ReactNode; title: string; badge?: ReactNode; description?: string; actions?: ReactNode }) { return ( {}} onFocus={keepInView} style={{ display: "flex", alignItems: "center", gap: 14, marginBottom: "var(--dh-card-gap, 14px)" }}>
{icon}
-

{title}

+
+

{title}

+ {badge} +
{description &&
{description}
}
{actions} diff --git a/src/pages/Content.tsx b/src/pages/Content.tsx index 44124fd..40248bb 100644 --- a/src/pages/Content.tsx +++ b/src/pages/Content.tsx @@ -1,7 +1,7 @@ import { toaster } from "@decky/api"; import { DialogButtonPrimary as Button, Dropdown, Focusable, Navigation, PanelSection, PanelSectionRow, Spinner, TextField } from "@decky/ui"; import { useEffect, useRef, useState } from "react"; -import { FaArrowCircleUp, FaCog, FaFileExport, FaListUl, FaSearch, FaSync, FaTimes, FaArrowUp } from "react-icons/fa"; +import { FaArrowCircleUp, FaCog, FaFileExport, FaSearch, FaSync, FaTimes, FaArrowUp } from "react-icons/fa"; import { downloadAsset, exportLogs, getApps, getSettings, REGISTRY_UPDATED } from "../api"; import { useT } from "../i18n"; import type { App, Asset, HideableButton, RepoPreference, UpdateChannel, View } from "../types"; @@ -20,7 +20,7 @@ import { DeckyHubUpdate } from "../components/DeckyHubUpdate"; // big enough to trip GitHub's secondary (burst) rate limit. const RELEASE_CONCURRENCY = 8; -const NAV_LINKS = [["updates", FaArrowCircleUp], ["discover", FaSearch], ["repositories", FaListUl], ["settings", FaCog]] as const; +const NAV_LINKS = [["updates", FaArrowCircleUp], ["discover", FaSearch], ["settings", FaCog]] as const; // Same no-responsibility note the install confirm shows, kept in view on the Updates and Discover pages. const disclaimerStyle = { marginBottom: "var(--dh-card-gap, 14px)", padding: "8px 12px", color: "#ffb3b3", fontSize: "0.85em", background: "rgba(255, 107, 107, 0.08)", borderLeft: "3px solid #ff6b6b", borderRadius: 6 }; @@ -202,13 +202,17 @@ export function Content({ fullPage }: { fullPage?: View }) { ); - const visibleApps = apps.filter((app) => - view === "updates" - ? app.updateAvailable === true - : (!query || `${app.name} ${app.repo} ${app.category} ${app.description ?? ""}`.toLowerCase().includes(query.toLowerCase())) && - (installedFilter === "All" || (installedFilter === "Installed") === Boolean(app.installedVersion)) && - (!activeCategory || app.category === activeCategory), - ); + const visibleApps = apps + .filter((app) => + view === "updates" + ? app.updateAvailable === true + : (!query || `${app.name} ${app.repo} ${app.category} ${app.description ?? ""}`.toLowerCase().includes(query.toLowerCase())) && + (installedFilter === "All" || (installedFilter === "Installed") === Boolean(app.installedVersion)) && + (!activeCategory || app.category === activeCategory), + ) + // Apps with an update first; sort is stable, so registry order holds otherwise. + .sort((a, b) => Number(b.updateAvailable === true) - Number(a.updateAvailable === true)); + const updateCount = apps.filter((app) => app.updateAvailable).length; // Decky plugins the Updates page can update in one batch (see updateDeckyPlugins). const batchUpdates = view === "updates" && !hiddenButtons.includes("update") ? visibleApps.flatMap((app) => { @@ -256,6 +260,7 @@ export function Content({ fullPage }: { fullPage?: View }) { : } title={info.title} + badge={view === "updates" && updateCount > 0 && {updateCount}} description={info.description} actions={ <> @@ -320,13 +325,13 @@ export function Content({ fullPage }: { fullPage?: View }) { ); - // The Quick Access view loads installed apps the same way the Updates page does. - const updateCount = apps.filter((app) => app.updateAvailable).length; + // The Quick Access view loads installed apps the same way the Updates page does, + // and lists Updates only while there is one. const navigation = ( <> - {NAV_LINKS.map(([page, Icon]) => ( + {NAV_LINKS.filter(([page]) => page !== "updates" || updateCount > 0).map(([page, Icon]) => ( + + + From be40f286a202bbf90973699e985595e26ad733d4 Mon Sep 17 00:00:00 2001 From: Andrii Shevchuk-Yuhanets Date: Sun, 25 Oct 2026 22:20:40 -0400 Subject: [PATCH 14/15] feat: Discover Sort dropdown, search aligned with the filters (2.1.1-rc.30) (#83) Co-authored-by: Claude Opus 5.5 --- devtools/decky-mock/e2e/deckyhub.spec.ts | 16 +++++++++ devtools/decky-mock/ui.tsx | 4 ++- package.json | 2 +- src/i18n/de.ts | 4 +++ src/i18n/en.ts | 4 +++ src/i18n/es.ts | 4 +++ src/i18n/fr.ts | 4 +++ src/i18n/ja.ts | 4 +++ src/i18n/ru.ts | 4 +++ src/i18n/uk.ts | 4 +++ src/i18n/zh.ts | 4 +++ src/pages/Content.tsx | 42 +++++++++++++++++++++--- 12 files changed, 89 insertions(+), 7 deletions(-) diff --git a/devtools/decky-mock/e2e/deckyhub.spec.ts b/devtools/decky-mock/e2e/deckyhub.spec.ts index fb090f7..f3d6311 100644 --- a/devtools/decky-mock/e2e/deckyhub.spec.ts +++ b/devtools/decky-mock/e2e/deckyhub.spec.ts @@ -533,6 +533,22 @@ test("Discover filters by category", async ({ page }) => { await expect(app.getByRole("heading", { name: "MAKO Decky" })).toBeVisible(); }); +test("Discover sorts A to Z, Z to A and by most recently added", async ({ page }) => { + const app = mock(page); + await app.getByRole("button", { name: "/deckyhub/discover" }).click(); + // MAKO Decky is the registry's first entry, Unifideck its third. + const order = async () => { + const names = await app.getByRole("heading").allTextContents(); + return names.indexOf("MAKO Decky") - names.indexOf("Unifideck"); + }; + + await expect.poll(order).toBeLessThan(0); + await app.getByLabel("Sort").selectOption("added"); + await expect.poll(order).toBeGreaterThan(0); + await app.getByLabel("Sort").selectOption("za"); + await expect.poll(order).toBeGreaterThan(0); +}); + test("A GitHub rate limit surfaces a friendly message instead of a raw status code", async ({ page }) => { const resetInTwoMinutes = Math.floor(Date.now() / 1000) + 120; await page.route("https://api.github.com/repos/eugeniosegala/MAKO/releases?per_page=20", (route) => diff --git a/devtools/decky-mock/ui.tsx b/devtools/decky-mock/ui.tsx index 9c561f4..901cb42 100644 --- a/devtools/decky-mock/ui.tsx +++ b/devtools/decky-mock/ui.tsx @@ -140,7 +140,9 @@ export function TextField({ onChange, bIsPassword, bShowClearAction, + "aria-label": ariaLabel, }: { + "aria-label"?: string; label?: string; value?: string; onChange?: (event: React.ChangeEvent) => void; @@ -150,7 +152,7 @@ export function TextField({ return (
)}