Author SHA1 Message Date
brad 1f0d5ec0f4 Fix the QML scope error that disabled all camera movement
The joystick, the keyboard and every movement command did nothing. The cause
was in the journal all along:

    main.qml: ReferenceError: stick is not defined

`stick` is the id of the Joystick inside the `fullRepresentation` Component.
Ids declared inside a Component are not visible from the enclosing scope, so
the inNx, inNy and inEngaged properties declared on the root object could not
resolve it. Those bindings threw, inEngaged stayed false, the tick timer never
ran, and no input of any kind produced a command. Everything else kept
working, which is why status, zoom and binary resolution all looked healthy in
the log while dragging did nothing.

The three properties now live inside the fullRepresentation, where both
`stick` and the root object are in scope.

Also fixed from the same log: the configuration page logged "does not have a
property called cfg_<name>Default" for all eight keys plus `title`, because
Plasma assigns those when it instantiates a config page and they were never
declared. They are declared now, so the Defaults button has something to
compare against.

Startup probed for the binary twice, because Component.onCompleted and
onConfiguredBinChanged both fired while the component was still being set up,
duplicating every follow-up query. The second path is now guarded.

The diagnostics readout is one label built by one guarded function instead of
a Repeater over an array of expressions. In that earlier shape a single
throwing sub-expression discards the whole model and the panel renders empty,
which is precisely the failure mode it is supposed to diagnose, and is what it
did.

The real repair is to the verification, in tests/lint_qml.py. qmllint had
flagged `stick`, and a filter discarded it. Without the KDE QML modules
present qmllint cannot resolve PlasmoidItem, so nothing inside it has a known
scope and all 87 identifiers in a clean file are reported as unqualified
access. Filtering that category wholesale was the only way to get a readable
result, and it threw away the one finding that mattered.

tests/qmlstubs now holds minimal stand-ins for org.kde.plasma.plasmoid,
org.kde.plasma.components, org.kde.plasma.plasma5support and
org.kde.kirigami, so the types resolve and the scope analysis is meaningful.
With the stubs in place the only remaining unqualified accesses are `root`,
`sh` and `modelData`, each allowlisted with a reason, so anything else is a
genuine finding. Confirmed by reintroducing the bug: the linter fails and
names the exact lines and identifier.

Tested: 169 checks across seven suites plus the new linter, all passing.
shellcheck clean.
2026-10-07 23:29:08 -07:00
brad 29b9971da8 Stop the keyboard path from disabling the pointer path
Dragging the joystick stopped doing anything, at the same time as the
keyboard support arrived and without the keyboard working either. One cause
explains both.

Before the keyboard existed, the tick timer ran on `stick.engaged` and read
`stick.nx` directly, so dragging depended on nothing but the pad. Adding the
keyboard routed that same path through the new helper: inEngaged reads
keyEngaged, which reads keyVec, which calls Logic.vectorFromKeys. If that
function is not callable the binding throws, keyVec is undefined, and
inNx and inEngaged go with it, so the timer never runs and the pointer stops
working too. Coupling the previously working path to brand new code with no
fallback was the mistake, independent of why the helper might be missing.

One way it can be missing: Qt caches compiled QML and JS on disk, and
replacing an applet's files does not reliably invalidate that cache, so a
stale compiled logic.js from before the helper was added is enough. install.sh
now clears the cache on every install, which costs one slower load.

The keyboard vector is now computed defensively and the pointer path reads
plain numbers that cannot throw, so a missing or stale helper degrades to "no
keyboard" rather than "nothing works".

Removed the two gates that could also silently stop everything. resolvedBin
starts as the configured name instead of empty, so there is always something
to run and the search can only upgrade it; the tick timer no longer requires
resolution to have succeeded; and refresh, action and refreshTracking no
longer return early doing nothing. A failed probe keeps the fallback and says
so rather than disabling the widget, which matters because a packaged install
is on PATH regardless.

Added a Diagnostics toggle showing live internal state: the resolved and
configured binary, the device, which helpers are actually callable, the
stick's own numbers, the keyboard vector, whether the panel holds focus,
whether the timer is running, the busy flag, the sequence number, and the last
command with its exit code. Every command and result also goes to the journal
via console.log. Every failure so far has presented as "nothing happens",
which is the one symptom that carries no information at all.

Tested: 169 checks across seven suites, all passing. shellcheck clean, QML
free of syntax errors. The cache clearing was exercised against a fake cache
tree under a temporary XDG_CACHE_HOME.

