Resolve linkctl explicitly in the widget, and surface failures
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.
This commit is contained in:
@@ -63,8 +63,13 @@ them at that cost.
|
|||||||
```
|
```
|
||||||
|
|
||||||
Then right-click a panel or the desktop, Add Widgets, and search for
|
Then right-click a panel or the desktop, Add Widgets, and search for
|
||||||
"Insta360". Pass `--restart` when reinstalling over a running copy, since
|
"Insta360".
|
||||||
Plasma otherwise keeps the old one loaded.
|
|
||||||
|
**Reinstalling needs `--restart`.** Plasma loads an applet's QML once and keeps
|
||||||
|
it. Pulling new code and restarting the desktop is not enough on its own,
|
||||||
|
because the installed copy is only replaced by `install.sh`. After updating
|
||||||
|
this repo, run `./plasmoid/install.sh --restart`, or new behaviour will appear
|
||||||
|
to be missing while the old code carries on running.
|
||||||
|
|
||||||
The joystick is a velocity control: how far you push sets how fast the camera
|
The joystick is a velocity control: how far you push sets how fast the camera
|
||||||
moves, and it stops when you let go. Degrees per step, step interval, deadzone
|
moves, and it stops when you let go. Degrees per step, step interval, deadzone
|
||||||
@@ -165,6 +170,7 @@ case where the gimbal is parked facing down.
|
|||||||
lua tests/test_obs_linkctl.lua # pure helpers: quoting, parsing, commands
|
lua tests/test_obs_linkctl.lua # pure helpers: quoting, parsing, commands
|
||||||
lua tests/test_obs_lifecycle.lua # the OBS script against a mock OBS API
|
lua tests/test_obs_lifecycle.lua # the OBS script against a mock OBS API
|
||||||
node tests/test_joystick_logic.js # widget command building, parsing, key vectors
|
node tests/test_joystick_logic.js # widget command building, parsing, key vectors
|
||||||
|
tests/test_resolver.sh # binary resolution, run in a real shell
|
||||||
python3 tests/test_gamepad.py # gamepad event decoding and deadzone maths
|
python3 tests/test_gamepad.py # gamepad event decoding and deadzone maths
|
||||||
tests/test_gamepad_loop.sh # the gamepad read loop, fed through a FIFO
|
tests/test_gamepad_loop.sh # the gamepad read loop, fed through a FIFO
|
||||||
bin/linkctl-macro --print tracking on # show the command without running it
|
bin/linkctl-macro --print tracking on # show the command without running it
|
||||||
@@ -198,6 +204,27 @@ Versions of this repo before that fix wrote the Plasma 5 form.
|
|||||||
that works but the key does not, check for a conflict in System Settings, and
|
that works but the key does not, check for a conflict in System Settings, and
|
||||||
confirm `--list` shows the entry as `present`.
|
confirm `--list` shows the entry as `present`.
|
||||||
|
|
||||||
|
**The widget cannot control the camera, but `linkctl` works in a terminal.**
|
||||||
|
Almost always `PATH`. Plasma runs applet commands through
|
||||||
|
`KProcess::setShellCommand`, which is `/bin/sh -c` inheriting plasmashell's
|
||||||
|
environment, and that environment never sources `~/.bashrc` or a login
|
||||||
|
profile. An install that puts `linkctl` on `PATH` from a shell rc file, which
|
||||||
|
includes `mise` and `asdf` shims and `~/.local/bin` on some distributions, is
|
||||||
|
therefore invisible to the widget. The widget now searches those locations
|
||||||
|
itself at startup and says so in its status line if it finds nothing; the
|
||||||
|
settings page accepts a full path to override it. Compare what the session
|
||||||
|
sees with what your shell sees:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl --user show-environment | grep '^PATH'
|
||||||
|
command -v linkctl
|
||||||
|
```
|
||||||
|
|
||||||
|
**The widget worked, then stopped after a restart or a replug.** The Link's
|
||||||
|
`/dev/videoN` number is not stable. If the Video device field in the widget's
|
||||||
|
settings is set, clear it and let auto-detection run: a pinned node that has
|
||||||
|
since moved makes every command fail.
|
||||||
|
|
||||||
**Gamepad does nothing.** Check `bin/linkctl-gamepad --list` sees it. If the
|
**Gamepad does nothing.** Check `bin/linkctl-gamepad --list` sees it. If the
|
||||||
device is listed but nothing moves, run `--monitor` and push the stick: no
|
device is listed but nothing moves, run `--monitor` and push the stick: no
|
||||||
events means a permissions or driver problem, events with unexpected axis
|
events means a permissions or driver problem, events with unexpected axis
|
||||||
|
|||||||
@@ -23,6 +23,36 @@ function baseCmd(linkctl, device) {
|
|||||||
return c;
|
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.
|
* Build the shell command for one joystick tick.
|
||||||
*
|
*
|
||||||
@@ -137,8 +167,25 @@ function errorMessage(stdout, stderr, exitCode) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// not JSON, fall through to stderr
|
// 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 !== "") {
|
if (err !== "") {
|
||||||
return 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;
|
return "linkctl exited " + exitCode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,17 @@ import "logic.js" as Logic
|
|||||||
PlasmoidItem {
|
PlasmoidItem {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
readonly property string linkctl: Plasmoid.configuration.linkctlPath || "linkctl"
|
readonly property string configuredBin: Plasmoid.configuration.linkctlPath || "linkctl"
|
||||||
readonly property string device: Plasmoid.configuration.device
|
readonly property string device: Plasmoid.configuration.device
|
||||||
|
|
||||||
|
// The binary's absolute path, found at startup. Empty until resolved, and
|
||||||
|
// commands are held back until then: Plasma runs these through a plain
|
||||||
|
// `sh -c` with plasmashell's environment, which does not include whatever
|
||||||
|
// a login shell adds to PATH, so a bare name often cannot be run here
|
||||||
|
// even when it works in a terminal.
|
||||||
|
property string resolvedBin: ""
|
||||||
|
property string probeSource: ""
|
||||||
|
readonly property bool ready: root.resolvedBin !== ""
|
||||||
readonly property int stepMax: Plasmoid.configuration.maxStep
|
readonly property int stepMax: Plasmoid.configuration.maxStep
|
||||||
readonly property int tickMs: Plasmoid.configuration.tickInterval
|
readonly property int tickMs: Plasmoid.configuration.tickInterval
|
||||||
readonly property bool invertPan: Plasmoid.configuration.invertPan
|
readonly property bool invertPan: Plasmoid.configuration.invertPan
|
||||||
@@ -92,7 +101,20 @@ PlasmoidItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function baseCmd() {
|
function baseCmd() {
|
||||||
return Logic.baseCmd(root.linkctl, root.device);
|
return Logic.baseCmd(root.resolvedBin, root.device);
|
||||||
|
}
|
||||||
|
|
||||||
|
// An absolute path is taken as given; anything else is searched for.
|
||||||
|
function resolveBinary() {
|
||||||
|
if (root.configuredBin.charAt(0) === "/") {
|
||||||
|
root.resolvedBin = root.configuredBin;
|
||||||
|
root.refresh();
|
||||||
|
root.refreshTracking();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.statusText = "Looking for linkctl...";
|
||||||
|
root.probeSource = Logic.resolveCommand(root.configuredBin);
|
||||||
|
sh.exec(root.probeSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
function absorb(stdout) {
|
function absorb(stdout) {
|
||||||
@@ -125,6 +147,21 @@ PlasmoidItem {
|
|||||||
const err = (data.stderr || "").trim();
|
const err = (data.stderr || "").trim();
|
||||||
disconnectSource(source);
|
disconnectSource(source);
|
||||||
root.busy = false;
|
root.busy = false;
|
||||||
|
|
||||||
|
if (source === root.probeSource) {
|
||||||
|
root.probeSource = "";
|
||||||
|
const found = String(out).trim().split("\n")[0].trim();
|
||||||
|
if (found !== "") {
|
||||||
|
root.resolvedBin = found;
|
||||||
|
root.refresh();
|
||||||
|
root.refreshTracking();
|
||||||
|
} else {
|
||||||
|
root.statusText = "linkctl not found. Set its full path "
|
||||||
|
+ "in this widget's settings.";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
root.absorb(out);
|
root.absorb(out);
|
||||||
} else {
|
} else {
|
||||||
@@ -138,19 +175,48 @@ PlasmoidItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
// Plasma's executable engine silently drops a request when an
|
||||||
|
// identical command is still running, so `busy` would otherwise be
|
||||||
|
// able to stick on and freeze every later step.
|
||||||
|
id: busyWatchdog
|
||||||
|
interval: 4000
|
||||||
|
running: root.busy
|
||||||
|
onTriggered: {
|
||||||
|
root.busy = false;
|
||||||
|
root.statusText = "linkctl did not respond in time.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
|
if (!root.ready) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
sh.exec(root.baseCmd() + " status");
|
sh.exec(root.baseCmd() + " status");
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshTracking() {
|
function refreshTracking() {
|
||||||
|
if (!root.ready) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
sh.exec(root.baseCmd() + " tracking status");
|
sh.exec(root.baseCmd() + " tracking status");
|
||||||
}
|
}
|
||||||
|
|
||||||
function action(args) {
|
function action(args) {
|
||||||
|
if (!root.ready) {
|
||||||
|
root.resolveBinary();
|
||||||
|
return;
|
||||||
|
}
|
||||||
sh.exec(root.baseCmd() + " " + args);
|
sh.exec(root.baseCmd() + " " + args);
|
||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: refresh()
|
Component.onCompleted: resolveBinary()
|
||||||
|
|
||||||
|
// Re-resolve if the configured path is edited.
|
||||||
|
onConfiguredBinChanged: {
|
||||||
|
root.resolvedBin = "";
|
||||||
|
root.resolveBinary();
|
||||||
|
}
|
||||||
|
|
||||||
fullRepresentation: ColumnLayout {
|
fullRepresentation: ColumnLayout {
|
||||||
id: panel
|
id: panel
|
||||||
@@ -200,9 +266,6 @@ PlasmoidItem {
|
|||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
horizontalAlignment: Text.AlignHCenter
|
horizontalAlignment: Text.AlignHCenter
|
||||||
text: {
|
text: {
|
||||||
if (isNaN(root.panDeg) && isNaN(root.tiltDeg)) {
|
|
||||||
return root.statusText;
|
|
||||||
}
|
|
||||||
const p = isNaN(root.panDeg) ? "?" : root.panDeg.toFixed(1);
|
const p = isNaN(root.panDeg) ? "?" : root.panDeg.toFixed(1);
|
||||||
const t = isNaN(root.tiltDeg) ? "?" : root.tiltDeg.toFixed(1);
|
const t = isNaN(root.tiltDeg) ? "?" : root.tiltDeg.toFixed(1);
|
||||||
return "Pan " + p + "° Tilt " + t + "°";
|
return "Pan " + p + "° Tilt " + t + "°";
|
||||||
@@ -210,6 +273,18 @@ PlasmoidItem {
|
|||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always visible, so a failure part way through a session is not
|
||||||
|
// hidden behind a stale position readout.
|
||||||
|
PlasmaComponents.Label {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
text: root.statusText
|
||||||
|
opacity: 0.7
|
||||||
|
font: Kirigami.Theme.smallFont
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
maximumLineCount: 3
|
||||||
|
}
|
||||||
|
|
||||||
Joystick {
|
Joystick {
|
||||||
id: stick
|
id: stick
|
||||||
Layout.alignment: Qt.AlignHCenter
|
Layout.alignment: Qt.AlignHCenter
|
||||||
@@ -235,7 +310,7 @@ PlasmoidItem {
|
|||||||
// for movement, so an idle popup costs nothing.
|
// for movement, so an idle popup costs nothing.
|
||||||
interval: root.tickMs
|
interval: root.tickMs
|
||||||
repeat: true
|
repeat: true
|
||||||
running: root.inEngaged
|
running: root.inEngaged && root.ready
|
||||||
triggeredOnStart: true
|
triggeredOnStart: true
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
if (root.busy) {
|
if (root.busy) {
|
||||||
@@ -259,6 +334,16 @@ PlasmoidItem {
|
|||||||
root.refresh();
|
root.refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Component.onCompleted can run before the popup is on screen, so
|
||||||
|
// the focus grab there does not always stick. Claim it again each
|
||||||
|
// time the popup opens, which is when the keys need to work.
|
||||||
|
function onExpandedChanged() {
|
||||||
|
if (root.expanded) {
|
||||||
|
panel.forceActiveFocus();
|
||||||
|
} else {
|
||||||
|
root.clearKeys();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
@@ -326,8 +411,12 @@ PlasmoidItem {
|
|||||||
icon.name: "view-refresh"
|
icon.name: "view-refresh"
|
||||||
text: "Refresh"
|
text: "Refresh"
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.refresh();
|
if (!root.ready) {
|
||||||
root.refreshTracking();
|
root.resolveBinary();
|
||||||
|
} else {
|
||||||
|
root.refresh();
|
||||||
|
root.refreshTracking();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,7 +125,39 @@ deep(ctx.absorb('{"pan":1}\nbroken {\n{"tilt":2}'), { pan: 1, tilt: 2 },
|
|||||||
"absorb survives a malformed line between good ones");
|
"absorb survives a malformed line between good ones");
|
||||||
deep(ctx.absorb('Pan: 12.3°'), {}, "absorb ignores human-readable output");
|
deep(ctx.absorb('Pan: 12.3°'), {}, "absorb ignores human-readable output");
|
||||||
|
|
||||||
|
// --- resolveCommand ----------------------------------------------------
|
||||||
|
// Plasma runs applet commands through `sh -c` with plasmashell's environment,
|
||||||
|
// which never sources a login profile. A bare name therefore has to be
|
||||||
|
// searched for explicitly or the widget cannot run it at all.
|
||||||
|
const rc = ctx.resolveCommand("linkctl");
|
||||||
|
eq(rc.indexOf("n='linkctl';") === 0, true, "resolveCommand sets the name first");
|
||||||
|
eq(rc.includes('command -v "$n"'), true, "resolveCommand tries PATH first");
|
||||||
|
eq(rc.includes('"$HOME/.local/share/mise/shims"'), true,
|
||||||
|
"resolveCommand searches mise shims");
|
||||||
|
eq(rc.includes('"$HOME/.local/bin"'), true, "resolveCommand searches ~/.local/bin");
|
||||||
|
eq(rc.includes("/usr/local/bin"), true, "resolveCommand searches /usr/local/bin");
|
||||||
|
eq(rc.includes('[ -x "$d/$n" ]'), true, "resolveCommand tests for executable");
|
||||||
|
// $HOME must reach the shell unquoted-but-protected, never single-quoted,
|
||||||
|
// or it would be looked up literally.
|
||||||
|
eq(rc.includes("'$HOME"), false, "resolveCommand does not single-quote $HOME");
|
||||||
|
|
||||||
|
// A hostile configured name must stay inert rather than running.
|
||||||
|
const rcEvil = ctx.resolveCommand("x'; touch /tmp/pwned; '");
|
||||||
|
eq(rcEvil.indexOf("n='x'\\''; touch /tmp/pwned; '\\'''") === 0, true,
|
||||||
|
"resolveCommand quotes a malicious binary name");
|
||||||
|
|
||||||
// --- errorMessage ------------------------------------------------------
|
// --- errorMessage ------------------------------------------------------
|
||||||
|
eq(ctx.errorMessage("", "", 127),
|
||||||
|
"linkctl could not be run. Set its full path in settings.",
|
||||||
|
"errorMessage explains exit 127 as a missing binary");
|
||||||
|
eq(ctx.errorMessage("", "", 3).includes("node number changes"), true,
|
||||||
|
"errorMessage points at a stale pinned device for exit 3");
|
||||||
|
eq(ctx.errorMessage("", "", 6).includes("video"), true,
|
||||||
|
"errorMessage names the video group for exit 6");
|
||||||
|
// A real message from linkctl still wins over the canned text.
|
||||||
|
eq(ctx.errorMessage('{"error":"something specific"}', "", 3),
|
||||||
|
"something specific", "errorMessage prefers linkctl's own message");
|
||||||
|
|
||||||
eq(ctx.errorMessage('{"error":"camera not found"}', "", 3), "camera not found",
|
eq(ctx.errorMessage('{"error":"camera not found"}', "", 3), "camera not found",
|
||||||
"errorMessage prefers the json error");
|
"errorMessage prefers the json error");
|
||||||
eq(ctx.errorMessage("", "permission denied", 6), "permission denied",
|
eq(ctx.errorMessage("", "permission denied", 6), "permission denied",
|
||||||
|
|||||||
Executable
+97
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Checks the binary-resolution command the Plasma widget generates, by running
|
||||||
|
# it in a real shell against real directories.
|
||||||
|
#
|
||||||
|
# This matters because Plasma executes 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 a shell rc file (mise or asdf shims,
|
||||||
|
# ~/.local/bin on some distributions) is invisible to the widget even though
|
||||||
|
# it works in a terminal. tests/test_joystick_logic.js checks the string's
|
||||||
|
# shape; this checks that the string actually finds a binary.
|
||||||
|
#
|
||||||
|
# Run: tests/test_resolver.sh
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
HERE=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
readonly HERE
|
||||||
|
readonly LOGIC="$HERE/../plasmoid/package/contents/ui/logic.js"
|
||||||
|
|
||||||
|
command -v node >/dev/null 2>&1 || { echo "SKIP: node not available"; exit 0; }
|
||||||
|
|
||||||
|
# Ask logic.js for the command, so the test cannot drift from the shipped code.
|
||||||
|
CMD=$(node -e '
|
||||||
|
const fs = require("fs"), vm = require("vm");
|
||||||
|
const src = fs.readFileSync(process.argv[1], "utf8");
|
||||||
|
const ctx = { Math, JSON, String };
|
||||||
|
vm.createContext(ctx);
|
||||||
|
vm.runInContext(src.replace(/^\s*\.pragma\s+library\s*$/m, ""), ctx);
|
||||||
|
process.stdout.write(ctx.resolveCommand("linkctl"));
|
||||||
|
' "$LOGIC")
|
||||||
|
|
||||||
|
[[ -n $CMD ]] || { echo "FAIL: could not generate the resolver command"; exit 1; }
|
||||||
|
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
cleanup() { rm -rf "$TMP"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
checks=0
|
||||||
|
failures=0
|
||||||
|
|
||||||
|
# Run the generated command with a deliberately bare environment, the way
|
||||||
|
# plasmashell would, and compare what it prints.
|
||||||
|
expect() {
|
||||||
|
local label=$1 home=$2 path=$3 want=$4 got
|
||||||
|
checks=$((checks + 1))
|
||||||
|
got=$(env -i HOME="$home" PATH="$path" sh -c "$CMD" 2>/dev/null)
|
||||||
|
if [[ $got == "$want" ]]; then
|
||||||
|
printf 'ok %s\n' "$label"
|
||||||
|
else
|
||||||
|
failures=$((failures + 1))
|
||||||
|
printf 'FAIL %s\n got: %s\n want: %s\n' \
|
||||||
|
"$label" "${got:-<empty>}" "${want:-<empty>}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
make_exe() { mkdir -p "$(dirname "$1")" && printf '#!/bin/sh\n' > "$1" && chmod +x "$1"; }
|
||||||
|
|
||||||
|
mkdir -p "$TMP/empty"
|
||||||
|
|
||||||
|
# On PATH: the ordinary case, and the only one that worked before the fix.
|
||||||
|
make_exe "$TMP/onpath/linkctl"
|
||||||
|
expect "finds it on PATH" "$TMP/empty" "$TMP/onpath:/usr/bin:/bin" \
|
||||||
|
"$TMP/onpath/linkctl"
|
||||||
|
|
||||||
|
# Not on PATH at all. These are the cases that broke the widget while the
|
||||||
|
# terminal kept working.
|
||||||
|
make_exe "$TMP/mise/.local/share/mise/shims/linkctl"
|
||||||
|
expect "finds a mise shim off PATH" "$TMP/mise" "/usr/bin:/bin" \
|
||||||
|
"$TMP/mise/.local/share/mise/shims/linkctl"
|
||||||
|
|
||||||
|
make_exe "$TMP/localbin/.local/bin/linkctl"
|
||||||
|
expect "finds ~/.local/bin off PATH" "$TMP/localbin" "/usr/bin:/bin" \
|
||||||
|
"$TMP/localbin/.local/bin/linkctl"
|
||||||
|
|
||||||
|
make_exe "$TMP/cargo/.cargo/bin/linkctl"
|
||||||
|
expect "finds ~/.cargo/bin off PATH" "$TMP/cargo" "/usr/bin:/bin" \
|
||||||
|
"$TMP/cargo/.cargo/bin/linkctl"
|
||||||
|
|
||||||
|
# Absent entirely: must print nothing, so the widget can say so plainly
|
||||||
|
# rather than running something unexpected.
|
||||||
|
expect "prints nothing when absent" "$TMP/empty" "/usr/bin:/bin" ""
|
||||||
|
|
||||||
|
# Present but not executable must not count as found.
|
||||||
|
mkdir -p "$TMP/noexec/.local/bin"
|
||||||
|
printf 'not executable' > "$TMP/noexec/.local/bin/linkctl"
|
||||||
|
expect "ignores a non-executable file" "$TMP/noexec" "/usr/bin:/bin" ""
|
||||||
|
|
||||||
|
# PATH wins over the fallback list, so an explicit install is preferred.
|
||||||
|
make_exe "$TMP/both/.local/bin/linkctl"
|
||||||
|
make_exe "$TMP/preferred/linkctl"
|
||||||
|
expect "prefers PATH over the search list" "$TMP/both" \
|
||||||
|
"$TMP/preferred:/usr/bin:/bin" "$TMP/preferred/linkctl"
|
||||||
|
|
||||||
|
printf '\n%d checks, %d failures\n' "$checks" "$failures"
|
||||||
|
[[ $failures -eq 0 ]]
|
||||||
Reference in New Issue
Block a user