diff --git a/.gitea/workflows/decky.yml b/.gitea/workflows/decky.yml index a5363174..e72bde05 100644 --- a/.gitea/workflows/decky.yml +++ b/.gitea/workflows/decky.yml @@ -46,7 +46,10 @@ env: REGISTRY: git.unom.io OWNER: unom PACKAGE: punktfunk-decky # generic-registry package name - PLUGIN: punktfunk # plugin.json "name" == zip top-level dir + # The plugin's ON-DISK dir == the zip's top-level dir. Deliberately NOT plugin.json "name" + # (that is the brand-cased label Decky lists, and it locates a plugin by matching it, not by + # the folder) — see clients/decky/scripts/package.sh. + PLUGIN: punktfunk jobs: build-publish: diff --git a/clients/decky/plugin.json b/clients/decky/plugin.json index 9f179723..dc6a5f77 100644 --- a/clients/decky/plugin.json +++ b/clients/decky/plugin.json @@ -1,5 +1,5 @@ { - "name": "punktfunk", + "name": "Punktfunk", "author": "enrico", "flags": ["debug"], "api_version": 1, diff --git a/clients/decky/scripts/deploy.sh b/clients/decky/scripts/deploy.sh index 2d3eb202..db004de3 100755 --- a/clients/decky/scripts/deploy.sh +++ b/clients/decky/scripts/deploy.sh @@ -12,7 +12,9 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")/.." && pwd)" DECK="${DECK:?set DECK=deck@}" -NAME="$(python3 -c 'import json;print(json.load(open("'"$HERE"'/plugin.json"))["name"])')" +# The on-disk plugin DIR (what scripts/package.sh staged into out/), not plugin.json "name" — +# that field is the brand-cased label Decky shows in its plugin list. See package.sh's header. +NAME=punktfunk STAGE_LOCAL="$HERE/out/$NAME" [ -d "$STAGE_LOCAL" ] || { echo "$STAGE_LOCAL missing — run scripts/package.sh first" >&2; exit 1; } diff --git a/clients/decky/scripts/package.sh b/clients/decky/scripts/package.sh index 9c07cf12..6e517563 100755 --- a/clients/decky/scripts/package.sh +++ b/clients/decky/scripts/package.sh @@ -5,9 +5,13 @@ # package.json,decky.pyi,LICENSE,README.md} # out/punktfunk/ (the same tree, unzipped — rsync this with scripts/deploy.sh) # -# Decky extracts the zip with --strip-components=1, so the single top-level dir MUST equal -# plugin.json "name". Run after `pnpm build` (or use `pnpm run package`). Host-agnostic: needs -# only bash, python3 and zip. +# The single top-level dir is the plugin's ON-DISK folder name (Decky extracts the zip as-is, +# so the dir in the zip becomes ~/homebrew/plugins/). It is deliberately NOT read from +# plugin.json "name": that field is the user-visible label ("Punktfunk", brand-cased, shown in +# Decky's plugin list) and Decky locates an installed plugin by MATCHING it, never by the folder +# name. Keeping the folder lowercase means a rename of the label can't strand the old directory +# next to a new one (which would show up as two plugins). +# Run after `pnpm build` (or use `pnpm run package`). Host-agnostic: needs only bash, python3 and zip. set -euo pipefail HERE="$(cd "$(dirname "$0")/.." && pwd)" cd "$HERE" @@ -15,7 +19,7 @@ cd "$HERE" [ -f dist/index.js ] || { echo "dist/index.js missing — run 'pnpm build' first" >&2; exit 1; } [ -f LICENSE ] || { echo "LICENSE missing (required by the Decky store)" >&2; exit 1; } -NAME="$(python3 -c 'import json;print(json.load(open("plugin.json"))["name"])')" +NAME=punktfunk # the on-disk plugin dir (see the header) — NOT plugin.json "name" VER="$(python3 -c 'import json;print(json.load(open("package.json"))["version"])')" STAGE="$(mktemp -d)" diff --git a/clients/decky/src/hooks.ts b/clients/decky/src/hooks.ts index 0c1911dc..7c7c4c7d 100644 --- a/clients/decky/src/hooks.ts +++ b/clients/decky/src/hooks.ts @@ -122,6 +122,25 @@ function advertMatchesSaved(a: DiscoveredHost, s: SavedHost): boolean { ); } +/** + * The label a saved row shows. + * + * A saved record whose name IS its own address is a PLACEHOLDER, not a choice: `hosts add` + * falls back to the address when the pairing path had nothing better, so the row ends up + * captioned with the same string it already prints underneath. When the box is on the air it + * is advertising its actual hostname — prefer that, and the row reads "home-worker-5" instead + * of "192.168.1.21". + * + * A real saved name always wins over the advert, even a stale one: it may be a name the user + * chose, and a live advert must never quietly overwrite that. Compared against the SAVED + * address, so a host that moved DHCP lease still recognises its old address as a placeholder. + */ +function hostLabel(s: SavedHost, advert?: DiscoveredHost): string { + const placeholder = !s.name || s.name === s.addr || s.name === `${s.addr}:${s.port}`; + if (!placeholder) return s.name; + return advert?.name || s.name || s.addr; +} + /** * Join the saved store and the live browse into the rows the panel draws. * @@ -134,7 +153,7 @@ export function mergeHosts(saved: SavedHost[], discovered: DiscoveredHost[]): Ho // Prefer a live advert's address: the host may have moved since it was last saved. const advert = discovered.find((a) => advertMatchesSaved(a, s)); return { - name: s.name || s.addr, + name: hostLabel(s, advert), addr: advert?.addr ?? s.addr, port: advert?.port ?? s.port, fp: s.fp_hex, @@ -387,7 +406,10 @@ export async function applyUpdate( // before any result could arrive — so never await it. Decky shows its own confirm prompt. void backend.callable("utilities/install_plugin")( info.artifact, - "punktfunk", + // The name Decky uninstalls before extracting the new zip — it locates the folder by + // matching plugin.json "name", so this must equal THIS build's plugin.json name (the + // brand-cased one), not the lowercase on-disk dir. + "Punktfunk", info.latest, info.hash, INSTALL_TYPE_UPDATE, diff --git a/clients/decky/src/index.tsx b/clients/decky/src/index.tsx index 24c5e180..e5ec1d6b 100644 --- a/clients/decky/src/index.tsx +++ b/clients/decky/src/index.tsx @@ -337,9 +337,11 @@ export default definePlugin(() => { // 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", + // `name` must stay in sync with plugin.json (the loader keys plugins by it) — and it is + // USER-VISIBLE: Decky labels the entry in its plugin list with it, so it carries the brand + // case. Decky finds an installed plugin by matching plugin.json "name" (never the folder + // name), so this is independent of the on-disk dir, which stays lowercase `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:
Punktfunk
, diff --git a/clients/decky/src/steam.ts b/clients/decky/src/steam.ts index 26f1fcac..176808bc 100644 --- a/clients/decky/src/steam.ts +++ b/clients/decky/src/steam.ts @@ -70,9 +70,18 @@ declare const appStore: * 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; + // Call it as a METHOD on appStore — NEVER as an extracted function. Its implementation + // reads the store's own state (`this.m_mapApps`), so `const get = appStore.GetAppOverview…; + // get(id)` throws on the lost `this`, and the catch below turns that into a permanent + // "true". That is not a stale-data bug but a total one: the guard then answers "still + // exists" for EVERY appId, so a dangling id is never dropped, the reuse path repoints a + // dead shortcut (silent no-ops), and "recreate" reports success having done nothing. + // `typeof` first: `appStore` is a Steam-injected global, and a bare reference to a missing + // one is a ReferenceError that optional chaining does NOT prevent. + if (typeof appStore === "undefined" || !appStore?.GetAppOverviewByAppID) { + return true; // no way to verify — preserve the reuse path + } + return appStore.GetAppOverviewByAppID(appId) != null; } catch { return true; }