Files
punktfunk/clients/decky/scripts/test-backend.py
T
enricobuehler 2fd303e22f refactor(decky): delete the second client
The Decky plugin was a second client. It had its own mDNS discovery, its own host-store
editor, its own settings UI over the entire client settings store, its own per-game pin store
and picker, and its own fullscreen route with three tabs — about 3,000 lines of TypeScript and
Python mirroring, in two other languages, things the Rust client already does. Every one of
them drifted from the original: the TXT parser fell behind each key the host advert added, the
settings screen modelled a subset of a store that kept growing.

They existed because when this plugin was written there was nothing headless to ask. There has
been since v0.22.0, so this deletes them.

GONE, frontend: page.tsx (the fullscreen route), settings.tsx (a seven-page sidebar over the
whole store), hostmgmt.tsx (add/edit/forget), library.tsx (the games picker), ui.tsx (row
primitives only the page used).

GONE, backend: get/set_settings, list/refresh_devices, library, get/set_pins, list_hosts,
add/edit/forget_host, probe_host, reset_config, wake, the avahi browse and its TXT parser, and
the direct reads of client-known-hosts.json.

WHAT REPLACES THE BACKEND is four shells, each about fifteen lines of build-argv-run-parse:

  discover()    -> punktfunk discover --json
  hosts()       -> punktfunk hosts list --probe --json
  pair()        -> punktfunk pair <addr:port> --pin N --name LABEL
  trust_host()  -> punktfunk hosts add <addr:port> --fp HEX --name LABEL

trust_host is the ONLY write this backend makes to the client's store, and it goes through the
CLI — which writes temp+rename into a user-owned directory, so a root backend driving it
cannot lock the desktop client out of its own files. Nothing here opens client-known-hosts.json
or client-profiles.json any more; `hosts list --json` returns profile bindings and pinned cards
already resolved against the catalog.

_cli_argv mirrors the deleted _session_argv exactly, pointed at `punktfunk`: the flatpak app id
stays LAST, because flatpak treats everything after it as the app's own argv. The
LD_LIBRARY_PATH repair applies unchanged — Decky's PyInstaller leak breaks the flatpak's
libcurl whichever binary inside the sandbox is being started.

A client too old for a verb now announces itself DETERMINISTICALLY: exit 5 plus
`unknown command "<verb>"`, mapped to `client-outdated`, which the panel renders as one
explanatory row plus the update button that fixes it. That replaces guessing from GTK-init
noise, which survives only where the update check still drives `punktfunk-client` directly.

KEPT unchanged in mechanism, because only a Decky plugin can do them: runner_info,
shortcut_art, apply_controller_config, check_update/update_client, kill_stream.

The settings screen is not lost, it moved: console home -> Settings has the same rows over the
same store, is gamepad-navigable, and is one tap from this same panel. Per-game pins have no
shared equivalent yet — decky-pinned.json is deliberately left ON DISK, untouched, so a later
migration can read it.

test-backend.py is rewritten against what is left — argv shape, the exit-code mapping, and the
Steam configset editor, which was untested until now and is the riskiest thing that survived:
it edits a file holding hundreds of other games' bindings, in place.
2026-08-04 20:41:07 +02:00

172 lines
7.2 KiB
Python

