diff --git a/clients/decky/assets/grid.png b/clients/decky/assets/grid.png index d9a5b6b1..062a8cbd 100644 Binary files a/clients/decky/assets/grid.png and b/clients/decky/assets/grid.png differ diff --git a/clients/decky/assets/gridwide.png b/clients/decky/assets/gridwide.png index de8e3537..805dacbb 100644 Binary files a/clients/decky/assets/gridwide.png and b/clients/decky/assets/gridwide.png differ diff --git a/clients/decky/assets/hero.png b/clients/decky/assets/hero.png index da8ba932..ff24462a 100644 Binary files a/clients/decky/assets/hero.png and b/clients/decky/assets/hero.png differ diff --git a/clients/decky/assets/icon.png b/clients/decky/assets/icon.png index f7650b38..623c77f0 100644 Binary files a/clients/decky/assets/icon.png and b/clients/decky/assets/icon.png differ diff --git a/clients/decky/assets/logo.png b/clients/decky/assets/logo.png index 92c53483..508d53e3 100644 Binary files a/clients/decky/assets/logo.png and b/clients/decky/assets/logo.png differ diff --git a/clients/decky/bin/punktfunkrun.sh b/clients/decky/bin/punktfunkrun.sh index 1f2ef2be..b14926f1 100755 --- a/clients/decky/bin/punktfunkrun.sh +++ b/clients/decky/bin/punktfunkrun.sh @@ -12,7 +12,7 @@ # Per-session parameters arrive as environment variables, set as the shortcut's Steam launch # options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves # every host (and every pinned game): -# PF_HOST host[:port] to connect to (required) +# PF_HOST host[:port] to connect to (required for streaming; optional for browse) # PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games) # PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect) # PF_MGMT management-API port for --browse (optional; client defaults to 47990) @@ -36,24 +36,31 @@ set -u APPID="${PF_APPID:-io.unom.Punktfunk}" FLATPAK="${PF_FLATPAK:-flatpak}" -if [ -z "${PF_HOST:-}" ]; then - echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2 - exit 2 -fi - # exec so the flatpak client IS the game process — when it exits, Steam ends the "game" and # Gaming Mode reclaims focus automatically (no manual refocus needed). # --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the # Deck/gamescope env, and ignores the flag harmlessly on older builds that predate it). if [ -n "${PF_BROWSE:-}" ]; then - # The gamepad library launcher: browse the host's games on-screen, A streams one, - # session end returns to the launcher, B quits back to Gaming Mode. + # The gamepad UI. BARE `--browse` (no PF_HOST) opens the console home — the self-contained + # host picker + pairing + settings, gamepad-navigable — which is what the stateless, visible + # library shortcut launches. `--browse ` opens straight into that host's library (the + # per-host "open on screen" action). A streams a game, session end returns here, B quits. + if [ -z "${PF_HOST:-}" ]; then + echo "punktfunkrun: gamepad UI $APPID --browse (console home)" >&2 + exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse --fullscreen + fi echo "punktfunkrun: library $APPID --browse $PF_HOST" >&2 if [ -n "${PF_MGMT:-}" ]; then exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen fi exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --fullscreen fi + +# Streaming modes need a host (browse above is the only host-less path). +if [ -z "${PF_HOST:-}" ]; then + echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2 + exit 2 +fi if [ -n "${PF_LAUNCH:-}" ]; then # A pinned game: the id rides the session Hello and the host launches that title. echo "punktfunkrun: streaming $APPID --connect $PF_HOST --launch $PF_LAUNCH" >&2 diff --git a/clients/decky/controller_config/punktfunk.vdf b/clients/decky/controller_config/punktfunk.vdf new file mode 100755 index 00000000..6c590856 --- /dev/null +++ b/clients/decky/controller_config/punktfunk.vdf @@ -0,0 +1,757 @@ +"controller_mappings" +{ + "version" "3" + "revision" "2" + "title" "Punktfunk" + "description" "Native touchscreen + full gamepad passthrough for the Punktfunk streaming client." + "creator" "0" + "progenitor" "template://controller_neptune_gamepad_fps.vdf" + "url" "template://controller_neptune_gamepad_fps.vdf" + "export_type" "unknown" + "controller_type" "controller_neptune" + "controller_caps" "23117823" + "major_revision" "0" + "minor_revision" "0" + "Timestamp" "0" + "localization" + { + "english" + { + "title" "Punktfunk" + "description" "Native touchscreen + full gamepad for Punktfunk streaming." + } + } + "group" + { + "id" "0" + "mode" "four_buttons" + "name" "" + "description" "" + "inputs" + { + "button_a" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button A, , " + } + } + } + "disabled_activators" + { + } + } + "button_b" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button B, , " + } + } + } + "disabled_activators" + { + } + } + "button_x" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button X, , " + } + } + } + "disabled_activators" + { + } + } + "button_y" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button Y, , " + } + } + } + "disabled_activators" + { + } + } + } + } + "group" + { + "id" "1" + "mode" "dpad" + "name" "" + "description" "" + "inputs" + { + "dpad_north" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button dpad_up, , " + } + "settings" + { + "haptic_intensity" "1" + } + } + } + "disabled_activators" + { + } + } + "dpad_south" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button dpad_down, , " + } + "settings" + { + "haptic_intensity" "1" + } + } + } + "disabled_activators" + { + } + } + "dpad_east" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button dpad_right, , " + } + "settings" + { + "haptic_intensity" "1" + } + } + } + "disabled_activators" + { + } + } + "dpad_west" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button dpad_left, , " + } + "settings" + { + "haptic_intensity" "1" + } + } + } + "disabled_activators" + { + } + } + } + } + "group" + { + "id" "2" + "mode" "joystick_move" + "name" "" + "description" "" + "inputs" + { + "click" + { + "activators" + { + "Soft_Press" + { + "bindings" + { + "binding" "xinput_button JOYSTICK_RIGHT, , " + } + } + } + "disabled_activators" + { + } + } + } + } + "group" + { + "id" "3" + "mode" "joystick_move" + "name" "" + "description" "" + "inputs" + { + "click" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button JOYSTICK_LEFT, , " + } + } + } + "disabled_activators" + { + } + } + } + "settings" + { + "deadzone_inner_radius" "7199" + } + } + "group" + { + "id" "4" + "mode" "trigger" + "name" "" + "description" "" + "inputs" + { + } + "settings" + { + "output_trigger" "1" + } + } + "group" + { + "id" "5" + "mode" "trigger" + "name" "" + "description" "" + "inputs" + { + } + "settings" + { + "output_trigger" "2" + } + } + "group" + { + "id" "6" + "mode" "joystick_move" + "name" "" + "description" "" + "inputs" + { + "click" + { + "activators" + { + "Soft_Press" + { + "bindings" + { + "binding" "xinput_button JOYSTICK_RIGHT, , " + } + } + } + "disabled_activators" + { + } + } + } + } + "group" + { + "id" "8" + "mode" "joystick_move" + "name" "" + "description" "" + "inputs" + { + "click" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button JOYSTICK_RIGHT, , " + } + } + } + "disabled_activators" + { + } + } + } + } + "group" + { + "id" "9" + "mode" "dpad" + "name" "" + "description" "" + "inputs" + { + "dpad_north" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button DPAD_UP, , " + } + "settings" + { + "haptic_intensity" "1" + } + } + } + "disabled_activators" + { + } + } + "dpad_south" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button DPAD_DOWN, , " + } + "settings" + { + "haptic_intensity" "1" + } + } + } + "disabled_activators" + { + } + } + "dpad_east" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button DPAD_RIGHT, , " + } + "settings" + { + "haptic_intensity" "1" + } + } + } + "disabled_activators" + { + } + } + "dpad_west" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button DPAD_LEFT, , " + } + "settings" + { + "haptic_intensity" "1" + } + } + } + "disabled_activators" + { + } + } + } + "settings" + { + "requires_click" "0" + "haptic_intensity_override" "0" + } + } + "group" + { + "id" "10" + "mode" "single_button" + "name" "" + "description" "" + "inputs" + { + "click" + { + "activators" + { + "Soft_Press" + { + "bindings" + { + "binding" "xinput_button START, , " + } + } + } + "disabled_activators" + { + } + } + } + } + "group" + { + "id" "11" + "mode" "single_button" + "name" "" + "description" "" + "inputs" + { + "click" + { + "activators" + { + "Soft_Press" + { + "bindings" + { + "binding" "xinput_button SELECT, , " + } + } + } + "disabled_activators" + { + } + } + } + } + "group" + { + "id" "12" + "mode" "mouse_joystick" + "name" "" + "description" "" + "inputs" + { + "click" + { + "activators" + { + "Soft_Press" + { + "bindings" + { + "binding" "xinput_button JOYSTICK_RIGHT, , " + } + } + } + "disabled_activators" + { + } + } + } + } + "group" + { + "id" "13" + "mode" "flickstick" + "name" "" + "description" "" + "inputs" + { + "click" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button JOYSTICK_RIGHT, , " + } + } + } + "disabled_activators" + { + } + } + } + } + "group" + { + "id" "14" + "mode" "flickstick" + "name" "" + "description" "" + "inputs" + { + "click" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button JOYSTICK_LEFT, , " + } + } + } + "disabled_activators" + { + } + } + } + } + "group" + { + "id" "15" + "mode" "flickstick" + "name" "" + "description" "" + "inputs" + { + "click" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button JOYSTICK_RIGHT, , " + } + } + } + "disabled_activators" + { + } + } + } + } + "group" + { + "id" "16" + "mode" "flickstick" + "name" "" + "description" "" + "inputs" + { + "click" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button JOYSTICK_LEFT, , " + } + } + } + "disabled_activators" + { + } + } + } + } + "group" + { + "id" "7" + "mode" "switches" + "name" "" + "description" "" + "inputs" + { + "button_escape" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button start, , " + } + } + } + "disabled_activators" + { + } + } + "button_menu" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button select, , " + } + } + } + "disabled_activators" + { + } + } + "left_bumper" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button shoulder_left, , " + } + } + } + "disabled_activators" + { + } + } + "right_bumper" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "xinput_button shoulder_right, , " + } + } + } + "disabled_activators" + { + } + } + "button_back_left" + { + "activators" + { + } + "disabled_activators" + { + } + } + "button_back_right" + { + "activators" + { + } + "disabled_activators" + { + } + } + "button_back_left_upper" + { + "activators" + { + } + "disabled_activators" + { + } + } + "button_back_right_upper" + { + "activators" + { + } + "disabled_activators" + { + } + } + "always_on_action" + { + "activators" + { + "Full_Press" + { + "bindings" + { + "binding" "controller_action ts_n, , " + } + } + } + "disabled_activators" + { + } + } + } + } + "preset" + { + "id" "0" + "name" "Default" + "group_source_bindings" + { + "7" "switch active" + "0" "button_diamond active" + "1" "left_trackpad active" + "11" "left_trackpad inactive" + "16" "left_trackpad inactive" + "2" "right_trackpad inactive" + "6" "right_trackpad inactive" + "10" "right_trackpad inactive" + "12" "right_trackpad active" + "15" "right_trackpad inactive" + "3" "joystick active" + "14" "joystick inactive" + "4" "left_trigger active" + "5" "right_trigger active" + "8" "right_joystick active" + "13" "right_joystick inactive" + "9" "dpad active" + } + } + "settings" + { + "left_trackpad_mode" "0" + "right_trackpad_mode" "0" + } +} diff --git a/clients/decky/main.py b/clients/decky/main.py index 198c9146..9e8f7cfc 100644 --- a/clients/decky/main.py +++ b/clients/decky/main.py @@ -89,6 +89,93 @@ def _pins_path() -> Path: return _client_config_dir() / "decky-pinned.json" +# --- Steam Input controller config injection (native touchscreen via the ts_n command) -------- +# The Deck's touchscreen only reaches the app as native wl_touch when a Steam Input layout with +# the "Touchscreen Native Support" (controller_action ts_n) command is active for the game. We +# ship that layout (controller_config/punktfunk.vdf, built on Steam's gamepad-fps template) and +# point our shortcuts at it, EmuDeck-style: drop it in controller_base/templates/ (so it is also +# a selectable "Punktfunk" template) AND set each account's configset entry for our shortcut's +# game key to that template. Steam keys non-Steam games by their LOWERCASE NAME (verified on the +# Deck: our "Punktfunk" shortcut → the "punktfunk" configset key), so both our shortcuts (same +# name) share one entry. controller_neptune = the Deck's built-in controller type. +CONTROLLER_TEMPLATE = "punktfunk.vdf" + + +def _steam_root() -> Path: + """Steam's base dir on SteamOS (~/.steam/steam symlinks here).""" + return Path(decky.DECKY_USER_HOME) / ".local" / "share" / "Steam" + + +def _controller_template_src() -> Path: + return Path(decky.DECKY_PLUGIN_DIR) / "controller_config" / CONTROLLER_TEMPLATE + + +def _chown_like_parent(path: Path) -> None: + """The Decky backend runs as root, so files it CREATES in the deck-owned Steam tree land + root-owned — which would stop Steam (running as the user) from rewriting them. Match the + parent dir's owner so Steam retains write access. Best-effort.""" + try: + st = path.parent.stat() + os.chown(path, st.st_uid, st.st_gid) + except OSError: + pass + + +def _configset_dirs() -> list[Path]: + """Every Steam account's controller-config dir holding configset_controller_neptune.vdf.""" + base = _steam_root() / "steamapps" / "common" / "Steam Controller Configs" + return [p / "config" for p in sorted(base.glob("*")) if (p / "config").is_dir()] + + +def _upsert_configset_entry(text: str, key: str, source_type: str, source_val: str) -> str: + """Set the top-level ``"" { "" "" }`` block in a + configset_controller_neptune.vdf, replacing any existing block for that key (case-insensitive) + or inserting one before the file's final closing brace. Targeted (only our key is touched) so + the hundreds of other game entries stay byte-for-byte intact. Creates the wrapping + ``"controller_config" { }`` skeleton when the file is empty/new.""" + block = f'\t"{key}"\n\t{{\n\t\t"{source_type}"\t\t"{source_val}"\n\t}}\n' + if '"controller_config"' not in text: + return '"controller_config"\n{\n' + block + "}\n" + + lower = text.lower() + needle = f'"{key.lower()}"' + # Find the key token that begins a top-level entry (its own line), then its "{ … }" block. + search_from = 0 + while True: + idx = lower.find(needle, search_from) + if idx == -1: + break + # Must be a standalone key line (preceded only by whitespace back to a newline). + line_start = text.rfind("\n", 0, idx) + 1 + if text[line_start:idx].strip() != "": + search_from = idx + len(needle) + continue + brace = text.find("{", idx) + if brace == -1: + break + depth = 0 + i = brace + while i < len(text): + if text[i] == "{": + depth += 1 + elif text[i] == "}": + depth -= 1 + if depth == 0: + break + i += 1 + end = i + 1 + # Consume the trailing newline after the block so we don't accumulate blank lines. + if end < len(text) and text[end] == "\n": + end += 1 + return text[:line_start] + block + text[end:] + + # Not present — insert before the last closing brace (the controller_config block's end). + last_close = text.rstrip().rfind("}") + if last_close == -1: + return text.rstrip() + "\n" + block + return text[:last_close] + block + text[last_close:] + + def _parse_library_tsv(stdout: str) -> list[dict]: """Parse the flatpak client's ``--library`` output: one ``id\\tstore\\ttitle`` line per game plus a trailing ``N game(s)`` count line (no tabs — it self-skips here). A title @@ -726,10 +813,10 @@ class Plugin: return {"ok": False, "error": str(exc)} async def shortcut_art(self) -> dict: - """The Steam-shortcut artwork shipped with the plugin (``assets/``, generated by - ``scripts/gen-steam-art.py``): base64 PNGs for SetCustomArtworkForApp plus the - icon's absolute path for SetShortcutIcon (which wants a file, not bytes). Missing - files are simply omitted — artwork is cosmetic and must never block a launch.""" + """The Steam-shortcut artwork shipped with the plugin (committed under ``assets/``): + base64 PNGs (grid/gridwide/hero/logo) for SetCustomArtworkForApp plus the icon's + absolute path for SetShortcutIcon (which wants a file, not bytes). Missing files are + simply omitted — artwork is cosmetic and must never block a launch.""" art: dict = {} base = Path(decky.DECKY_PLUGIN_DIR) / "assets" for key, fname in ( @@ -746,6 +833,54 @@ class Plugin: art["icon_path"] = str(icon) if icon.exists() else "" return art + async def apply_controller_config(self, name: str = "Punktfunk") -> dict: + """Install our Steam Input layout (native touchscreen `ts_n` + gamepad passthrough) and + point the shortcut(s) at it, so the Deck touchscreen reaches the client as native touch + with zero manual controller setup. Best-effort + idempotent — a controller tweak must + never block a launch, so failures are reported, not raised. Both shortcuts share the same + name → the same lowercase configset key, so one entry per account covers both.""" + src = _controller_template_src() + if not src.exists(): + return {"ok": False, "error": "template-missing", "detail": str(src)} + key = name.strip().lower() + applied: list[str] = [] + errors: list[str] = [] + # 1) Ship it as a selectable template (also the safe fallback if Steam clobbers the + # configset write on exit): controller_base/templates/punktfunk.vdf. + try: + tdir = _steam_root() / "controller_base" / "templates" + tdir.mkdir(parents=True, exist_ok=True) + dst = tdir / CONTROLLER_TEMPLATE + shutil.copyfile(src, dst) + _chown_like_parent(dst) + applied.append("template") + except OSError as e: + errors.append(f"template: {e}") + # 2) Point each Steam account's configset at that template for our game key. + dirs = _configset_dirs() + for d in dirs: + f = d / "configset_controller_neptune.vdf" + try: + text = f.read_text(encoding="utf-8") if f.exists() else "" + new = _upsert_configset_entry(text, key, "template", CONTROLLER_TEMPLATE) + if new != text: + if f.exists(): # keep one recoverable backup before our first edit + bak = f.with_name(f.name + ".pf-bak") + if not bak.exists(): + shutil.copyfile(f, bak) + _chown_like_parent(bak) + existed = f.exists() + f.write_text(new, encoding="utf-8") + if not existed: # a freshly-created file is root-owned — hand it to the user + _chown_like_parent(f) + applied.append(f"configset:{d.parent.name}") + except OSError as e: + errors.append(f"{d.parent.name}: {e}") + decky.logger.info( + "apply_controller_config key=%s applied=%s errors=%s", key, applied, errors + ) + return {"ok": not errors, "applied": applied, "errors": errors, "accounts": len(dirs)} + async def runner_info(self) -> dict: """The wrapper-script path + flatpak app id the frontend needs to create the Steam shortcut. The shortcut invokes the script through ``/bin/sh`` (see steam.ts), so no diff --git a/clients/decky/scripts/gen-steam-art.py b/clients/decky/scripts/gen-steam-art.py deleted file mode 100644 index 97195368..00000000 --- a/clients/decky/scripts/gen-steam-art.py +++ /dev/null @@ -1,297 +0,0 @@ -#!/usr/bin/env python3 -"""Generate the Steam-shortcut artwork for the Decky plugin (committed, like the tray icons). - -The plugin registers a non-Steam shortcut ("Punktfunk") whose grid/hero/logo/icon Steam -would otherwise render as a gray placeholder tile. These assets brand it: the lens mark -(same geometry as scripts/gen-tray-icons.py / web's brand-mark.tsx) over the brand-navy -gradient, plus a monoline "punktfunk" wordmark built from stroke segments ("punktfunk" -needs only p·u·n·k·t·f). The frontend applies them via -SteamClient.Apps.SetCustomArtworkForApp / SetShortcutIcon (src/steam.ts). - -Outputs (checked in; re-run only when the brand changes): - clients/decky/assets/grid.png 600 x 900 library capsule (portrait) - clients/decky/assets/gridwide.png 920 x 430 wide capsule (recent games / search) - clients/decky/assets/hero.png 1920 x 620 game-page banner - clients/decky/assets/logo.png transparent overlaid on the hero by Steam - clients/decky/assets/icon.png 256 x 256 list icon (SetShortcutIcon) - -Pure stdlib. Unlike the tiny tray icons this rasterizes big surfaces, so edges are -antialiased analytically from signed distances (one sample per pixel) instead of 4x4 -supersampling. -""" - -import math -import struct -import zlib -from pathlib import Path - -HERE = Path(__file__).resolve().parent.parent # clients/decky -OUT = HERE / "assets" - -# Brand-mark geometry in its 1000-unit viewbox (identical to gen-tray-icons.py). -R = 194.41 -C1 = (403.037, 597.262) # light circle, behind -C2 = (597.8075, 402.8525) # deep circle, in front -BB_MIN = (C1[0] - R, C2[1] - R) -BB_MAX = (C2[0] + R, C1[1] + R) -MARK_CENTER = ((BB_MIN[0] + BB_MAX[0]) / 2, (BB_MIN[1] + BB_MAX[1]) / 2) -MARK_SPAN = BB_MAX[0] - BB_MIN[0] - -COL_LIGHT = (0xA7, 0x9F, 0xF8) -COL_DEEP = (0x6C, 0x5B, 0xF3) -COL_HI = (0xD2, 0xC9, 0xFB) -WORD = (0xEF, 0xEC, 0xFD) # wordmark: near-white lavender -BG_TOP = (0x28, 0x1E, 0x46) -BG_BOT = (0x12, 0x0D, 0x22) - - -# ------------------------------------------------------------------------------------------ -# Wordmark: monoline glyphs as polylines in a unit box (y down; x-height top y=0, baseline -# y=1, ascender to -0.5, descender to +1.5). Arcs are sampled into the polylines, so the -# rasterizer only ever measures distance-to-segment; round caps/joins fall out of that. -# ------------------------------------------------------------------------------------------ -def _arc(cx, cy, r, a0, a1, n=24): - """Polyline along a circle arc; degrees, 0 = +x, angles grow clockwise on screen.""" - pts = [] - for i in range(n + 1): - a = math.radians(a0 + (a1 - a0) * i / n) - pts.append((cx + r * math.cos(a), cy + r * math.sin(a))) - return pts - - -GLYPHS = { - # letter: (advance, [polyline, ...]) - "p": (1.05, [[(0, 0), (0, 1.5)], _arc(0.5, 0.5, 0.5, 0, 360)]), - "u": (1.05, [[(0, 0), (0, 0.5)], _arc(0.5, 0.5, 0.5, 0, 180), [(1, 0), (1, 0.5)]]), - "n": (1.05, [[(0, 0), (0, 1)], _arc(0.5, 0.5, 0.5, 180, 360), [(1, 0.5), (1, 1)]]), - "k": (1.0, [[(0, -0.5), (0, 1)], [(0, 0.62), (0.78, 0)], [(0.30, 0.38), (0.85, 1)]]), - "t": (0.85, [[(0.42, -0.42), (0.42, 1)], [(0, 0), (0.84, 0)]]), - "f": ( - 0.85, - [[(0.42, 1), (0.42, -0.15)] + _arc(0.75, -0.15, 0.33, 180, 270, 12), [(0, 0), (0.78, 0)]], - ), -} -GAP = 0.34 # inter-letter gap, in glyph units -STROKE = 0.26 # stroke thickness, in glyph units -ASCENT, DESCENT = -0.5, 1.5 # glyph-space vertical extent - - -def word_segments(text): - """The word's stroke segments [(x1,y1,x2,y2)] in glyph units, plus its unit width.""" - segs = [] - x = 0.0 - for ch in text: - adv, lines = GLYPHS[ch] - for line in lines: - for (x1, y1), (x2, y2) in zip(line, line[1:]): - segs.append((x + x1, y1, x + x2, y2)) - x += adv + GAP - return segs, x - GAP - - -def render_word_alpha(text, unit_px): - """Coverage (0..255) buffer of the word at `unit_px` pixels per glyph unit.""" - segs, width_u = word_segments(text) - half = STROKE / 2 * unit_px - pad = half + 1.5 - w = math.ceil(width_u * unit_px + 2 * pad) - h = math.ceil((DESCENT - ASCENT) * unit_px + 2 * pad) - ox, oy = pad, pad - ASCENT * unit_px - px_segs = [(ox + a * unit_px, oy + b * unit_px, ox + c * unit_px, oy + d * unit_px) for a, b, c, d in segs] - # Bucket segments per pixel column range so each pixel tests only nearby strokes. - buf = bytearray(w * h) - for x1, y1, x2, y2 in px_segs: - lo_x = max(0, math.floor(min(x1, x2) - pad)) - hi_x = min(w, math.ceil(max(x1, x2) + pad)) - lo_y = max(0, math.floor(min(y1, y2) - pad)) - hi_y = min(h, math.ceil(max(y1, y2) + pad)) - dx, dy = x2 - x1, y2 - y1 - len2 = dx * dx + dy * dy - for py in range(lo_y, hi_y): - row = py * w - fy = py + 0.5 - for px in range(lo_x, hi_x): - fx = px + 0.5 - if len2 > 0: - t = max(0.0, min(1.0, ((fx - x1) * dx + (fy - y1) * dy) / len2)) - else: - t = 0.0 - d = math.hypot(fx - (x1 + t * dx), fy - (y1 + t * dy)) - cov = 0.5 + (half - d) - if cov > 0: - v = min(255, round(min(1.0, cov) * 255)) - if v > buf[row + px]: - buf[row + px] = v - return buf, w, h - - -# ------------------------------------------------------------------------------------------ -# Canvas: RGBA bytearray, straight alpha, painted back to front. -# ------------------------------------------------------------------------------------------ -class Canvas: - def __init__(self, w, h): - self.w, self.h = w, h - self.buf = bytearray(w * h * 4) - - def fill_gradient(self, top, bottom): - for y in range(self.h): - t = y / max(1, self.h - 1) - c = bytes( - ( - round(top[0] + (bottom[0] - top[0]) * t), - round(top[1] + (bottom[1] - top[1]) * t), - round(top[2] + (bottom[2] - top[2]) * t), - 255, - ) - ) - self.buf[y * self.w * 4 : (y + 1) * self.w * 4] = c * self.w - - def _blend(self, i, rgb, a): - """`rgb` over the pixel at byte offset i with coverage a (0..1).""" - if a <= 0: - return - b = self.buf - ia = 1.0 - a - da = b[i + 3] / 255.0 - oa = a + da * ia - if oa <= 0: - return - for k in range(3): - b[i + k] = round((rgb[k] * a + b[i + k] * da * ia) / oa) - b[i + 3] = round(oa * 255) - - def glow(self, cx, cy, radius, rgb, strength): - """Soft gaussian-ish radial glow (for the mark's halo on the big surfaces).""" - lo_x = max(0, math.floor(cx - 2.2 * radius)) - hi_x = min(self.w, math.ceil(cx + 2.2 * radius)) - lo_y = max(0, math.floor(cy - 2.2 * radius)) - hi_y = min(self.h, math.ceil(cy + 2.2 * radius)) - for y in range(lo_y, hi_y): - for x in range(lo_x, hi_x): - d2 = ((x + 0.5 - cx) ** 2 + (y + 0.5 - cy) ** 2) / (radius * radius) - a = strength * math.exp(-2.5 * d2) - if a > 1 / 255: - self._blend((y * self.w + x) * 4, rgb, a) - - def mark(self, cx, cy, span): - """The lens mark centered at (cx, cy) with the given pixel span.""" - scale = span / MARK_SPAN - c1 = (cx + (C1[0] - MARK_CENTER[0]) * scale, cy + (C1[1] - MARK_CENTER[1]) * scale) - c2 = (cx + (C2[0] - MARK_CENTER[0]) * scale, cy + (C2[1] - MARK_CENTER[1]) * scale) - r = R * scale - lo_x = max(0, math.floor(min(c1[0], c2[0]) - r - 2)) - hi_x = min(self.w, math.ceil(max(c1[0], c2[0]) + r + 2)) - lo_y = max(0, math.floor(min(c1[1], c2[1]) - r - 2)) - hi_y = min(self.h, math.ceil(max(c1[1], c2[1]) + r + 2)) - for y in range(lo_y, hi_y): - for x in range(lo_x, hi_x): - fx, fy = x + 0.5, y + 0.5 - cov1 = min(1.0, max(0.0, 0.5 + r - math.hypot(fx - c1[0], fy - c1[1]))) - cov2 = min(1.0, max(0.0, 0.5 + r - math.hypot(fx - c2[0], fy - c2[1]))) - if cov1 <= 0 and cov2 <= 0: - continue - i = (y * self.w + x) * 4 - self._blend(i, COL_LIGHT, cov1) - self._blend(i, COL_DEEP, cov2) - self._blend(i, COL_HI, min(cov1, cov2)) - - def word(self, text, unit_px, cx, cy): - """The wordmark centered at (cx, cy); `unit_px` = pixels per glyph unit.""" - alpha, w, h = render_word_alpha(text, unit_px) - ox = round(cx - w / 2) - # Optical vertical centering on the x-height band (0..1 in glyph units), not the - # ascender/descender box — the word reads centered that way. - pad = STROKE / 2 * unit_px + 1.5 - band_mid = pad - ASCENT * unit_px + 0.5 * unit_px - oy = round(cy - band_mid) - for y in range(h): - ty = y + oy - if not 0 <= ty < self.h: - continue - for x in range(w): - a = alpha[y * w + x] - if a: - tx = x + ox - if 0 <= tx < self.w: - self._blend((ty * self.w + tx) * 4, WORD, a / 255.0) - - def round_corners(self, radius): - """Multiply alpha with a rounded-rect mask (icon).""" - for y in range(self.h): - for x in range(self.w): - dx = max(0.0, max(radius - (x + 0.5), (x + 0.5) - (self.w - radius))) - dy = max(0.0, max(radius - (y + 0.5), (y + 0.5) - (self.h - radius))) - if dx > 0 and dy > 0: - cov = min(1.0, max(0.0, 0.5 + radius - math.hypot(dx, dy))) - i = (y * self.w + x) * 4 - self.buf[i + 3] = round(self.buf[i + 3] * cov) - - def png(self): - def chunk(tag, data): - return ( - struct.pack(">I", len(data)) - + tag - + data - + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) - ) - - ihdr = struct.pack(">IIBBBBB", self.w, self.h, 8, 6, 0, 0, 0) - raw = b"".join( - b"\x00" + bytes(self.buf[y * self.w * 4 : (y + 1) * self.w * 4]) for y in range(self.h) - ) - return ( - b"\x89PNG\r\n\x1a\n" - + chunk(b"IHDR", ihdr) - + chunk(b"IDAT", zlib.compress(raw, 9)) - + chunk(b"IEND", b"") - ) - - -def save(name, canvas): - OUT.mkdir(parents=True, exist_ok=True) - out = OUT / name - out.write_bytes(canvas.png()) - print(f"wrote {out.relative_to(HERE.parent.parent)} ({canvas.w}x{canvas.h})") - - -def main(): - # Portrait capsule: mark in the upper half, wordmark beneath. - c = Canvas(600, 900) - c.fill_gradient(BG_TOP, BG_BOT) - c.glow(300, 340, 260, COL_DEEP, 0.35) - c.mark(300, 340, 320) - c.word("punktfunk", 44, 300, 640) - save("grid.png", c) - - # Wide capsule: mark left, wordmark right of it. - c = Canvas(920, 430) - c.fill_gradient(BG_TOP, BG_BOT) - c.glow(230, 215, 200, COL_DEEP, 0.35) - c.mark(230, 215, 240) - c.word("punktfunk", 40, 620, 220) - save("gridwide.png", c) - - # Hero: ambient banner — the mark rides the right third; Steam overlays logo.png itself. - c = Canvas(1920, 620) - c.fill_gradient(BG_TOP, BG_BOT) - c.glow(1500, 310, 330, COL_DEEP, 0.4) - c.mark(1500, 310, 400) - save("hero.png", c) - - # Logo (transparent): mark + wordmark side by side, overlaid on the hero by Steam. - c = Canvas(1120, 300) - c.mark(150, 150, 240) - c.word("punktfunk", 62, 660, 155) - save("logo.png", c) - - # Icon: brand tile, rounded corners, mark only. - c = Canvas(256, 256) - c.fill_gradient(BG_TOP, BG_BOT) - c.glow(128, 128, 110, COL_DEEP, 0.3) - c.mark(128, 128, 190) - c.round_corners(36) - save("icon.png", c) - - -if __name__ == "__main__": - main() diff --git a/clients/decky/scripts/package.sh b/clients/decky/scripts/package.sh index da4e1ec4..9c07cf12 100755 --- a/clients/decky/scripts/package.sh +++ b/clients/decky/scripts/package.sh @@ -26,8 +26,12 @@ cp main.py plugin.json package.json LICENSE "$DEST/" # The stream-launch wrapper (target of the Steam shortcut) — must stay executable. cp bin/punktfunkrun.sh "$DEST/bin/punktfunkrun.sh" chmod 0755 "$DEST/bin/punktfunkrun.sh" -# Steam-shortcut artwork (grid/hero/logo/icon — scripts/gen-steam-art.py, committed). +# Steam-shortcut artwork (grid/gridwide/hero/logo/icon — committed under assets/). cp assets/*.png "$DEST/assets/" +# The Steam Input controller layout (native touchscreen `ts_n` + gamepad passthrough) the +# backend installs (apply_controller_config → controller_base/templates + the shortcut config). +mkdir -p "$DEST/controller_config" +cp controller_config/punktfunk.vdf "$DEST/controller_config/punktfunk.vdf" [ -f decky.pyi ] && cp decky.pyi "$DEST/" [ -f README.md ] && cp README.md "$DEST/" diff --git a/clients/decky/src/backend.ts b/clients/decky/src/backend.ts index 77f73388..2d63e2f1 100644 --- a/clients/decky/src/backend.ts +++ b/clients/decky/src/backend.ts @@ -150,6 +150,14 @@ export const setPins = callable<[pins: PinnedGame[]], { ok: boolean; error?: str ); export const runnerInfo = callable<[], RunnerInfo>("runner_info"); export const shortcutArt = callable<[], ShortcutArt>("shortcut_art"); +// Install the Steam Input layout (native touchscreen `ts_n` + gamepad passthrough) and point our +// shortcut(s) at it, so the Deck touchscreen reaches the client as native touch with no manual +// controller setup. Best-effort + idempotent; keyed by the shared shortcut NAME (both shortcuts +// use the same name → the same lowercase configset key), so one call covers both. +export const applyControllerConfig = callable< + [name: string], + { ok: boolean; applied?: string[]; errors?: string[]; accounts?: number; error?: string; detail?: string } +>("apply_controller_config"); export const getSettings = callable<[], StreamSettings>("get_settings"); export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>( "set_settings", diff --git a/clients/decky/src/index.tsx b/clients/decky/src/index.tsx index d18441b3..67c0472b 100644 --- a/clients/decky/src/index.tsx +++ b/clients/decky/src/index.tsx @@ -31,6 +31,7 @@ import { import { streamPin } from "./library"; import { PunktfunkRoute, ROUTE } from "./page"; import { PairModal } from "./pair"; +import { ensureGamepadUiShortcut } from "./steam"; // ---------------------------------------------------------------------------------------- // QAM panel — quick status + entry into the full page + one-tap stream for known hosts @@ -196,6 +197,10 @@ const QamPanel: FC = () => { 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". diff --git a/clients/decky/src/steam.ts b/clients/decky/src/steam.ts index 0b2663e0..135854a0 100644 --- a/clients/decky/src/steam.ts +++ b/clients/decky/src/steam.ts @@ -1,14 +1,23 @@ -// Launch the stream as a Steam game so gamescope focuses + fullscreens it. +// Launch Punktfunk as Steam games so gamescope focuses + fullscreens them. // // THE LAUNCH MECHANISM (verified against MoonDeck): gamescope only gives focus/fullscreen to // the window tree Steam launched via `reaper` (it detects the "current app" by AppID — see -// gamescope#484). So we cannot launch the flatpak from the plugin backend; we register ONE -// hidden non-Steam shortcut whose exe is `/bin/sh` running our wrapper script -// (bin/punktfunkrun.sh), pass the per-session host as the shortcut's Steam launch options, -// and start it with RunGame. The wrapper then execs -// `flatpak run io.unom.Punktfunk --connect ` as a reaper descendant. +// gamescope#484). So we cannot launch the flatpak from the plugin backend; we register non-Steam +// shortcuts whose exe is `/bin/sh` running our wrapper script (bin/punktfunkrun.sh), and start +// them with RunGame. The wrapper then execs the flatpak client as a reaper descendant. +// +// TWO shortcuts, both named "Punktfunk" (so they share ONE Steam Input controller-config key — +// see applyControllerConfig): +// • STREAM — hidden, stateful: the per-session launcher. Its launch options carry the host / +// pinned game (PF_HOST/PF_LAUNCH/PF_BROWSE), rewritten per launch, so one shortcut serves +// every host. Driven by the QAM/pins/host-library actions. Hidden — an implementation detail. +// • GAMEPAD UI — visible, stateless: fixed launch options = bare `--browse` (PF_BROWSE, no +// host) → the client's console home (host picker + pairing + settings, gamepad-navigable). +// This is the library-visible "Punktfunk" app the user opens directly. +// +// Both get the shipped artwork and the native-touch controller config. -import { runnerInfo, shortcutArt, wake } from "./backend"; +import { applyControllerConfig, runnerInfo, shortcutArt, wake } 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 @@ -46,32 +55,33 @@ declare const collectionStore: | { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void } | undefined; -// 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 { +/** 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. */ +function setShortcutHidden(appId: number, hidden: boolean): void { const attempt = () => { try { - collectionStore?.SetAppsAsHidden?.([appId], false); + collectionStore?.SetAppsAsHidden?.([appId], hidden); } catch { /* overview not registered yet, or the API changed — cosmetic, ignore */ } }; attempt(); // succeeds immediately for an already-registered (reused) shortcut setTimeout(attempt, 2500); // fresh shortcut: retry once its app overview lands +}; + +// Bump when the shipped artwork changes so existing shortcuts re-apply it once (per appId). +const ART_VERSION = 2; +function artKey(appId: number): string { + return `punktfunk:shortcutArt:${appId}`; } -// 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. + * Apply the plugin's grid/hero/logo/icon to a shortcut (idempotent, once per ART_VERSION per + * appId). Cosmetic and fully best-effort: any failure is swallowed and retried on the next call. */ async function applyArtwork(appId: number): Promise { try { - if (localStorage.getItem(ART_KEY) === `${appId}:${ART_VERSION}`) { + if (localStorage.getItem(artKey(appId)) === `${ART_VERSION}`) { return; } const art = await shortcutArt(); @@ -89,13 +99,14 @@ async function applyArtwork(appId: number): Promise { if (art.icon_path) { SteamClient.Apps.SetShortcutIcon(appId, art.icon_path); } - localStorage.setItem(ART_KEY, `${appId}:${ART_VERSION}`); + localStorage.setItem(artKey(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. +// The shortcut name is user-visible (Steam overlay + library) — brand-case it. BOTH shortcuts +// share it so Steam keys them to the SAME controller config (configset key = lowercase name). const SHORTCUT_NAME = "Punktfunk"; // The shortcut's exe is /bin/sh, NOT the script itself: Decky extracts plugin zips without @@ -111,76 +122,128 @@ function gameIdFromAppId(appId: number): string { return ((BigInt(appId) << 32n) | 0x02000000n).toString(); } -// Persist our shortcut appId across reloads so we reuse ONE shortcut instead of churning the -// library (the appId is stable for the life of the shortcut). -const STORAGE_KEY = "punktfunk:shortcutAppId"; +// Persist each shortcut's appId across reloads so we reuse ONE per role instead of churning the +// library (an appId is stable for the life of the shortcut). The STREAM key is the historical +// one, so existing single-shortcut installs migrate into the (now hidden) stream role, and the +// visible gamepad-UI shortcut is created alongside. +const STORAGE_KEY_STREAM = "punktfunk:shortcutAppId"; +const STORAGE_KEY_UI = "punktfunk:uiAppId"; -function rememberAppId(appId: number) { +function remember(key: string, appId: number) { try { - localStorage.setItem(STORAGE_KEY, String(appId)); + localStorage.setItem(key, String(appId)); } catch { /* ignore */ } } -function recallAppId(): number | null { +function recall(key: string): number | null { try { - const v = localStorage.getItem(STORAGE_KEY); + const v = localStorage.getItem(key); return v ? Number(v) : null; } catch { return null; } } +// Install the native-touch controller config once per plugin session (idempotent file writes in +// the root backend). Keyed by the shared shortcut NAME, so this single call covers both +// shortcuts. Gated in localStorage so we don't rewrite Steam's config dir on every launch; bump +// CONFIG_VERSION to force a reinstall after the shipped .vdf changes. +const CONFIG_KEY = "punktfunk:controllerConfig"; +const CONFIG_VERSION = 1; +async function ensureControllerConfig(): Promise { + try { + if (localStorage.getItem(CONFIG_KEY) === `${CONFIG_VERSION}`) { + return; + } + const r = await applyControllerConfig(SHORTCUT_NAME); + if (r?.ok) { + localStorage.setItem(CONFIG_KEY, `${CONFIG_VERSION}`); + } else { + console.warn("punktfunk: controller config not fully applied", r); + } + } catch (e) { + console.warn("punktfunk: controller config not applied", e); + } +} + /** - * 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. + * Ensure the STREAM shortcut (hidden, stateful) — the per-session launcher whose launch options + * are rewritten per stream. Branded, artworked, native-touch config applied, and HIDDEN (it is + * an implementation detail; the visible entry is the gamepad-UI shortcut). Returns its appId + + * the current runner path. Reuses/repoints the remembered shortcut (the plugin dir can change + * across reinstalls, and pre-two-shortcut installs had this one visible). */ -async function ensureShortcut(): Promise<{ appId: number; runner: string }> { +async function ensureStreamShortcut(): Promise<{ appId: number; runner: string }> { const info = await runnerInfo(); if (!info.exists) { throw new Error(`launch wrapper missing at ${info.runner}`); } const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir + void ensureControllerConfig(); // fire-and-forget — never blocks the launch - const remembered = recallAppId(); + const remembered = recall(STORAGE_KEY_STREAM); if (remembered != null) { - // Re-point + rename the existing shortcut (cheap + idempotent — migrates old installs). 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 + setShortcutHidden(remembered, true); // migrate pre-two-shortcut installs (were visible) + void applyArtwork(remembered); return { appId: remembered, runner: info.runner }; } const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, ""); SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME); - unhideShortcut(appId); - void applyArtwork(appId); // fire-and-forget — cosmetic, never blocks the launch - rememberAppId(appId); + setShortcutHidden(appId, true); + void applyArtwork(appId); + remember(STORAGE_KEY_STREAM, appId); return { appId, runner: info.runner }; } /** - * Best-effort: turn Steam Input OFF for our shortcut so SDL's HIDAPI Steam Deck driver can open the - * Deck's controls (paddles · trackpads · gyro) directly. There is no confirmed-stable SteamClient - * API for this, so it is feature-detected and MUST never block or throw into the launch — the manual - * toggle (game page → ⚙ → Controller Settings → Steam Input Off, surfaced in the plugin Settings) is - * the documented source of truth. No-op when the optional API is absent. + * Ensure the GAMEPAD-UI shortcut (visible, stateless) — the library-facing "Punktfunk" entry + * that opens the client's console home (bare `--browse`: host picker + pairing + settings). + * Fixed launch options (no per-session state), branded, artworked, native-touch config applied, + * kept VISIBLE. Idempotent — call on plugin mount so the library entry always exists and stays + * repointed to the current plugin dir. Best-effort: returns null on any failure. */ -function disableSteamInputForShortcut(appId: number): void { +export async function ensureGamepadUiShortcut(): Promise { try { - const input = ( - SteamClient as unknown as { - Input?: { SetSteamInputEnabledForApp?: (appId: number, enabled: boolean) => void }; - } - ).Input; - input?.SetSteamInputEnabledForApp?.(appId, false); - } catch { - /* a controller tweak must never break the launch */ + const info = await runnerInfo(); + if (!info.exists) { + return null; + } + const startDir = info.runner.replace(/\/[^/]*$/, ""); + void ensureControllerConfig(); + // Bare browse: PF_BROWSE with no PF_HOST → the wrapper runs `--browse --fullscreen` (console + // home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg. + const launchOpts = `PF_BROWSE=1 %command% "${info.runner}"`; + + let appId = recall(STORAGE_KEY_UI); + if (appId != null) { + SteamClient.Apps.SetShortcutExe(appId, SHELL); + SteamClient.Apps.SetShortcutStartDir(appId, startDir); + SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME); + } else { + appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, ""); + SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME); + remember(STORAGE_KEY_UI, appId); + } + SteamClient.Apps.SetAppLaunchOptions(appId, launchOpts); + setShortcutHidden(appId, false); // the visible library entry + void applyArtwork(appId); + return appId; + } catch (e) { + console.warn("punktfunk: gamepad-UI shortcut not ensured", e); + return null; + } +} + +/** Launch the stateless gamepad-UI shortcut (console home) from the plugin, e.g. a QAM button. */ +export async function launchGamepadUi(): Promise { + const appId = await ensureGamepadUiShortcut(); + if (appId != null) { + SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100); } } @@ -210,9 +273,9 @@ export function isSafeLaunchId(id: string): boolean { /** * Launch a stream to `host:port` fullscreen in Gaming Mode (optionally straight into a - * library title, or into the gamepad library launcher). Encodes the target into the - * shortcut's launch options (so one generic shortcut serves every host and every pinned - * game), then RunGame. + * library title, or into a host's gamepad library). Encodes the target into the STREAM + * shortcut's launch options (so one hidden shortcut serves every host and every pinned game), + * then RunGame. */ export async function launchStream( host: string, @@ -224,10 +287,7 @@ export async function launchStream( // Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is // known), and the connect that follows has its own retry window, so a failure never blocks launch. const waking = wake(host, port).catch(() => ({ ok: false })); - const { appId, runner } = await ensureShortcut(); - // Best-effort so the Deck's rich controls reach the client; no-op if the API is absent (the user - // disables Steam Input manually — see the Settings instruction). - disableSteamInputForShortcut(appId); + const { appId, runner } = await ensureStreamShortcut(); const target = port && port !== 9777 ? `${host}:${port}` : host; const env = [`PF_HOST=${target}`]; if (opts.browse) { @@ -251,7 +311,7 @@ export async function launchStream( /** Stop the running stream shortcut (best-effort; the in-stream chord/back also works). */ export function stopStream(): void { - const appId = recallAppId(); + const appId = recall(STORAGE_KEY_STREAM); if (appId != null) { SteamClient.Apps.TerminateApp(gameIdFromAppId(appId), false); }