feat(decky): visible branded Steam shortcut, one-tap client updates, fullscreen-page polish

- The "Punktfunk" shortcut is no longer hidden: it now ships committed
  artwork (grid/wide/hero/logo/icon, generated by scripts/gen-steam-art.py
  — a pure-stdlib SDF renderer drawing the lens mark + a monoline
  "punktfunk" wordmark) applied via SetCustomArtworkForApp /
  SetShortcutIcon. Existing installs are unhidden and re-arted once per
  ART_VERSION; relaunching the library entry streams to the last host.
- Updates cover the flatpak CLIENT too: check_update compares the
  user-scope installed commit against its remote, applyUpdate runs
  `flatpak update --user` first (awaited) and the plugin reinstall —
  which reloads the panel — last; docs spell out the sudo-less --user
  update ("sudo flatpak update" silently skips per-user installs).
- Fullscreen page: DialogButton stretches to 100% width in the gamepad
  UI, so the Stream/Pair/Refresh/… actions filled whole rows — sized to
  content + right-aligned now; the header drops its Update button (About
  tab + QAM banner keep the flow) and the back button gets a real 40px
  hit target.
- Settings: the disable-Steam-Input note also shows for Automatic — on a
  Deck that now forwards the built-in controller as a Steam Deck pad
  (paddles/trackpads/gyro), which needs Steam Input off for the shortcut.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 17:16:40 +00:00
parent e9c1f4083a
commit 058630f542
17 changed files with 682 additions and 102 deletions
+61 -14
View File
@@ -8,7 +8,7 @@
// and start it with RunGame. The wrapper then execs
// `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant.
import { runnerInfo } from "./backend";
import { runnerInfo, shortcutArt } from "./backend";
// SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed
// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the
@@ -24,24 +24,35 @@ declare const SteamClient: {
SetShortcutName(appId: number, name: string): void;
SetShortcutExe(appId: number, exe: string): void;
SetShortcutStartDir(appId: number, dir: string): void;
SetShortcutIcon(appId: number, iconPath: string): void;
SetAppLaunchOptions(appId: number, options: string): void;
// assetType: 0 = grid (portrait capsule), 1 = hero, 2 = logo, 3 = wide grid.
SetCustomArtworkForApp(
appId: number,
base64Image: string,
imageType: string,
assetType: number,
): Promise<unknown>;
RunGame(gameId: string, _unused: string, _i: number, _j: number): void;
TerminateApp(gameId: string, _b: boolean): void;
};
};
// Steam removed `SteamClient.Apps.SetAppHidden`. Hiding a non-Steam shortcut now goes through
// `collectionStore.SetAppsAsHidden([appId], true)` — but that looks the app up in appStore, which
// only registers a freshly-created shortcut a moment later (calling it immediately throws on a
// null overview). So hiding is BEST-EFFORT + DEFERRED and must NEVER block the launch.
// Steam removed `SteamClient.Apps.SetAppHidden`; visibility goes through
// `collectionStore.SetAppsAsHidden` — but that looks the app up in appStore, which only
// registers a freshly-created shortcut a moment later (calling it immediately throws on a
// null overview). So visibility changes are BEST-EFFORT + DEFERRED, never launch-blocking.
declare const collectionStore:
| { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void }
| undefined;
function hideShortcut(appId: number): void {
// The shortcut used to be hidden ("implementation detail"); it is user-visible now — it
// carries proper artwork and living in the library is how users relaunch their last host.
// Existing installs still have theirs hidden, so unhide is applied every ensure (idempotent).
function unhideShortcut(appId: number): void {
const attempt = () => {
try {
collectionStore?.SetAppsAsHidden?.([appId], true);
collectionStore?.SetAppsAsHidden?.([appId], false);
} catch {
/* overview not registered yet, or the API changed — cosmetic, ignore */
}
@@ -50,6 +61,40 @@ function hideShortcut(appId: number): void {
setTimeout(attempt, 2500); // fresh shortcut: retry once its app overview lands
}
// Bump when the shipped artwork changes so existing shortcuts re-apply it once.
const ART_VERSION = 1;
const ART_KEY = "punktfunk:shortcutArt";
/**
* Apply the plugin's grid/hero/logo/icon to the shortcut (idempotent, once per ART_VERSION).
* Cosmetic and fully best-effort: any failure is swallowed and retried on the next launch.
*/
async function applyArtwork(appId: number): Promise<void> {
try {
if (localStorage.getItem(ART_KEY) === `${appId}:${ART_VERSION}`) {
return;
}
const art = await shortcutArt();
const assets: [string | undefined, number][] = [
[art.grid, 0],
[art.hero, 1],
[art.logo, 2],
[art.gridwide, 3],
];
for (const [data, assetType] of assets) {
if (data) {
await SteamClient.Apps.SetCustomArtworkForApp(appId, data, "png", assetType);
}
}
if (art.icon_path) {
SteamClient.Apps.SetShortcutIcon(appId, art.icon_path);
}
localStorage.setItem(ART_KEY, `${appId}:${ART_VERSION}`);
} catch (e) {
console.warn("punktfunk: shortcut artwork not applied", e);
}
}
// The shortcut name is user-visible (Steam overlay + library while streaming) — brand-case it.
const SHORTCUT_NAME = "Punktfunk";
@@ -87,10 +132,11 @@ function recallAppId(): number | null {
}
/**
* Ensure exactly one hidden "Punktfunk" shortcut exists (exe = /bin/sh; the wrapper script is
* appended per-launch via the launch options), and return its appId + the current runner path.
* Reuses the remembered shortcut, re-pointing it each time — the plugin dir can change across
* reinstalls, and pre-0.4 shortcuts pointed at the script directly and relied on its exec bit.
* Ensure exactly one "Punktfunk" shortcut exists (exe = /bin/sh; the wrapper script is
* appended per-launch via the launch options), branded and visible in the library, and
* return its appId + the current runner path. Reuses the remembered shortcut, re-pointing
* it each time — the plugin dir can change across reinstalls, pre-0.4 shortcuts pointed at
* the script directly, and pre-0.7 shortcuts were hidden and artless.
*/
async function ensureShortcut(): Promise<{ appId: number; runner: string }> {
const info = await runnerInfo();
@@ -105,14 +151,15 @@ async function ensureShortcut(): Promise<{ appId: number; runner: string }> {
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
unhideShortcut(remembered); // pre-0.7 installs hid it
void applyArtwork(remembered); // fire-and-forget — cosmetic, never blocks the launch
return { appId: remembered, runner: info.runner };
}
const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
// Hide it from the library — it's an implementation detail, launched programmatically.
// Best-effort + deferred (see hideShortcut); never let it block the launch.
hideShortcut(appId);
unhideShortcut(appId);
void applyArtwork(appId); // fire-and-forget — cosmetic, never blocks the launch
rememberAppId(appId);
return { appId, runner: info.runner };
}