Files
face-unlock/src/lib/system.sh
T

431 lines
16 KiB
Bash

# shellcheck shell=bash
#
# The few things that need root, and how the menu gets them done.
#
# The menu runs as the user. When it needs root it runs this same program
# again through sudo (or run0, or doas) with --root and one verb, the way
# cachy-auto-update does, so the password is typed into the terminal the user
# is already looking at. The verbs are all there is: there is no way to hand
# the root side a command of one's own.
#
# --root set <Key> <Value> one line of /etc/face-unlock/config
# --root pam-enable <service> sudo, polkit-1, a lock screen, or lockscreens
# --root pam-disable <service> for all lock screens installed; see pam.sh
# --root socket-enable start the daemon's socket, and at boot
# --root socket-disable
# --root migrate move over from plasma-face-unlock, see migrate.sh
# --root polkit-agent-install install a polkit agent, see fu_polkit_agent_install
FU_SELF="${FU_SELF:-$(readlink -f "${BASH_SOURCE[1]:-$0}")}"
# What each system setting may be set to. Anything else is refused on the root
# side, whatever the menu sent.
declare -A FU_SYS_VALID=(
[Camera]='^(auto|/dev/video[0-9]+)$'
[Liveness]='^(off|light|heavy)$'
[Strictness]='^(relaxed|normal|strict)$'
[Attention]='^(yes|no)$'
[ScanSeconds]='^([2-9]|1[0-5])$'
[Adapt]='^(yes|no)$'
[SkipLidClosed]='^(yes|no)$'
[MaxFailures]='^([1-9]|1[0-9]|20)$'
[LockoutMinutes]='^[1-9][0-9]{0,3}$'
)
# fu_root <verb> [args...]
fu_root() {
local candidate
if [[ $EUID -eq 0 ]]; then
fu_root_verb "$@"
return
fi
for candidate in sudo run0 doas; do
if fu_have "$candidate"; then
"$candidate" "$FU_SELF" --root "$@"
return
fi
done
fu_bad "$(fu_msg "This needs root, and neither sudo, run0 nor doas is installed.")"
return 1
}
fu_root_verb() {
local verb="${1:-}"
[[ $# -gt 0 ]] && shift
if [[ $EUID -ne 0 ]]; then
fu_bad "$(fu_msg "This command has to run as root.")"
return 2
fi
case "$verb" in
set)
local key="${1:-}" value="${2:-}"
if [[ -z ${FU_SYS_VALID[$key]+set} || ! $value =~ ${FU_SYS_VALID[$key]} ]]; then
fu_bad "$(fu_msg "Not a valid setting: %s=%s" "$key" "$value")"
return 1
fi
fu_kv_set "$FU_SYSCONFIG" "$key" "$value" "$FU_PRETTY (system settings)" || return 1
chmod 0644 "$FU_SYSCONFIG"
;;
pam-enable|pam-disable)
local service="${1:-}" known=0 s rc=0
local -a services=("$service")
for s in "${FU_PAM_SERVICES[@]}" "${FU_PAM_LOCKERS[@]}"; do
[[ $s == "$service" ]] && known=1
done
if [[ $service == lockscreens ]]; then
known=1
mapfile -t services < <(fu_pam_lockers)
fi
(( known )) || { fu_bad "$(fu_msg "Not a service this can be used for: %s" "$service")"; return 1; }
for s in "${services[@]}"; do
if [[ $verb == pam-enable ]]; then
fu_pam_enable "$s" || rc=1
else
fu_pam_disable "$s" || rc=1
fi
done
return $rc
;;
socket-enable)
systemctl enable --now "$FU_UNIT_SOCKET" > /dev/null 2>&1
;;
socket-disable)
systemctl disable --now "$FU_UNIT_SOCKET" > /dev/null 2>&1
;;
migrate)
fu_migrate_system
;;
polkit-agent-install)
fu_polkit_agent_install
;;
*)
fu_bad "$(fu_msg "Unknown command: %s" "$verb")"
return 1
;;
esac
}
# ---------------------------------------------------------------------------
# The two services
# ---------------------------------------------------------------------------
fu_socket_enabled() {
systemctl is-enabled --quiet "$FU_UNIT_SOCKET" 2>/dev/null
}
fu_agent_available() {
fu_have systemctl && [[ -n ${XDG_RUNTIME_DIR:-} ]]
}
fu_agent_enabled() {
systemctl --user is-enabled --quiet "$FU_UNIT_AGENT" 2>/dev/null
}
fu_agent_running() {
systemctl --user is-active --quiet "$FU_UNIT_AGENT" 2>/dev/null
}
fu_agent_enable() {
systemctl --user daemon-reload > /dev/null 2>&1 || true
systemctl --user enable --now "$FU_UNIT_AGENT" > /dev/null 2>&1
}
# fu_agent_autostarts
# Whether the agent's service starts with the session. It is wanted by
# graphical-session.target, which Hyprland only reaches when it runs under
# uwsm. Plasma, GNOME and niri-session always do.
fu_agent_autostarts() {
[[ $FU_DESKTOP != hyprland ]] || systemctl --user is-active --quiet graphical-session.target
}
# fu_agent_hint [unit]
# What to add to Hyprland's config so it starts the lock screen agent, or the
# polkit agent's unit, in the Lua it has since 0.56 or in the older format.
fu_agent_hint() {
local unit="${1:-$FU_UNIT_AGENT}"
# shellcheck disable=SC2088 # shown to the user, not a path to open
local conf="~/.config/hypr/hyprland.conf"
# shellcheck disable=SC2088
[[ -f $FU_XDG_CONFIG/hypr/hyprland.lua ]] && conf="~/.config/hypr/hyprland.lua"
if [[ $unit == "$FU_UNIT_AGENT" ]]; then
fu_note "$(fu_msg "Hyprland does not start the lock screen agent by itself. Add this to %s:" "$conf")"
else
fu_note "$(fu_msg "Hyprland does not start the polkit agent by itself. Add this to %s:" "$conf")"
fi
if [[ $conf == *.lua ]]; then
fu_code "hl.on(\"hyprland.start\", function ()"
fu_code " hl.exec_cmd(\"systemctl --user start $unit\")"
fu_code "end)"
else
fu_code "exec-once = systemctl --user start $unit"
fi
}
# ---------------------------------------------------------------------------
# The polkit agent
# ---------------------------------------------------------------------------
# polkit asks for the password in a window of the session's polkit agent: to
# add a face, and in the admin prompts. Plasma and GNOME bring one, Hyprland
# and Niri do not. Without one no window opens, and polkit just says no.
FU_POLKIT_AGENT_MISSING=''
# fu_polkit_agent_missing
# Whether no polkit agent runs in this session. polkit has no call to ask
# that, so this tries to register an agent of its own: polkit refuses it when
# there is one. When it is let in, busctl ends at once, and polkit drops it
# again. Any other answer counts as not missing, so nobody is warned wrongly.
# Asked once per run.
fu_polkit_agent_missing() {
local session
if [[ -z $FU_POLKIT_AGENT_MISSING ]]; then
FU_POLKIT_AGENT_MISSING=no
if [[ $FU_DESKTOP != plasma && $FU_DESKTOP != gnome && -n ${WAYLAND_DISPLAY:-} ]] && fu_have busctl; then
session="${XDG_SESSION_ID:-$(loginctl show-user "$UID" -p Display --value 2> /dev/null)}"
if [[ -n $session ]] && busctl call --system org.freedesktop.PolicyKit1 \
/org/freedesktop/PolicyKit1/Authority org.freedesktop.PolicyKit1.Authority \
RegisterAuthenticationAgent '(sa{sv})ss' unix-session 1 session-id s "$session" \
C /io/github/loonixtools/FaceUnlock/Probe > /dev/null 2>&1; then
FU_POLKIT_AGENT_MISSING=yes
fi
fi
fi
[[ $FU_POLKIT_AGENT_MISSING == yes ]]
}
# fu_polkit_agent_install
# Root side. hyprpolkitagent where the distribution has it, else KDE's agent,
# which runs anywhere and which niri suggests. The package manager shows what
# it installs and asks.
fu_polkit_agent_install() {
if fu_have pacman; then
pacman -Si hyprpolkitagent > /dev/null 2>&1 && { pacman -S --needed hyprpolkitagent; return; }
pacman -S --needed polkit-kde-agent
elif fu_have dnf; then
dnf -q info hyprpolkitagent > /dev/null 2>&1 && { dnf install hyprpolkitagent; return; }
dnf install polkit-kde
elif fu_have apt-get; then
apt-cache show hyprpolkitagent > /dev/null 2>&1 && { apt-get install hyprpolkitagent; return; }
apt-get install polkit-kde-agent-1
else
fu_bad "$(fu_msg "No package manager found to install a polkit agent with.")"
return 1
fi
}
# _fu_polkit_agent_unit
# The user service of the polkit agent that is installed, if any.
_fu_polkit_agent_unit() {
local unit
for unit in hyprpolkitagent.service plasma-polkit-agent.service; do
if systemctl --user list-unit-files --quiet "$unit" > /dev/null 2>&1; then
printf '%s\n' "$unit"
return 0
fi
done
return 1
}
# fu_polkit_agent_fix
# Installs a polkit agent if there is none, and starts it now and with the
# session.
fu_polkit_agent_fix() {
local unit
if ! unit="$(_fu_polkit_agent_unit)"; then
fu_say " $(fu_msg "Installing a polkit agent. That needs your password.")"
fu_root polkit-agent-install || { fu_bad "$(fu_msg "Could not install a polkit agent.")"; return 1; }
systemctl --user daemon-reload > /dev/null 2>&1 || true
unit="$(_fu_polkit_agent_unit)" || { fu_bad "$(fu_msg "Could not install a polkit agent.")"; return 1; }
fi
if [[ $unit == plasma-polkit-agent.service ]]; then
# KDE's has no [Install] section, and outside Plasma nothing makes it
# wait for the desktop: it would start before there is one.
local dropin="$FU_XDG_CONFIG/systemd/user/$unit.d"
mkdir -p "$dropin" && printf '# Written by %s.\n[Unit]\nAfter=graphical-session.target\n' "$FU_NAME" > "$dropin/$FU_NAME.conf"
systemctl --user daemon-reload > /dev/null 2>&1 || true
systemctl --user add-wants graphical-session.target "$unit" > /dev/null 2>&1
systemctl --user start "$unit" > /dev/null 2>&1
else
systemctl --user enable --now "$unit" > /dev/null 2>&1
fi
if ! systemctl --user is-active --quiet "$unit"; then
fu_bad "$(fu_msg "The polkit agent did not start. See: systemctl --user status %s" "$unit")"
return 1
fi
FU_POLKIT_AGENT_MISSING=no
fu_ok "$(fu_msg "The polkit agent is running. Password windows open now.")"
fu_agent_autostarts || fu_agent_hint "$unit"
}
# ---------------------------------------------------------------------------
# The bubble's GNOME Shell extension
# ---------------------------------------------------------------------------
# GNOME lets no program draw above its windows; its extensions may. The
# package brings one, and turning face unlock on switches it on.
FU_GNOME_EXTENSION="face-unlock@loonixtools.github.io"
# fu_gnome_extension_enable
# gnome-extensions only knows the extensions GNOME found when the session
# started. One that came with the package later goes straight into the
# setting, and GNOME loads it at the next login: returns 2 then.
fu_gnome_extension_enable() {
local list new
fu_have gsettings || return 1
if gnome-extensions info "$FU_GNOME_EXTENSION" > /dev/null 2>&1; then
gnome-extensions enable "$FU_GNOME_EXTENSION" > /dev/null 2>&1 && return 0
fi
list="$(gsettings get org.gnome.shell enabled-extensions 2>/dev/null)" || return 1
case "$list" in
*"'$FU_GNOME_EXTENSION'"*) return 2 ;;
'@as []'|'[]') new="['$FU_GNOME_EXTENSION']" ;;
*) new="${list%]}, '$FU_GNOME_EXTENSION']" ;;
esac
gsettings set org.gnome.shell enabled-extensions "$new" || return 1
return 2
}
fu_gnome_extension_disable() {
local list
fu_have gsettings || return 0
gnome-extensions disable "$FU_GNOME_EXTENSION" > /dev/null 2>&1 && return 0
list="$(gsettings get org.gnome.shell enabled-extensions 2>/dev/null)" || return 0
list="${list//, \'$FU_GNOME_EXTENSION\'/}"
list="${list//\'$FU_GNOME_EXTENSION\', /}"
list="${list//\'$FU_GNOME_EXTENSION\'/}"
[[ $list == '[]' ]] && list='@as []'
gsettings set org.gnome.shell enabled-extensions "$list" > /dev/null 2>&1 || true
}
# ---------------------------------------------------------------------------
# The lock screen where it is a program of its own (Hyprland, Niri)
# ---------------------------------------------------------------------------
# Either face-unlock's (`face-unlock lock`, with the bubble), or the user's
# own program. hyprlock then shows what the bubble would as a line of text: a
# label in its config reads a file the agent writes (see
# src/agent/locktext.h). Asked once in a window, and under Settings.
FU_HYPRLOCK_SNIPPET="$FU_XDG_CONFIG/$FU_NAME/hyprlock.conf"
FU_HYPRLOCK_MARK="# $FU_NAME: what face unlock is doing, at the top"
fu_own_lock_here() {
[[ $FU_DESKTOP != plasma && $FU_DESKTOP != gnome ]]
}
# fu_hyprlock_text_on
# 0 done, 1 could not write, 2 no hyprlock, 3 hyprlock has no config here.
fu_hyprlock_text_on() {
local conf="$FU_XDG_CONFIG/hypr/hyprlock.conf"
fu_have hyprlock || return 2
[[ -f $conf ]] || return 3
mkdir -p "${FU_HYPRLOCK_SNIPPET%/*}" || return 1
cat > "$FU_HYPRLOCK_SNIPPET" <<- EOF || return 1
# Written by $FU_NAME: what face unlock is doing, as a line at the top of
# hyprlock. Picking $FU_NAME's lock screen in its menu takes it out again.
label {
monitor =
text = cmd[update:0:1] cat "\$XDG_RUNTIME_DIR/$FU_NAME/lock-text" 2>/dev/null
color = rgba(255, 255, 255, 0.95)
font_size = 20
shadow_passes = 2
shadow_size = 3
halign = center
valign = top
position = 0, -48
zindex = 10
}
EOF
grep -qxF "$FU_HYPRLOCK_MARK" "$conf" && return 0
# With ~ where it can, like a hand-written config. hyprlock expands it.
local snippet="$FU_HYPRLOCK_SNIPPET"
# shellcheck disable=SC2088 # for hyprlock, not for the shell
[[ $snippet == "$HOME"/* ]] && snippet="~/${snippet#"$HOME"/}"
printf '\n%s\nsource = %s\n' "$FU_HYPRLOCK_MARK" "$snippet" >> "$conf" || return 1
}
# fu_hyprlock_text_off
# The mark, the source line under it, and the empty lines before it. Written
# back in place, so a hyprlock.conf that is a link to a dotfile stays one.
fu_hyprlock_text_off() {
local conf="$FU_XDG_CONFIG/hypr/hyprlock.conf" content
rm -f "$FU_HYPRLOCK_SNIPPET"
[[ -f $conf ]] && grep -qxF "$FU_HYPRLOCK_MARK" "$conf" || return 0
content="$(awk -v mark="$FU_HYPRLOCK_MARK" '
$0 == mark { held = ""; skip = 1; next }
skip && /^[[:space:]]*source[[:space:]]*=/ { skip = 0; next }
{ skip = 0 }
/^[[:space:]]*$/ { held = held $0 "\n"; next }
{ printf "%s", held; held = ""; print }
' "$conf")" || return 1
printf '%s\n' "$content" > "$conf"
}
# fu_own_lock_hint
# Where face-unlock's lock screen has to be started from: the user's key and
# idle lock, which are theirs to change.
fu_own_lock_hint() {
# shellcheck disable=SC2088 # shown to the user, not a path to open
case "$FU_DESKTOP" in
hyprland)
if [[ -f $FU_XDG_CONFIG/hypr/hyprland.lua ]]; then
fu_note "$(fu_msg "To lock with it, add this to %s, and set %s in %s:" "~/.config/hypr/hyprland.lua" "lock_cmd = $FU_NAME lock" "hypridle.conf")"
fu_code "hl.bind(\"SUPER + L\", hl.dsp.exec_cmd(\"$FU_NAME lock\"))"
else
fu_note "$(fu_msg "To lock with it, add this to %s, and set %s in %s:" "~/.config/hypr/hyprland.conf" "lock_cmd = $FU_NAME lock" "hypridle.conf")"
fu_code "bind = SUPER, L, exec, $FU_NAME lock"
fi
;;
niri)
fu_note "$(fu_msg "To lock with it, add this to the binds in %s, and use %s in swayidle:" "~/.config/niri/config.kdl" "$FU_NAME lock")"
fu_code "Mod+Alt+L { spawn \"$FU_NAME\" \"lock\"; }"
;;
*)
fu_note "$(fu_msg "To lock with it, run %s where your lock screen is started now." "$FU_NAME lock")"
;;
esac
}
# fu_lock_style_set <own|yours>
fu_lock_style_set() {
# shellcheck disable=SC2088 # shown to the user, not a path to open
local conf="~/.config/hypr/hyprlock.conf"
case "$1" in
own)
fu_config_set LockScreenStyle own || { fu_bad "$(fu_msg "Could not save the setting.")"; return 1; }
fu_hyprlock_text_off
fu_ok "$(fu_msg "You lock with face-unlock's lock screen now.")"
fu_own_lock_hint
;;
yours)
fu_config_set LockScreenStyle yours || { fu_bad "$(fu_msg "Could not save the setting.")"; return 1; }
fu_hyprlock_text_on
case $? in
0) fu_ok "$(fu_msg "hyprlock shows what face unlock is doing at the top, from the next time it locks.")" ;;
1) fu_bad "$(fu_msg "Could not add the line to %s." "$conf")" ;;
2) fu_ok "$(fu_msg "Your lock screen stays as it is. Press Enter on the empty password field to scan.")" ;;
3) fu_note "$(fu_msg "hyprlock has no %s, so it cannot show the text." "$conf")" ;;
esac
;;
*) return 1 ;;
esac
}
# fu_lock_choose
# Asks in a window which lock screen to use, and carries it out. 1 when the
# window was closed or cannot open.
fu_lock_choose() {
local choice
[[ -n ${WAYLAND_DISPLAY:-} ]] || return 1
choice="$("$FU_AGENT" --choose-lock-screen 2> /dev/null)" || return 1
fu_lock_style_set "$choice"
}
fu_agent_disable() {
systemctl --user disable --now "$FU_UNIT_AGENT" > /dev/null 2>&1 || true
}