#!/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. # # Wraps linkctl so it is safe to bind to a global hotkey: # - resolves the linkctl binary even in the sparse environment a desktop # shortcut is launched with # - passes --force, because a deliberate keypress means "do it now" even # when no application currently holds the camera open # - reports the result as a desktop notification, since tracking state and # gimbal position are otherwise invisible # # Usage: # linkctl-macro tracking on # linkctl-macro tracking toggle # linkctl-macro preset load home # linkctl-macro center # # Any linkctl subcommand works; arguments are passed through untouched. # # Options (before the linkctl subcommand): # --no-notify suppress the desktop notification # --print echo the resolved command instead of running it # # Configuration, from the environment or ~/.config/linkctl-macro.conf: # LINKCTL path to the linkctl binary # LINKCTL_DEVICE video node to target, e.g. /dev/video0 (for multi-camera) # LINKCTL_FORCE 1 (default) to pass --force, 0 to respect the idle guard # LINKCTL_NOTIFY always (default) | error | never set -uo pipefail readonly CONF="${XDG_CONFIG_HOME:-$HOME/.config}/linkctl-macro.conf" # shellcheck source=/dev/null [[ -r $CONF ]] && source "$CONF" : "${LINKCTL_DEVICE:=}" : "${LINKCTL_FORCE:=1}" : "${LINKCTL_NOTIFY:=always}" # A desktop shortcut does not inherit an interactive shell's PATH, so look in # the places linkctl is actually installed to before giving up. # # Sets LINKCTL_BIN on success and RESOLVE_ERR on failure. Both are globals # rather than stdout, because a command substitution would run this in a # subshell and discard the error message. resolve_linkctl() { local c LINKCTL_BIN= RESOLVE_ERR= if [[ -n ${LINKCTL:-} ]]; then # An explicit setting is authoritative, so a bad one is an error here # rather than a confusing exec failure later. if [[ -x $LINKCTL ]]; then LINKCTL_BIN=$LINKCTL return 0 fi RESOLVE_ERR="LINKCTL is set to '$LINKCTL', which is not executable." return 1 fi if c=$(command -v linkctl 2>/dev/null); then LINKCTL_BIN=$c return 0 fi for c in /usr/local/bin/linkctl /usr/bin/linkctl \ "$HOME/.local/bin/linkctl" "$HOME/.cargo/bin/linkctl" \ /var/lib/flatpak/exports/bin/linkctl; do [[ -x $c ]] && { LINKCTL_BIN=$c; return 0; } done RESOLVE_ERR="linkctl not found on PATH or in the usual install locations." return 1 } # Map linkctl's documented exit codes onto something a person can act on. hint_for() { case $1 in 2) echo "Invalid arguments." ;; 3) echo "Camera not found. Is it plugged in?" ;; 4) echo "Several cameras found. Set LINKCTL_DEVICE in $CONF." ;; 5) echo "Camera is idle and --force was disabled." ;; 6) echo "Permission denied. Add yourself to the 'video' group or install the udev rule." ;; 7) echo "This control is not supported on this camera model." ;; 8) echo "Device I/O error. Try replugging the camera." ;; 9) echo "linkctl configuration error." ;; 11) echo "Invalid value for this control." ;; 12) echo "Vendor control failed. AI tracking is experimental." ;; 13) echo "Camera is busy." ;; *) echo "linkctl exited with status $1." ;; esac } notify() { local urgency=$1 body=$2 [[ $LINKCTL_NOTIFY == never ]] && return 0 [[ $LINKCTL_NOTIFY == error && $urgency != critical ]] && return 0 command -v notify-send >/dev/null 2>&1 || return 0 # The synchronous hint makes repeated keypresses replace the previous # popup instead of stacking a queue of them. notify-send \ --app-name="Insta360 Link" \ --icon=camera-web \ --urgency="$urgency" \ --expire-time=2500 \ --hint=string:x-canonical-private-synchronous:linkctl-macro \ "Insta360 Link" "$body" } main() { local no_notify=0 print_only=0 while [[ $# -gt 0 ]]; do case $1 in --no-notify) no_notify=1; shift ;; --print) print_only=1; shift ;; --) shift; break ;; *) break ;; esac done if [[ $# -eq 0 ]]; then echo "usage: linkctl-macro [--no-notify] [--print] " >&2 echo " eg: linkctl-macro tracking toggle" >&2 return 2 fi (( no_notify )) && LINKCTL_NOTIFY=never if ! resolve_linkctl; then local msg="${RESOLVE_ERR:-linkctl not found.} Set LINKCTL in $CONF." notify critical "$msg" echo "linkctl-macro: $msg" >&2 return 127 fi local -a cmd=("$LINKCTL_BIN") [[ $LINKCTL_FORCE == 1 ]] && cmd+=(--force) [[ -n $LINKCTL_DEVICE ]] && cmd+=(--device "$LINKCTL_DEVICE") cmd+=("$@") if (( print_only )); then printf '%q ' "${cmd[@]}"; echo return 0 fi local out status out=$("${cmd[@]}" 2>&1) status=$? if (( status == 0 )); then # linkctl already prints the resulting state, eg "Tracking: On", which # is exactly the feedback worth surfacing. Fall back to the request. local body=${out%%$'\n'*} [[ -z $body ]] && body="$*" notify normal "$body" [[ -n $out ]] && printf '%s\n' "$out" else local body=${out%%$'\n'*} [[ -n $body ]] && body="$body"$'\n'"$(hint_for "$status")" \ || body=$(hint_for "$status") notify critical "$body" [[ -n $out ]] && printf '%s\n' "$out" >&2 printf 'linkctl-macro: %s\n' "$(hint_for "$status")" >&2 fi return $status } main "$@"