feat(decky): native-touch controller layout + restructured shortcuts + artwork
Ship a Steam Input controller layout (controller_config/punktfunk.vdf) whose always-on `ts_n` command enables native touchscreen delivery on the Deck, and have the backend auto-install it (apply_controller_config: copy to controller_base/templates + upsert the per-account configset entry, chown to the user, back up first). This is what makes the Deck touchscreen reach the client as native touch under gamescope without disabling Steam Input (impossible on the Deck) — no manual controller setup. Two shortcuts sharing the "Punktfunk" name (so one config key covers both): a hidden stateful stream entry and a visible stateless entry that launches straight into the gamepad UI. Both get full artwork (grid/gridwide/hero/logo/icon, replaced with exported PNGs). Drop the art-generation script. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+139
-4
@@ -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 ``"<key>" { "<source_type>" "<source_val>" }`` 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
|
||||
|
||||
Reference in New Issue
Block a user