From c16a17f10b904b6d98fd25334d2fb11db1c86a70 Mon Sep 17 00:00:00 2001 From: The_miro Date: Thu, 30 Jul 2026 13:22:27 +0200 Subject: [PATCH] feat(hyprlua,hyprdrive,niri): switch presence detection from webcam motion to FIDO key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the OpenCV motion-detection daemon with a fido2-token -L check — presence now means "a FIDO2/U2F key is plugged in", queried without any touch/tap. Adaptive poll: 20s under light load, 120s under moderate, 600s under heavy. Drops the old motion "grace window" since key presence doesn't flicker like camera motion did. Removes presence_detect.py, the presence camera config/test flow from enroll-biometrics.sh, and the python-opencv dependency (kept v4l-utils, still used by howdy's camera setup). Adds a standalone presence-test.sh instead of folding the FIDO check into enroll-biometrics.sh's dialog TUI — there's nothing to enroll, so it doesn't belong in that flow. --- desktopenvs/hyprdrive/hypr/hypridle.conf | 13 +- desktopenvs/hyprdrive/scripts/caffeine.sh | 5 +- .../hyprdrive/scripts/enroll-biometrics.sh | 94 +++------- .../hyprdrive/scripts/presence-detect.sh | 161 ++++++------------ .../hyprdrive/scripts/presence-status.sh | 16 +- .../hyprdrive/scripts/presence-test.sh | 28 +++ .../scripts/python/presence_detect.py | 84 --------- desktopenvs/hyprlua/hypr/hypridle.conf | 13 +- desktopenvs/hyprlua/scripts/caffeine.sh | 5 +- .../hyprlua/scripts/enroll-biometrics.sh | 94 +++------- .../hyprlua/scripts/presence-detect.sh | 161 ++++++------------ .../hyprlua/scripts/presence-status.sh | 16 +- desktopenvs/hyprlua/scripts/presence-test.sh | 28 +++ .../hyprlua/scripts/python/presence_detect.py | 84 --------- desktopenvs/niri/niri/modules/autostart.kdl | 6 +- desktopenvs/niri/scripts/caffeine.sh | 5 +- desktopenvs/niri/scripts/presence-test.sh | 1 + .../modules/Desktop-Environments/hyprdrive.sh | 4 +- .../modules/Desktop-Environments/hyprland.sh | 4 +- setup/modules/Desktop-Environments/hyprlua.sh | 6 +- setup/modules/Desktop-Environments/niri.sh | 2 +- 21 files changed, 241 insertions(+), 589 deletions(-) create mode 100755 desktopenvs/hyprdrive/scripts/presence-test.sh delete mode 100755 desktopenvs/hyprdrive/scripts/python/presence_detect.py create mode 100755 desktopenvs/hyprlua/scripts/presence-test.sh delete mode 100755 desktopenvs/hyprlua/scripts/python/presence_detect.py create mode 120000 desktopenvs/niri/scripts/presence-test.sh diff --git a/desktopenvs/hyprdrive/hypr/hypridle.conf b/desktopenvs/hyprdrive/hypr/hypridle.conf index 27b7f92..f4ca3ac 100644 --- a/desktopenvs/hyprdrive/hypr/hypridle.conf +++ b/desktopenvs/hyprdrive/hypr/hypridle.conf @@ -3,9 +3,10 @@ # # hypridle watches for user inactivity and triggers actions (lock, suspend) # after configurable timeouts. It integrates with the presence-detection daemon -# (presence-detect.sh) which resets the idle timer every 20s (backed off to 120s -# under heavy CPU/RAM load) while the webcam detects motion, so these timeouts -# only fire when the user has truly stepped away from the machine. +# (presence-detect.sh) which resets the idle timer every 20s under light load +# (backed off to 120s under moderate load, 600s under heavy CPU/RAM load) while +# a FIDO key is plugged in, so these timeouts only fire when the user has truly +# stepped away from the machine. # # Reference: https://wiki.hypr.land/Hypr-Ecosystem/hypridle/ # ============================================================================= @@ -34,9 +35,9 @@ general { ignore_dbus_inhibit = false # respect systemd-inhibit locks (presence-detect, caffeine) } -# Presence detection resets the idle timer every 20s (up to 120s under load) -# while motion is detected, so these timeouts only run when you've actually -# stepped away. +# Presence detection resets the idle timer every 20s (backed off to 120s/600s +# under load) while a FIDO key is plugged in, so these timeouts only run when +# you've actually stepped away. # First idle listener: lock the screen after 30s of inactivity. listener { diff --git a/desktopenvs/hyprdrive/scripts/caffeine.sh b/desktopenvs/hyprdrive/scripts/caffeine.sh index 03db103..cf4ce29 100755 --- a/desktopenvs/hyprdrive/scripts/caffeine.sh +++ b/desktopenvs/hyprdrive/scripts/caffeine.sh @@ -2,8 +2,9 @@ # Toggle idle inhibit via systemd-inhibit (hypridle respects the logind idle hint). # The PID file tracks the background sleep process used to hold the inhibitor lock. # This lock is shared with presence-detect.sh, which also inhibits idle while -# motion is detected — OWNED_FLAG marks lock ownership between the two, so clear -# it on every manual toggle: this action is always a manual takeover of the lock. +# a FIDO key is plugged in — OWNED_FLAG marks lock ownership between the two, +# so clear it on every manual toggle: this action is always a manual takeover +# of the lock. PID_FILE="/tmp/caffeine-inhibit.pid" OWNED_FLAG="/tmp/presence-inhibit-owned" diff --git a/desktopenvs/hyprdrive/scripts/enroll-biometrics.sh b/desktopenvs/hyprdrive/scripts/enroll-biometrics.sh index 09cfece..57fd994 100755 --- a/desktopenvs/hyprdrive/scripts/enroll-biometrics.sh +++ b/desktopenvs/hyprdrive/scripts/enroll-biometrics.sh @@ -1,14 +1,11 @@ #!/bin/bash -# enroll-biometrics.sh — TUI for face biometric setup. +# enroll-biometrics.sh — TUI for howdy face-auth enrollment + howdy/FIDO PAM 2FA setup. # -# Two subsystems: -# 1. Presence detection — configure/test the webcam used by presence-detect.sh -# 2. Howdy face auth — enroll/manage/test face models for PAM authentication +# For testing FIDO key presence detection (presence-detect.sh), see +# presence-test.sh instead — kept separate since it's not an "enrollment" +# step (fido2-token needs no setup, it just checks the key over USB). BACKTITLE="Biometric Enrollment" -PRESENCE_CFG="${XDG_CONFIG_HOME:-$HOME/.config}/presence-detect.conf" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -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 @@ -106,21 +103,6 @@ list_cameras() { done } -get_camera_id() { - if [[ -n "$PRESENCE_DETECT_CAMERA" ]]; then - echo "$PRESENCE_DETECT_CAMERA" - elif [[ -f "$PRESENCE_CFG" ]]; then - grep -oP 'CAMERA=\K[0-9]+' "$PRESENCE_CFG" 2>/dev/null || echo 0 - else - echo 0 - fi -} - -set_camera_id() { - mkdir -p "$(dirname "$PRESENCE_CFG")" - printf 'CAMERA=%s\n' "$1" > "$PRESENCE_CFG" -} - # Grab a single still from a v4l2 node into $2. No sudo: /dev/video* is acl/group # readable by the user. The first frame off a cold sensor is often black, so pull # a few and keep the last. @@ -206,40 +188,6 @@ pick_camera_with_preview() { 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" \ - "Presence detection will use /dev/video${PICKED_CAM}.\n\nRestart presence-detect.sh for the change to take effect." \ - 9 62 -} - -presence_test_camera() { - local cam; cam=$(get_camera_id) - - clear - printf "\n\033[1;35m Testing presence detection on /dev/video%s...\033[0m\n" "$cam" - printf "\033[35m ─────────────────────────────────────────\033[0m\n\n" - printf " Move around in front of the camera.\n\n" - - python3 "$PYTHON_DETECT" "$cam" 2>/dev/null - local rc=$? - - case $rc in - 0) msg "Test Result" "Motion detected!\n\nPresence detection is working correctly." 8 52 ;; - 1) msg "Test Result" \ - "No motion detected.\n\nMake sure you are in front of the camera,\nmoving slightly, with adequate lighting." \ - 10 56 ;; - 2) msg "Camera Error" \ - "Could not open /dev/video${cam}.\n\nTry configuring a different camera first." \ - 9 56 ;; - *) msg "Error" "Unexpected exit code ($rc) from detection script." 7 52 ;; - esac -} - # ── Howdy helpers ───────────────────────────────────────────────────────────── # Detection must survive howdy 2.x's layout: its CLI lives under root-only # /usr/lib/security/howdy/ with /usr/bin/howdy symlinked into it, so a normal @@ -636,28 +584,24 @@ main_menu() { local choice choice=$(dialog --backtitle "$BACKTITLE" \ --title " Biometric Enrollment " \ - --menu "\nSelect an option:" 23 70 10 \ - "1" "Presence detection — configure camera" \ - "2" "Presence detection — test detection" \ - "3" "Howdy face auth — configure camera" \ - "4" "Howdy face auth — add face model" \ - "5" "Howdy face auth — list enrolled models" \ - "6" "Howdy face auth — remove face model" \ - "7" "Howdy face auth — test authentication" \ - "8" "PAM 2FA — set up howdy + FIDO key" \ - "9" "PAM 2FA — remove howdy + FIDO key" \ + --menu "\nSelect an option:" 20 70 8 \ + "1" "Howdy face auth — configure camera" \ + "2" "Howdy face auth — add face model" \ + "3" "Howdy face auth — list enrolled models" \ + "4" "Howdy face auth — remove face model" \ + "5" "Howdy face auth — test authentication" \ + "6" "PAM 2FA — set up howdy + FIDO key" \ + "7" "PAM 2FA — remove howdy + FIDO key" \ 3>&1 1>&2 2>&3) || { clear; exit 0; } case "$choice" in - 1) presence_configure_camera ;; - 2) presence_test_camera ;; - 3) howdy_configure_camera ;; - 4) howdy_add ;; - 5) howdy_list ;; - 6) howdy_remove ;; - 7) howdy_test ;; - 8) pam_setup ;; - 9) pam_teardown ;; + 1) howdy_configure_camera ;; + 2) howdy_add ;; + 3) howdy_list ;; + 4) howdy_remove ;; + 5) howdy_test ;; + 6) pam_setup ;; + 7) pam_teardown ;; esac main_menu } diff --git a/desktopenvs/hyprdrive/scripts/presence-detect.sh b/desktopenvs/hyprdrive/scripts/presence-detect.sh index 9a2fb65..f061fdd 100755 --- a/desktopenvs/hyprdrive/scripts/presence-detect.sh +++ b/desktopenvs/hyprdrive/scripts/presence-detect.sh @@ -1,77 +1,42 @@ #!/bin/bash -# Webcam presence detection daemon. -# Checks for motion at an adaptive interval (20s normally, backed off to 120s -# under heavy system load) and shares caffeine's systemd-inhibit idle lock -# while the user is detected, so hypridle never fires during an active session. +# FIDO key presence detection daemon. +# Checks whether a FIDO2/U2F security key is plugged in, at an adaptive +# interval (20s under light load, 120s under moderate load, 600s under heavy +# load), and shares caffeine's systemd-inhibit idle lock while a key is +# present, so hypridle never fires during an active session. # -# Reads the physical camera directly, but only briefly: each tick opens the -# device, grabs a handful of frames, and releases it, so the camera stays free -# for other apps (video calls, howdy) the rest of the time. If another app is -# already holding the camera when a tick fires, the grab fails — and that -# "busy" is itself proof the user is present (someone's on a call), so it counts -# as presence rather than an error. This replaced an earlier v4l2loopback-mirror -# design that held the camera open 24/7 and only served one reader at a time. +# Detection: `fido2-token -L` (libfido2) enumerates connected FIDO CTAP +# devices by USB HID usage page — no touch/tap required, so this is a pure +# presence check, not an authentication. Non-empty output = a key is present. # -# Camera selection: set PRESENCE_DETECT_CAMERA env var or write -# CAMERA= to ~/.config/presence-detect.conf -# -# Exit codes from python helper: -# 0 = motion (present) 1 = no motion (away) -# 2 = camera unavailable -> skip the tick, leave inhibit state unchanged -# 3 = camera busy / in use -> treat as present (another app holds the camera) +# This replaced an earlier webcam motion-detection design: simpler, no camera +# contention with video calls/howdy, and no OpenCV dependency. -# Resolve the script's real directory so the Python helper path stays valid -# even when invoked via a symlink or from a different cwd. -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PYTHON_DETECT="$SCRIPT_DIR/python/presence_detect.py" # Shared with caffeine.sh: both the manual toggle and this daemon drive the # same systemd-inhibit lock, so caffeine-status.sh reflects either source. PID_FILE="/tmp/caffeine-inhibit.pid" # Marks that *this daemon* (not the manual caffeine toggle) currently holds -# the lock, so a "no motion" tick never releases a manually-started session. +# the lock, so a "key removed" tick never releases a manually-started session. OWNED_FLAG="/tmp/presence-inhibit-owned" -# Records whether the camera currently SEES the user (motion, or the camera -# being in use by another app, on the last tick). presence-status.sh reads this -# so the Eww widget can show presence as a distinct signal from the idle lock. -# It reflects live camera state only while the daemon is actively watching: -# during a MANUAL caffeine session the daemon stops reading the camera entirely -# (to leave it free) and clears this flag, so presence reads false there. +# Records whether a FIDO key is present on the last tick. presence-status.sh +# reads this so the Eww widget can show presence as a distinct signal from +# the idle lock. During a MANUAL caffeine session the daemon still checks (the +# check is cheap and doesn't touch any device other apps might want), so this +# stays live even then — only the inhibit ownership differs. PRESENCE_FLAG="/tmp/presence-detected" -PRESENCE_CFG="${XDG_CONFIG_HOME:-$HOME/.config}/presence-detect.conf" -INTERVAL_FAST=2 # near-idle system (< LOAD_LIGHT): poll fast for responsiveness -INTERVAL_IDLE=20 # seconds between checks under normal load -INTERVAL_BUSY=120 # seconds between checks when CPU or RAM is >= LOAD_BUSY -LOAD_BUSY=0.70 # CPU-or-RAM fraction at/above which we back off to INTERVAL_BUSY -LOAD_LIGHT=0.30 # CPU-or-RAM fraction below which we speed up to INTERVAL_FAST +INTERVAL_LIGHT=20 # seconds between checks when CPU-or-RAM usage < LOAD_LIGHT +INTERVAL_MID=120 # seconds between checks when usage is between the two thresholds +INTERVAL_HEAVY=600 # seconds between checks when usage >= LOAD_HEAVY +LOAD_LIGHT=0.20 # CPU-or-RAM fraction below which we poll at INTERVAL_LIGHT +LOAD_HEAVY=0.50 # CPU-or-RAM fraction at/above which we back off to INTERVAL_HEAVY NPROC="$(nproc)" -# Grace window: keep reporting "present" for this long after the last detected -# motion, so brief stillness (reading, thinking, a slow moment) never drops the -# lock the instant one tick sees nothing. Any motion tick resets the clock. -GRACE_SECONDS=90 - -# Resolve camera ID: env var takes highest priority, then config file, then default 0. -_camera_id() { - if [[ -n "$PRESENCE_DETECT_CAMERA" ]]; then - echo "$PRESENCE_DETECT_CAMERA" - elif [[ -f "$PRESENCE_CFG" ]]; then - # -oP 'CAMERA=\K[0-9]+': Perl-style look-behind strips "CAMERA=" prefix. - grep -oP 'CAMERA=\K[0-9]+' "$PRESENCE_CFG" 2>/dev/null || echo 0 - else - echo 0 - fi -} - -# True if any process OTHER than this daemon currently holds the real camera -# open — a video call, howdy, etc. `fuser` lists same-user holders without root -# (every camera app runs as us), and we only ever call this when we aren't -# reading the camera ourselves, so any holder is another app. If fuser isn't -# installed we return false: the tick then just tries the camera and the Python -# helper's busy/in-use path (exit 3) still covers an already-held device. -_camera_in_use() { - command -v fuser >/dev/null 2>&1 || return 1 - fuser -s "/dev/video${1}" 2>/dev/null +# True if any FIDO2/U2F authenticator is currently enumerable over USB. +# fido2-token -L lists one line per connected device and needs no PIN, touch, +# or tap — it's a pure USB HID enumeration, so this never prompts the key. +_fido_key_present() { + [[ -n "$(fido2-token -L 2>/dev/null)" ]] } # Prints current CPU-or-RAM usage as a fraction (0..1): the higher of the @@ -87,14 +52,14 @@ _resource_usage() { } # Three-tier poll interval based on current resource usage: -# >= LOAD_BUSY -> INTERVAL_BUSY (heavy load: back off, stay out of the way) -# < LOAD_LIGHT -> INTERVAL_FAST (near-idle: poll fast, cheap and responsive) -# otherwise -> INTERVAL_IDLE (normal load) +# >= LOAD_HEAVY -> INTERVAL_HEAVY (heavy load: back off, stay out of the way) +# < LOAD_LIGHT -> INTERVAL_LIGHT (near-idle: poll fast, cheap and responsive) +# otherwise -> INTERVAL_MID (moderate load) _next_interval() { local u; u="$(_resource_usage)" - awk -v u="$u" -v busy="$LOAD_BUSY" -v light="$LOAD_LIGHT" \ - -v fb="$INTERVAL_BUSY" -v ff="$INTERVAL_FAST" -v fi="$INTERVAL_IDLE" \ - 'BEGIN{ if (u >= busy) print fb; else if (u < light) print ff; else print fi }' + awk -v u="$u" -v heavy="$LOAD_HEAVY" -v light="$LOAD_LIGHT" \ + -v fh="$INTERVAL_HEAVY" -v fl="$INTERVAL_LIGHT" -v fm="$INTERVAL_MID" \ + 'BEGIN{ if (u >= heavy) print fh; else if (u < light) print fl; else print fm }' } # Returns true if the inhibitor sentinel process is still alive. @@ -109,12 +74,12 @@ _start_inhibit() { # --what=idle: target the logind idle-inhibit lock that hypridle polls. # "sleep infinity" is the sentinel; its PID is saved so we can kill it later. systemd-inhibit --what=idle --who="presence-detect" \ - --why="User presence detected" --mode=block \ + --why="FIDO key present" --mode=block \ sleep infinity & echo $! > "$PID_FILE" touch "$OWNED_FLAG" # logger writes to the system journal — visible via `journalctl -t presence-detect`. - logger -t presence-detect "Motion detected — idle inhibited" + logger -t presence-detect "FIDO key present — idle inhibited" } _stop_inhibit() { @@ -125,7 +90,7 @@ _stop_inhibit() { # Killing the sleep process releases the systemd-inhibit lock automatically. kill "$(cat "$PID_FILE")" 2>/dev/null rm -f "$PID_FILE" "$OWNED_FLAG" - logger -t presence-detect "No motion — idle inhibit released" + logger -t presence-detect "FIDO key removed — idle inhibit released" } _cleanup() { @@ -139,53 +104,23 @@ _cleanup() { # Intercept termination signals to ensure the inhibitor PID is never orphaned. trap _cleanup SIGTERM SIGINT SIGHUP -# Epoch seconds of the last tick that saw motion; drives the GRACE_SECONDS -# window below. 0 = never seen yet, so a no-motion tick at startup releases -# immediately (nothing is held anyway). -LAST_MOTION=0 - while true; do # When idle is inhibited MANUALLY (a caffeine toggle, not this daemon), the - # lock is already held regardless of presence, so there's no reason to look - # at the camera — skip the read entirely and leave the device free for other - # apps. Detected the same way caffeine-manual-status.sh does it: the shared - # lock is alive but we don't own it. We clear PRESENCE_FLAG because we're no - # longer watching and must not report a stale "camera sees you". - if _inhibit_running && [[ ! -f "$OWNED_FLAG" ]]; then + # lock is already held regardless of presence. Detected the same way + # caffeine-manual-status.sh does it: the shared lock is alive but we don't + # own it. We still update PRESENCE_FLAG below since checking is cheap and + # doesn't touch a device other apps care about — only inhibit ownership + # differs during a manual session. + if _fido_key_present; then + touch "$PRESENCE_FLAG" + # No-op if a manual caffeine session already holds the lock — only + # _stop_inhibit needs the ownership check; starting is always safe + # since _start_inhibit itself no-ops when a lock is already held. + _start_inhibit + else rm -f "$PRESENCE_FLAG" - sleep "$(_next_interval)" - continue + _stop_inhibit fi - DEVICE="$(_camera_id)" - now="$(date +%s)" - - # Back-off: if another app already holds the camera (a video call, howdy, - # ...), don't even try to read it — attempting would be pointless (we'd just - # get EBUSY) and a simultaneous open could make the app itself fail. A camera - # in use is proof you're present, so mark presence and wait it out instead. - if _camera_in_use "$DEVICE"; then - LAST_MOTION="$now"; touch "$PRESENCE_FLAG"; _start_inhibit - sleep "$(_next_interval)" - continue - fi - - # Run the OpenCV motion detector; stderr suppressed to keep the journal clean. - python3 "$PYTHON_DETECT" "$DEVICE" 2>/dev/null - rc=$? - case $rc in - # Motion (0) or camera busy/in use (3): user is present. rc=3 means - # another app (a video call, howdy) is holding the camera, which is - # itself proof you're here, so it keeps the session awake even though we - # can't read frames. Both refresh the grace clock and mark presence. - 0|3) LAST_MOTION="$now"; touch "$PRESENCE_FLAG"; _start_inhibit ;; - # No motion: only actually release once we've been still for the whole - # grace window. Within it, leave the flag/lock exactly as they were so a - # brief pause in movement doesn't flicker presence off. - 1) if (( now - LAST_MOTION >= GRACE_SECONDS )); then - rm -f "$PRESENCE_FLAG"; _stop_inhibit - fi ;; - # rc=2 = camera unavailable (unplugged/gone) — silently skip, state unchanged - esac sleep "$(_next_interval)" done diff --git a/desktopenvs/hyprdrive/scripts/presence-status.sh b/desktopenvs/hyprdrive/scripts/presence-status.sh index dcab9c2..36aeec0 100755 --- a/desktopenvs/hyprdrive/scripts/presence-status.sh +++ b/desktopenvs/hyprdrive/scripts/presence-status.sh @@ -1,13 +1,13 @@ #!/bin/bash -# Reports the PRESENCE input for the Eww caffeine widget: whether the webcam -# presence-detect daemon currently sees the user (motion on its last tick), -# INDEPENDENT of whether the idle lock is held or who holds it. +# Reports the PRESENCE input for the Eww caffeine widget: whether the +# presence-detect daemon currently sees a FIDO key plugged in (on its last +# tick), INDEPENDENT of whether the idle lock is held or who holds it. # -# presence-detect.sh writes PRESENCE_FLAG on every "present" tick and removes it -# on every "away" tick, so this is a clean, standalone signal. Note: during a -# MANUAL caffeine session the daemon stops reading the camera (to leave it free) -# and clears the flag, so presence reads false there. For the MANUAL-inhibit -# input, see caffeine-manual-status.sh. +# presence-detect.sh writes PRESENCE_FLAG on every "present" tick and removes +# it on every "away" tick, so this is a clean, standalone signal — it stays +# live even during a MANUAL caffeine session, since checking for the key is +# cheap and doesn't contend with anything. For the MANUAL-inhibit input, see +# caffeine-manual-status.sh. # # Prints "true" when presence is detected, else "false". PRESENCE_FLAG="/tmp/presence-detected" diff --git a/desktopenvs/hyprdrive/scripts/presence-test.sh b/desktopenvs/hyprdrive/scripts/presence-test.sh new file mode 100755 index 0000000..f0ed720 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/presence-test.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# presence-test.sh — standalone check for FIDO key presence detection. +# +# Kept separate from enroll-biometrics.sh: there's nothing to enroll or +# configure here (fido2-token needs no setup, it just enumerates the key over +# USB), so bundling it into the enrollment TUI was more confusing than useful. +# +# Runs the exact same check presence-detect.sh uses on every poll tick. + +command -v fido2-token &>/dev/null || { + printf "\n\033[1;31m fido2-token not found\033[0m (from libfido2).\n" + printf " Install it: sudo pacman -S libfido2\n\n" + exit 1 +} + +printf "\n\033[1;35m Checking for a connected FIDO key...\033[0m\n" +printf "\033[35m ─────────────────────────────────────────\033[0m\n\n" + +out=$(fido2-token -L 2>/dev/null) +if [[ -n "$out" ]]; then + printf " \033[1;32mFIDO key detected:\033[0m\n\n" + printf '%s\n' "$out" | sed 's/^/ /' + printf "\n Presence detection is working correctly.\n\n" +else + printf " \033[1;33mNo FIDO key detected.\033[0m\n\n" + printf " Make sure your key is plugged in.\n\n" + exit 1 +fi diff --git a/desktopenvs/hyprdrive/scripts/python/presence_detect.py b/desktopenvs/hyprdrive/scripts/python/presence_detect.py deleted file mode 100755 index a9f0214..0000000 --- a/desktopenvs/hyprdrive/scripts/python/presence_detect.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env python3 -""" -Webcam motion presence detector using frame differencing. -Exit codes: - 0 = motion detected (user present) - 1 = no motion (user likely away) - 2 = camera unavailable (device node missing / gone) -> caller skips tick - 3 = camera busy / in use (another app holds it) -> treat as present -Usage: presence_detect.py [camera_id] - -Reads the physical camera directly, only for the ~0.5s it takes to grab a few -frames, then releases it — so a video call, howdy, etc. can use the camera the -rest of the time. When one of those apps IS holding the camera, we can't read -it; rather than mistake that for "no motion", we report it as busy/in-use (3), -which the daemon treats as presence: a camera in use means the user is here. -""" -import os -import sys -import time -import cv2 -import numpy as np - -FRAMES_TO_CHECK = 8 -DIFFS_NEEDED = 2 # require motion in at least N consecutive-frame diffs -PIXEL_DELTA_THRESHOLD = 18 # per-pixel grayscale delta to count as "changed" -MOTION_AREA_RATIO = 0.008 # fraction of pixels that must change to call it motion -BLUR_KSIZE = (21, 21) # Gaussian blur kernel to suppress sensor noise -# Spacing between the frames we diff. Without it the frames are grabbed -# back-to-back within a few milliseconds, so slow, small movements barely differ -# between adjacent frames. Spacing the grabs out gives slow motion a real -# temporal baseline and guarantees each compared frame is a fresh one. -INTER_FRAME_DELAY = 0.06 # seconds between grabs (~0.5s total observation window) - - -def detect(camera_id: int) -> int: - # A missing device node means the camera is genuinely gone (unplugged, module - # not loaded) -> "unavailable" (2), and the daemon leaves presence untouched. - # A node that exists but won't open (below) or won't yield frames (further - # down) means something else is streaming it -> "busy/in use" (3) = present. - if not os.path.exists(f"/dev/video{camera_id}"): - return 2 - - cap = cv2.VideoCapture(camera_id) - if not cap.isOpened(): - # Node exists but we can't open it: another app owns the camera. - return 3 - - motion_diffs = 0 - frames_read = 0 - prev_gray = None - try: - for i in range(FRAMES_TO_CHECK): - if i > 0: - time.sleep(INTER_FRAME_DELAY) - ok, frame = cap.read() - if not ok: - continue - frames_read += 1 - gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) - gray = cv2.GaussianBlur(gray, BLUR_KSIZE, 0) - - if prev_gray is not None: - delta = cv2.absdiff(prev_gray, gray) - changed = np.count_nonzero(delta > PIXEL_DELTA_THRESHOLD) - if changed / delta.size >= MOTION_AREA_RATIO: - motion_diffs += 1 - - prev_gray = gray - finally: - cap.release() - - # Opened but produced no frames at all: on V4L2 a second opener can succeed - # yet have its reads starved while another app streams the device. Treat that - # as busy/in-use (present) rather than "no motion", so an active video call - # keeps the session awake even though we never see a usable frame. - if frames_read == 0: - return 3 - - return 0 if motion_diffs >= DIFFS_NEEDED else 1 - - -if __name__ == "__main__": - camera_id = int(sys.argv[1]) if len(sys.argv) > 1 else 0 - sys.exit(detect(camera_id)) diff --git a/desktopenvs/hyprlua/hypr/hypridle.conf b/desktopenvs/hyprlua/hypr/hypridle.conf index 27b7f92..f4ca3ac 100644 --- a/desktopenvs/hyprlua/hypr/hypridle.conf +++ b/desktopenvs/hyprlua/hypr/hypridle.conf @@ -3,9 +3,10 @@ # # hypridle watches for user inactivity and triggers actions (lock, suspend) # after configurable timeouts. It integrates with the presence-detection daemon -# (presence-detect.sh) which resets the idle timer every 20s (backed off to 120s -# under heavy CPU/RAM load) while the webcam detects motion, so these timeouts -# only fire when the user has truly stepped away from the machine. +# (presence-detect.sh) which resets the idle timer every 20s under light load +# (backed off to 120s under moderate load, 600s under heavy CPU/RAM load) while +# a FIDO key is plugged in, so these timeouts only fire when the user has truly +# stepped away from the machine. # # Reference: https://wiki.hypr.land/Hypr-Ecosystem/hypridle/ # ============================================================================= @@ -34,9 +35,9 @@ general { ignore_dbus_inhibit = false # respect systemd-inhibit locks (presence-detect, caffeine) } -# Presence detection resets the idle timer every 20s (up to 120s under load) -# while motion is detected, so these timeouts only run when you've actually -# stepped away. +# Presence detection resets the idle timer every 20s (backed off to 120s/600s +# under load) while a FIDO key is plugged in, so these timeouts only run when +# you've actually stepped away. # First idle listener: lock the screen after 30s of inactivity. listener { diff --git a/desktopenvs/hyprlua/scripts/caffeine.sh b/desktopenvs/hyprlua/scripts/caffeine.sh index 03db103..cf4ce29 100755 --- a/desktopenvs/hyprlua/scripts/caffeine.sh +++ b/desktopenvs/hyprlua/scripts/caffeine.sh @@ -2,8 +2,9 @@ # Toggle idle inhibit via systemd-inhibit (hypridle respects the logind idle hint). # The PID file tracks the background sleep process used to hold the inhibitor lock. # This lock is shared with presence-detect.sh, which also inhibits idle while -# motion is detected — OWNED_FLAG marks lock ownership between the two, so clear -# it on every manual toggle: this action is always a manual takeover of the lock. +# a FIDO key is plugged in — OWNED_FLAG marks lock ownership between the two, +# so clear it on every manual toggle: this action is always a manual takeover +# of the lock. PID_FILE="/tmp/caffeine-inhibit.pid" OWNED_FLAG="/tmp/presence-inhibit-owned" diff --git a/desktopenvs/hyprlua/scripts/enroll-biometrics.sh b/desktopenvs/hyprlua/scripts/enroll-biometrics.sh index 09cfece..57fd994 100755 --- a/desktopenvs/hyprlua/scripts/enroll-biometrics.sh +++ b/desktopenvs/hyprlua/scripts/enroll-biometrics.sh @@ -1,14 +1,11 @@ #!/bin/bash -# enroll-biometrics.sh — TUI for face biometric setup. +# enroll-biometrics.sh — TUI for howdy face-auth enrollment + howdy/FIDO PAM 2FA setup. # -# Two subsystems: -# 1. Presence detection — configure/test the webcam used by presence-detect.sh -# 2. Howdy face auth — enroll/manage/test face models for PAM authentication +# For testing FIDO key presence detection (presence-detect.sh), see +# presence-test.sh instead — kept separate since it's not an "enrollment" +# step (fido2-token needs no setup, it just checks the key over USB). BACKTITLE="Biometric Enrollment" -PRESENCE_CFG="${XDG_CONFIG_HOME:-$HOME/.config}/presence-detect.conf" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -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 @@ -106,21 +103,6 @@ list_cameras() { done } -get_camera_id() { - if [[ -n "$PRESENCE_DETECT_CAMERA" ]]; then - echo "$PRESENCE_DETECT_CAMERA" - elif [[ -f "$PRESENCE_CFG" ]]; then - grep -oP 'CAMERA=\K[0-9]+' "$PRESENCE_CFG" 2>/dev/null || echo 0 - else - echo 0 - fi -} - -set_camera_id() { - mkdir -p "$(dirname "$PRESENCE_CFG")" - printf 'CAMERA=%s\n' "$1" > "$PRESENCE_CFG" -} - # Grab a single still from a v4l2 node into $2. No sudo: /dev/video* is acl/group # readable by the user. The first frame off a cold sensor is often black, so pull # a few and keep the last. @@ -206,40 +188,6 @@ pick_camera_with_preview() { 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" \ - "Presence detection will use /dev/video${PICKED_CAM}.\n\nRestart presence-detect.sh for the change to take effect." \ - 9 62 -} - -presence_test_camera() { - local cam; cam=$(get_camera_id) - - clear - printf "\n\033[1;35m Testing presence detection on /dev/video%s...\033[0m\n" "$cam" - printf "\033[35m ─────────────────────────────────────────\033[0m\n\n" - printf " Move around in front of the camera.\n\n" - - python3 "$PYTHON_DETECT" "$cam" 2>/dev/null - local rc=$? - - case $rc in - 0) msg "Test Result" "Motion detected!\n\nPresence detection is working correctly." 8 52 ;; - 1) msg "Test Result" \ - "No motion detected.\n\nMake sure you are in front of the camera,\nmoving slightly, with adequate lighting." \ - 10 56 ;; - 2) msg "Camera Error" \ - "Could not open /dev/video${cam}.\n\nTry configuring a different camera first." \ - 9 56 ;; - *) msg "Error" "Unexpected exit code ($rc) from detection script." 7 52 ;; - esac -} - # ── Howdy helpers ───────────────────────────────────────────────────────────── # Detection must survive howdy 2.x's layout: its CLI lives under root-only # /usr/lib/security/howdy/ with /usr/bin/howdy symlinked into it, so a normal @@ -636,28 +584,24 @@ main_menu() { local choice choice=$(dialog --backtitle "$BACKTITLE" \ --title " Biometric Enrollment " \ - --menu "\nSelect an option:" 23 70 10 \ - "1" "Presence detection — configure camera" \ - "2" "Presence detection — test detection" \ - "3" "Howdy face auth — configure camera" \ - "4" "Howdy face auth — add face model" \ - "5" "Howdy face auth — list enrolled models" \ - "6" "Howdy face auth — remove face model" \ - "7" "Howdy face auth — test authentication" \ - "8" "PAM 2FA — set up howdy + FIDO key" \ - "9" "PAM 2FA — remove howdy + FIDO key" \ + --menu "\nSelect an option:" 20 70 8 \ + "1" "Howdy face auth — configure camera" \ + "2" "Howdy face auth — add face model" \ + "3" "Howdy face auth — list enrolled models" \ + "4" "Howdy face auth — remove face model" \ + "5" "Howdy face auth — test authentication" \ + "6" "PAM 2FA — set up howdy + FIDO key" \ + "7" "PAM 2FA — remove howdy + FIDO key" \ 3>&1 1>&2 2>&3) || { clear; exit 0; } case "$choice" in - 1) presence_configure_camera ;; - 2) presence_test_camera ;; - 3) howdy_configure_camera ;; - 4) howdy_add ;; - 5) howdy_list ;; - 6) howdy_remove ;; - 7) howdy_test ;; - 8) pam_setup ;; - 9) pam_teardown ;; + 1) howdy_configure_camera ;; + 2) howdy_add ;; + 3) howdy_list ;; + 4) howdy_remove ;; + 5) howdy_test ;; + 6) pam_setup ;; + 7) pam_teardown ;; esac main_menu } diff --git a/desktopenvs/hyprlua/scripts/presence-detect.sh b/desktopenvs/hyprlua/scripts/presence-detect.sh index 9a2fb65..f061fdd 100755 --- a/desktopenvs/hyprlua/scripts/presence-detect.sh +++ b/desktopenvs/hyprlua/scripts/presence-detect.sh @@ -1,77 +1,42 @@ #!/bin/bash -# Webcam presence detection daemon. -# Checks for motion at an adaptive interval (20s normally, backed off to 120s -# under heavy system load) and shares caffeine's systemd-inhibit idle lock -# while the user is detected, so hypridle never fires during an active session. +# FIDO key presence detection daemon. +# Checks whether a FIDO2/U2F security key is plugged in, at an adaptive +# interval (20s under light load, 120s under moderate load, 600s under heavy +# load), and shares caffeine's systemd-inhibit idle lock while a key is +# present, so hypridle never fires during an active session. # -# Reads the physical camera directly, but only briefly: each tick opens the -# device, grabs a handful of frames, and releases it, so the camera stays free -# for other apps (video calls, howdy) the rest of the time. If another app is -# already holding the camera when a tick fires, the grab fails — and that -# "busy" is itself proof the user is present (someone's on a call), so it counts -# as presence rather than an error. This replaced an earlier v4l2loopback-mirror -# design that held the camera open 24/7 and only served one reader at a time. +# Detection: `fido2-token -L` (libfido2) enumerates connected FIDO CTAP +# devices by USB HID usage page — no touch/tap required, so this is a pure +# presence check, not an authentication. Non-empty output = a key is present. # -# Camera selection: set PRESENCE_DETECT_CAMERA env var or write -# CAMERA= to ~/.config/presence-detect.conf -# -# Exit codes from python helper: -# 0 = motion (present) 1 = no motion (away) -# 2 = camera unavailable -> skip the tick, leave inhibit state unchanged -# 3 = camera busy / in use -> treat as present (another app holds the camera) +# This replaced an earlier webcam motion-detection design: simpler, no camera +# contention with video calls/howdy, and no OpenCV dependency. -# Resolve the script's real directory so the Python helper path stays valid -# even when invoked via a symlink or from a different cwd. -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PYTHON_DETECT="$SCRIPT_DIR/python/presence_detect.py" # Shared with caffeine.sh: both the manual toggle and this daemon drive the # same systemd-inhibit lock, so caffeine-status.sh reflects either source. PID_FILE="/tmp/caffeine-inhibit.pid" # Marks that *this daemon* (not the manual caffeine toggle) currently holds -# the lock, so a "no motion" tick never releases a manually-started session. +# the lock, so a "key removed" tick never releases a manually-started session. OWNED_FLAG="/tmp/presence-inhibit-owned" -# Records whether the camera currently SEES the user (motion, or the camera -# being in use by another app, on the last tick). presence-status.sh reads this -# so the Eww widget can show presence as a distinct signal from the idle lock. -# It reflects live camera state only while the daemon is actively watching: -# during a MANUAL caffeine session the daemon stops reading the camera entirely -# (to leave it free) and clears this flag, so presence reads false there. +# Records whether a FIDO key is present on the last tick. presence-status.sh +# reads this so the Eww widget can show presence as a distinct signal from +# the idle lock. During a MANUAL caffeine session the daemon still checks (the +# check is cheap and doesn't touch any device other apps might want), so this +# stays live even then — only the inhibit ownership differs. PRESENCE_FLAG="/tmp/presence-detected" -PRESENCE_CFG="${XDG_CONFIG_HOME:-$HOME/.config}/presence-detect.conf" -INTERVAL_FAST=2 # near-idle system (< LOAD_LIGHT): poll fast for responsiveness -INTERVAL_IDLE=20 # seconds between checks under normal load -INTERVAL_BUSY=120 # seconds between checks when CPU or RAM is >= LOAD_BUSY -LOAD_BUSY=0.70 # CPU-or-RAM fraction at/above which we back off to INTERVAL_BUSY -LOAD_LIGHT=0.30 # CPU-or-RAM fraction below which we speed up to INTERVAL_FAST +INTERVAL_LIGHT=20 # seconds between checks when CPU-or-RAM usage < LOAD_LIGHT +INTERVAL_MID=120 # seconds between checks when usage is between the two thresholds +INTERVAL_HEAVY=600 # seconds between checks when usage >= LOAD_HEAVY +LOAD_LIGHT=0.20 # CPU-or-RAM fraction below which we poll at INTERVAL_LIGHT +LOAD_HEAVY=0.50 # CPU-or-RAM fraction at/above which we back off to INTERVAL_HEAVY NPROC="$(nproc)" -# Grace window: keep reporting "present" for this long after the last detected -# motion, so brief stillness (reading, thinking, a slow moment) never drops the -# lock the instant one tick sees nothing. Any motion tick resets the clock. -GRACE_SECONDS=90 - -# Resolve camera ID: env var takes highest priority, then config file, then default 0. -_camera_id() { - if [[ -n "$PRESENCE_DETECT_CAMERA" ]]; then - echo "$PRESENCE_DETECT_CAMERA" - elif [[ -f "$PRESENCE_CFG" ]]; then - # -oP 'CAMERA=\K[0-9]+': Perl-style look-behind strips "CAMERA=" prefix. - grep -oP 'CAMERA=\K[0-9]+' "$PRESENCE_CFG" 2>/dev/null || echo 0 - else - echo 0 - fi -} - -# True if any process OTHER than this daemon currently holds the real camera -# open — a video call, howdy, etc. `fuser` lists same-user holders without root -# (every camera app runs as us), and we only ever call this when we aren't -# reading the camera ourselves, so any holder is another app. If fuser isn't -# installed we return false: the tick then just tries the camera and the Python -# helper's busy/in-use path (exit 3) still covers an already-held device. -_camera_in_use() { - command -v fuser >/dev/null 2>&1 || return 1 - fuser -s "/dev/video${1}" 2>/dev/null +# True if any FIDO2/U2F authenticator is currently enumerable over USB. +# fido2-token -L lists one line per connected device and needs no PIN, touch, +# or tap — it's a pure USB HID enumeration, so this never prompts the key. +_fido_key_present() { + [[ -n "$(fido2-token -L 2>/dev/null)" ]] } # Prints current CPU-or-RAM usage as a fraction (0..1): the higher of the @@ -87,14 +52,14 @@ _resource_usage() { } # Three-tier poll interval based on current resource usage: -# >= LOAD_BUSY -> INTERVAL_BUSY (heavy load: back off, stay out of the way) -# < LOAD_LIGHT -> INTERVAL_FAST (near-idle: poll fast, cheap and responsive) -# otherwise -> INTERVAL_IDLE (normal load) +# >= LOAD_HEAVY -> INTERVAL_HEAVY (heavy load: back off, stay out of the way) +# < LOAD_LIGHT -> INTERVAL_LIGHT (near-idle: poll fast, cheap and responsive) +# otherwise -> INTERVAL_MID (moderate load) _next_interval() { local u; u="$(_resource_usage)" - awk -v u="$u" -v busy="$LOAD_BUSY" -v light="$LOAD_LIGHT" \ - -v fb="$INTERVAL_BUSY" -v ff="$INTERVAL_FAST" -v fi="$INTERVAL_IDLE" \ - 'BEGIN{ if (u >= busy) print fb; else if (u < light) print ff; else print fi }' + awk -v u="$u" -v heavy="$LOAD_HEAVY" -v light="$LOAD_LIGHT" \ + -v fh="$INTERVAL_HEAVY" -v fl="$INTERVAL_LIGHT" -v fm="$INTERVAL_MID" \ + 'BEGIN{ if (u >= heavy) print fh; else if (u < light) print fl; else print fm }' } # Returns true if the inhibitor sentinel process is still alive. @@ -109,12 +74,12 @@ _start_inhibit() { # --what=idle: target the logind idle-inhibit lock that hypridle polls. # "sleep infinity" is the sentinel; its PID is saved so we can kill it later. systemd-inhibit --what=idle --who="presence-detect" \ - --why="User presence detected" --mode=block \ + --why="FIDO key present" --mode=block \ sleep infinity & echo $! > "$PID_FILE" touch "$OWNED_FLAG" # logger writes to the system journal — visible via `journalctl -t presence-detect`. - logger -t presence-detect "Motion detected — idle inhibited" + logger -t presence-detect "FIDO key present — idle inhibited" } _stop_inhibit() { @@ -125,7 +90,7 @@ _stop_inhibit() { # Killing the sleep process releases the systemd-inhibit lock automatically. kill "$(cat "$PID_FILE")" 2>/dev/null rm -f "$PID_FILE" "$OWNED_FLAG" - logger -t presence-detect "No motion — idle inhibit released" + logger -t presence-detect "FIDO key removed — idle inhibit released" } _cleanup() { @@ -139,53 +104,23 @@ _cleanup() { # Intercept termination signals to ensure the inhibitor PID is never orphaned. trap _cleanup SIGTERM SIGINT SIGHUP -# Epoch seconds of the last tick that saw motion; drives the GRACE_SECONDS -# window below. 0 = never seen yet, so a no-motion tick at startup releases -# immediately (nothing is held anyway). -LAST_MOTION=0 - while true; do # When idle is inhibited MANUALLY (a caffeine toggle, not this daemon), the - # lock is already held regardless of presence, so there's no reason to look - # at the camera — skip the read entirely and leave the device free for other - # apps. Detected the same way caffeine-manual-status.sh does it: the shared - # lock is alive but we don't own it. We clear PRESENCE_FLAG because we're no - # longer watching and must not report a stale "camera sees you". - if _inhibit_running && [[ ! -f "$OWNED_FLAG" ]]; then + # lock is already held regardless of presence. Detected the same way + # caffeine-manual-status.sh does it: the shared lock is alive but we don't + # own it. We still update PRESENCE_FLAG below since checking is cheap and + # doesn't touch a device other apps care about — only inhibit ownership + # differs during a manual session. + if _fido_key_present; then + touch "$PRESENCE_FLAG" + # No-op if a manual caffeine session already holds the lock — only + # _stop_inhibit needs the ownership check; starting is always safe + # since _start_inhibit itself no-ops when a lock is already held. + _start_inhibit + else rm -f "$PRESENCE_FLAG" - sleep "$(_next_interval)" - continue + _stop_inhibit fi - DEVICE="$(_camera_id)" - now="$(date +%s)" - - # Back-off: if another app already holds the camera (a video call, howdy, - # ...), don't even try to read it — attempting would be pointless (we'd just - # get EBUSY) and a simultaneous open could make the app itself fail. A camera - # in use is proof you're present, so mark presence and wait it out instead. - if _camera_in_use "$DEVICE"; then - LAST_MOTION="$now"; touch "$PRESENCE_FLAG"; _start_inhibit - sleep "$(_next_interval)" - continue - fi - - # Run the OpenCV motion detector; stderr suppressed to keep the journal clean. - python3 "$PYTHON_DETECT" "$DEVICE" 2>/dev/null - rc=$? - case $rc in - # Motion (0) or camera busy/in use (3): user is present. rc=3 means - # another app (a video call, howdy) is holding the camera, which is - # itself proof you're here, so it keeps the session awake even though we - # can't read frames. Both refresh the grace clock and mark presence. - 0|3) LAST_MOTION="$now"; touch "$PRESENCE_FLAG"; _start_inhibit ;; - # No motion: only actually release once we've been still for the whole - # grace window. Within it, leave the flag/lock exactly as they were so a - # brief pause in movement doesn't flicker presence off. - 1) if (( now - LAST_MOTION >= GRACE_SECONDS )); then - rm -f "$PRESENCE_FLAG"; _stop_inhibit - fi ;; - # rc=2 = camera unavailable (unplugged/gone) — silently skip, state unchanged - esac sleep "$(_next_interval)" done diff --git a/desktopenvs/hyprlua/scripts/presence-status.sh b/desktopenvs/hyprlua/scripts/presence-status.sh index dcab9c2..36aeec0 100755 --- a/desktopenvs/hyprlua/scripts/presence-status.sh +++ b/desktopenvs/hyprlua/scripts/presence-status.sh @@ -1,13 +1,13 @@ #!/bin/bash -# Reports the PRESENCE input for the Eww caffeine widget: whether the webcam -# presence-detect daemon currently sees the user (motion on its last tick), -# INDEPENDENT of whether the idle lock is held or who holds it. +# Reports the PRESENCE input for the Eww caffeine widget: whether the +# presence-detect daemon currently sees a FIDO key plugged in (on its last +# tick), INDEPENDENT of whether the idle lock is held or who holds it. # -# presence-detect.sh writes PRESENCE_FLAG on every "present" tick and removes it -# on every "away" tick, so this is a clean, standalone signal. Note: during a -# MANUAL caffeine session the daemon stops reading the camera (to leave it free) -# and clears the flag, so presence reads false there. For the MANUAL-inhibit -# input, see caffeine-manual-status.sh. +# presence-detect.sh writes PRESENCE_FLAG on every "present" tick and removes +# it on every "away" tick, so this is a clean, standalone signal — it stays +# live even during a MANUAL caffeine session, since checking for the key is +# cheap and doesn't contend with anything. For the MANUAL-inhibit input, see +# caffeine-manual-status.sh. # # Prints "true" when presence is detected, else "false". PRESENCE_FLAG="/tmp/presence-detected" diff --git a/desktopenvs/hyprlua/scripts/presence-test.sh b/desktopenvs/hyprlua/scripts/presence-test.sh new file mode 100755 index 0000000..f0ed720 --- /dev/null +++ b/desktopenvs/hyprlua/scripts/presence-test.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# presence-test.sh — standalone check for FIDO key presence detection. +# +# Kept separate from enroll-biometrics.sh: there's nothing to enroll or +# configure here (fido2-token needs no setup, it just enumerates the key over +# USB), so bundling it into the enrollment TUI was more confusing than useful. +# +# Runs the exact same check presence-detect.sh uses on every poll tick. + +command -v fido2-token &>/dev/null || { + printf "\n\033[1;31m fido2-token not found\033[0m (from libfido2).\n" + printf " Install it: sudo pacman -S libfido2\n\n" + exit 1 +} + +printf "\n\033[1;35m Checking for a connected FIDO key...\033[0m\n" +printf "\033[35m ─────────────────────────────────────────\033[0m\n\n" + +out=$(fido2-token -L 2>/dev/null) +if [[ -n "$out" ]]; then + printf " \033[1;32mFIDO key detected:\033[0m\n\n" + printf '%s\n' "$out" | sed 's/^/ /' + printf "\n Presence detection is working correctly.\n\n" +else + printf " \033[1;33mNo FIDO key detected.\033[0m\n\n" + printf " Make sure your key is plugged in.\n\n" + exit 1 +fi diff --git a/desktopenvs/hyprlua/scripts/python/presence_detect.py b/desktopenvs/hyprlua/scripts/python/presence_detect.py deleted file mode 100755 index a9f0214..0000000 --- a/desktopenvs/hyprlua/scripts/python/presence_detect.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env python3 -""" -Webcam motion presence detector using frame differencing. -Exit codes: - 0 = motion detected (user present) - 1 = no motion (user likely away) - 2 = camera unavailable (device node missing / gone) -> caller skips tick - 3 = camera busy / in use (another app holds it) -> treat as present -Usage: presence_detect.py [camera_id] - -Reads the physical camera directly, only for the ~0.5s it takes to grab a few -frames, then releases it — so a video call, howdy, etc. can use the camera the -rest of the time. When one of those apps IS holding the camera, we can't read -it; rather than mistake that for "no motion", we report it as busy/in-use (3), -which the daemon treats as presence: a camera in use means the user is here. -""" -import os -import sys -import time -import cv2 -import numpy as np - -FRAMES_TO_CHECK = 8 -DIFFS_NEEDED = 2 # require motion in at least N consecutive-frame diffs -PIXEL_DELTA_THRESHOLD = 18 # per-pixel grayscale delta to count as "changed" -MOTION_AREA_RATIO = 0.008 # fraction of pixels that must change to call it motion -BLUR_KSIZE = (21, 21) # Gaussian blur kernel to suppress sensor noise -# Spacing between the frames we diff. Without it the frames are grabbed -# back-to-back within a few milliseconds, so slow, small movements barely differ -# between adjacent frames. Spacing the grabs out gives slow motion a real -# temporal baseline and guarantees each compared frame is a fresh one. -INTER_FRAME_DELAY = 0.06 # seconds between grabs (~0.5s total observation window) - - -def detect(camera_id: int) -> int: - # A missing device node means the camera is genuinely gone (unplugged, module - # not loaded) -> "unavailable" (2), and the daemon leaves presence untouched. - # A node that exists but won't open (below) or won't yield frames (further - # down) means something else is streaming it -> "busy/in use" (3) = present. - if not os.path.exists(f"/dev/video{camera_id}"): - return 2 - - cap = cv2.VideoCapture(camera_id) - if not cap.isOpened(): - # Node exists but we can't open it: another app owns the camera. - return 3 - - motion_diffs = 0 - frames_read = 0 - prev_gray = None - try: - for i in range(FRAMES_TO_CHECK): - if i > 0: - time.sleep(INTER_FRAME_DELAY) - ok, frame = cap.read() - if not ok: - continue - frames_read += 1 - gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) - gray = cv2.GaussianBlur(gray, BLUR_KSIZE, 0) - - if prev_gray is not None: - delta = cv2.absdiff(prev_gray, gray) - changed = np.count_nonzero(delta > PIXEL_DELTA_THRESHOLD) - if changed / delta.size >= MOTION_AREA_RATIO: - motion_diffs += 1 - - prev_gray = gray - finally: - cap.release() - - # Opened but produced no frames at all: on V4L2 a second opener can succeed - # yet have its reads starved while another app streams the device. Treat that - # as busy/in-use (present) rather than "no motion", so an active video call - # keeps the session awake even though we never see a usable frame. - if frames_read == 0: - return 3 - - return 0 if motion_diffs >= DIFFS_NEEDED else 1 - - -if __name__ == "__main__": - camera_id = int(sys.argv[1]) if len(sys.argv) > 1 else 0 - sys.exit(detect(camera_id)) diff --git a/desktopenvs/niri/niri/modules/autostart.kdl b/desktopenvs/niri/niri/modules/autostart.kdl index 1fdcaa9..ba6bfd7 100644 --- a/desktopenvs/niri/niri/modules/autostart.kdl +++ b/desktopenvs/niri/niri/modules/autostart.kdl @@ -71,9 +71,9 @@ spawn-at-startup "dunst" // swayidle doesn't block waiting for it to exit. spawn-at-startup "bash" "-c" "swayidle -w timeout 300 'swaylock -f' timeout 600 'systemctl suspend' before-sleep 'swaylock -f'" -// presence-detect.sh — webcam-based automatic idle inhibitor. -// WHY: detects when the user is physically at the desk (via camera) and holds -// the idle inhibitor active so the screen doesn't lock unnecessarily. +// presence-detect.sh — FIDO-key-based automatic idle inhibitor. +// WHY: detects when a FIDO2/U2F security key is plugged in and holds the idle +// inhibitor active so the screen doesn't lock unnecessarily. // Complements the manual caffeine.sh toggle (Mod+Shift+C). spawn-at-startup "bash" "-c" "~/.config/scripts/presence-detect.sh" diff --git a/desktopenvs/niri/scripts/caffeine.sh b/desktopenvs/niri/scripts/caffeine.sh index d4ee2b9..54d6862 100755 --- a/desktopenvs/niri/scripts/caffeine.sh +++ b/desktopenvs/niri/scripts/caffeine.sh @@ -1,8 +1,9 @@ #!/bin/bash # Toggle idle inhibit via systemd-inhibit (swayidle respects the logind idle hint). # This lock is shared with presence-detect.sh, which also inhibits idle while -# motion is detected — OWNED_FLAG marks lock ownership between the two, so clear -# it on every manual toggle: this action is always a manual takeover of the lock. +# a FIDO key is plugged in — OWNED_FLAG marks lock ownership between the two, +# so clear it on every manual toggle: this action is always a manual takeover +# of the lock. PID_FILE="/tmp/caffeine-inhibit.pid" OWNED_FLAG="/tmp/presence-inhibit-owned" diff --git a/desktopenvs/niri/scripts/presence-test.sh b/desktopenvs/niri/scripts/presence-test.sh new file mode 120000 index 0000000..de74c44 --- /dev/null +++ b/desktopenvs/niri/scripts/presence-test.sh @@ -0,0 +1 @@ +../../hyprlua/scripts/presence-test.sh \ No newline at end of file diff --git a/setup/modules/Desktop-Environments/hyprdrive.sh b/setup/modules/Desktop-Environments/hyprdrive.sh index 1816781..7d98ed5 100755 --- a/setup/modules/Desktop-Environments/hyprdrive.sh +++ b/setup/modules/Desktop-Environments/hyprdrive.sh @@ -153,8 +153,8 @@ HYPRDRIVE_PACKAGES=( wf-recorder sound-theme-freedesktop - python-opencv # webcam presence-detection daemon - v4l-utils + libfido2 # fido2-token; FIDO key presence-detection daemon + v4l-utils # howdy camera enrollment ) sudo pacman -Syu --noconfirm --needed -- "${HYPRDRIVE_PACKAGES[@]}" diff --git a/setup/modules/Desktop-Environments/hyprland.sh b/setup/modules/Desktop-Environments/hyprland.sh index a6596e8..f88e81b 100755 --- a/setup/modules/Desktop-Environments/hyprland.sh +++ b/setup/modules/Desktop-Environments/hyprland.sh @@ -13,8 +13,8 @@ # - EWW and config source paths point to desktopenvs/hyprland/ (not hyprlua/) # - hypr-usr/ device-specific .conf files are placed at ~/.config/ root level # and sourced by hyprland.conf via 'source' directives -# - hyprlua.sh additionally installs python-opencv and v4l-utils for the -# webcam-based presence/idle detection daemon +# - hyprlua.sh additionally installs libfido2 and v4l-utils for the +# FIDO-key-based presence/idle detection daemon # # High-level install flow: # 1. System update + Flatpak diff --git a/setup/modules/Desktop-Environments/hyprlua.sh b/setup/modules/Desktop-Environments/hyprlua.sh index d15c099..af078ef 100755 --- a/setup/modules/Desktop-Environments/hyprlua.sh +++ b/setup/modules/Desktop-Environments/hyprlua.sh @@ -13,7 +13,7 @@ # - Config source paths point to desktopenvs/hyprlua/ # - Device-specific overrides live in ~/.config/hypr/usr/ (Lua modules # loaded via require("usr.*")), not at ~/.config/ root -# - Adds python-opencv + v4l-utils for the webcam presence-detection daemon +# - Adds libfido2 (FIDO key presence-detection daemon) + v4l-utils (howdy camera setup) # - Tablet mode also installs evdev-right-click-emulation from AUR # - gsettings sets prefer-dark globally (relevant for GTK apps under Hyprland) # - qt6ct is NOT installed (hyprlua dropped it from the package list) @@ -155,8 +155,8 @@ HYPRLUA_PACKAGES=( wf-recorder # screen recorder for wlroots-based compositors sound-theme-freedesktop # standard freedesktop sound event samples - python-opencv # OpenCV Python bindings; webcam presence-detection daemon - v4l-utils # Video4Linux2 tools for enumerating and configuring webcams + libfido2 # fido2-token; FIDO key presence-detection daemon + v4l-utils # Video4Linux2 tools for howdy's camera enrollment ) sudo pacman -Syu --noconfirm --needed -- "${HYPRLUA_PACKAGES[@]}" diff --git a/setup/modules/Desktop-Environments/niri.sh b/setup/modules/Desktop-Environments/niri.sh index face325..4469790 100755 --- a/setup/modules/Desktop-Environments/niri.sh +++ b/setup/modules/Desktop-Environments/niri.sh @@ -27,7 +27,7 @@ sudo pacman -Syu --noconfirm --needed \ thunar tumbler thunar-archive-plugin thunar-shares-plugin thunar-volman \ pcmanfm-qt udisks2 ly kew \ pavucontrol playerctl wf-recorder sound-theme-freedesktop \ - xinput jq python-opencv v4l-utils \ + xinput jq libfido2 v4l-utils \ gtk-layer-shell # GTK3 wlr-layer-shell library EWW's gtk-layer-shell-sys # crate links against; without it `cargo build` below # fails at that build script with a pkg-config error.