Files
punktfunk/clients/decky/src/index.tsx
T
enricobuehlerandClaude Opus 4.8 8c9baf3214 fix(decky): recreate the library shortcut when its cached appId is stale
The visible "Punktfunk" shortcut's Steam appId is cached in SteamUI
localStorage, which lives in Steam's CEF profile — so it survives a
plugin uninstall/reinstall. Deleting the shortcut left the key dangling;
on the next mount ensure*Shortcut recalled the dead appId, took the reuse
branch, and ran SetShortcut* against a non-existent id (silent no-ops).
AddShortcut was never reached, so the entry never came back. The same
stale-reuse also made the hidden stream shortcut RunGame a dead gameid.

Verify the remembered appId still maps to a live shortcut via
appStore.GetAppOverviewByAppID before reusing it; a confident miss falls
through to AddShortcut. When appStore is unavailable, assume it exists so
a false negative can't spawn a duplicate library entry.

Also add a "Recreate library shortcut" recovery button to the QAM About
section (recreateShortcuts): drops any dead cached keys and re-ensures.
Covers deleting the shortcut without reinstalling, where no mount fires
the self-heal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:41:32 +02:00

252 lines
8.5 KiB
TypeScript

// Plugin entry: the Quick Access Menu panel + route registration. The fullscreen page lives
// in page.tsx; shared hooks/actions in hooks.ts; the Steam-shortcut launch in steam.ts.
import {
ButtonItem,
Field,
Navigation,
PanelSection,
PanelSectionRow,
Spinner,
showModal,
staticClasses,
} from "@decky/ui";
import { definePlugin, routerHook, toaster } from "@decky/api";
import { FC } from "react";
import {
FaDownload,
FaLock,
FaLockOpen,
FaPlay,
FaPlus,
FaSyncAlt,
FaTv,
} from "react-icons/fa";
import { PluginErrorBoundary } from "./boundary";
import {
applyUpdate,
checkForUpdatesNow,
hasUpdate,
mergeHosts,
needsPair,
pinIsOnline,
startStream,
toHost,
useHosts,
usePins,
useSavedHosts,
useUpdate,
} from "./hooks";
import { streamPin } from "./library";
import { PunktfunkRoute, ROUTE } from "./page";
import { PairModal } from "./pair";
import { ensureGamepadUiShortcut, recreateShortcuts } from "./steam";
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut.
// Deleting the shortcut (optionally + reinstalling the plugin) leaves a stale appId in Steam's
// CEF localStorage that self-heal fixes on the next mount, but this gives an in-session button
// that works even without a reload. Always ends in a toast so the tap has feedback.
async function recreatePunktfunkShortcut(): Promise<void> {
const appId = await recreateShortcuts();
toaster.toast({
title: "Punktfunk",
body: appId != null ? "Shortcut restored to your library" : "Couldn't create the shortcut",
});
}
// ----------------------------------------------------------------------------------------
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts
// and pinned games.
// ----------------------------------------------------------------------------------------
const QamPanel: FC = () => {
const { hosts: discovered, scanning, refresh: refreshDiscovered } = useHosts();
const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts();
const { info: update, checking, check } = useUpdate();
const pins = usePins();
const hosts = mergeHosts(saved, discovered);
const busy = scanning || loadingSaved;
const refresh = () => {
void refreshDiscovered();
void refreshSaved();
};
return (
<>
{hasUpdate(update) && (
<PanelSection title="Update available">
<PanelSectionRow>
<ButtonItem
layout="below"
onClick={() => applyUpdate(update!, check)}
label={
update!.update_available
? `Plugin v${update!.current} → v${update!.latest}${
update!.client_update_available ? " + client" : ""
}`
: "New client version"
}
description="Installing can take a couple of minutes"
>
<FaDownload style={{ marginRight: "0.5em" }} />
Update Punktfunk
</ButtonItem>
</PanelSectionRow>
</PanelSection>
)}
<PanelSection title="Punktfunk">
<PanelSectionRow>
<ButtonItem
layout="below"
description="Host details, stream settings, and help"
onClick={() => {
Navigation.Navigate(ROUTE);
Navigation.CloseSideMenus();
}}
>
<FaTv style={{ marginRight: "0.5em" }} />
Open Punktfunk
</ButtonItem>
</PanelSectionRow>
</PanelSection>
{/* Pinned games — the "jump straight into Playnite" rows. Pin games from a host's
picker (fullscreen page → host row → games button). */}
{pins.pins.length > 0 && (
<PanelSection title="Pinned Games">
{pins.pins.map((pin) => {
const online = pinIsOnline(pin, hosts);
return (
<PanelSectionRow key={`${pin.host_fp}:${pin.game_id}`}>
<ButtonItem
layout="below"
onClick={() => streamPin(pin, hosts.map(toHost), pins)}
label={pin.title}
description={`${pin.host_name}${online ? "" : " · offline?"}${
pin.paired ? "" : " · pairing required"
}`}
>
<FaPlay style={{ marginRight: "0.5em" }} />
Stream
</ButtonItem>
</PanelSectionRow>
);
})}
</PanelSection>
)}
<PanelSection title="Hosts">
<PanelSectionRow>
<ButtonItem layout="below" onClick={refresh} disabled={busy}>
{busy ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} />
)}
{busy ? "Scanning…" : "Refresh"}
</ButtonItem>
</PanelSectionRow>
{hosts.length === 0 && busy && (
<PanelSectionRow>
<Field focusable={false} description="Scanning your network…" />
</PanelSectionRow>
)}
{hosts.length === 0 && !busy && (
<PanelSectionRow>
<Field
focusable={false}
label="No hosts found"
description="Open Punktfunk to add a host by address, or start a host on this network and refresh."
/>
</PanelSectionRow>
)}
{hosts.map((v) => {
const pair = needsPair(v);
const h = toHost(v);
return (
<PanelSectionRow key={v.fp || `${v.addr}:${v.port}`}>
<ButtonItem
layout="below"
onClick={() =>
pair
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
: startStream(h)
}
label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
{pair ? <FaLock /> : <FaLockOpen />}
{v.name}
</span>
}
description={`${v.addr}:${v.port} · ${v.online ? "online" : "offline"}${
pair ? " · pairing required" : v.paired ? " · paired" : ""
}`}
>
{pair ? "Pair & Stream" : "Stream"}
</ButtonItem>
</PanelSectionRow>
);
})}
</PanelSection>
<PanelSection title="About">
<PanelSectionRow>
<Field
focusable={false}
label="Version"
description={
update
? `v${update.current}${update.channel ? ` · ${update.channel}` : " · dev build"}`
: "…"
}
/>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem
layout="below"
disabled={checking}
onClick={() => void checkForUpdatesNow(check)}
>
{checking ? "Checking…" : "Check for updates"}
</ButtonItem>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem
layout="below"
description="Missing the Punktfunk entry in your library? This puts it back."
onClick={() => void recreatePunktfunkShortcut()}
>
<FaPlus style={{ marginRight: "0.5em" }} />
Recreate library shortcut
</ButtonItem>
</PanelSectionRow>
</PanelSection>
</>
);
};
export default definePlugin(() => {
routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true });
// Ensure the visible, stateless "Punktfunk" library entry (opens the gamepad UI / console
// home) exists and is repointed to the current plugin dir — also installs the native-touch
// controller config. Fire-and-forget: cosmetic library upkeep must never block plugin load.
void ensureGamepadUiShortcut();
return {
// `name` is the plugin's INTERNAL id — it must stay in sync with plugin.json (the loader
// keys plugins by it), so it stays lowercase; user-facing strings say "Punktfunk".
name: "punktfunk",
// `staticClasses?.Title` is guarded so a future client that drops the export can't throw
// at plugin-load time (an error boundary only catches render-time, not load-time, errors).
titleView: <div className={staticClasses?.Title}>Punktfunk</div>,
content: (
<PluginErrorBoundary>
<QamPanel />
</PluginErrorBoundary>
),
icon: <FaTv />,
onDismount() {
routerHook.removeRoute(ROUTE);
},
};
});