Author SHA1 Message Date
branderson 53bdd7da86 Merge pull request 'Add GPL-3.0 licensing and prepare for a public repository' (#4) from public-repo-prep into main
Reviewed-on: #4
Reviewed-by: branderson <brad@branderson.io>
2026-10-16 14:55:39 -07:00
brad f95853dc25 Add GPL-3.0 licensing and prepare for a public repository
metadata.json claimed MIT while no LICENSE file existed, so the repository
had no effective licence at all. It is now GPL-3.0-or-later: the full FSF
text in LICENSE, the identifier in metadata.json, and REUSE-style
SPDX-FileCopyrightText and SPDX-License-Identifier headers on all 33 source
files, which is the convention KDE itself uses.

Worth recording that GPL-3.0 is a preference here rather than an obligation.
Nothing in the dependency graph forces it: linkctl is MIT and is invoked as a
subprocess rather than linked, and the KDE QML modules the applet imports are
LGPL.

Removed two things that should not ship in a public repository. tests's QML
linter had an absolute sandbox path, /tmp/pyside/PySide6/qmllint, baked into
its search list; it now looks for Qt's qmllint and PySide6's pyside6-qmllint
on PATH and honours a QMLLINT override, and says how to install one when it
finds nothing. The systemd unit carried a Documentation= URL pointing at a
host that does not exist, and its ExecStart silently assumed a particular
checkout directory; the URL is gone and the path is now an obvious
PATH/TO/CHECKOUT placeholder next to the comment telling you to edit it.

Added a Credits section naming the work this is built on, since the project
is mostly glue: linkctl does the camera control, schlarpc's firmware teardown
documents the extension-unit protocol, and vrwallace's controller established
that the Link 2 needs both pan and tilt axes in a single VIDIOC_S_EXT_CTRLS
call, which is the detail the whole thing depends on.

Also made the two Python tests executable, matching the other scripts.

Audited for operator-specific detail: no credentials, tokens, private
hostnames, IP addresses or absolute home paths in any tracked file or
anywhere in the history's diffs. Three merge commits do carry Reviewed-on and
Reviewed-by trailers naming a private forge, added by the forge at merge
time; those are commit metadata rather than file content and are handled
separately.

Tested: 169 checks across seven suites plus the QML linter, all passing after
the header insertion, which confirms it did not disturb the order-sensitive
`.pragma library` line in logic.js or the qmldir module definitions.
shellcheck clean.
2026-10-13 20:54:23 -07:00
branderson 043dd69e5e Merge pull request 'Fix QML scope error that disabled all camera movement, and close the lint gap that hid it' (#3) from widget-resolve-linkctl into main
Reviewed-on: #3
Reviewed-by: branderson <brad@branderson.io>
2026-10-07 23:52:28 -07:00
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
branderson 3cd99e7bf5 Merge pull request 'Add keyboard, gamepad and directional-shortcut control for the gimbal' (#2) from joystick-keyboard-and-gamepad into main
Reviewed-on: #2
Reviewed-by: branderson <brad@branderson.io>
2026-10-07 22:27:07 -07:00
36 changed files with 1580 additions and 27 deletions
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+67 -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
@@ -221,6 +263,29 @@ needs both axes set in one `VIDIOC_S_EXT_CTRLS` call. `linkctl` does this
correctly; raw `v4l2-ctl` needs `--set-ctrl pan_absolute=0,tilt_absolute=0`
as one argument.
## Credits
This is glue around other people's reverse engineering, and none of it would
exist without them:
- [`illegalstudio/linkctl`](https://github.com/illegalstudio/linkctl) (MIT)
does the actual camera control. Everything here shells out to it.
- [`schlarpc/insta360-link-firmware-re`](https://github.com/schlarpc/insta360-link-firmware-re)
documents the camera's UVC extension-unit protocol and internal parameter
space from a firmware teardown. It is the reference for anything not already
exposed by `linkctl`.
- [`vrwallace/Insta360-Link-1-and-2-Controller-for-Linux`](https://github.com/vrwallace/Insta360-Link-1-and-2-Controller-for-Linux)
established that the Link 2 rejects single-axis pan/tilt writes and needs
both axes in one `VIDIOC_S_EXT_CTRLS` call, which is the detail that makes
gimbal control work at all.
## License
GPL-3.0-or-later. See [LICENSE](LICENSE).
`linkctl` itself is MIT and separately licensed; this project invokes it as a
subprocess and does not include or link against it.
## Status
The helpers, the shell scripts and the gamepad read loop are tested offline,
+3
View File
@@ -1,4 +1,7 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 branderson
# SPDX-License-Identifier: GPL-3.0-or-later
"""Drive an Insta360 Link gimbal from a game controller.
Reads the kernel joystick device directly (``/dev/input/js*``) and issues
+3
View File
@@ -1,4 +1,7 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 branderson
# SPDX-License-Identifier: GPL-3.0-or-later
#
# linkctl-macro - run a linkctl command as a desktop macro.
#
+6 -3
View File
@@ -1,3 +1,6 @@
# SPDX-FileCopyrightText: 2026 branderson
# SPDX-License-Identifier: GPL-3.0-or-later
# Run the gamepad bridge as a user service.
#
# Install:
@@ -15,14 +18,14 @@
[Unit]
Description=Insta360 Link gamepad control
Documentation=https://gitbox.example.com/insta360-link-tools
After=graphical-session.target
PartOf=graphical-session.target
[Service]
Type=simple
# Absolute path required: systemd does not search PATH for ExecStart.
ExecStart=%h/workspace/insta360-link-tools/bin/linkctl-gamepad --wait
# EDIT THIS. systemd does not search PATH for ExecStart, so it needs the
# absolute path to your checkout. %h expands to your home directory.
ExecStart=%h/PATH/TO/CHECKOUT/bin/linkctl-gamepad --wait
Restart=on-failure
RestartSec=5
# The joystick device and the camera are the only things it touches.
+3
View File
@@ -1,4 +1,7 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 branderson
# SPDX-License-Identifier: GPL-3.0-or-later
#
# install-shortcuts.sh - register global hotkeys for the Insta360 Link under
# KDE Plasma.
+3
View File
@@ -1,3 +1,6 @@
# SPDX-FileCopyrightText: 2026 branderson
# SPDX-License-Identifier: GPL-3.0-or-later
# Example configuration for bin/linkctl-macro.
# Copy to ~/.config/linkctl-macro.conf and uncomment what you need.
# This file is sourced by bash, so it is shell syntax.
+3
View File
@@ -1,3 +1,6 @@
-- SPDX-FileCopyrightText: 2026 branderson
-- SPDX-License-Identifier: GPL-3.0-or-later
--[[
obs-linkctl.lua - Insta360 Link control inside OBS.
+21
View File
@@ -1,4 +1,7 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 branderson
# SPDX-License-Identifier: GPL-3.0-or-later
#
# install.sh - install the Insta360 Link Joystick applet into Plasma.
#
@@ -43,6 +46,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 +92,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'."
@@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 branderson
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick
import org.kde.plasma.configuration
@@ -1,4 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-FileCopyrightText: 2026 branderson
SPDX-License-Identifier: GPL-3.0-or-later -->
<kcfg xmlns="http://www.kde.org/standards/kcfg/1.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.kde.org/standards/kcfg/1.0
@@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 branderson
// SPDX-License-Identifier: GPL-3.0-or-later
/*
* A circular virtual joystick.
*
@@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 branderson
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick
import QtQuick.Controls as QQC2
import org.kde.kirigami as Kirigami
@@ -16,6 +19,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:"
+67
View File
@@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 branderson
// SPDX-License-Identifier: GPL-3.0-or-later
/*
* Pure helpers for the joystick applet.
*
@@ -23,6 +26,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 +187,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;
}
+221 -16
View File
@@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 branderson
// SPDX-License-Identifier: GPL-3.0-or-later
/*
* Insta360 Link Joystick - a Plasma 6 applet.
*
@@ -22,8 +25,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 +62,24 @@ PlasmoidItem {
property bool keyUp: false
property bool keyDown: false
readonly property var keyVec: Logic.vectorFromKeys({
// 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
})
readonly property bool keyEngaged: root.keyVec.nx !== 0 || root.keyVec.ny !== 0
: { 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 +118,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 +169,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 +199,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 +236,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 +313,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 +320,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 +353,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 +383,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,10 +460,81 @@ PlasmoidItem {
icon.name: "view-refresh"
text: "Refresh"
onClicked: {
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;
}
}
Item {
Layout.fillHeight: true
+1 -1
View File
@@ -10,7 +10,7 @@
"Description": "Virtual joystick and quick controls for an Insta360 Link webcam",
"Icon": "camera-web",
"Id": "com.github.insta360linktools.joystick",
"License": "MIT",
"License": "GPL-3.0-or-later",
"Name": "Insta360 Link Joystick",
"Version": "1.0"
},
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 branderson
# SPDX-License-Identifier: GPL-3.0-or-later
"""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",
}
def find_qmllint() -> str | None:
"""Locate qmllint.
Set QMLLINT to point at a specific binary; otherwise the Qt and PySide6
names are tried on PATH. Qt ships it as qmllint (sometimes suffixed with
the Qt version), and PySide6-Essentials installs pyside6-qmllint.
"""
override = os.environ.get("QMLLINT")
if override:
return override if os.path.exists(override) else None
for name in ("qmllint", "qmllint6", "qmllint-qt6", "pyside6-qmllint"):
found = shutil.which(name)
if found:
return found
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 found on PATH. Install Qt's qmllint, or "
"`pip install PySide6-Essentials` for pyside6-qmllint, or set "
"QMLLINT=/path/to/qmllint.")
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())
+15
View File
@@ -0,0 +1,15 @@
// SPDX-FileCopyrightText: 2026 branderson
// SPDX-License-Identifier: GPL-3.0-or-later
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 })
}
+11
View File
@@ -0,0 +1,11 @@
// SPDX-FileCopyrightText: 2026 branderson
// SPDX-License-Identifier: GPL-3.0-or-later
pragma Singleton
import QtQuick
QtObject {
readonly property int gridUnit: 18
readonly property int smallSpacing: 4
readonly property int largeSpacing: 8
}
+6
View File
@@ -0,0 +1,6 @@
# SPDX-FileCopyrightText: 2026 branderson
# SPDX-License-Identifier: GPL-3.0-or-later
module org.kde.kirigami
singleton Units 1.0 Units.qml
singleton Theme 1.0 Theme.qml
@@ -0,0 +1,6 @@
// SPDX-FileCopyrightText: 2026 branderson
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick.Controls as QQC
QQC.Button {}
@@ -0,0 +1,6 @@
// SPDX-FileCopyrightText: 2026 branderson
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick.Controls as QQC
QQC.Label {}
@@ -0,0 +1,6 @@
// SPDX-FileCopyrightText: 2026 branderson
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick.Controls as QQC
QQC.Slider {}
@@ -0,0 +1,7 @@
# SPDX-FileCopyrightText: 2026 branderson
# SPDX-License-Identifier: GPL-3.0-or-later
module org.kde.plasma.components
Label 1.0 Label.qml
Button 1.0 Button.qml
Slider 1.0 Slider.qml
@@ -0,0 +1,13 @@
// SPDX-FileCopyrightText: 2026 branderson
// SPDX-License-Identifier: GPL-3.0-or-later
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,5 @@
# SPDX-FileCopyrightText: 2026 branderson
# SPDX-License-Identifier: GPL-3.0-or-later
module org.kde.plasma.plasma5support
DataSource 1.0 DataSource.qml
@@ -0,0 +1,17 @@
// SPDX-FileCopyrightText: 2026 branderson
// SPDX-License-Identifier: GPL-3.0-or-later
// 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,9 @@
// SPDX-FileCopyrightText: 2026 branderson
// SPDX-License-Identifier: GPL-3.0-or-later
pragma Singleton
import QtQuick
QtObject {
readonly property Configuration configuration: Configuration {}
}
@@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: 2026 branderson
// SPDX-License-Identifier: GPL-3.0-or-later
// 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,7 @@
# SPDX-FileCopyrightText: 2026 branderson
# SPDX-License-Identifier: GPL-3.0-or-later
module org.kde.plasma.plasmoid
PlasmoidItem 1.0 PlasmoidItem.qml
Configuration 1.0 Configuration.qml
singleton Plasmoid 1.0 Plasmoid.qml
Regular → Executable
+3
View File
@@ -1,4 +1,7 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 branderson
# SPDX-License-Identifier: GPL-3.0-or-later
"""Tests for linkctl-gamepad's pure helpers.
Run: python3 tests/test_gamepad.py
+3
View File
@@ -1,4 +1,7 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 branderson
# SPDX-License-Identifier: GPL-3.0-or-later
#
# End-to-end test of linkctl-gamepad's read loop, without a controller.
#
+50
View File
@@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 branderson
// SPDX-License-Identifier: GPL-3.0-or-later
/*
* Tests for the joystick applet's pure helpers.
* Run: node tests/test_joystick_logic.js
@@ -125,7 +128,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",
+3
View File
@@ -1,3 +1,6 @@
-- SPDX-FileCopyrightText: 2026 branderson
-- SPDX-License-Identifier: GPL-3.0-or-later
-- Lifecycle tests for obs/obs-linkctl.lua against a mock OBS API.
-- Run: lua tests/test_obs_lifecycle.lua
--
+3
View File
@@ -1,3 +1,6 @@
-- SPDX-FileCopyrightText: 2026 branderson
-- SPDX-License-Identifier: GPL-3.0-or-later
-- Offline tests for the pure helpers in obs/obs-linkctl.lua.
-- Run: lua tests/test_obs_linkctl.lua
--
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 branderson
# SPDX-License-Identifier: GPL-3.0-or-later
#
# 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 ]]