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.
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
#!/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
|
||||
avahi/TSV/error parsers against fixture strings. The LibraryError fixtures are pinned to
|
||||
the REAL Display strings in clients/linux/src/library.rs — if those are reworded, the
|
||||
classifier degrades to ``client-error`` and the matching assertion here fails on purpose.
|
||||
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.
|
||||
|
||||
python3 clients/decky/scripts/test-backend.py
|
||||
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
|
||||
@@ -40,136 +40,129 @@ def check(name: str, cond: bool):
|
||||
failures += 1
|
||||
|
||||
|
||||
# ---- _parse_library_tsv -----------------------------------------------------------------
|
||||
tsv = (
|
||||
"steam:570\tsteam\tDota 2\n"
|
||||
"custom:abc\tcustom\tTabs\tin\ttitle\n" # tabs inside the title survive (split max 2)
|
||||
"2 game(s)\n" # the count trailer has no tabs — self-skips
|
||||
# ---- _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",
|
||||
],
|
||||
)
|
||||
games = main._parse_library_tsv(tsv)
|
||||
check("tsv: two games parsed", len(games) == 2)
|
||||
check("tsv: fields", games[0] == {"id": "steam:570", "store": "steam", "title": "Dota 2"})
|
||||
check("tsv: tabs in title preserved", games[1]["title"] == "Tabs\tin\ttitle")
|
||||
check("tsv: empty input", main._parse_library_tsv("0 game(s)\n") == [])
|
||||
|
||||
# ---- _classify_library_error (fixtures = library.rs Display strings) --------------------
|
||||
# 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: not-paired",
|
||||
main._classify_library_error(
|
||||
"library: The host didn't recognize this device. Pair with the host first — the "
|
||||
"library is authorized by this device's certificate (no token needed)."
|
||||
)
|
||||
== "not-paired",
|
||||
)
|
||||
check(
|
||||
"err: pin-mismatch",
|
||||
main._classify_library_error(
|
||||
"library: The host's certificate doesn't match the pinned fingerprint. "
|
||||
"Re-pair with a PIN to re-establish trust."
|
||||
)
|
||||
== "pin-mismatch",
|
||||
)
|
||||
check(
|
||||
"err: unreachable",
|
||||
main._classify_library_error(
|
||||
"library: Couldn't reach the host's management API: connection refused. Check the "
|
||||
"host is updated and reachable."
|
||||
)
|
||||
== "unreachable",
|
||||
)
|
||||
check(
|
||||
"err: http",
|
||||
main._classify_library_error("library: The management API returned HTTP 500.") == "http",
|
||||
)
|
||||
check(
|
||||
"err: outdated client (GTK init noise)",
|
||||
main._classify_library_error("cannot open display: \nGtk-WARNING: init failed")
|
||||
"err: unknown verb => client-outdated",
|
||||
main._cli_error(5, 'unknown command "discover"\n\npunktfunk — the Punktfunk client')
|
||||
== "client-outdated",
|
||||
)
|
||||
check("err: generic fallback", main._classify_library_error("boom") == "client-error")
|
||||
|
||||
# ---- _parse_avahi_browse (incl. the new id/mgmt TXT keys) --------------------------------
|
||||
avahi = (
|
||||
"+;eth0;IPv4;living-room;_punktfunk._udp;local\n"
|
||||
"=;eth0;IPv4;living-room;_punktfunk._udp;local;lr.local;192.168.1.42;9777;"
|
||||
'"proto=punktfunk/1" "fp=aabbcc" "pair=required" "id=abc123" "mgmt=47990"\n'
|
||||
"=;eth0;IPv6;living-room;_punktfunk._udp;local;lr.local;fe80::1;9777;"
|
||||
'"proto=punktfunk/1" "fp=aabbcc" "pair=required" "id=abc123" "mgmt=47990"\n'
|
||||
"=;eth0;IPv4;bare-host;_punktfunk._udp;local;bh.local;192.168.1.77;9777;"
|
||||
'"proto=punktfunk/1" "fp=ddeeff" "pair=optional"\n'
|
||||
check(
|
||||
"err: exit 5 without that phrase is NOT outdated",
|
||||
main._cli_error(5, 'no saved host matches "desk"') == "unresolved",
|
||||
)
|
||||
hosts = main._parse_avahi_browse(avahi)
|
||||
check("avahi: two hosts (id-dedup, IPv4 preferred)", len(hosts) == 2)
|
||||
lr = next(h for h in hosts if h["name"] == "living-room")
|
||||
check("avahi: ipv4 wins", lr["host"] == "192.168.1.42")
|
||||
check("avahi: mgmt parsed", lr["mgmt"] == 47990)
|
||||
check("avahi: id parsed", lr["id"] == "abc123")
|
||||
bare = next(h for h in hosts if h["name"] == "bare-host")
|
||||
check("avahi: mgmt absent -> 0", bare["mgmt"] == 0)
|
||||
check("avahi: id absent -> empty", bare["id"] == "")
|
||||
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")
|
||||
|
||||
# ---- pins store (round-trip through the real methods, isolated HOME) --------------------
|
||||
# ---- _cli_json: a zero exit with junk on stdout is a FAILURE, not an empty result --------
|
||||
import asyncio # noqa: E402
|
||||
import shutil # noqa: E402
|
||||
|
||||
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
|
||||
plugin = main.Plugin()
|
||||
pin = {
|
||||
"game_id": "steam:570",
|
||||
"title": "Dota 2",
|
||||
"store": "steam",
|
||||
"host_fp": "AABBCC",
|
||||
"host_id": "abc123",
|
||||
"host_name": "living-room",
|
||||
"host": "192.168.1.42",
|
||||
"port": 9777,
|
||||
"mgmt": 47990,
|
||||
"added_at": 1780000000,
|
||||
}
|
||||
dupe = dict(pin, title="Dota 2 again")
|
||||
junk = {"title": "no game id"}
|
||||
res = asyncio.run(plugin.set_pins([pin, dupe, junk]))
|
||||
check("pins: write ok", res.get("ok") is True)
|
||||
got = asyncio.run(plugin.get_pins())["pins"]
|
||||
check("pins: dedup + junk dropped", len(got) == 1)
|
||||
check("pins: unpaired without known-hosts", got[0]["paired"] is False)
|
||||
# Mark the host paired in the client's known-hosts store — get_pins must pick it up.
|
||||
cfg = main._client_config_dir()
|
||||
cfg.mkdir(parents=True, exist_ok=True)
|
||||
(cfg / "client-known-hosts.json").write_text(
|
||||
'{"hosts": [{"name": "living-room", "addr": "192.168.1.42", "port": 9777, '
|
||||
'"fp_hex": "aabbcc", "paired": true}]}'
|
||||
)
|
||||
got = asyncio.run(plugin.get_pins())["pins"]
|
||||
check("pins: paired via known-hosts fp (case-insensitive)", got[0]["paired"] is True)
|
||||
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
|
||||
|
||||
# ---- `--list-audio` parsing (the settings tab's device pickers) --------------------------
|
||||
sinks, sources = main._parse_audio_endpoints(
|
||||
"sink\talsa_output.pci-0000_04_00.6.analog-stereo\tSteam Deck Speakers\n"
|
||||
"sink\tbluez_output.AC_12_2F.1\tWH-1000XM4\n"
|
||||
"source\talsa_input.pci-0000_04_00.6.analog-stereo\tSteam Deck Microphone\n"
|
||||
)
|
||||
check("audio: sinks parsed", [d["name"] for d in sinks] == [
|
||||
"alsa_output.pci-0000_04_00.6.analog-stereo", "bluez_output.AC_12_2F.1"
|
||||
])
|
||||
check("audio: sources parsed", len(sources) == 1)
|
||||
check("audio: description kept", sinks[1]["description"] == "WH-1000XM4")
|
||||
def _fake_cli(rc: int, out: str, err: str = ""):
|
||||
async def run(_args, timeout=20.0):
|
||||
return rc, out, err
|
||||
|
||||
# Junk the picker must not offer: no node.name is unusable (it is the id that gets stored), a
|
||||
# short line is malformed, and an unknown kind belongs to neither list. A blank description
|
||||
# falls back to the name so no entry renders unlabelled.
|
||||
sinks, sources = main._parse_audio_endpoints(
|
||||
"sink\t\tNo node name\n"
|
||||
"sink\tonly-two-columns\n"
|
||||
"monitor\tsome.monitor\tNot a sink or source\n"
|
||||
"source\tbare.node\t\n"
|
||||
"\n"
|
||||
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"
|
||||
)
|
||||
check("audio: junk lines dropped", sinks == [])
|
||||
check("audio: blank description falls back to the node name", sources == [
|
||||
{"name": "bare.node", "description": "bare.node"}
|
||||
])
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user