Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e21e7ffdb4 | ||
|
|
189dc05811 | ||
|
|
22e764bea3 | ||
|
|
58839c2d32 | ||
|
|
768cf2f460 | ||
|
|
7d3654ad41 | ||
|
|
1dc5ac9b25 |
@@ -76,7 +76,7 @@ punktfunk-host plugins enable # once, if the runner isn't on yet
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
||||
@@ -109,6 +109,32 @@ Platform-split, and deliberately so:
|
||||
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:
|
||||
|
||||
```sh
|
||||
systemctl --user edit punktfunk-scripting
|
||||
```
|
||||
```ini
|
||||
[Service]
|
||||
PrivateTmp=no
|
||||
ReadWritePaths=/tmp
|
||||
```
|
||||
```sh
|
||||
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
|
||||
|
||||
```sh
|
||||
|
||||
@@ -24,14 +24,14 @@
|
||||
},
|
||||
"plugin": {
|
||||
"name": "@punktfunk/plugin-virtualhere",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"bin": {
|
||||
"punktfunk-plugin-virtualhere": "./dist/cli.js",
|
||||
},
|
||||
"dependencies": {
|
||||
"@punktfunk/host": "^0.1.2",
|
||||
"@punktfunk/plugin-kit": "^0.2.0",
|
||||
"effect": "^4.0.0-beta.102",
|
||||
"effect": "4.0.0-beta.102",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@punktfunk/plugin-virtualhere",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"description": "Punktfunk plugin: hands a couch's physical USB device (pad, wheel, HOTAS) to the host for the length of a session over VirtualHere, and gives it back after. Integration only \u2014 VirtualHere is sold separately by VirtualHere Pty. Ltd.",
|
||||
@@ -40,7 +40,7 @@
|
||||
"dependencies": {
|
||||
"@punktfunk/host": "^0.1.2",
|
||||
"@punktfunk/plugin-kit": "^0.2.0",
|
||||
"effect": "^4.0.0-beta.102"
|
||||
"effect": "4.0.0-beta.102"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.0",
|
||||
|
||||
+126
-37
@@ -6,56 +6,145 @@
|
||||
// every other consumer on the box (the tray app, an operator's own script) and silently eat
|
||||
// their replies. The vendor binary is the only thing that knows the framing, and their docs
|
||||
// confirm non-root can drive the daemon — which suits the `systemctl --user` runner.
|
||||
//
|
||||
// ⚠ Those FIFOs live in `/tmp`, so the runner must be able to SEE the real `/tmp`. The
|
||||
// `punktfunk-scripting` systemd unit used to set `PrivateTmp=yes`, which gave it a private
|
||||
// namespace: the binary launched fine and could then never reach the daemon behind it, which
|
||||
// presents as timeouts on a box where `vhclient` works perfectly in a shell. Fixed host-side
|
||||
// 2026-08-03; check it first if these symptoms come back.
|
||||
import { execFile } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { Effect } from "effect";
|
||||
import { IPC_TIMEOUT_MS, type VhIpcBackend, VhIpcError } from "./types.js";
|
||||
|
||||
/**
|
||||
* Client binary names we will resolve from PATH, per platform. An operator-supplied
|
||||
* `clientBinary` must be an absolute path; anything else is rejected rather than executed,
|
||||
* so config cannot point us at an arbitrary name picked up from a hostile PATH (design §11).
|
||||
* Client binary names we resolve from PATH, per platform, in preference order.
|
||||
*
|
||||
* ALL of them are tried — the list is architecture variants and exactly one normally exists on a
|
||||
* given box. (Until 2026-08-03 only the first was ever used, so an ARM Linux host with a correctly
|
||||
* installed `vhclientarm64` was told to install the client it already had.)
|
||||
*/
|
||||
export const KNOWN_BINARIES: readonly string[] =
|
||||
process.platform === "darwin"
|
||||
? ["vhclientosx"]
|
||||
: ["vhclientx86_64", "vhclientarm64", "vhclienti386", "vhclientarmhf"];
|
||||
|
||||
export const resolveBinary = (configured?: string): string => {
|
||||
if (configured !== undefined && configured !== "") return configured;
|
||||
return KNOWN_BINARIES[0] as string;
|
||||
/**
|
||||
* Where the vendor binary is, or why we cannot say.
|
||||
*
|
||||
* Deliberately NOT a thrown error or a failed layer: a missing client is the ordinary state of a
|
||||
* fresh install (VirtualHere is sold separately and installed by hand), and taking the plugin down
|
||||
* for it would lose the console page that explains the problem. The reason rides along to the
|
||||
* first `send`, where it surfaces as a normal `VhIpcError` — which is what the diagnostics ladder
|
||||
* already renders, with a remedy, in the Diagnostics tab and in `doctor`.
|
||||
*/
|
||||
export type BinaryResolution =
|
||||
| { readonly kind: "resolved"; readonly binary: string }
|
||||
| { readonly kind: "unresolved"; readonly reason: string };
|
||||
|
||||
/** An existing, executable regular file? */
|
||||
const isExecutableFile = (file: string): boolean => {
|
||||
try {
|
||||
if (!fs.statSync(file).isFile()) return false;
|
||||
fs.accessSync(file, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const makeExecBackend = (binary: string): VhIpcBackend => ({
|
||||
/** The first [`KNOWN_BINARIES`] entry that resolves to an executable on `PATH`. */
|
||||
const probePath = (): string | undefined => {
|
||||
const dirs = (process.env.PATH ?? "")
|
||||
.split(path.delimiter)
|
||||
.filter((d) => d !== "");
|
||||
for (const name of KNOWN_BINARIES) {
|
||||
for (const dir of dirs) {
|
||||
const candidate = path.join(dir, name);
|
||||
if (isExecutableFile(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the client binary: an operator-supplied `clientBinary` when given, else a PATH probe.
|
||||
*
|
||||
* An operator-supplied path **must be absolute**, and anything else is rejected rather than
|
||||
* executed (design §11) — a bare name would be resolved out of the *runner's* PATH, which is not
|
||||
* the PATH of the shell the operator tested in and which they cannot see. That rule was documented
|
||||
* here and in the README from the start but never actually implemented; it is now.
|
||||
*/
|
||||
export const resolveBinary = (configured?: string): BinaryResolution => {
|
||||
if (configured !== undefined && configured !== "") {
|
||||
if (!path.isAbsolute(configured)) {
|
||||
return {
|
||||
kind: "unresolved",
|
||||
reason:
|
||||
`clientBinary must be an absolute path — got "${configured}". A bare name would be ` +
|
||||
`resolved from the plugin runner's PATH, not the PATH of the shell you tested in. ` +
|
||||
`Use the full path (e.g. /usr/local/bin/${KNOWN_BINARIES[0]}), or remove the setting ` +
|
||||
`and let the plugin find the client itself.`,
|
||||
};
|
||||
}
|
||||
if (!isExecutableFile(configured)) {
|
||||
return {
|
||||
kind: "unresolved",
|
||||
reason:
|
||||
`clientBinary "${configured}" is not an executable file — check the path, and that ` +
|
||||
`the account running the plugin runner is allowed to execute it.`,
|
||||
};
|
||||
}
|
||||
return { kind: "resolved", binary: configured };
|
||||
}
|
||||
const found = probePath();
|
||||
return found !== undefined
|
||||
? { kind: "resolved", binary: found }
|
||||
: {
|
||||
kind: "unresolved",
|
||||
reason:
|
||||
`no VirtualHere client found on PATH (looked for ${KNOWN_BINARIES.join(", ")}). ` +
|
||||
`Install the VirtualHere client, or set clientBinary to its absolute path.`,
|
||||
};
|
||||
};
|
||||
|
||||
export const makeExecBackend = (
|
||||
resolution: BinaryResolution,
|
||||
): VhIpcBackend => ({
|
||||
kind: "exec",
|
||||
send: (verb) =>
|
||||
Effect.callback<string, VhIpcError>((resume) => {
|
||||
// argv, never a shell string — operator config never reaches a shell.
|
||||
const child = execFile(
|
||||
binary,
|
||||
["-t", verb],
|
||||
{ timeout: IPC_TIMEOUT_MS, windowsHide: true },
|
||||
(err, stdout, stderr) => {
|
||||
if (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
resume(
|
||||
Effect.fail(
|
||||
new VhIpcError({
|
||||
verb,
|
||||
reason:
|
||||
code === "ENOENT"
|
||||
? `${binary} not found — install the VirtualHere client or set clientBinary`
|
||||
: `${binary} failed (${code ?? "error"})${stderr ? `: ${stderr.trim()}` : ""}`,
|
||||
cause: err,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resume(Effect.succeed(stdout));
|
||||
},
|
||||
);
|
||||
return Effect.sync(() => {
|
||||
child.kill();
|
||||
});
|
||||
}),
|
||||
resolution.kind === "unresolved"
|
||||
? Effect.fail(new VhIpcError({ verb, reason: resolution.reason }))
|
||||
: Effect.callback<string, VhIpcError>((resume) => {
|
||||
const binary = resolution.binary;
|
||||
// argv, never a shell string — operator config never reaches a shell.
|
||||
const child = execFile(
|
||||
binary,
|
||||
["-t", verb],
|
||||
{ timeout: IPC_TIMEOUT_MS, windowsHide: true },
|
||||
(err, stdout, stderr) => {
|
||||
if (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
resume(
|
||||
Effect.fail(
|
||||
new VhIpcError({
|
||||
verb,
|
||||
reason:
|
||||
code === "ENOENT"
|
||||
? `${binary} vanished between resolution and use — was the VirtualHere client uninstalled?`
|
||||
: `${binary} failed (${code ?? "error"})${stderr ? `: ${stderr.trim()}` : ""}`,
|
||||
cause: err,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resume(Effect.succeed(stdout));
|
||||
},
|
||||
);
|
||||
return Effect.sync(() => {
|
||||
child.kill();
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -15,7 +15,11 @@ import { makeExecBackend, resolveBinary } from "./exec.js";
|
||||
import { pipeBackend } from "./pipe.js";
|
||||
import { type VhIpcBackend, type VhIpcError, VhVerbRejected } from "./types.js";
|
||||
|
||||
export { KNOWN_BINARIES, resolveBinary } from "./exec.js";
|
||||
export {
|
||||
type BinaryResolution,
|
||||
KNOWN_BINARIES,
|
||||
resolveBinary,
|
||||
} from "./exec.js";
|
||||
export { PIPE_PATH } from "./pipe.js";
|
||||
export * from "./types.js";
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Binary resolution: the PATH probe (all architecture variants, not just the first), the
|
||||
// absolute-path rule the docs always claimed, and the promise that an unresolved binary surfaces
|
||||
// as a readable IPC error instead of taking the plugin down.
|
||||
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Effect } from "effect";
|
||||
import {
|
||||
KNOWN_BINARIES,
|
||||
makeExecBackend,
|
||||
resolveBinary,
|
||||
} from "../src/ipc/exec.js";
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "vh-exec-"));
|
||||
afterAll(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
const REAL_PATH = process.env.PATH;
|
||||
beforeEach(() => {
|
||||
process.env.PATH = REAL_PATH;
|
||||
});
|
||||
afterAll(() => {
|
||||
process.env.PATH = REAL_PATH;
|
||||
});
|
||||
|
||||
/** Drop an executable stub called `name` into a fresh dir and put that dir on PATH. */
|
||||
const stubOnPath = (name: string): string => {
|
||||
const dir = fs.mkdtempSync(path.join(ROOT, "bin-"));
|
||||
const file = path.join(dir, name);
|
||||
fs.writeFileSync(file, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
||||
process.env.PATH = dir;
|
||||
return file;
|
||||
};
|
||||
|
||||
describe("resolveBinary — PATH probe", () => {
|
||||
test("finds this platform's client", () => {
|
||||
// Off KNOWN_BINARIES, not a literal: the list differs on darwin, and a hardcoded
|
||||
// `vhclientx86_64` would make this suite pass only on Linux.
|
||||
const file = stubOnPath(KNOWN_BINARIES[0] as string);
|
||||
expect(resolveBinary()).toEqual({ kind: "resolved", binary: file });
|
||||
});
|
||||
|
||||
test("finds a NON-FIRST architecture variant", () => {
|
||||
// The regression this exists for: KNOWN_BINARIES listed four names and only ever tried
|
||||
// index 0, so an ARM box with a working client was told to install the client it had.
|
||||
if (process.platform === "darwin") return; // one name only, nothing to skip past
|
||||
const file = stubOnPath("vhclientarm64");
|
||||
expect(resolveBinary()).toEqual({ kind: "resolved", binary: file });
|
||||
});
|
||||
|
||||
test("no client anywhere reports what it looked for", () => {
|
||||
process.env.PATH = fs.mkdtempSync(path.join(ROOT, "empty-"));
|
||||
const res = resolveBinary();
|
||||
expect(res.kind).toBe("unresolved");
|
||||
if (res.kind !== "unresolved") throw new Error("unreachable");
|
||||
for (const name of KNOWN_BINARIES) expect(res.reason).toContain(name);
|
||||
});
|
||||
|
||||
test("a non-executable file with the right name is not a client", () => {
|
||||
const dir = fs.mkdtempSync(path.join(ROOT, "noexec-"));
|
||||
fs.writeFileSync(path.join(dir, KNOWN_BINARIES[0] as string), "", {
|
||||
mode: 0o644,
|
||||
});
|
||||
process.env.PATH = dir;
|
||||
expect(resolveBinary().kind).toBe("unresolved");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBinary — operator-supplied clientBinary", () => {
|
||||
test("an absolute path to a real executable is taken", () => {
|
||||
const file = stubOnPath("anything-at-all");
|
||||
expect(resolveBinary(file)).toEqual({ kind: "resolved", binary: file });
|
||||
});
|
||||
|
||||
test("a RELATIVE path is refused, not executed", () => {
|
||||
// Documented in the README and in exec.ts from the start; never implemented until now.
|
||||
// A bare name resolves from the RUNNER's PATH, which the operator cannot see.
|
||||
const res = resolveBinary("vhclientarm64");
|
||||
expect(res.kind).toBe("unresolved");
|
||||
if (res.kind !== "unresolved") throw new Error("unreachable");
|
||||
expect(res.reason).toContain("absolute path");
|
||||
});
|
||||
|
||||
test("an absolute path that isn't there says so, and names the path", () => {
|
||||
const missing = path.join(ROOT, "definitely-not-here");
|
||||
const res = resolveBinary(missing);
|
||||
expect(res.kind).toBe("unresolved");
|
||||
if (res.kind !== "unresolved") throw new Error("unreachable");
|
||||
expect(res.reason).toContain(missing);
|
||||
});
|
||||
|
||||
test("an empty string means 'unset', not 'a binary called empty'", () => {
|
||||
stubOnPath(KNOWN_BINARIES[0] as string);
|
||||
expect(resolveBinary("").kind).toBe("resolved");
|
||||
});
|
||||
});
|
||||
|
||||
describe("an unresolved binary is an IPC error, not a dead plugin", () => {
|
||||
test("send fails with the resolution's reason", async () => {
|
||||
const backend = makeExecBackend({
|
||||
kind: "unresolved",
|
||||
reason: "no VirtualHere client found on PATH",
|
||||
});
|
||||
const exit = await Effect.runPromiseExit(backend.send("LIST"));
|
||||
expect(exit._tag).toBe("Failure");
|
||||
// The reason has to reach the operator verbatim — it IS the remedy the ladder shows.
|
||||
expect(JSON.stringify(exit)).toContain(
|
||||
"no VirtualHere client found on PATH",
|
||||
);
|
||||
});
|
||||
|
||||
test("the backend still identifies itself as the exec transport", () => {
|
||||
// Diagnostics reports the transport before it reports the failure; an unresolved binary
|
||||
// must not make the plugin claim it is talking a named pipe.
|
||||
expect(makeExecBackend({ kind: "unresolved", reason: "x" }).kind).toBe(
|
||||
"exec",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user