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>
This commit is contained in:
@@ -10,9 +10,17 @@ import {
|
|||||||
showModal,
|
showModal,
|
||||||
staticClasses,
|
staticClasses,
|
||||||
} from "@decky/ui";
|
} from "@decky/ui";
|
||||||
import { definePlugin, routerHook } from "@decky/api";
|
import { definePlugin, routerHook, toaster } from "@decky/api";
|
||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { FaDownload, FaLock, FaLockOpen, FaPlay, FaSyncAlt, FaTv } from "react-icons/fa";
|
import {
|
||||||
|
FaDownload,
|
||||||
|
FaLock,
|
||||||
|
FaLockOpen,
|
||||||
|
FaPlay,
|
||||||
|
FaPlus,
|
||||||
|
FaSyncAlt,
|
||||||
|
FaTv,
|
||||||
|
} from "react-icons/fa";
|
||||||
import { PluginErrorBoundary } from "./boundary";
|
import { PluginErrorBoundary } from "./boundary";
|
||||||
import {
|
import {
|
||||||
applyUpdate,
|
applyUpdate,
|
||||||
@@ -31,7 +39,19 @@ import {
|
|||||||
import { streamPin } from "./library";
|
import { streamPin } from "./library";
|
||||||
import { PunktfunkRoute, ROUTE } from "./page";
|
import { PunktfunkRoute, ROUTE } from "./page";
|
||||||
import { PairModal } from "./pair";
|
import { PairModal } from "./pair";
|
||||||
import { ensureGamepadUiShortcut } from "./steam";
|
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
|
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts
|
||||||
@@ -190,6 +210,16 @@ const QamPanel: FC = () => {
|
|||||||
{checking ? "Checking…" : "Check for updates"}
|
{checking ? "Checking…" : "Check for updates"}
|
||||||
</ButtonItem>
|
</ButtonItem>
|
||||||
</PanelSectionRow>
|
</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>
|
</PanelSection>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -55,6 +55,29 @@ declare const collectionStore:
|
|||||||
| { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void }
|
| { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void }
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
|
// SteamUI's appStore indexes every registered app/shortcut by appId; a remembered appId whose
|
||||||
|
// overview is gone was deleted out from under us (the user removed the library entry). We must
|
||||||
|
// verify this because the remembered appId lives in Steam's CEF localStorage — which survives a
|
||||||
|
// plugin UNINSTALL/REINSTALL — so a manually-deleted shortcut otherwise leaves a dangling appId
|
||||||
|
// that the reuse path below silently repoints (SetShortcut* on a dead id is a no-op), and the
|
||||||
|
// entry never comes back.
|
||||||
|
declare const appStore:
|
||||||
|
| { GetAppOverviewByAppID?: (appId: number) => unknown | null }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
/** True if a remembered appId still maps to a live Steam shortcut. When appStore is unavailable
|
||||||
|
* we can't tell, so assume it exists — better to keep reusing than risk a duplicate library
|
||||||
|
* entry from a false "missing". A confident null means the shortcut was deleted → recreate. */
|
||||||
|
function shortcutStillExists(appId: number): boolean {
|
||||||
|
try {
|
||||||
|
const get = appStore?.GetAppOverviewByAppID;
|
||||||
|
if (!get) return true; // no way to verify — preserve the reuse path
|
||||||
|
return get(appId) != null;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Set a shortcut's library visibility (best-effort, deferred — the overview registers a moment
|
/** Set a shortcut's library visibility (best-effort, deferred — the overview registers a moment
|
||||||
* after AddShortcut). Hides the stateful stream shortcut; keeps the gamepad-UI one visible. */
|
* after AddShortcut). Hides the stateful stream shortcut; keeps the gamepad-UI one visible. */
|
||||||
function setShortcutHidden(appId: number, hidden: boolean): void {
|
function setShortcutHidden(appId: number, hidden: boolean): void {
|
||||||
@@ -199,8 +222,10 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string }
|
|||||||
const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir
|
const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir
|
||||||
void ensureControllerConfig(); // fire-and-forget — never blocks the launch
|
void ensureControllerConfig(); // fire-and-forget — never blocks the launch
|
||||||
|
|
||||||
|
// Reuse the remembered shortcut only if it still exists — a stale appId (shortcut deleted, key
|
||||||
|
// outlived it across a reinstall) must fall through to AddShortcut, not be silently repointed.
|
||||||
const remembered = recall(STORAGE_KEY_STREAM);
|
const remembered = recall(STORAGE_KEY_STREAM);
|
||||||
if (remembered != null) {
|
if (remembered != null && shortcutStillExists(remembered)) {
|
||||||
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
|
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
|
||||||
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
|
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
|
||||||
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
|
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
|
||||||
@@ -236,8 +261,11 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
|||||||
// home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg.
|
// home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg.
|
||||||
const launchOpts = `PF_BROWSE=1 %command% "${info.runner}"`;
|
const launchOpts = `PF_BROWSE=1 %command% "${info.runner}"`;
|
||||||
|
|
||||||
|
// Reuse the remembered entry only if it still exists; a stale appId (deleted shortcut whose
|
||||||
|
// localStorage key survived a plugin reinstall) falls through to AddShortcut so the visible
|
||||||
|
// library entry actually comes back instead of repointing a dead id.
|
||||||
let appId = recall(STORAGE_KEY_UI);
|
let appId = recall(STORAGE_KEY_UI);
|
||||||
if (appId != null) {
|
if (appId != null && shortcutStillExists(appId)) {
|
||||||
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
||||||
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
||||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||||
@@ -256,6 +284,30 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force the visible "Punktfunk" library entry back into existence — the recovery button for
|
||||||
|
* "my shortcut disappeared". Drops any remembered appId that no longer maps to a live shortcut
|
||||||
|
* (so it can't shadow a fresh AddShortcut), then re-ensures. Safe to press anytime: a shortcut
|
||||||
|
* that still exists is left in place (no duplicate); a missing one is recreated. Covers the case
|
||||||
|
* self-heal-on-mount can't — deleting the shortcut WITHOUT reinstalling (no mount → no ensure).
|
||||||
|
* Returns the (new or existing) visible appId, or null on failure.
|
||||||
|
*/
|
||||||
|
export async function recreateShortcuts(): Promise<number | null> {
|
||||||
|
for (const key of [STORAGE_KEY_STREAM, STORAGE_KEY_UI]) {
|
||||||
|
const id = recall(key);
|
||||||
|
if (id != null && !shortcutStillExists(id)) {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(artKey(id)); // stale art marker for the dead appId
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Recreate the visible entry now; the hidden stream shortcut re-registers lazily on next launch.
|
||||||
|
return ensureGamepadUiShortcut();
|
||||||
|
}
|
||||||
|
|
||||||
/** Launch the stateless gamepad-UI shortcut (console home) from the plugin, e.g. a QAM button. */
|
/** Launch the stateless gamepad-UI shortcut (console home) from the plugin, e.g. a QAM button. */
|
||||||
export async function launchGamepadUi(): Promise<void> {
|
export async function launchGamepadUi(): Promise<void> {
|
||||||
const appId = await ensureGamepadUiShortcut();
|
const appId = await ensureGamepadUiShortcut();
|
||||||
|
|||||||
Reference in New Issue
Block a user