fix(hyprlua): set howdy device_path + add camera chooser with kitty preview

Enrollment failed because howdy's own config.ini kept device_path=none,
so VideoCapture aborted before ever opening the lens (generic exit 1).
The presence-detect camera setting is separate and never reached howdy.

- add a "Howdy face auth — configure camera" menu entry that writes
  device_path into /usr/lib/security/howdy/config.ini
- guard howdy_add: detect an unset/stale device_path and offer to fix it
  before enrolling, instead of surfacing howdy's opaque exit 1
- shared camera chooser shows a live still from the selected node via
  kitty's icat (gated on KITTY_WINDOW_ID), used for presence + howdy
- support non-IR colour webcams: detect IR vs colour from the preview's
  saturation and tune recording_plugin (ffmpeg) + dark_threshold per type

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-06-13 22:35:11 +02:00
parent 88f3b72343
commit 5f83f10f48
1 changed files with 187 additions and 26 deletions

View File

@ -10,6 +10,12 @@ PRESENCE_CFG="${XDG_CONFIG_HOME:-$HOME/.config}/presence-detect.conf"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHON_DETECT="$SCRIPT_DIR/python/presence_detect.py" PYTHON_DETECT="$SCRIPT_DIR/python/presence_detect.py"
# howdy keeps its own camera setting in this INI, entirely separate from the
# presence-detect camera. Enrollment aborts in VideoCapture before touching the
# lens if `device_path` is left at the package default of `none`, so the howdy
# menu has to write a real /dev/video* node in here.
HOWDY_CONFIG="/usr/lib/security/howdy/config.ini"
# ── PAM 2FA (howdy face + FIDO U2F) ──────────────────────────────────────────── # ── PAM 2FA (howdy face + FIDO U2F) ────────────────────────────────────────────
# Both factors required, with the normal password stack kept as a fallback so a # Both factors required, with the normal password stack kept as a fallback so a
# dead camera or absent key can never lock you out. # dead camera or absent key can never lock you out.
@ -95,8 +101,47 @@ set_camera_id() {
printf 'CAMERA=%s\n' "$1" > "$PRESENCE_CFG" printf 'CAMERA=%s\n' "$1" > "$PRESENCE_CFG"
} }
# ── Presence detection ──────────────────────────────────────────────────────── # Grab a single still from a v4l2 node into $2. No sudo: /dev/video* is acl/group
presence_configure_camera() { # readable by the user. The first frame off a cold sensor is often black, so pull
# a few and keep the last.
camera_capture_frame() {
local dev="$1" out="$2"
command -v ffmpeg &>/dev/null || return 2
ffmpeg -hide_banner -loglevel error -f v4l2 -i "$dev" \
-frames:v 5 -update 1 -y "$out" </dev/null 2>/dev/null
[[ -s "$out" ]]
}
# Render an image inline via kitty's graphics protocol. KITTY_WINDOW_ID is the
# reliable signal — $TERM is frequently xterm-256color even under kitty.
preview_frame() {
local img="$1"
[[ -s "$img" ]] || return 1
command -v kitty &>/dev/null || return 1
[[ -n "$KITTY_WINDOW_ID" || "$TERM" == *kitty* ]] || return 1
kitty +kitten icat --align left --clear "$img"
}
# IR sensors deliver near-zero chroma; colour webcams don't. Returns 0 when the
# still looks monochrome (likely IR), 1 otherwise. Assumes colour when it can't
# measure — that's the case that needs the extra non-IR tuning anyway.
camera_looks_ir() {
local img="$1" sat
[[ -s "$img" ]] || return 1
command -v ffmpeg &>/dev/null || return 1
sat=$(ffmpeg -hide_banner -loglevel error -i "$img" \
-vf signalstats,metadata=mode=print:file=- -f null - 2>/dev/null \
| awk -F= '/SATAVG/{print $2; exit}')
[[ -z "$sat" ]] && return 1
awk -v s="$sat" 'BEGIN{exit !(s < 12)}'
}
# Shared camera chooser: menu → live still preview in kitty → confirm. The pick
# is returned in the global PICKED_CAM (numeric id); returns 1 if cancelled.
PICKED_CAM=""
PICKED_CAM_FRAME=""
pick_camera_with_preview() {
local title="$1" current="$2"
local -a cam_items local -a cam_items
mapfile -t cam_items < <(list_cameras) mapfile -t cam_items < <(list_cameras)
@ -104,21 +149,51 @@ presence_configure_camera() {
msg "No Cameras Found" \ msg "No Cameras Found" \
"No video devices found at /dev/video*.\n\nMake sure your webcam is connected." \ "No video devices found at /dev/video*.\n\nMake sure your webcam is connected." \
10 55 10 55
return return 1
fi fi
local current; current=$(get_camera_id) while true; do
local choice local choice
choice=$(dialog --backtitle "$BACKTITLE" \ choice=$(dialog --backtitle "$BACKTITLE" \
--title " Select Camera " \ --title " $title " \
--menu "\nCurrent: /dev/video${current}\n\nSelect camera to use for presence detection:" \ --menu "\nCurrent: ${current}\n\nSelect a camera:" \
16 62 6 \ 16 62 6 \
"${cam_items[@]}" \ "${cam_items[@]}" \
3>&1 1>&2 2>&3) || return 3>&1 1>&2 2>&3) || return 1
set_camera_id "$choice" local dev="/dev/video${choice}"
clear
printf "\n\033[1;35m Preview — %s\033[0m\n" "$dev"
printf "\033[35m ─────────────────────────────────────────\033[0m\n\n"
local frame="$TMP_D/cam_preview.png"
camera_capture_frame "$dev" "$frame"
case $? in
0) preview_frame "$frame" \
|| printf " (kitty preview unavailable; frame saved to %s)\n" "$frame" ;;
2) printf " ffmpeg is not installed — skipping preview.\n" ;;
*) printf " Could not capture a frame from %s.\n The device may be busy or not a capture node.\n" "$dev" ;;
esac
printf "\n"
local ans
read -rp " Use ${dev}? [Y]es / [n]o, pick another / [c]ancel: " ans
case "${ans,,}" in
""|y|yes) PICKED_CAM="$choice"; PICKED_CAM_FRAME="$frame"; return 0 ;;
c|cancel) return 1 ;;
*) continue ;;
esac
done
}
# ── Presence detection ────────────────────────────────────────────────────────
presence_configure_camera() {
local current; current=$(get_camera_id)
pick_camera_with_preview "Select Camera (Presence)" "/dev/video${current}" || return
set_camera_id "$PICKED_CAM"
msg "Camera Set" \ msg "Camera Set" \
"Presence detection will use /dev/video${choice}.\n\nRestart presence-detect.sh for the change to take effect." \ "Presence detection will use /dev/video${PICKED_CAM}.\n\nRestart presence-detect.sh for the change to take effect." \
9 62 9 62
} }
@ -181,9 +256,93 @@ howdy_require() {
howdy_installed || { msg "Install Failed" "howdy installation failed.\nInstall it manually: ${helper} -S howdy" 8 52; return 1; } howdy_installed || { msg "Install Failed" "howdy installation failed.\nInstall it manually: ${helper} -S howdy" 8 52; return 1; }
} }
# ── Howdy camera (device_path) ────────────────────────────────────────────────
# config.ini is root-only, so reads and writes both go through sudo. Returns the
# bare value (e.g. /dev/video1, or `none` when unconfigured).
howdy_get_device_path() {
sudo grep -oP '^\s*device_path\s*=\s*\K.*' "$HOWDY_CONFIG" 2>/dev/null | head -1
}
# Generic [video] key writer: replaces the key in place, or inserts it under the
# [video] header if absent. Every key we touch (device_path, recording_plugin,
# dark_threshold) lives only in that section, so a global replace is safe.
howdy_set_cfg() {
local key="$1" val="$2"
if sudo grep -qE "^\s*${key}\s*=" "$HOWDY_CONFIG"; then
sudo sed -i -E "s|^(\s*${key}\s*=).*|\1 ${val}|" "$HOWDY_CONFIG"
else
sudo sed -i -E "/^\[video\]/a ${key} = ${val}" "$HOWDY_CONFIG"
fi
}
howdy_set_device_path() { howdy_set_cfg device_path "$1"; }
# True when howdy points at a device node that actually exists.
howdy_camera_ok() {
local dp; dp=$(howdy_get_device_path)
[[ -n "$dp" && "$dp" != none && -e "$dp" ]]
}
# howdy's stock tuning assumes a Windows-Hello IR sensor. A regular colour
# webcam works too — dlib doesn't care about IR — but needs two tweaks:
# • recording_plugin = ffmpeg (opencv mis-decodes many YUYV colour streams to
# grey garbage; ffmpeg reads them cleanly — the same path the preview used)
# • dark_threshold = 80 (IR's flashing emitter wants a low value; a
# colour feed in normal light gets rejected as "too dark" at the default 50)
# The camera-type question defaults from the preview's measured colourfulness.
howdy_apply_camera_profile() {
local frame="$1" dev="$2"
local dn=""
camera_looks_ir "$frame" && dn="--defaultno" # looks IR ⇒ default the answer to "No"
dialog --backtitle "$BACKTITLE" --title " Camera Type " $dn --yesno \
"\nIs this a regular (non-IR / colour) webcam?\n\n Yes — tune howdy for a colour camera\n (ffmpeg recorder, dark_threshold 80).\n Works in normal light; less secure.\n\n No — IR sensor (Windows Hello style).\n Keep howdy's default IR tuning." 16 62
case $? in
0) howdy_set_cfg recording_plugin ffmpeg
howdy_set_cfg dark_threshold 80
msg "Howdy Camera Set" \
"howdy will use ${dev}.\n\nTuned for a NON-IR colour webcam:\n recorder = ffmpeg\n dark_threshold = 80\n\nRun 'add face model' next. If frames come out\ngrey or fail, try the other /dev/video node." 14 62 ;;
1) howdy_set_cfg recording_plugin opencv
howdy_set_cfg dark_threshold 50
msg "Howdy Camera Set" \
"howdy will use ${dev}.\n\nTuned for an IR sensor (howdy defaults):\n recorder = opencv\n dark_threshold = 50" 12 62 ;;
*) msg "Howdy Camera Set" \
"howdy will use ${dev}.\n\nCamera-type tuning left unchanged." 9 56 ;;
esac
}
howdy_configure_camera() {
howdy_require || return
local current; current=$(howdy_get_device_path)
[[ -z "$current" ]] && current="(unset)"
pick_camera_with_preview "Select Camera (Howdy)" "$current" || return
local dev="/dev/video${PICKED_CAM}"
howdy_set_device_path "$dev"
howdy_apply_camera_profile "$PICKED_CAM_FRAME" "$dev"
}
# Howdy aborts in VideoCapture when device_path is unset/wrong, surfacing only a
# generic exit code 1. Catch that here and offer to fix it before enrolling.
howdy_ensure_camera() {
howdy_camera_ok && return 0
local dp; dp=$(howdy_get_device_path)
dialog --backtitle "$BACKTITLE" --title " Camera Not Configured " --yesno \
"\nhowdy has no usable camera (device_path = ${dp:-none}).\n\nEnrollment will fail until this is set.\nConfigure the howdy camera now?" 11 60 \
|| return 1
howdy_configure_camera
howdy_camera_ok || {
msg "Still Not Configured" \
"howdy's camera is still unset, aborting enrollment." 7 52
return 1
}
}
# ── Howdy operations ────────────────────────────────────────────────────────── # ── Howdy operations ──────────────────────────────────────────────────────────
howdy_add() { howdy_add() {
howdy_require || return howdy_require || return
howdy_ensure_camera || return
local name local name
name=$(dialog --backtitle "$BACKTITLE" \ name=$(dialog --backtitle "$BACKTITLE" \
@ -449,26 +608,28 @@ main_menu() {
local choice local choice
choice=$(dialog --backtitle "$BACKTITLE" \ choice=$(dialog --backtitle "$BACKTITLE" \
--title " Biometric Enrollment " \ --title " Biometric Enrollment " \
--menu "\nSelect an option:" 22 70 9 \ --menu "\nSelect an option:" 23 70 10 \
"1" "Presence detection — configure camera" \ "1" "Presence detection — configure camera" \
"2" "Presence detection — test detection" \ "2" "Presence detection — test detection" \
"3" "Howdy face auth — add face model" \ "3" "Howdy face auth — configure camera" \
"4" "Howdy face auth — list enrolled models" \ "4" "Howdy face auth — add face model" \
"5" "Howdy face auth — remove face model" \ "5" "Howdy face auth — list enrolled models" \
"6" "Howdy face auth — test authentication" \ "6" "Howdy face auth — remove face model" \
"7" "PAM 2FA — set up howdy + FIDO key" \ "7" "Howdy face auth — test authentication" \
"8" "PAM 2FA — remove howdy + FIDO key" \ "8" "PAM 2FA — set up howdy + FIDO key" \
"9" "PAM 2FA — remove howdy + FIDO key" \
3>&1 1>&2 2>&3) || { clear; exit 0; } 3>&1 1>&2 2>&3) || { clear; exit 0; }
case "$choice" in case "$choice" in
1) presence_configure_camera ;; 1) presence_configure_camera ;;
2) presence_test_camera ;; 2) presence_test_camera ;;
3) howdy_add ;; 3) howdy_configure_camera ;;
4) howdy_list ;; 4) howdy_add ;;
5) howdy_remove ;; 5) howdy_list ;;
6) howdy_test ;; 6) howdy_remove ;;
7) pam_setup ;; 7) howdy_test ;;
8) pam_teardown ;; 8) pam_setup ;;
9) pam_teardown ;;
esac esac
main_menu main_menu
} }