#!/usr/bin/env python3
"""Unit checks for main.py's pure helpers — stdlib only, no Decky runtime needed.
Stubs the ``decky`` module (main.py imports it at module level), then asserts the argv
shapes, the exit-code mapping and the Steam VDF editor against fixtures.
Needs Python >= 3.10 for `X | None` annotations — macOS ships 3.9, so run it explicitly:
python3.13 clients/decky/scripts/test-backend.py
"""
import sys
import types
from pathlib import Path
# ---- stub the decky module before importing main.py ------------------------------------
decky = types.ModuleType("decky")
decky.DECKY_USER_HOME = "/tmp/pf-test-home"
decky.DECKY_PLUGIN_DIR = "/tmp/pf-test-plugin"
class _Log:
def __getattr__(self, _name):
return lambda *a, **k: None
decky.logger = _Log()
sys.modules["decky"] = decky
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import main # noqa: E402 (the plugin backend)
failures = 0
def check(name: str, cond: bool):
global failures
print(("ok " if cond else "FAIL") + " " + name)
if not cond:
failures += 1
# ---- _cli_argv: the flatpak app id must stay LAST ---------------------------------------
#
# `flatpak run --command=X <app-id> ARGS` — everything after the app id is the APP's argv, so
# an app id that drifts left silently turns our flags into the client's. This is the shape the
# deleted _session_argv used and the one thing about it that is easy to get wrong.
main._client_argv = lambda: ["/usr/bin/flatpak", "run", "--arch=x86_64", "io.unom.Punktfunk"]
main._flatpak = lambda: "/usr/bin/flatpak"
check(
"cli argv: flatpak form, app id last",
main._cli_argv()
== [
"/usr/bin/flatpak",
"run",
"--arch=x86_64",
"--command=punktfunk",
"io.unom.Punktfunk",
],
)
# A native install: the CLI is the client binary's sibling. Absent => no CLI at all, which the
# caller must see as "unavailable" rather than as an empty result.
tmp = Path("/tmp/pf-test-native/bin")
tmp.mkdir(parents=True, exist_ok=True)
(tmp / "punktfunk-client").write_text("")
main._client_argv = lambda: [str(tmp / "punktfunk-client")]
check("cli argv: native without a sibling CLI is None", main._cli_argv() is None)
(tmp / "punktfunk").write_text("")
check("cli argv: native sibling found", main._cli_argv() == [str(tmp / "punktfunk")])
# ---- _cli_error: the CLI's exit-code contract -------------------------------------------
#
# Exit 5 + `unknown command` is how a client too old for a verb announces itself — the ONE
# signature the panel turns into "update the client" plus the button that fixes it. Getting it
# wrong makes an out-of-date client look like a broken plugin.
check(
"err: unknown verb => client-outdated",
main._cli_error(5, 'unknown command "discover"\n\npunktfunk — the Punktfunk client')
== "client-outdated",
)
check(
"err: exit 5 without that phrase is NOT outdated",
main._cli_error(5, 'no saved host matches "desk"') == "unresolved",
)
check("err: connect failed", main._cli_error(2, "unreachable 10.0.0.1:9777") == "unreachable")
check("err: trust rejected", main._cli_error(3, "wrong PIN") == "refused")
check("err: needs a person", main._cli_error(6, "pair it first") == "needs-pairing")
check("err: nothing ran", main._cli_error(-1, "") == "client-unavailable")
check("err: unmapped code falls back", main._cli_error(4, "renderer") == "client-error")
# ---- _cli_json: a zero exit with junk on stdout is a FAILURE, not an empty result --------
import asyncio # noqa: E402
def _fake_cli(rc: int, out: str, err: str = ""):
async def run(_args, timeout=20.0):
return rc, out, err
return run
main._run_cli = _fake_cli(0, '{"hosts": [{"name": "desk"}]}')
got = asyncio.run(main._cli_json(["discover", "--json"]))
check("json: payload merged under ok", got == {"ok": True, "hosts": [{"name": "desk"}]})
main._run_cli = _fake_cli(0, "not json at all")
got = asyncio.run(main._cli_json(["discover", "--json"]))
check("json: unparseable stdout is an error, not an empty list", got["ok"] is False)
check("json: ...and says so specifically", got["error"] == "client-error")
main._run_cli = _fake_cli(5, "", 'unknown command "discover"')
got = asyncio.run(main._cli_json(["discover", "--json"]))
check("json: old client surfaces as client-outdated", got["error"] == "client-outdated")
check("json: detail carries the CLI's own last line", "unknown command" in got["detail"])
# ---- _field_from (flatpak info parsing, drives the client update check) ------------------
info = " ID: io.unom.Punktfunk\n Origin: punktfunk-origin\n Commit: abc123def\n"
check("field: commit", main._field_from(info, "Commit") == "abc123def")
check("field: origin", main._field_from(info, "Origin") == "punktfunk-origin")
check("field: absent", main._field_from(info, "Nope") == "")
# ---- _looks_outdated (the GTK-init signature of a client predating a headless flag) ------
check("outdated: gtk init noise", main._looks_outdated("cannot open display: \nGtk-WARNING") is True)
check("outdated: an ordinary error is not", main._looks_outdated("connection refused") is False)
# ---- _semver_tuple (plugin update comparison) --------------------------------------------
check("semver: plain", main._semver_tuple("1.2.3") == (1, 2, 3))
check("semver: pre-release suffix dropped", main._semver_tuple("1.2.3-rc1") == (1, 2, 3))
check("semver: short forms pad", main._semver_tuple("2") == (2, 0, 0))
check("semver: ordering", main._semver_tuple("0.10.0") > main._semver_tuple("0.9.9"))
# ---- _upsert_configset_entry (Steam Input layout binding) --------------------------------
#
# Untested until now, and the riskiest thing that survived the cut: it edits a file holding
# HUNDREDS of other games' controller bindings, in place. Every assertion below is about not
# touching them.
empty = main._upsert_configset_entry("", "punktfunk", "template", "punktfunk.vdf")
check("vdf: builds the skeleton when the file is new", '"controller_config"' in empty)
check("vdf: the entry lands", '"punktfunk"' in empty and '"punktfunk.vdf"' in empty)
existing = (
'"controller_config"\n'
"{\n"
'\t"halflife2"\n'
"\t{\n"
'\t\t"template"\t\t"other.vdf"\n'
"\t}\n"
"}\n"
)
added = main._upsert_configset_entry(existing, "punktfunk", "template", "punktfunk.vdf")
check("vdf: an existing game's entry survives insertion", '"halflife2"' in added)
check("vdf: ours is inserted", '"punktfunk"' in added)
# Re-running must REPLACE our block, not accumulate a second one (this runs on every plugin
# session gated only by a localStorage marker, so idempotence is the whole contract).
twice = main._upsert_configset_entry(added, "punktfunk", "template", "punktfunk.vdf")
check("vdf: idempotent", twice.count('"punktfunk"\n') == 1)
check("vdf: neighbour still intact after the rewrite", '"halflife2"' in twice)
# Steam keys non-Steam games by their LOWERCASE name, and files on disk may carry either case —
# a case-sensitive match would append a duplicate the game never reads.
mixed = existing.replace('"halflife2"', '"Punktfunk"')
replaced = main._upsert_configset_entry(mixed, "punktfunk", "template", "punktfunk.vdf")
check("vdf: matches an existing key case-insensitively", replaced.count("unktfunk\"\n") == 1)
print()
if failures:
print(f"{failures} check(s) FAILED")
sys.exit(1)
print("all checks passed")