Not verified in a live Plasma session. The coupling described above is plain
in the code and is a real defect whatever triggered it; whether a stale QML
cache was the trigger is unconfirmed.
2026-10-07 23:19:28 -07:00
brad 82974f242d Never reuse a command string for Plasma's executable engine
Plasma's executable data engine keys its command containers by the command
string. DataEngine::connectSource calls requestSource, which returns a
pre-existing container when one matches, and then delivers that container's
cached data; the process itself only starts when the container is first
constructed. Re-issuing a byte-identical command therefore reports success,
with a plausible exit code and stdout, while running nothing at all.

That is precisely what the joystick produces. A stick held at full deflection
emits the same command every tick, so the camera would take one step and then
stop, with the widget showing no error because it received a successful
result. The same applies to a key held down, and to repeated presses of
Center or Home.

Every command now carries a trailing shell comment holding a counter, so the
engine never sees a string it has already cached. `#` starts a comment in sh,
and the commands already contain quotes so they always go through sh -c,
meaning the suffix changes the engine's key without changing what runs.

Resolution state moved from comparing the probe's command string to a plain
boolean, since the string is no longer stable across calls.

Tested: 65 node checks, including that successive sequence numbers differ and
that the suffix lands after a chained diagonal command. tests/test_resolver.sh
additionally runs the generated command through a real shell both with and
without the suffix and requires identical output, so the uniqueness trick
cannot quietly alter behaviour. Full suite of 172 checks across seven suites
passes, shellcheck clean, QML free of syntax errors.

