Files
insta360-link-tools/kde/install-shortcuts.sh
T
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

291 lines
10 KiB
Bash
Executable File

#!/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.
#
# Plasma binds global shortcuts to application entries, so for each action
# this installs a .desktop file and the matching entry in kglobalshortcutsrc.
# The shortcuts then work from anywhere in the session, on Wayland as well as
# X11, which OBS's own hotkeys do not under Wayland.
#
# Usage:
# ./install-shortcuts.sh --dry-run show what would change, touch nothing
# ./install-shortcuts.sh install
# ./install-shortcuts.sh --list show what is currently registered
# ./install-shortcuts.sh --uninstall remove everything it installed
#
# Edit the BINDINGS table below to change the keys or the actions. Re-running
# the script is safe and applies the new table.
set -euo pipefail
# Degrees moved by one press of a directional shortcut.
NUDGE=${NUDGE:-5}
# id | key sequence | linkctl-macro arguments | human label
#
# Meta+Alt is used because Plasma leaves that combination largely free.
# Anything Plasma already owns is reported as a conflict rather than stolen.
BINDINGS=(
"tracking-toggle|Meta+Alt+T|tracking toggle|Insta360: Toggle AI tracking"
"tracking-on|Meta+Alt+1|tracking on|Insta360: AI tracking on"
"tracking-off|Meta+Alt+0|tracking off|Insta360: AI tracking off"
"preset-home|Meta+Alt+H|preset load home|Insta360: Load home preset"
"center|Meta+Alt+C|center|Insta360: Recenter gimbal"
# Directional nudges. kglobalaccel fires once per keypress and gives no
# press-and-hold, so each of these is a discrete step rather than smooth
# motion. For continuous movement use the Plasma widget or a gamepad.
"pan-left|Meta+Alt+Left|left $NUDGE|Insta360: Pan left"
"pan-right|Meta+Alt+Right|right $NUDGE|Insta360: Pan right"
"tilt-up|Meta+Alt+Up|up $NUDGE|Insta360: Tilt up"
"tilt-down|Meta+Alt+Down|down $NUDGE|Insta360: Tilt down"
)
# The entries are visible by default: KApplicationTrader skips NoDisplay
# services, so hiding them also hides them from the Shortcuts page in System
# Settings. Set HIDE_FROM_MENU=1 to keep the launcher clean at that cost.
HIDE_FROM_MENU=${HIDE_FROM_MENU:-0}
readonly APPDIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
readonly PREFIX="linkctl-"
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
readonly SCRIPT_DIR
MACRO=${MACRO:-$SCRIPT_DIR/../bin/linkctl-macro}
DRY_RUN=0
MODE=install
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
info() { printf '%s\n' "$*"; }
run() {
if (( DRY_RUN )); then
printf ' would run: %s\n' "$*"
else
"$@"
fi
}
# Like run(), but discards the command's own chatter. Kept separate so that a
# redirect never swallows the dry-run description.
run_quiet() {
if (( DRY_RUN )); then
printf ' would run: %s\n' "$*"
else
"$@" >/dev/null 2>&1
fi
}
# Plasma 6 ships the *6 tools, Plasma 5 the *5 ones.
detect_plasma() {
if command -v kwriteconfig6 >/dev/null 2>&1; then
KWRITE=kwriteconfig6; KREAD=kreadconfig6
SYCOCA=kbuildsycoca6; PLASMA=6
elif command -v kwriteconfig5 >/dev/null 2>&1; then
KWRITE=kwriteconfig5; KREAD=kreadconfig5
SYCOCA=kbuildsycoca5; PLASMA=5
else
die "neither kwriteconfig6 nor kwriteconfig5 found; is this a KDE Plasma session?"
fi
}
desktop_file() { printf '%s/%s%s.desktop\n' "$APPDIR" "$PREFIX" "$1"; }
desktop_name() { printf '%s%s.desktop\n' "$PREFIX" "$1"; }
write_desktop() {
local id=$1 args=$2 label=$3 keys=$4 path
path=$(desktop_file "$id")
if (( DRY_RUN )); then
printf ' would write %s\n' "$path"
printf ' Exec=%s %s\n' "$MACRO" "$args"
printf ' X-KDE-Shortcuts=%s\n' "$keys"
return
fi
local nodisplay=false
(( HIDE_FROM_MENU )) && nodisplay=true
# X-KDE-Shortcuts is where kglobalacceld reads an entry's *default*
# shortcut from, so declaring it here is what makes the binding show up in
# System Settings instead of "No default shortcuts".
cat > "$path" <<EOF
[Desktop Entry]
Type=Application
Name=$label
Comment=Insta360 Link control via linkctl
Exec=$MACRO $args
Icon=camera-web
Terminal=false
NoDisplay=$nodisplay
Categories=AudioVideo;
X-KDE-Shortcuts=$keys
X-KDE-GlobalAccel-CommandShortcut=true
StartupNotify=false
EOF
info " wrote $path"
}
# Warn if some other action already owns this key sequence.
check_conflict() {
local id=$1 keys=$2 me existing
me=$(desktop_name "$id")
existing=$(grep -rl --include=kglobalshortcutsrc -F "$keys" \
"${XDG_CONFIG_HOME:-$HOME/.config}" 2>/dev/null || true)
[[ -z $existing ]] && return 0
local owner
owner=$(grep -B100 -F "$keys" "$existing" 2>/dev/null \
| grep -oE '^\[[^]]+\](\[[^]]+\])?' | tail -1 || true)
if [[ -n $owner && $owner != *"$me"* ]]; then
printf ' note: %s may already be bound by %s\n' "$keys" "$owner"
fi
}
stop_daemon() {
# Write while the daemon is down: it rewrites kglobalshortcutsrc on exit
# and would otherwise discard these entries.
if (( PLASMA == 6 )); then
run systemctl --user stop plasma-kglobalaccel.service 2>/dev/null \
|| run kquitapp6 kglobalacceld 2>/dev/null || true
else
run kquitapp5 kglobalaccel5 2>/dev/null || true
fi
}
start_daemon() {
if (( PLASMA == 6 )); then
if ! run systemctl --user start plasma-kglobalaccel.service 2>/dev/null; then
run kstart kglobalacceld 2>/dev/null \
|| info " could not restart kglobalacceld; log out and back in to apply"
fi
else
run kstart5 kglobalaccel5 2>/dev/null \
|| info " could not restart kglobalaccel5; log out and back in to apply"
fi
}
do_install() {
[[ -x $MACRO ]] || die "linkctl-macro not executable at $MACRO (set MACRO=/path)"
MACRO=$(cd -- "$(dirname -- "$MACRO")" && pwd)/$(basename -- "$MACRO")
info "Plasma $PLASMA detected; using $KWRITE"
info "macro script: $MACRO"
(( DRY_RUN )) && info "(dry run: nothing will be changed)"
run mkdir -p "$APPDIR"
info "Checking for conflicts:"
local entry id keys args label
for entry in "${BINDINGS[@]}"; do
IFS='|' read -r id keys args label <<< "$entry"
check_conflict "$id" "$keys"
done
info "Writing application entries:"
for entry in "${BINDINGS[@]}"; do
IFS='|' read -r id keys args label <<< "$entry"
write_desktop "$id" "$args" "$label" "$keys"
done
info "Stopping the global shortcut daemon:"
stop_daemon
sleep 1
info "Registering shortcuts:"
for entry in "${BINDINGS[@]}"; do
IFS='|' read -r id keys args label <<< "$entry"
# _launch is a bare key sequence. Plasma 5 stored a
# "keys,default,name" triplet here and Plasma 6 does not: it passes
# the value straight to QKeySequence::fromString, so a triplet parses
# to nothing and the shortcut silently never binds. Alternates are
# separated by a tab, and "none" means unbound.
run "$KWRITE" --file kglobalshortcutsrc \
--group services --group "$(desktop_name "$id")" \
--key _launch "$keys"
info " $keys -> linkctl $args"
done
info "Rebuilding the application cache:"
run_quiet "$SYCOCA" --noincremental \
|| info " $SYCOCA reported a problem (usually harmless)"
info "Restarting the global shortcut daemon:"
start_daemon
if (( DRY_RUN )); then
info "Dry run complete."
else
info ""
info "Done. Check them under System Settings > Keyboard > Shortcuts,"
info "searching for 'Insta360'. Test one with: $MACRO tracking toggle"
fi
}
do_uninstall() {
info "Removing shortcuts and application entries:"
stop_daemon
sleep 1
local entry id keys args label path
for entry in "${BINDINGS[@]}"; do
IFS='|' read -r id keys args label <<< "$entry"
run "$KWRITE" --file kglobalshortcutsrc \
--group services --group "$(desktop_name "$id")" \
--key _launch --delete
path=$(desktop_file "$id")
[[ -e $path ]] && run rm -f "$path" && info " removed $path"
done
run_quiet "$SYCOCA" --noincremental || true
start_daemon
info "Done."
}
do_list() {
local entry id keys args label value path dflt problems=0
printf '%-20s %-12s %-14s %-12s %s\n' ACTION KEY _launch DEFAULT ENTRY
for entry in "${BINDINGS[@]}"; do
IFS='|' read -r id keys args label <<< "$entry"
value=$("$KREAD" --file kglobalshortcutsrc \
--group services --group "$(desktop_name "$id")" \
--key _launch 2>/dev/null || true)
path=$(desktop_file "$id")
if [[ -r $path ]]; then
dflt=$(sed -n 's/^X-KDE-Shortcuts=//p' "$path" | head -1)
else
dflt=""
fi
# A comma here means a Plasma 5 triplet, which Plasma 6 cannot parse.
if [[ $value == *,* ]]; then
problems=1
value="$value <-- INVALID"
fi
printf '%-20s %-12s %-14s %-12s %s\n' \
"$id" "$keys" "${value:-<unset>}" "${dflt:-<none>}" \
"$([[ -r $path ]] && echo present || echo MISSING)"
done
if (( problems )); then
echo
echo "A _launch value containing commas is the Plasma 5 format and never"
echo "binds under Plasma 6. Re-run this script to rewrite them."
fi
}
while [[ $# -gt 0 ]]; do
case $1 in
--dry-run|-n) DRY_RUN=1; shift ;;
--uninstall) MODE=uninstall; shift ;;
--list|-l) MODE=list; shift ;;
-h|--help)
# Print the header comment block, stopping at the first code line.
awk 'NR>1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' \
"${BASH_SOURCE[0]}"
exit 0 ;;
*) die "unknown option: $1" ;;
esac
done
detect_plasma
case $MODE in
install) do_install ;;
uninstall) do_uninstall ;;
list) do_list ;;
esac