feat(hyprlua,hyprdrive,niri): switch presence detection from webcam motion to FIDO key

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.
main
Amir Alexander Abdelbaki 2026-07-30 13:22:27 +02:00
parent 96f15c82af
commit c16a17f10b
21 changed files with 241 additions and 589 deletions

View File

@ -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 {

View File

@ -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"

View File

@ -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
}

View File

@ -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=<id> 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

View File

@ -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"

View File

@ -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

View File

@ -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))

View File

@ -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 {

View File

@ -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"

View File

@ -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
}

View File

@ -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=<id> 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

View File

@ -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"

View File

@ -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

View File

@ -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))

View File

@ -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"

View File

@ -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"

View File

@ -0,0 +1 @@
../../hyprlua/scripts/presence-test.sh

View File

@ -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[@]}"

View File

@ -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

View File

@ -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[@]}"

View File

@ -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.