punktfunk-plugin-virtualhere

Hands a couch's physical USB device — a DualSense, a racing wheel, a HOTAS, an arcade stick — to the punktfunk host for the length of a session, and gives it back when the session ends. The game sees the real device, not an emulated one.

VirtualHere is a product of VirtualHere Pty. Ltd. and is sold separately. This plugin is not affiliated with or endorsed by VirtualHere. It only drives a VirtualHere client you install and license yourself — nothing from VirtualHere is bundled, vendored, or downloaded by this plugin.

What it does

VirtualHere is a USB-over-IP server/client pair. The server runs on the couch, where the device is physically plugged in; the client runs on the punktfunk host. Mounting the device is a one-line command — the hard part is doing it at the right moment, letting go afterwards, and never leaving the couch without its controller.

That is this plugin:

  • Rules, not one hard-coded address. Match a device by name (case-insensitive substring) and it survives the couch rebooting or the device moving port — addresses churn, names don't. Scope a rule to one client, or to one game.
  • Bracketed on the session. Bind when the stream starts, release when it stops (or on session/client boundaries — your choice). The couch keeps its own controller the rest of the time.
  • It gives the device back. A journal written before every mount, an unconditional reconcile on startup, a watchdog, an idle sweep, and a release on clean shutdown. If this plugin is killed mid-stream, the device comes home on the next runner restart.
  • It tells you why it isn't working. A Diagnostics panel (and the doctor CLI command — the same ladder, the same wording) walks the whole two-sided setup and prints a remedy for each thing that's wrong.
  • A console page. Live status over SSE, a browse of what the couch is sharing, and a rule editor. Adding a rule from the Devices tab writes a name-based one, which survives the couch rebooting.

Requirements

  • A punktfunk host (Linux or Windows) with the plugin runner enabled.
  • A VirtualHere USB Client on the host, running — ideally installed as a service so it survives logout. (Download; free.)
  • A VirtualHere USB Server on the couch, sharing the device. Free for one device; a licence is needed beyond that, and to run the client as a service. (Purchase.)

Servers exist for Windows, Linux, macOS and Android couches. There is no VirtualHere server for iOS or tvOS, so Apple mobile couches cannot use this — nothing on the punktfunk side can change that.

Install

punktfunk-host plugins add virtualhere
punktfunk-host plugins enable      # once, if the runner isn't on yet

Configure

<config_dir>/virtualhere/config.json:

{
  "bindOn": "stream",              // stream (default) | session | client
  "rules": [
    { "id": "pad",   "match": { "name": "DualSense" }, "forClient": "Living Room" },
    { "id": "wheel", "match": { "name": "Fanatec" },   "forApp": "steam:244210" }
  ]
}
Key Default What it does
bindOn stream When to take and give back the device. stream is narrowest — only while video actually flows — and is the only trigger carrying both the client and the app, so both filters work.
rules[].match.name Case-insensitive substring. The recommended form. Matching more than one visible device binds nothing rather than guessing.
rules[].match.address An exact VirtualHere address (couch-deck.11). Wins over name. Brittle across reboots.
rules[].forClient any Only bind for this punktfunk client name.
rules[].forApp any Only bind for this app id. bindOn: "stream" only.
rules[].enabled true Switch a rule off without deleting it.
clientBinary auto Absolute path to the VirtualHere client. Linux/macOS only — the Windows transport talks a named pipe and needs no binary, so setting this on Windows does nothing. Must be absolute: a bare name is refused rather than executed, because it would resolve from the plugin runner's PATH and not from the shell you tested in. Left unset, the plugin looks for vhclientx86_64, vhclientarm64, vhclienti386 and vhclientarmhf on PATH (vhclientosx on macOS).
watchdogSecs 30 How often the background pass runs: reconcile our belief with VirtualHere's, and sweep if idle. 0 disables both.
idleSweep true Hand back anything still held once the host reports no stream and no session — the backstop for a stop event that never arrived. Under bindOn: "client" it additionally waits until the last couch has disconnected, so the device still stays with the host between streams.

Only the operator names devices. Nothing a client sends influences which device gets bound. That is deliberate: attaching USB to the host is an input-trust escalation, and a couch that could name its own device could attach a virtual keyboard.

CLI

punktfunk-plugin-virtualhere list      # what VirtualHere can currently see
punktfunk-plugin-virtualhere status    # what this plugin currently holds
punktfunk-plugin-virtualhere bind pad  # manual override
punktfunk-plugin-virtualhere release   # hand everything back now
punktfunk-plugin-virtualhere doctor    # the precondition ladder — paste this into support threads

How it talks to VirtualHere

Platform-split, and deliberately so:

  • Windows — the plugin speaks the \\.\pipe\vhclient named pipe directly. Each connection gets its own pipe instance, so request and response are correlated. Verified working from the runner's low-privilege NT AUTHORITY\LocalService account; the vendor's -t CLI wrapper hangs indefinitely in that context, so we do not use it.
  • Linux/macOS — the plugin shells out to the vendor's -t one-shot. Here the IPC is two FIFOs and the response FIFO is a shared global with no correlation id, so reading it ourselves would steal other consumers' replies.

Every call is serialized, argv-only (never a shell), and addresses are validated before they reach a verb.

If it works in your shell but not from the plugin (Linux)

The FIFOs are in /tmp, so the plugin runner has to see the real /tmp. Punktfunk's punktfunk-scripting unit used to set PrivateTmp=yes, which gave it a private one — the plugin launched vhclient fine and could then never reach the daemon behind it. Fixed host-side after punktfunk 0.23.0; on a host that predates the fix, apply the drop-in yourself:

systemctl --user edit punktfunk-scripting
[Service]
PrivateTmp=no
ReadWritePaths=/tmp
systemctl --user restart punktfunk-scripting

Where the logs are

Plugin output goes to the punktfunk web console's Logs page — pick the Plugins filter. (On a host at 0.23.0 or older that page carries host lines only; use journalctl --user -u punktfunk-scripting -f on Linux, and on Windows run the runner in the foreground, since its scheduled task writes no log file.)

Development

bun install
bun run typecheck
bun test
bun run build

Layout: contract/ is the shared schema, DTOs and the HttpApi contract; plugin/ is the runner-side plugin built on @punktfunk/plugin-kit; ui/ is the console SPA, whose typed client is derived from the same contract.

cd ui && bun run dev serves the SPA against a running plugin (set VIRTUALHERE_URL to its loopback port). There is no fixture mode — this plugin's whole subject is a live two-sided USB setup, so a mocked device list would mostly prove that the mock works.

Licence

MIT OR Apache-2.0. See LICENSE.

S
Description
No description provided
Readme
283 KiB
Languages
TypeScript 99.2%
CSS 0.5%
HTML 0.3%