This is a defect established by reading Plasma's source, not a confirmed
diagnosis of the reported fault. The two earlier theories for that fault, PATH
resolution and a stale pinned device node, were both ruled out by the
reporter: linkctl is installed from a pacman package into /usr/bin, and the
widget's device field was never set.
2026-10-07 23:10:02 -07:00
brad fa2d4e89d8 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.
2026-10-07 22:51:23 -07:00
21 changed files with 776 additions and 23 deletions
+44 -2
View File
@@ -63,8 +63,13 @@ them at that cost.
```
Then right-click a panel or the desktop, Add Widgets, and search for
"Insta360". Pass `--restart` when reinstalling over a running copy, since
Plasma otherwise keeps the old one loaded.
"Insta360".
**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
moves, and it stops when you let go. Degrees per step, step interval, deadzone
@@ -165,6 +170,8 @@ case where the gimbal is parked facing down.
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
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/lint_qml.py # QML scope errors, with KDE types resolved
python3 tests/test_gamepad.py # gamepad event decoding and deadzone maths
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
@@ -178,6 +185,20 @@ The lifecycle suite drives the script the way OBS does, so it catches broken
wiring (hotkeys not firing, scene actions not matching, the preset name being
captured too early) without needing OBS or a camera.
`tests/lint_qml.py` is worth understanding before trusting a clean QML run.
Plain `qmllint` cannot resolve the KDE QML modules, so it reports every
identifier inside `PlasmoidItem` as "Unqualified access" and buries real
findings in roughly ninety lines of noise. `tests/qmlstubs/` holds minimal
stand-ins for those modules so the types resolve and the scope analysis means
something; with them in place the only legitimate unqualified accesses are a
short allowlist, so anything else is a real finding. This exists because
filtering that noise wholesale once hid a `ReferenceError: stick is not
defined` that disabled all camera movement.
If you add a configuration key to `contents/config/main.xml`, add it to
`tests/qmlstubs/org/kde/plasma/plasmoid/Configuration.qml` too, or the linter
will not catch a typo in the key name.
`test_gamepad_loop.sh` substitutes a FIFO for `/dev/input/js*` and plays
scripted events through it, so the select/read/decode/tick path is covered
without a controller attached. It is also the test that pins down the
@@ -198,6 +219,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
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
device is listed but nothing moves, run `--monitor` and push the stick: no
events means a permissions or driver problem, events with unexpected axis
+18
View File
@@ -43,6 +43,22 @@ else
die "neither kpackagetool6 nor kpackagetool5 found; is this a KDE Plasma session?"
fi
# Qt caches compiled QML and JS on disk. Replacing the applet's files does not
# invalidate that cache reliably, and a stale compiled logic.js missing a
# newer helper function is enough to break bindings that reference it. Clearing
# it costs only a slower first load.
clear_qml_cache() {
local cleared=0 d
for d in "${XDG_CACHE_HOME:-$HOME/.cache}"/plasmashell/qmlcache \
"${XDG_CACHE_HOME:-$HOME/.cache}"/org.kde.plasmashell/qmlcache \
"${XDG_CACHE_HOME:-$HOME/.cache}"/qmlcache; do
if [[ -d $d ]]; then
rm -rf -- "$d" && echo " cleared $d" && cleared=1
fi
done
(( cleared )) || echo " no QML cache to clear"
}
restart_shell() {
(( RESTART )) || return 0
echo "Restarting plasmashell:"
@@ -73,6 +89,8 @@ install)
echo "Installing:"
"$KPT" --type Plasma/Applet --install "$PKG"
fi
echo "Clearing the compiled QML cache:"
clear_qml_cache
restart_shell
echo
echo "Done. Right-click a panel or the desktop, Add Widgets, search 'Insta360'."
@@ -16,6 +16,20 @@ Kirigami.FormLayout {
property alias cfg_invertPan: invertPan.checked
property alias cfg_invertTilt: invertTilt.checked
// Plasma also assigns a cfg_<name>Default for every key, plus a title,
// when it instantiates a config page. Without these declared it logs
// "does not have a property called cfg_<name>Default" for each one and
// the Defaults button has nothing to compare against.
property string cfg_linkctlPathDefault: "linkctl"
property string cfg_deviceDefault: ""
property string cfg_homePresetDefault: "home"
property int cfg_maxStepDefault: 6
property int cfg_tickIntervalDefault: 160
property int cfg_deadzoneDefault: 15
property bool cfg_invertPanDefault: false
property bool cfg_invertTiltDefault: false
property string title: ""
QQC2.TextField {
id: linkctlPath
Kirigami.FormData.label: "linkctl binary:"
+64
View File
@@ -23,6 +23,53 @@ function baseCmd(linkctl, device) {
return c;
}
/*
* Make a command string unique by appending a shell comment.
*
* Plasma's executable data engine keys its containers by the command string.
* DataEngine::connectSource hands back a pre-existing container and delivers
* its *cached* data rather than running the command again; the process only
* starts when the container is first created. So re-issuing an identical
* command can report success without the camera moving at all, which is
* exactly what a stick held at full deflection produces every tick.
*
* `#` starts a comment in sh, so the trailing counter changes the key the
* engine sees without changing what runs.
*/
function uniqueify(cmd, seq) {
return cmd + " # " + seq;
}
// 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.
*
@@ -137,8 +184,25 @@ function errorMessage(stdout, stderr, exitCode) {
} 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;
}
+223 -21
View File
@@ -22,8 +22,24 @@ import "logic.js" as Logic
PlasmoidItem {
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
// 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.
// Starts as the configured name so commands always have something to
// run. The probe only upgrades this to an absolute path; it can never
// leave the widget unable to act.
property string resolvedBin: Plasmoid.configuration.linkctlPath || "linkctl"
property bool resolving: false
// Incremented per command so no two command strings are ever identical.
property int seq: 0
readonly property bool ready: root.resolvedBin !== ""
property string lastCmd: ""
property int lastExit: -1
readonly property int stepMax: Plasmoid.configuration.maxStep
readonly property int tickMs: Plasmoid.configuration.tickInterval
readonly property bool invertPan: Plasmoid.configuration.invertPan
@@ -43,17 +59,24 @@ PlasmoidItem {
property bool keyUp: false
property bool keyDown: false
readonly property var keyVec: Logic.vectorFromKeys({
left: root.keyLeft, right: root.keyRight,
up: root.keyUp, down: root.keyDown
})
readonly property bool keyEngaged: root.keyVec.nx !== 0 || root.keyVec.ny !== 0
// Keyboard vector. Computed defensively on purpose: the pointer path
// reads inNx/inEngaged too, so anything that throws in here would
// disable dragging as well. Qt caches compiled QML and JS on disk, and a
// stale cached logic.js lacking a newer helper is enough to do it.
readonly property bool keyboardAvailable: typeof Logic !== "undefined"
&& typeof Logic.vectorFromKeys === "function"
readonly property var keyVec: root.keyboardAvailable
? Logic.vectorFromKeys({
left: root.keyLeft, right: root.keyRight,
up: root.keyUp, down: root.keyDown
})
: { nx: 0, ny: 0 }
readonly property real keyNx: (root.keyVec && typeof root.keyVec.nx === "number")
? root.keyVec.nx : 0
readonly property real keyNy: (root.keyVec && typeof root.keyVec.ny === "number")
? root.keyVec.ny : 0
readonly property bool keyEngaged: root.keyNx !== 0 || root.keyNy !== 0
// The pointer wins while it is down, so a drag is never fought over by a
// key left held down underneath it.
readonly property real inNx: stick.held ? stick.nx : root.keyVec.nx
readonly property real inNy: stick.held ? stick.ny : root.keyVec.ny
readonly property bool inEngaged: stick.engaged || root.keyEngaged
// WASD and the arrow keys. Returns "" for anything that is not a
// movement key, so the handler can leave it unaccepted.
@@ -92,7 +115,25 @@ PlasmoidItem {
}
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;
}
if (typeof Logic.resolveCommand !== "function") {
// Stale cached logic.js: keep the configured name and carry on.
root.refresh();
return;
}
root.statusText = "Looking for linkctl...";
root.resolving = true;
sh.exec(Logic.resolveCommand(root.configuredBin));
}
function absorb(stdout) {
@@ -125,6 +166,27 @@ PlasmoidItem {
const err = (data.stderr || "").trim();
disconnectSource(source);
root.busy = false;
root.lastExit = code;
console.log("[insta360] exit", code, "out:", String(out).trim(),
"err:", err);
if (root.resolving) {
root.resolving = false;
const found = String(out).trim().split("\n")[0].trim();
if (found !== "") {
root.resolvedBin = found;
root.refresh();
root.refreshTracking();
} else {
// Keep the configured name as the fallback rather than
// blocking: on a packaged install it is on PATH anyway.
root.statusText = "Could not locate linkctl; using '"
+ root.configuredBin + "'.";
root.refresh();
}
return;
}
if (code === 0) {
root.absorb(out);
} else {
@@ -134,7 +196,28 @@ PlasmoidItem {
function exec(cmd) {
root.busy = true;
connectSource(cmd);
root.seq += 1;
// Never reuse a command string: the engine would serve the
// previous run's cached output and not run anything.
const unique = (typeof Logic.uniqueify === "function")
? Logic.uniqueify(cmd, root.seq)
: cmd + " # " + root.seq;
root.lastCmd = unique;
console.log("[insta360] run:", unique);
connectSource(unique);
}
}
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.";
}
}
@@ -150,10 +233,37 @@ PlasmoidItem {
sh.exec(root.baseCmd() + " " + args);
}
Component.onCompleted: refresh()
property bool started: false
Component.onCompleted: {
root.started = true;
root.resolveBinary();
}
// Re-resolve if the configured path is edited. Guarded because this also
// fires while the component is still being set up, which would otherwise
// probe twice and duplicate every follow-up query.
onConfiguredBinChanged: {
if (!root.started) {
return;
}
root.resolvedBin = root.configuredBin;
root.resolveBinary();
}
fullRepresentation: ColumnLayout {
id: panel
// The combined pointer and keyboard vector. These live here, not on
// root, because `stick` is an id declared inside this Component and
// ids are not visible from the enclosing scope. Binding to it from
// root raised "ReferenceError: stick is not defined" and silently
// disabled every form of movement.
//
// The pointer wins while it is down, so a drag is never fought over
// by a key left held down underneath it.
readonly property real inNx: stick.held ? stick.nx : root.keyNx
readonly property real inNy: stick.held ? stick.ny : root.keyNy
readonly property bool inEngaged: stick.engaged || root.keyEngaged
Layout.minimumWidth: Kirigami.Units.gridUnit * 14
Layout.minimumHeight: Kirigami.Units.gridUnit * 22
spacing: Kirigami.Units.smallSpacing
@@ -200,9 +310,6 @@ PlasmoidItem {
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
text: {
if (isNaN(root.panDeg) && isNaN(root.tiltDeg)) {
return root.statusText;
}
const p = isNaN(root.panDeg) ? "?" : root.panDeg.toFixed(1);
const t = isNaN(root.tiltDeg) ? "?" : root.tiltDeg.toFixed(1);
return "Pan " + p + "° Tilt " + t + "°";
@@ -210,6 +317,18 @@ PlasmoidItem {
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 {
id: stick
Layout.alignment: Qt.AlignHCenter
@@ -231,17 +350,19 @@ PlasmoidItem {
}
Timer {
id: moveTimer
// Drives both input methods. Only runs while something is asking
// for movement, so an idle popup costs nothing.
interval: root.tickMs
repeat: true
running: root.inEngaged
running: panel.inEngaged
triggeredOnStart: true
onTriggered: {
if (root.busy) {
return; // let the previous step finish, skip this one
}
const cmd = Logic.tickCommand(root.baseCmd(), root.inNx, root.inNy,
const cmd = Logic.tickCommand(root.baseCmd(),
panel.inNx, panel.inNy,
root.stepMax, root.invertPan,
root.invertTilt);
if (cmd !== "") {
@@ -259,6 +380,16 @@ PlasmoidItem {
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 {
@@ -326,8 +457,79 @@ PlasmoidItem {
icon.name: "view-refresh"
text: "Refresh"
onClicked: {
root.refresh();
root.refreshTracking();
if (!root.ready) {
root.resolveBinary();
} else {
root.refresh();
root.refreshTracking();
}
}
}
PlasmaComponents.Button {
id: diagToggle
Layout.fillWidth: true
focusPolicy: Qt.NoFocus
checkable: true
icon.name: "help-about"
text: "Diagnostics"
}
// Live internal state. Exists because every failure so far has
// presented as "nothing happens", which is the one symptom that
// carries no information.
//
// Deliberately one label built by one guarded function rather than a
// Repeater over an array of expressions: in that shape a single
// throwing sub-expression discards the whole model and the panel
// renders empty, which is the failure mode it is meant to diagnose.
PlasmaComponents.Label {
Layout.fillWidth: true
visible: diagToggle.checked
font: Kirigami.Theme.smallFont
opacity: 0.85
wrapMode: Text.WrapAnywhere
textFormat: Text.PlainText
function line(label, value) {
try {
return label + ": " + value + "\n";
} catch (e) {
return label + ": <error: " + e + ">\n";
}
}
text: {
let out = "";
try {
out += line("binary", root.resolvedBin);
out += line("configured", root.configuredBin);
out += line("device",
root.device === "" ? "(auto)" : root.device);
out += line("helpers",
"vectorFromKeys=" + root.keyboardAvailable
+ " uniqueify="
+ (typeof Logic !== "undefined"
&& typeof Logic.uniqueify === "function")
+ " resolveCommand="
+ (typeof Logic !== "undefined"
&& typeof Logic.resolveCommand === "function"));
out += line("stick", "held=" + stick.held
+ " nx=" + stick.nx.toFixed(2)
+ " ny=" + stick.ny.toFixed(2)
+ " engaged=" + stick.engaged);
out += line("keys", "nx=" + root.keyNx.toFixed(2)
+ " ny=" + root.keyNy.toFixed(2)
+ " focus=" + panel.activeFocus);
out += line("input", "engaged=" + panel.inEngaged
+ " timer=" + moveTimer.running);
out += line("busy", root.busy + " seq=" + root.seq
+ " lastExit=" + root.lastExit);
out += line("lastCmd", root.lastCmd);
} catch (e) {
out += "diagnostics failed: " + e;
}
return out;
}
}
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Lint the applet's QML with the KDE types resolved.
Why this exists
---------------
Running qmllint on the applet without the KDE QML modules present reports
every single identifier inside `PlasmoidItem` as "Unqualified access",
because the type cannot be resolved and so nothing inside it has a known
scope. That is 87 warnings of pure noise on a clean file, which is why they
were previously filtered out wholesale, and that filter hid a real one:
ReferenceError: stick is not defined
`stick` is an id declared inside the `fullRepresentation` Component, and a
property on the root object cannot see into that scope. The binding threw at
runtime, so the movement timer never ran and every form of input silently
stopped working. qmllint had flagged it; the filter discarded it.
tests/qmlstubs holds minimal stand-ins for the KDE modules so qmllint can
resolve the types and reason about scope properly. With those in place the
only remaining unqualified accesses are the handful listed in ALLOWED below,
so anything new is a genuine finding rather than noise.
Run: python3 tests/lint_qml.py
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.normpath(os.path.join(HERE, ".."))
STUBS = os.path.join(HERE, "qmlstubs")
TARGETS = [
"plasmoid/package/contents/ui/main.qml",
"plasmoid/package/contents/ui/Joystick.qml",
]
# Identifiers that are legitimately unqualified, with the reason. Keep this
# list short and specific: a broad rule here is what let the scope bug
# through last time.
ALLOWED = {
# Ids declared on the outer component and used inside the nested
# fullRepresentation. QML resolves these at runtime under the default
# component behaviour; qmllint asks for "pragma ComponentBehavior: Bound"
# to make it explicit, which would change runtime semantics and is left
# as a separate decision.
"root",
"sh",
# Implicit property supplied by Repeater to its delegate.
"modelData",
}
CANDIDATES = [
shutil.which("qmllint"),
shutil.which("pyside6-qmllint"),
"/tmp/pyside/PySide6/qmllint",
]
def find_qmllint() -> str | None:
for c in CANDIDATES:
if c and os.path.exists(c):
return c
return None
def parse(output: str) -> tuple[list[tuple[str, str, str]], list[str]]:
"""Return (unqualified findings, hard errors).
A finding is (file, line, symbol). The symbol is recovered from the caret
line qmllint prints beneath the offending source.
"""
findings: list[tuple[str, str, str]] = []
errors: list[str] = []
lines = output.split("\n")
for i, line in enumerate(lines):
if line.startswith("Error:"):
errors.append(line)
continue
m = re.match(r"Warning: (.+?):(\d+):(\d+): Unqualified access", line)
if not m or i + 2 >= len(lines):
continue
src, caret = lines[i + 1], lines[i + 2]
col = caret.find("^")
if col < 0:
continue
end = col
while end < len(caret) and caret[end] == "^":
end += 1
findings.append((m.group(1), m.group(2), src[col:end]))
return findings, errors
def main() -> int:
qmllint = find_qmllint()
if not qmllint:
print("SKIP: qmllint not available "
"(pip install PySide6-Essentials provides one)")
return 0
env = dict(os.environ, LC_ALL="C.UTF-8")
failures = 0
checked = 0
for target in TARGETS:
path = os.path.join(ROOT, target)
if not os.path.exists(path):
print(f"FAIL {target}: missing")
failures += 1
continue
proc = subprocess.run(
[qmllint, "-I", STUBS, path],
capture_output=True, text=True, env=env, cwd=ROOT,
)
output = proc.stdout + proc.stderr
findings, errors = parse(output)
checked += 1
for err in errors:
print(f"FAIL {err}")
failures += 1
unexpected = [f for f in findings if f[2] not in ALLOWED]
if unexpected:
for _, line, sym in unexpected:
print(f"FAIL {target}:{line}: unqualified access to "
f"'{sym}'. If this is an id from another component, "
f"QML cannot resolve it from here.")
failures += 1
else:
allowed_count = len(findings)
print(f"ok {target} "
f"({allowed_count} allowed unqualified, 0 unexpected)")
# An unresolved import means the stubs have drifted and the scope
# analysis below it is worthless, so treat it as a failure.
if "was not found" in output:
for line in output.split("\n"):
if "was not found" in line:
print(f"FAIL unresolved type in {target}: {line.strip()}")
failures += 1
print(f"\n{checked} files linted, {failures} failures")
return 0 if failures == 0 else 1
if __name__ == "__main__":
sys.exit(main())
+12
View File
@@ -0,0 +1,12 @@
pragma Singleton
import QtQuick
QtObject {
readonly property color textColor: "black"
readonly property color backgroundColor: "white"
readonly property color alternateBackgroundColor: "#eee"
readonly property color highlightColor: "blue"
readonly property color separatorColor: "#ccc"
readonly property font smallFont: Qt.font({ pointSize: 8 })
readonly property font defaultFont: Qt.font({ pointSize: 10 })
}
@@ -0,0 +1,8 @@
pragma Singleton
import QtQuick
QtObject {
readonly property int gridUnit: 18
readonly property int smallSpacing: 4
readonly property int largeSpacing: 8
}
+3
View File
@@ -0,0 +1,3 @@
module org.kde.kirigami
singleton Units 1.0 Units.qml
singleton Theme 1.0 Theme.qml
@@ -0,0 +1,3 @@
import QtQuick.Controls as QQC
QQC.Button {}
@@ -0,0 +1,3 @@
import QtQuick.Controls as QQC
QQC.Label {}
@@ -0,0 +1,3 @@
import QtQuick.Controls as QQC
QQC.Slider {}
@@ -0,0 +1,4 @@
module org.kde.plasma.components
Label 1.0 Label.qml
Button 1.0 Button.qml
Slider 1.0 Slider.qml
@@ -0,0 +1,10 @@
import QtQuick
QtObject {
property string engine: ""
property var connectedSources: []
signal newData(string sourceName, var data)
function connectSource(source) {}
function disconnectSource(source) {}
function serviceForSource(source) { return null; }
}
@@ -0,0 +1,2 @@
module org.kde.plasma.plasma5support
DataSource 1.0 DataSource.qml
@@ -0,0 +1,14 @@
// The applet's configuration keys. Mirrors contents/config/main.xml; keep the
// two in step, or the linter will not catch a typo in a key name.
import QtQuick
QtObject {
property string linkctlPath: "linkctl"
property string device: ""
property string homePreset: "home"
property int maxStep: 6
property int tickInterval: 160
property int deadzone: 15
property bool invertPan: false
property bool invertTilt: false
}
@@ -0,0 +1,6 @@
pragma Singleton
import QtQuick
QtObject {
readonly property Configuration configuration: Configuration {}
}
@@ -0,0 +1,16 @@
// Stub of PlasmoidItem, only as complete as the applet needs. Exists so
// qmllint can resolve the type and therefore reason about scope; without it
// every identifier inside the applet is reported as unqualified and the
// genuine scope errors are indistinguishable from import noise.
import QtQuick
Item {
property Component fullRepresentation
property Component compactRepresentation
property int preferredRepresentation: 0
property bool expanded: false
property int status: 0
property string icon: ""
property string toolTipMainText: ""
property string toolTipSubText: ""
}
@@ -0,0 +1,4 @@
module org.kde.plasma.plasmoid
PlasmoidItem 1.0 PlasmoidItem.qml
Configuration 1.0 Configuration.qml
singleton Plasmoid 1.0 Plasmoid.qml
+47
View File
@@ -125,7 +125,54 @@ deep(ctx.absorb('{"pan":1}\nbroken {\n{"tilt":2}'), { pan: 1, tilt: 2 },
"absorb survives a malformed line between good ones");
deep(ctx.absorb('Pan: 12.3°'), {}, "absorb ignores human-readable output");
// --- uniqueify ---------------------------------------------------------
// Plasma's executable engine keys containers by command string and serves a
// pre-existing container's cached output instead of re-running, so repeating
// a command verbatim reports success without moving the camera.
eq(ctx.uniqueify("'linkctl' --force 'center'", 1),
"'linkctl' --force 'center' # 1", "uniqueify appends a comment");
eq(ctx.uniqueify("a", 1) === ctx.uniqueify("a", 2), false,
"successive sequence numbers differ");
// The suffix must be a shell comment so it cannot alter what runs.
eq(ctx.uniqueify("x", 7).includes(" # "), true, "suffix is a shell comment");
// A diagonal tick is two chained commands; the suffix must land at the end
// and so comment out nothing meaningful.
const tc = ctx.tickCommand(B, 1, 1, 6, false, false);
eq(ctx.uniqueify(tc, 3), tc + " # 3", "uniqueify appends after a chained command");
// --- 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 ------------------------------------------------------
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",
"errorMessage prefers the json error");
eq(ctx.errorMessage("", "permission denied", 6), "permission denied",
+124
View File
@@ -0,0 +1,124 @@
#!/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"
# The uniqueness suffix exists only to change the string Plasma keys its
# command containers by. It must not change what the shell actually runs.
ask_logic() {
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);
const tick = ctx.tickCommand(ctx.baseCmd("echo", ""), 1, 1, 6, false, false);
process.stdout.write(process.argv[2] === "unique"
? ctx.uniqueify(tick, 42) : tick);
' "$LOGIC" "$1"
}
checks=$((checks + 1))
plain_out=$(sh -c "$(ask_logic plain)" 2>&1)
unique_out=$(sh -c "$(ask_logic unique)" 2>&1)
if [[ $plain_out == "$unique_out" && -n $plain_out ]]; then
printf 'ok %s\n' "uniqueness suffix does not change what the shell runs"
else
failures=$((failures + 1))
printf 'FAIL %s\n plain: %s\n unique: %s\n' \
"uniqueness suffix does not change what the shell runs" \
"$plain_out" "$unique_out"
fi
printf '\n%d checks, %d failures\n' "$checks" "$failures"
[[ $failures -eq 0 ]]