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:
@@ -38,24 +38,46 @@ export interface StreamSettings {
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
current: string; // installed version (package.json)
|
||||
latest: string; // newest version in our registry for this channel
|
||||
current: string; // installed PLUGIN version (package.json)
|
||||
latest: string; // newest plugin version in our registry for this channel
|
||||
artifact: string; // immutable zip URL Decky should install
|
||||
hash: string; // sha256 of that zip (Decky verifies it)
|
||||
channel: string; // "latest" (stable) | "canary"
|
||||
update_available: boolean;
|
||||
update_available: boolean; // a newer PLUGIN build is available
|
||||
// The flatpak CLIENT (io.unom.Punktfunk) versions independently and is a per-user install, so
|
||||
// `sudo flatpak update` never touches it — the plugin offers a user-scope update instead.
|
||||
client_update_available: boolean;
|
||||
client_current: string; // installed client commit (short) — informational
|
||||
client_latest: string; // remote client commit (short) — informational
|
||||
error?: string; // "update-channel-unknown" (dev build) | "fetch-failed"
|
||||
}
|
||||
|
||||
// Steam-shortcut artwork (assets/ in the plugin dir): base64 PNGs keyed grid / gridwide /
|
||||
// hero / logo, plus the icon's absolute path (SetShortcutIcon wants a file). Keys for
|
||||
// missing files are absent.
|
||||
export interface ShortcutArt {
|
||||
grid?: string;
|
||||
gridwide?: string;
|
||||
hero?: string;
|
||||
logo?: string;
|
||||
icon_path: string;
|
||||
}
|
||||
|
||||
export const discover = callable<[], Host[]>("discover");
|
||||
export const pair = callable<
|
||||
[host: string, port: number, pin: string, name: string],
|
||||
PairResult
|
||||
>("pair");
|
||||
export const runnerInfo = callable<[], RunnerInfo>("runner_info");
|
||||
export const shortcutArt = callable<[], ShortcutArt>("shortcut_art");
|
||||
export const getSettings = callable<[], StreamSettings>("get_settings");
|
||||
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
|
||||
"set_settings",
|
||||
);
|
||||
export const killStream = callable<[], { ok: boolean }>("kill_stream");
|
||||
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
|
||||
// Update the flatpak client in the user installation (`flatpak update --user -y io.unom.Punktfunk`).
|
||||
export const updateClient = callable<
|
||||
[],
|
||||
{ ok: boolean; updated: boolean; error?: string }
|
||||
>("update_client");
|
||||
|
||||
+69
-28
@@ -2,7 +2,7 @@
|
||||
import { toaster } from "@decky/api";
|
||||
import { Navigation } from "@decky/ui";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { checkUpdate, discover, Host, UpdateInfo } from "./backend";
|
||||
import { checkUpdate, discover, Host, updateClient, UpdateInfo } from "./backend";
|
||||
import { launchStream } from "./steam";
|
||||
|
||||
export const DOCS_URL = "https://docs.punktfunk.unom.io/docs/steam-deck";
|
||||
@@ -77,6 +77,11 @@ export function useUpdate() {
|
||||
return { info, checking, check };
|
||||
}
|
||||
|
||||
/** True when EITHER the plugin or the flatpak client has a pending update. */
|
||||
export function hasUpdate(info: UpdateInfo | null | undefined): boolean {
|
||||
return !!info && (info.update_available || info.client_update_available);
|
||||
}
|
||||
|
||||
/** The explicit "Check for updates" action — always ends in a toast so the tap has feedback. */
|
||||
export async function checkForUpdatesNow(
|
||||
check: (force: boolean) => Promise<UpdateInfo | null>,
|
||||
@@ -85,44 +90,80 @@ export async function checkForUpdatesNow(
|
||||
let body: string;
|
||||
if (!res || res.error === "fetch-failed") {
|
||||
body = "Couldn’t reach the update server — are you online?";
|
||||
} else if (hasUpdate(res)) {
|
||||
const parts: string[] = [];
|
||||
if (res.update_available) parts.push(`plugin v${res.current} → v${res.latest}`);
|
||||
if (res.client_update_available) parts.push("client");
|
||||
body = `Update available: ${parts.join(" + ")}.`;
|
||||
} else if (res.error === "update-channel-unknown") {
|
||||
body = "Development build — update checks are disabled.";
|
||||
} else if (res.update_available) {
|
||||
body = `Update available: v${res.current} → v${res.latest}.`;
|
||||
body = "Development build — plugin updates are disabled; the client is up to date.";
|
||||
} else {
|
||||
body = `You’re up to date (v${res.current}).`;
|
||||
body = `You’re up to date (plugin v${res.current}).`;
|
||||
}
|
||||
toaster.toast({ title: "Punktfunk", body });
|
||||
}
|
||||
|
||||
export async function applyUpdate(info: UpdateInfo): Promise<void> {
|
||||
try {
|
||||
const backend = window.DeckyBackend;
|
||||
if (backend?.callable) {
|
||||
// Fire-and-forget: the loader reinstalls + reloads THIS plugin, tearing the panel down
|
||||
// before any result could arrive — so never await it. Decky shows its own confirm prompt.
|
||||
void backend.callable("utilities/install_plugin")(
|
||||
info.artifact,
|
||||
"punktfunk",
|
||||
info.latest,
|
||||
info.hash,
|
||||
INSTALL_TYPE_UPDATE,
|
||||
);
|
||||
/**
|
||||
* Apply whichever updates are pending. The flatpak CLIENT is updated first (a user-scope
|
||||
* `flatpak update`, awaited); then, if the PLUGIN itself has an update, Decky's install RPC
|
||||
* reinstalls it — which reloads the plugin and tears this panel down, so it goes last and is
|
||||
* fire-and-forget. `check` (when passed) refreshes the panel state after a client-only update so
|
||||
* the "Update available" button clears.
|
||||
*/
|
||||
export async function applyUpdate(
|
||||
info: UpdateInfo,
|
||||
check?: (force: boolean) => Promise<UpdateInfo | null>,
|
||||
): Promise<void> {
|
||||
if (info.client_update_available) {
|
||||
toaster.toast({ title: "Punktfunk", body: "Updating the client…" });
|
||||
try {
|
||||
const r = await updateClient();
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
// Decky's installer also phones the plugin store first, which can hang on some
|
||||
// networks before the actual install proceeds — set expectations.
|
||||
body: `Updating to v${info.latest} — confirm Decky’s prompt. This can take a couple of minutes.`,
|
||||
body: !r.ok
|
||||
? `Client update failed${r.error ? ` (${r.error})` : ""}.`
|
||||
: r.updated
|
||||
? "Client updated to the latest version."
|
||||
: "Client is already up to date.",
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
toaster.toast({ title: "Punktfunk", body: "Client update failed." });
|
||||
}
|
||||
} catch {
|
||||
// fall through to the manual path
|
||||
}
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: "Update from Decky → Developer → Install Plugin from URL.",
|
||||
});
|
||||
|
||||
if (info.update_available) {
|
||||
try {
|
||||
const backend = window.DeckyBackend;
|
||||
if (backend?.callable) {
|
||||
// Fire-and-forget: the loader reinstalls + reloads THIS plugin, tearing the panel down
|
||||
// before any result could arrive — so never await it. Decky shows its own confirm prompt.
|
||||
void backend.callable("utilities/install_plugin")(
|
||||
info.artifact,
|
||||
"punktfunk",
|
||||
info.latest,
|
||||
info.hash,
|
||||
INSTALL_TYPE_UPDATE,
|
||||
);
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
// Decky's installer also phones the plugin store first, which can hang on some
|
||||
// networks before the actual install proceeds — set expectations.
|
||||
body: `Updating the plugin to v${info.latest} — confirm Decky’s prompt. This can take a couple of minutes.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through to the manual path
|
||||
}
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: "Update the plugin from Decky → Developer → Install Plugin from URL.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Client-only update (no plugin reinstall): refresh so the button clears.
|
||||
if (check) void check(true);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
@@ -14,7 +14,7 @@ import { definePlugin, routerHook } from "@decky/api";
|
||||
import { FC } from "react";
|
||||
import { FaDownload, FaLock, FaLockOpen, FaSyncAlt, FaTv } from "react-icons/fa";
|
||||
import { PluginErrorBoundary } from "./boundary";
|
||||
import { applyUpdate, checkForUpdatesNow, startStream, useHosts, useUpdate } from "./hooks";
|
||||
import { applyUpdate, checkForUpdatesNow, hasUpdate, startStream, useHosts, useUpdate } from "./hooks";
|
||||
import { PunktfunkRoute, ROUTE } from "./page";
|
||||
import { PairModal } from "./pair";
|
||||
|
||||
@@ -27,13 +27,19 @@ const QamPanel: FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{update?.update_available && (
|
||||
{hasUpdate(update) && (
|
||||
<PanelSection title="Update available">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() => applyUpdate(update)}
|
||||
label={`v${update.current} → v${update.latest}`}
|
||||
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" }} />
|
||||
|
||||
+44
-21
@@ -28,6 +28,7 @@ import {
|
||||
DOCS_URL,
|
||||
applyUpdate,
|
||||
checkForUpdatesNow,
|
||||
hasUpdate,
|
||||
startStream,
|
||||
useHosts,
|
||||
useUpdate,
|
||||
@@ -52,6 +53,27 @@ const tabScroll: CSSProperties = {
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
// DialogButton stretches to 100% width in the gamepad UI — on a fullscreen row that means a
|
||||
// screen-wide button. Size action buttons to their content instead (right-aligned by the
|
||||
// Field's children container).
|
||||
const actionButton: CSSProperties = {
|
||||
width: "fit-content",
|
||||
minWidth: "6em",
|
||||
flexShrink: 0,
|
||||
};
|
||||
// Square icon-only button (details ⓘ, header back arrow) — needs an explicit height too, or
|
||||
// the zero padding collapses it to the icon's line height.
|
||||
const iconButton: CSSProperties = {
|
||||
width: "40px",
|
||||
minWidth: "40px",
|
||||
height: "40px",
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Host details — everything the mDNS advert told us, incl. the fingerprint to cross-check
|
||||
// against the host's own log / web console before trusting it.
|
||||
@@ -113,22 +135,22 @@ const HostRow: FC<{ host: Host; onPaired: () => void }> = ({ host, onPaired }) =
|
||||
}`}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<Focusable style={{ display: "flex", gap: "0.5em" }}>
|
||||
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
|
||||
<DialogButton
|
||||
style={{ width: "3em", minWidth: "3em", padding: 0 }}
|
||||
style={iconButton}
|
||||
onClick={() => showModal(<HostDetailsModal host={host} />)}
|
||||
>
|
||||
<FaInfoCircle />
|
||||
</DialogButton>
|
||||
{needsPair && (
|
||||
<DialogButton
|
||||
style={{ minWidth: "5em" }}
|
||||
style={{ ...actionButton, minWidth: "5em" }}
|
||||
onClick={() => showModal(<PairModal host={host} onPaired={onPaired} />)}
|
||||
>
|
||||
Pair
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton style={{ minWidth: "6em" }} onClick={() => startStream(host)}>
|
||||
<DialogButton style={actionButton} onClick={() => startStream(host)}>
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
Stream
|
||||
</DialogButton>
|
||||
@@ -153,7 +175,7 @@ const HostsTab: FC<{
|
||||
childrenContainerWidth="max"
|
||||
bottomSeparator={hosts.length ? "standard" : "none"}
|
||||
>
|
||||
<DialogButton style={{ minWidth: "8em" }} disabled={scanning} onClick={refresh}>
|
||||
<DialogButton style={{ ...actionButton, minWidth: "8em" }} disabled={scanning} onClick={refresh}>
|
||||
{scanning ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
@@ -212,20 +234,29 @@ const AboutTab: FC<{
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<DialogButton
|
||||
style={{ minWidth: "11em" }}
|
||||
style={{ ...actionButton, minWidth: "11em" }}
|
||||
disabled={checking}
|
||||
onClick={() => void checkForUpdatesNow(check)}
|
||||
>
|
||||
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
|
||||
</DialogButton>
|
||||
</Field>
|
||||
{update?.update_available && (
|
||||
{hasUpdate(update) && (
|
||||
<Field
|
||||
label={`Update available — v${update.latest}`}
|
||||
label={
|
||||
update!.update_available
|
||||
? `Plugin update — v${update!.latest}${
|
||||
update!.client_update_available ? " + client" : ""
|
||||
}`
|
||||
: "Client update available"
|
||||
}
|
||||
description="Installing can take a couple of minutes; Decky reloads the plugin when done"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<DialogButton style={{ minWidth: "9em" }} onClick={() => applyUpdate(update)}>
|
||||
<DialogButton
|
||||
style={{ ...actionButton, minWidth: "9em" }}
|
||||
onClick={() => applyUpdate(update!, check)}
|
||||
>
|
||||
<FaDownload style={{ marginRight: "0.4em" }} />
|
||||
Update
|
||||
</DialogButton>
|
||||
@@ -237,7 +268,7 @@ const AboutTab: FC<{
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<DialogButton
|
||||
style={{ minWidth: "8em" }}
|
||||
style={{ ...actionButton, minWidth: "8em" }}
|
||||
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
|
||||
>
|
||||
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
|
||||
@@ -254,7 +285,7 @@ const AboutTab: FC<{
|
||||
description="Force-stop the stream client if a session wedges"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<DialogButton style={{ minWidth: "8em" }} onClick={() => void forceStopStream()}>
|
||||
<DialogButton style={{ ...actionButton, minWidth: "8em" }} onClick={() => void forceStopStream()}>
|
||||
Force-stop
|
||||
</DialogButton>
|
||||
</Field>
|
||||
@@ -275,6 +306,7 @@ const PunktfunkPage: FC = () => {
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Header is title + back only — updates live on the About tab (and the QAM banner). */}
|
||||
<Focusable
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -285,21 +317,12 @@ const PunktfunkPage: FC = () => {
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<DialogButton
|
||||
style={{ width: "3em", minWidth: "3em", padding: 0 }}
|
||||
onClick={() => Navigation.NavigateBack()}
|
||||
>
|
||||
<DialogButton style={iconButton} onClick={() => Navigation.NavigateBack()}>
|
||||
<FaArrowLeft />
|
||||
</DialogButton>
|
||||
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
|
||||
Punktfunk
|
||||
</div>
|
||||
{update?.update_available && (
|
||||
<DialogButton style={{ minWidth: "9em" }} onClick={() => applyUpdate(update)}>
|
||||
<FaDownload style={{ marginRight: "0.4em" }} />
|
||||
Update v{update.latest}
|
||||
</DialogButton>
|
||||
)}
|
||||
</Focusable>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
|
||||
@@ -99,10 +99,10 @@ export const SettingsSection: FC = () => {
|
||||
onChange={(o) => patch({ gamepad: o.data as string })}
|
||||
/>
|
||||
</Field>
|
||||
{s.gamepad === "steamdeck" && (
|
||||
{(s.gamepad === "steamdeck" || s.gamepad === "auto") && (
|
||||
<Field
|
||||
label="⚠ Disable Steam Input"
|
||||
description="Steam Deck mode forwards the paddles, both trackpads, and gyro to the host. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
|
||||
description="On a Deck, Automatic forwards the built-in controller as a Steam Deck pad — paddles, both trackpads, and gyro included. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
|
||||
+61
-14
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user