The widget could not run linkctl even though the binary worked fine from a terminal and through linkctl-macro. Plasma executes applet commands with KProcess::setShellCommand, which is `/bin/sh -c` inheriting plasmashell's own environment. That environment never sources a login profile, so any install that puts linkctl on PATH from a shell rc file is invisible to the widget: mise and asdf shims, and ~/.local/bin on some distributions. bin/linkctl-macro has carried a resolver for exactly this reason since it was written, and the widget should have had one too rather than trusting a bare name. It now searches the same locations at startup, caches the absolute path, and holds commands back until it has one. Three further changes, all about not failing silently. The status line is now always visible. It previously appeared only while the pan and tilt readout was unknown, so any failure after the first successful poll was hidden behind a stale position. errorMessage explains the exit codes that are easy to misread. 127 is the shell failing to run the binary rather than anything about the camera, and 3 most often means a pinned device node has moved, which happens because the Link's /dev/videoN number is not stable across power cycles or replugs. A watchdog clears the busy flag after four seconds. Plasma's executable engine silently drops a request while an identical command is still running, so a lost callback could leave busy set and freeze every subsequent step with nothing to indicate why. Also corrected the focus timing for the keyboard support: Component.onCompleted can run before the popup is on screen, where the focus grab does not stick, so the panel claims focus whenever the popup opens and clears held keys when it closes. Tested: 164 checks across seven suites, all passing. tests/test_resolver.sh is new and runs the generated resolver in a real shell against real directories, confirming it finds a binary on PATH, finds mise shims, ~/.local/bin and ~/.cargo/bin when they are not on PATH, prefers PATH when both exist, ignores a non-executable file, and prints nothing when the binary is genuinely absent. 11 new node checks cover the command's shape and confirm a hostile configured name stays quoted. Not verified against a live Plasma session. The PATH mechanism is established from KProcess's documented behaviour and the resolver from a real shell, but whether this was the actual cause on the reporter's machine is unconfirmed.
192 lines
6.1 KiB
JavaScript
192 lines
6.1 KiB
JavaScript
/*
|
|
* Pure helpers for the joystick applet.
|
|
*
|
|
* Kept free of QML types so they can be unit-tested with node; see
|
|
* tests/test_joystick_logic.js. `.pragma library` means this file holds no
|
|
* per-instance state and cannot touch the QML context, which is exactly the
|
|
* constraint that keeps it testable.
|
|
*/
|
|
.pragma library
|
|
|
|
// Quote a value for /bin/sh. Config values (binary path, device, preset name)
|
|
// all reach a shell, so this is a boundary that has to hold.
|
|
function shq(s) {
|
|
return "'" + String(s).replace(/'/g, "'\\''") + "'";
|
|
}
|
|
|
|
// The invocation prefix shared by every command the applet runs.
|
|
function baseCmd(linkctl, device) {
|
|
var c = shq(linkctl || "linkctl") + " --force --json";
|
|
if (device && device !== "") {
|
|
c += " --device " + shq(device);
|
|
}
|
|
return c;
|
|
}
|
|
|
|
// Directories searched for the binary, in order. Mirrors the resolver in
|
|
// bin/linkctl-macro. $HOME is left for the shell to expand.
|
|
var SEARCH_DIRS = [
|
|
"/usr/local/bin",
|
|
"/usr/bin",
|
|
"\"$HOME/.local/bin\"",
|
|
"\"$HOME/.cargo/bin\"",
|
|
"\"$HOME/.local/share/mise/shims\"",
|
|
"\"$HOME/.asdf/shims\"",
|
|
"/var/lib/flatpak/exports/bin"
|
|
];
|
|
|
|
/*
|
|
* Build a shell command that prints the absolute path of the binary.
|
|
*
|
|
* Plasma runs applet commands through KProcess::setShellCommand, which is
|
|
* `/bin/sh -c` inheriting plasmashell's environment. That environment never
|
|
* sources a login profile, so anything that puts linkctl on PATH from
|
|
* ~/.bashrc or similar (mise and asdf shims, ~/.local/bin on some distros) is
|
|
* invisible to the applet even though it works in a terminal. Searching
|
|
* explicitly is the only reliable way to find it.
|
|
*/
|
|
function resolveCommand(name) {
|
|
return "n=" + shq(name || "linkctl") + "; "
|
|
+ "command -v \"$n\" 2>/dev/null || "
|
|
+ "{ for d in " + SEARCH_DIRS.join(" ") + "; do "
|
|
+ "[ -x \"$d/$n\" ] && printf '%s\\n' \"$d/$n\" && break; "
|
|
+ "done; }";
|
|
}
|
|
|
|
/*
|
|
* Build the shell command for one joystick tick.
|
|
*
|
|
* nx/ny are the normalised stick position, ny positive upwards. Returns ""
|
|
* when the deflection on both axes is too small to be worth a command, so the
|
|
* caller can skip the tick entirely.
|
|
*
|
|
* Each axis needs its own linkctl call because the relative commands are
|
|
* single-axis, so a diagonal push produces two, chained with ";".
|
|
*/
|
|
function tickCommand(base, nx, ny, stepMax, invertPan, invertTilt) {
|
|
var x = invertPan ? -nx : nx;
|
|
var y = invertTilt ? -ny : ny;
|
|
var dx = Math.abs(x) * stepMax;
|
|
var dy = Math.abs(y) * stepMax;
|
|
var parts = [];
|
|
// Per-axis threshold, so a mostly-horizontal push does not also creep
|
|
// vertically. Below 0.5 degrees the camera's 0.1 degree resolution makes
|
|
// the step pointless anyway.
|
|
if (dx >= 0.5) {
|
|
parts.push(base + (x > 0 ? " right " : " left ") + dx.toFixed(1));
|
|
}
|
|
if (dy >= 0.5) {
|
|
parts.push(base + (y > 0 ? " up " : " down ") + dy.toFixed(1));
|
|
}
|
|
return parts.join(" ; ");
|
|
}
|
|
|
|
/*
|
|
* Turn a set of held direction keys into a stick vector.
|
|
*
|
|
* `held` has boolean left/right/up/down members. Opposing keys cancel, and a
|
|
* diagonal is normalised to length 1 so holding two keys does not move the
|
|
* camera faster than holding one.
|
|
*
|
|
* Key codes are deliberately not mapped here: this file is a `.pragma
|
|
* library` and so cannot reach the Qt namespace for Qt.Key_*. The QML side
|
|
* resolves a key code to a direction name and passes the result in.
|
|
*/
|
|
function vectorFromKeys(held) {
|
|
var x = 0;
|
|
var y = 0;
|
|
if (held.left) {
|
|
x -= 1;
|
|
}
|
|
if (held.right) {
|
|
x += 1;
|
|
}
|
|
if (held.up) {
|
|
y += 1;
|
|
}
|
|
if (held.down) {
|
|
y -= 1;
|
|
}
|
|
var len = Math.sqrt(x * x + y * y);
|
|
if (len > 1) {
|
|
x /= len;
|
|
y /= len;
|
|
}
|
|
return { nx: x, ny: y };
|
|
}
|
|
|
|
/*
|
|
* Merge every JSON object found in linkctl's stdout into a flat update.
|
|
*
|
|
* A diagonal tick runs two linkctl calls, and each reports only the axis it
|
|
* changed, so the results have to be merged rather than replaced. Only keys
|
|
* actually present with the right type are returned, which keeps a null from
|
|
* an unrelated field from clobbering a known value.
|
|
*/
|
|
function absorb(stdout) {
|
|
var update = {};
|
|
var lines = String(stdout).split("\n");
|
|
for (var i = 0; i < lines.length; i++) {
|
|
var t = lines[i].trim();
|
|
if (t.length === 0 || t.charAt(0) !== "{") {
|
|
continue;
|
|
}
|
|
var o;
|
|
try {
|
|
o = JSON.parse(t);
|
|
} catch (e) {
|
|
continue;
|
|
}
|
|
if (typeof o.pan === "number") {
|
|
update.pan = o.pan;
|
|
}
|
|
if (typeof o.tilt === "number") {
|
|
update.tilt = o.tilt;
|
|
}
|
|
if (typeof o.zoom === "number") {
|
|
update.zoom = o.zoom;
|
|
}
|
|
if (typeof o.tracking === "boolean") {
|
|
update.tracking = o.tracking;
|
|
}
|
|
if (typeof o.state === "string") {
|
|
update.state = o.state;
|
|
}
|
|
}
|
|
return update;
|
|
}
|
|
|
|
// Pull a human-readable reason out of a failed linkctl run.
|
|
function errorMessage(stdout, stderr, exitCode) {
|
|
var err = String(stderr || "").trim();
|
|
try {
|
|
var o = JSON.parse(String(stdout || "").trim());
|
|
if (o && o.error) {
|
|
return String(o.error);
|
|
}
|
|
} catch (e) {
|
|
// not JSON, fall through to stderr
|
|
}
|
|
// 127 is the shell's "command not found", which here means the resolved
|
|
// path has stopped being valid rather than anything about the camera.
|
|
if (exitCode === 127) {
|
|
return "linkctl could not be run. Set its full path in settings.";
|
|
}
|
|
if (err !== "") {
|
|
return err;
|
|
}
|
|
// Exit codes that usually mean a pinned device node is stale. The Link's
|
|
// /dev/videoN numbering moves between boots and replugs.
|
|
if (exitCode === 3) {
|
|
return "Camera not found. If a device is set in settings, clear it: "
|
|
+ "the node number changes between boots.";
|
|
}
|
|
if (exitCode === 4) {
|
|
return "Several cameras found. Set the device in settings.";
|
|
}
|
|
if (exitCode === 6) {
|
|
return "Permission denied. Add yourself to the 'video' group.";
|
|
}
|
|
return "linkctl exited " + exitCode;
|
|
}
|