Fix QML scope error that disabled all camera movement, and close the lint gap that hid it #3

Merged
branderson merged 4 commits from widget-resolve-linkctl into main 2026-10-07 23:52:29 -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 ]]