Replaced my invented LIST fixture with real client output from VirtualHere's own support
threads, and it caught two bugs the invented one could not.
A device that is IN USE disappeared entirely. VirtualHere writes the suffix hyphenated —
"(In-use by you)" — and I had matched only the spaced form. The consequence was worse than a
missing field: the unmatched suffix became the last parenthesised group, so it was read as the
address, failed the address check, and the whole line was skipped. A wheel somebody was
already using looked exactly like a wheel that was never shared, and any rule naming it
reported "no visible device matches".
The hub's own address was being swallowed into the server name: `28 (labpc:7575)` gave a
server of "28 (labpc:7575)" rather than "28". What keeps hub and device lines apart is that a
hub address uses a colon (host:port) where a device uses a dot (server.port) — now documented
where the code relies on it.
Also widened the trailer match to `VirtualHere\b`: the running-as-a-service line is worded
differently across client versions ("VirtualHere Client is…" vs "VirtualHere is…"), and the
narrower pattern would have taken the older wording for a hub header.
Fixtures are now three real captures — the M0 spike's empty listing, a 2024 populated one, and
an older one that is the only sample showing the in-use suffix and the `*-->` auto-use marker.
63 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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
doctorCLI 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 needs no binary. |
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\vhclientnamed pipe directly. Each connection gets its own pipe instance, so request and response are correlated. Verified working from the runner's low-privilegeNT AUTHORITY\LocalServiceaccount; the vendor's-tCLI wrapper hangs indefinitely in that context, so we do not use it. - Linux/macOS — the plugin shells out to the vendor's
-tone-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.
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.