36 Commits
Author SHA1 Message Date
Felitendo 9ce9d77c33 chore: show feature requests before bug reports
release / Release and repositories (push) Skipped
release / Install from the APT repository (Ubuntu 26.04) (push) Skipped
release / Install from the APT repository (Debian 13) (push) Skipped
release / Install from the RPM repository (push) Skipped
release / Debian package (Ubuntu 26.04) (push) Failing after 1m39s
release / Debian package (Debian 13) (push) Canceled after 1s
release / RPM package (push) Failing after 2m2s
2026-09-26 21:19:07 +02:00
Felitendo 31b223db38 docs: fix typos in the safety part 2026-09-26 21:19:07 +02:00
Felitendo f572dfbc3d fix: build on debian 13, without the own lock screen there 2026-09-26 21:19:07 +02:00
Felitendo e4b76b0eaf chore: 2.0.0 2026-09-26 21:00:43 +02:00
Felitendo c057bd1ad4 chore: update readme 2026-09-26 20:51:52 +02:00
Felitendo 7317990620 docs: write the safety part as plain text 2026-09-26 20:46:58 +02:00
Felitendo 9dbd22478f docs: tidy the hyprland and niri note 2026-09-26 20:42:31 +02:00
Felitendo 39e8a7a588 chore: update readme 2026-09-26 20:38:34 +02:00
Felitendo ff0068907b docs: shorten the desktop part of the readme 2026-09-26 20:35:06 +02:00
Felitendo 8224b6f1d7 feat: warn when no polkit agent runs and offer to install one 2026-09-26 20:31:18 +02:00
Felitendo b8cdd255ed docs: shorten the readme 2026-09-26 20:09:15 +02:00
Felitendo b24703eeea fix: switch the daemon on after moving over from plasma-face-unlock 2026-09-26 20:03:16 +02:00
Felitendo b37996d518 docs: drop the note on the old name 2026-09-26 20:03:16 +02:00
Felitendo 1da82f21fc feat: ask which lock screen to use on hyprland and niri 2026-09-26 19:37:06 +02:00
Felitendo 5699158bb1 feat: open gtklock from outside once it can 2026-09-26 19:36:17 +02:00
Felitendo c228291520 feat: add an own lock screen with the bubble and your wallpaper 2026-09-26 19:35:41 +02:00
Felitendo c693ca216c feat: take the face in the lock screens of hyprland and niri 2026-09-26 19:35:04 +02:00
Felitendo 2a9b0d3f91 feat: show the bubble on gnome 2026-09-26 19:34:17 +02:00
Felitendo f232f796fc fix: signal a lock screen only once it handles the signal 2026-09-26 19:33:43 +02:00
Felitendo 0824c188fe fix: show the autostart lines in lua on hyprland 0.56 2026-09-26 19:33:15 +02:00
Felitendo 9586f4cd3c fix: notice somebody coming back on hyprland 2026-09-26 19:32:37 +02:00
Felitendo a6542de4a0 chore: commit different topics separately 2026-09-25 09:30:22 +02:00
Felitendo def308425c feat: take over faces and settings from plasma-face-unlock 2026-09-25 09:30:20 +02:00
Felitendo eaec9325af feat: support gnome, hyprland and niri 2026-09-25 09:29:42 +02:00
Felitendo bccd1f5510 fix: fail make check on shellcheck findings 2026-09-25 09:29:12 +02:00
Felitendo b75c2868fe build: use one compiler per core 2026-09-25 09:28:55 +02:00
Felitendo 6e6a6e6d03 refactor!: rename to face-unlock 2026-09-25 09:27:15 +02:00
Felitendo 44449f103b chore: switch to ko-fi 2026-09-24 13:21:23 +02:00
Felitendo 0d5a7e9ea4 chore: add issue templates 2026-09-24 12:39:54 +02:00
Felitendo 7f6ac327cc feat: add 12 translations 2026-09-24 11:11:55 +02:00
Felitendo a8e962f4b5 fix: keep temp paths out of the translation template 2026-09-24 11:11:55 +02:00
Felitendo 8d11676740 fix: translate messages in admin prompts 2026-09-24 10:38:02 +02:00
Felitendo b9c3b324a3 docs: commit and push only after a local check 2026-09-24 08:42:01 +02:00
Felitendo 72e8231344 docs: say which video gets past the photo check 2026-09-24 08:36:15 +02:00
Felitendo 75a4943e69 chore: file old readme commits under documentation 2026-09-24 02:00:30 +02:00
Felitendo c972ce3c5b docs: cut the readme down, fold the install steps 2026-09-24 01:45:42 +02:00
109 changed files with 23929 additions and 1696 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
buy_me_a_coffee: felitendo ko_fi: felitendo
@@ -0,0 +1,35 @@
name: 💡 Feature request
description: An idea to make face-unlock better.
labels:
- enhancement
body:
- type: checkboxes
id: checks
attributes:
label: Before you start
options:
- label: I searched the issues and this is not requested yet.
required: true
- type: textarea
id: problem
attributes:
label: What problem would this solve?
description: What are you trying to do, and what gets in the way?
validations:
required: true
- type: textarea
id: idea
attributes:
label: What would you like?
description: How it could work. A rough idea is fine.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Other ways you thought of
- type: textarea
id: more
attributes:
label: Anything else?
description: Mockups, screenshots, or other tools that do it well.
+97
View File
@@ -0,0 +1,97 @@
name: 🐛 Bug report
description: Something does not work as it should.
labels:
- bug
body:
- type: markdown
attributes:
value: Thanks for taking the time! The more you fill in, the faster it can be fixed.
- type: checkboxes
id: checks
attributes:
label: Before you start
options:
- label: I searched the issues and this is not reported yet.
required: true
- label: I use the latest version.
required: true
- type: textarea
id: what
attributes:
label: What happened?
description: What did you do, and what went wrong?
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
placeholder: |
1.
2.
3.
- type: textarea
id: expected
attributes:
label: What did you expect?
validations:
required: true
- type: dropdown
id: where
attributes:
label: Where does it happen?
multiple: true
options:
- Lock screen
- sudo
- Admin prompts
- Setting up a face
- The menu
- Other
validations:
required: true
- type: input
id: camera
attributes:
label: Camera
description: The model, and if it is an infrared camera.
placeholder: Logitech C920, not infrared
- type: input
id: version
attributes:
label: Version
description: The output of `face-unlock --version`.
placeholder: face-unlock 2.0.0
validations:
required: true
- type: dropdown
id: install
attributes:
label: How did you install it?
options:
- AUR (Arch, CachyOS, EndeavourOS, Manjaro)
- Fedora package
- Debian or Ubuntu package
- Built from source
- Other
validations:
required: true
- type: input
id: system
attributes:
label: System
description: Distribution, desktop and its version, and on Hyprland or Niri the lock screen.
placeholder: CachyOS, Hyprland 0.56, hyprlock
validations:
required: true
- type: textarea
id: logs
attributes:
label: Status and logs
description: The output of `face-unlock status`. If a scan goes wrong, also `journalctl -b -u face-unlockd` and `journalctl --user -b -u face-unlock-agent`.
render: shell
- type: textarea
id: more
attributes:
label: Anything else?
description: Screenshots, videos or anything else that could help.
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+3 -2
View File
@@ -80,7 +80,8 @@ while IFS=$'\t' read -r sha subject body; do
perf:* | perf\(*) kind=enh ;; perf:* | perf\(*) kind=enh ;;
docs:* | docs\(*) kind=docs ;; docs:* | docs\(*) kind=docs ;;
chore* | ci:* | ci\(* | build* | refactor* | test* | style*) kind=maint ;; chore* | ci:* | ci\(* | build* | refactor* | test* | style*) kind=maint ;;
# Older commits without a prefix, sorted by their first word. # Older commits without a prefix, sorted by what they say.
*README* | *readme* | *Readme*) kind=docs ;;
Add\ * | Put\ * | Introduce\ *) kind=feat ;; Add\ * | Put\ * | Introduce\ *) kind=feat ;;
Fix\ * | Repair\ * | Recover\ * | Stop\ *) kind=fix ;; Fix\ * | Repair\ * | Recover\ * | Stop\ *) kind=fix ;;
*\ *) kind=enh ;; *\ *) kind=enh ;;
@@ -124,7 +125,7 @@ $entry
If $name is useful to you, you can buy me a coffee. It keeps these tools going. Found a bug or have an idea? Tell me in the [issues](https://github.com/$repo/issues). If $name is useful to you, you can buy me a coffee. It keeps these tools going. Found a bug or have an idea? Tell me in the [issues](https://github.com/$repo/issues).
<a href="https://buymeacoffee.com/felitendo"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="48"></a> <a href="https://ko-fi.com/felitendo"><img src="https://storage.ko-fi.com/cdn/kofi5.png?v=6" alt="Buy me a coffee on Ko-fi" height="48"></a>
---- ----
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
check: check:
name: build, tests and shellcheck name: build, tests and shellcheck
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Arch has every Plasma 6 and Qt 6 development package this needs, and the # Arch has every Qt 6 and KDE Frameworks 6 package this needs, and the
# newest OpenCV, which is the one that moved things around. # newest OpenCV, which is the one that moved things around.
container: archlinux:latest container: archlinux:latest
steps: steps:
+15 -15
View File
@@ -24,12 +24,12 @@ jobs:
strategy: strategy:
matrix: matrix:
include: include:
# Plasma 6 arrived in Debian with trixie. # Qt 6 and KDE Frameworks 6 arrived in Debian with trixie.
- name: Debian 13 - name: Debian 13
image: debian:trixie image: debian:trixie
codename: trixie codename: trixie
suffix: "~deb13" suffix: "~deb13"
- name: Kubuntu 26.04 - name: Ubuntu 26.04
image: ubuntu:26.04 image: ubuntu:26.04
codename: resolute codename: resolute
suffix: "~ubuntu26.04" suffix: "~ubuntu26.04"
@@ -116,8 +116,8 @@ jobs:
- name: Look inside what was built - name: Look inside what was built
run: | run: |
rpm -qip dist/plasma-face-unlock-[0-9]*.rpm rpm -qip dist/face-unlock-[0-9]*.rpm
rpm -qlp dist/plasma-face-unlock-[0-9]*.rpm rpm -qlp dist/face-unlock-[0-9]*.rpm
- uses: actions/upload-artifact@v7 - uses: actions/upload-artifact@v7
with: with:
@@ -239,7 +239,7 @@ jobs:
- name: Debian 13 - name: Debian 13
image: debian:trixie image: debian:trixie
codename: trixie codename: trixie
- name: Kubuntu 26.04 - name: Ubuntu 26.04
image: ubuntu:26.04 image: ubuntu:26.04
codename: resolute codename: resolute
container: ${{ matrix.image }} container: ${{ matrix.image }}
@@ -254,13 +254,13 @@ jobs:
run: | run: |
apt-get update -qq && apt-get install -y --no-install-recommends gpg apt-get update -qq && apt-get install -y --no-install-recommends gpg
install -d -m 0755 /etc/apt/keyrings install -d -m 0755 /etc/apt/keyrings
gpg --dearmor -o /etc/apt/keyrings/plasma-face-unlock.gpg < pages/KEY.gpg gpg --dearmor -o /etc/apt/keyrings/face-unlock.gpg < pages/KEY.gpg
echo "deb [signed-by=/etc/apt/keyrings/plasma-face-unlock.gpg] file://$PWD/pages/deb/${{ matrix.codename }} ./" \ echo "deb [signed-by=/etc/apt/keyrings/face-unlock.gpg] file://$PWD/pages/deb/${{ matrix.codename }} ./" \
> /etc/apt/sources.list.d/plasma-face-unlock.list > /etc/apt/sources.list.d/face-unlock.list
apt-get update apt-get update
apt-get install -y plasma-face-unlock apt-get install -y face-unlock
useradd -m tester useradd -m tester
runuser -u tester -- plasma-face-unlock --version runuser -u tester -- face-unlock --version
verify-dnf: verify-dnf:
name: Install from the RPM repository name: Install from the RPM repository
@@ -281,15 +281,15 @@ jobs:
run: | run: |
rpm --import pages/KEY.gpg rpm --import pages/KEY.gpg
rpm --import signer/rpm-signer.asc rpm --import signer/rpm-signer.asc
cat > /etc/yum.repos.d/plasma-face-unlock.repo <<EOF cat > /etc/yum.repos.d/face-unlock.repo <<EOF
[plasma-face-unlock] [face-unlock]
name=plasma-face-unlock name=face-unlock
baseurl=file://$PWD/pages/rpm baseurl=file://$PWD/pages/rpm
enabled=1 enabled=1
gpgcheck=1 gpgcheck=1
repo_gpgcheck=1 repo_gpgcheck=1
gpgkey=file://$PWD/pages/KEY.gpg gpgkey=file://$PWD/pages/KEY.gpg
EOF EOF
dnf install -y plasma-face-unlock util-linux dnf install -y face-unlock util-linux
useradd -m tester useradd -m tester
runuser -u tester -- plasma-face-unlock --version runuser -u tester -- face-unlock --version
+26 -2
View File
@@ -1,6 +1,30 @@
# Changelog # Changelog
What each release brings, newest first. The [GitHub releases](https://github.com/LoonixTools/plasma-face-unlock/releases) add every commit that went into it. What each release brings, newest first. The [GitHub releases](https://github.com/LoonixTools/face-unlock/releases) add every commit that went into it.
## v2.0.0
_2026-09-26_
Welcome to face-unlock `v2.0.0`! plasma-face-unlock has a new name, because it no longer needs Plasma: it now works on GNOME, Hyprland and Niri too.
<p align="center">
<img width="480" alt="The window that asks which lock screen to use on Hyprland: face-unlock's own with the bubble, or your own hyprlock with a line of text at the top" src="https://raw.githubusercontent.com/LoonixTools/face-unlock/v2.0.0/res/screenshots/lock-choice.png">
</p>
### 🚨 Breaking changes
- plasma-face-unlock is now called face-unlock, and so is the command. Your faces and settings move over on their own.
- On Arch, `yay -S face-unlock` replaces the old package.
- On Fedora, Debian and Ubuntu the repository moved too. Remove the old `plasma-face-unlock` repository file and add the new one from the [install steps](https://github.com/LoonixTools/face-unlock#install).
### Highlights
- Works on GNOME, Hyprland and Niri
- Unlock hyprlock, swaylock, gtklock and waylock with your face
- A lock screen of its own, with the bubble and your wallpaper
- 12 new languages
- A warning when no polkit agent runs
## v1.0.1 ## v1.0.1
@@ -17,7 +41,7 @@ _2026-09-23_
Welcome to the very first release of plasma-face-unlock! It brings Face ID to KDE Plasma: look at the screen and it unlocks. The lock screen, sudo and admin prompts can all take your face instead of a password. Welcome to the very first release of plasma-face-unlock! It brings Face ID to KDE Plasma: look at the screen and it unlocks. The lock screen, sudo and admin prompts can all take your face instead of a password.
<p align="center"> <p align="center">
<img width="480" alt="The bubble drops down over the lock screen, finds the face and shows a green tick" src="https://raw.githubusercontent.com/LoonixTools/plasma-face-unlock/v1.0.0/res/screenshots/unlock.webp"> <img width="480" alt="The bubble drops down over the lock screen, finds the face and shows a green tick" src="https://raw.githubusercontent.com/LoonixTools/face-unlock/v1.0.0/res/screenshots/unlock.webp">
</p> </p>
### Highlights ### Highlights
+13 -6
View File
@@ -14,10 +14,14 @@
- Subject as short as possible. Imperative, lowercase, no trailing period. - Subject as short as possible. Imperative, lowercase, no trailing period.
- Body only when something genuinely cannot be inferred from the diff. - Body only when something genuinely cannot be inferred from the diff.
- Never add `Co-Authored-By`, "Generated with" or any other AI attribution to commits or PR descriptions. - Never add `Co-Authored-By`, "Generated with" or any other AI attribution to commits or PR descriptions.
- Do not commit or push before I have checked the changes locally and said they are fine. Then
commit and push. Several attempts at the same thing make one commit, and attempts that did not
work make none. Different things done in one session get a commit each. This also goes for
releases and tags.
## Layout ## Layout
- `src/plasma-face-unlock` and `src/lib/*.sh`: the command and its menu, plain bash. - `src/face-unlock` and `src/lib/*.sh`: the command and its menu, plain bash.
- `src/core`: camera, detection, recognition, liveness, face store. Used by the daemon and the tests. - `src/core`: camera, detection, recognition, liveness, face store. Used by the daemon and the tests.
- `src/daemon`: the root service. It owns the camera and the face data. - `src/daemon`: the root service. It owns the camera and the face data.
- `src/agent`: the Qt/QML program in the session: bubble, lock screen, setup window. - `src/agent`: the Qt/QML program in the session: bubble, lock screen, setup window.
@@ -45,10 +49,10 @@ A minor or major release (has `### Highlights`, gets a heading and the support s
_2026-09-24_ _2026-09-24_
Welcome to plasma-face-unlock `v1.4.0`! One or two sentences on what this release is about. Welcome to face-unlock `v1.4.0`! One or two sentences on what this release is about.
<p align="center"> <p align="center">
<img width="480" alt="What the picture shows" src="https://raw.githubusercontent.com/LoonixTools/plasma-face-unlock/v1.4.0/<path>"> <img width="480" alt="What the picture shows" src="https://raw.githubusercontent.com/LoonixTools/face-unlock/v1.4.0/<path>">
</p> </p>
### 🚨 Breaking changes ### 🚨 Breaking changes
@@ -82,7 +86,10 @@ Never test against the real setup: enrolling and the PAM files belong to the use
PAM file editing against copies of real PAM files. No camera, no root. PAM file editing against copies of real PAM files. No camera, no root.
- Without a camera, point the daemon at pictures or a video: `Camera=images:<dir>` or - Without a camera, point the daemon at pictures or a video: `Camera=images:<dir>` or
`Camera=file:<video>` in the config passed to `--config`. `Camera=file:<video>` in the config passed to `--config`.
- A development daemon needs no root: `plasma-face-unlockd --socket $XDG_RUNTIME_DIR/pfu/socket - A development daemon needs no root: `face-unlockd --socket $XDG_RUNTIME_DIR/fu/socket
--state-dir DIR --config FILE --models DIR`. It skips polkit when it does not run as root. Point --state-dir DIR --config FILE --models DIR`. It skips polkit when it does not run as root. Point
the other parts at it with `PFU_SOCKET`. the other parts at it with `FU_SOCKET`.
- `make check` after every change. New strings: `po/update-pot.sh`, then translate them in `po/de.po`. - face-unlock's own lock screen checks the password with PAM. `FU_PAM_CONFDIR=DIR` points it at a
`face-unlock-lock` file of its own (pam_permit, pam_deny), so no test counts as a wrong password
for the real account.
- `make check` after every change. New strings: `po/update-pot.sh`, then translate them in every `po/*.po`.
+98 -59
View File
@@ -1,4 +1,4 @@
# plasma-face-unlock: the compiled half # face-unlock: the compiled half
# #
# The Makefile is the entry point and calls this. Everything that has to be # The Makefile is the entry point and calls this. Everything that has to be
# compiled lives here: the daemon, the agent, the client the shell code uses, # compiled lives here: the daemon, the agent, the client the shell code uses,
@@ -6,9 +6,9 @@
# installed by the Makefile, which is where the paths come from. # installed by the Makefile, which is where the paths come from.
cmake_minimum_required(VERSION 3.22) cmake_minimum_required(VERSION 3.22)
project(plasma-face-unlock VERSION 1.0.1 LANGUAGES C CXX) project(face-unlock VERSION 2.0.0 LANGUAGES C CXX)
set(PFU_VERSION "${PROJECT_VERSION}" CACHE STRING "Version reported by every binary") set(FU_VERSION "${PROJECT_VERSION}" CACHE STRING "Version reported by every binary")
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -21,19 +21,19 @@ endif()
include(GNUInstallDirs) include(GNUInstallDirs)
option(PFU_BUILD_AGENT "Build the Plasma agent (bubble, lock screen, set up window)" ON) option(FU_BUILD_AGENT "Build the agent (bubble, lock screen, set up window)" ON)
option(PFU_BUILD_PAM "Build the PAM module" ON) option(FU_BUILD_PAM "Build the PAM module" ON)
option(PFU_BUILD_TESTS "Build the tests" ON) option(FU_BUILD_TESTS "Build the tests" ON)
# Where things go at run time. The defaults suit a normal install into /usr; # Where things go at run time. The defaults suit a normal install into /usr;
# the Makefile passes its own values so the two never disagree. # the Makefile passes its own values so the two never disagree.
set(PFU_LIBEXECDIR "${CMAKE_INSTALL_PREFIX}/lib/plasma-face-unlock" CACHE PATH "Helper programs") set(FU_LIBEXECDIR "${CMAKE_INSTALL_PREFIX}/lib/face-unlock" CACHE PATH "Helper programs")
set(PFU_MODELDIR "${CMAKE_INSTALL_FULL_DATADIR}/plasma-face-unlock/models" CACHE PATH "Neural network models") set(FU_MODELDIR "${CMAKE_INSTALL_FULL_DATADIR}/face-unlock/models" CACHE PATH "Neural network models")
set(PFU_LOCALEDIR "${CMAKE_INSTALL_FULL_LOCALEDIR}" CACHE PATH "Translations") set(FU_LOCALEDIR "${CMAKE_INSTALL_FULL_LOCALEDIR}" CACHE PATH "Translations")
set(PFU_SOCKET "/run/plasma-face-unlock/socket" CACHE STRING "The daemon's socket") set(FU_SOCKET "/run/face-unlock/socket" CACHE STRING "The daemon's socket")
set(PFU_STATEDIR "/var/lib/plasma-face-unlock" CACHE PATH "Face data") set(FU_STATEDIR "/var/lib/face-unlock" CACHE PATH "Face data")
set(PFU_CONFIG "/etc/plasma-face-unlock/config" CACHE FILEPATH "System settings") set(FU_CONFIG "/etc/face-unlock/config" CACHE FILEPATH "System settings")
set(PFU_PAMDIR "${CMAKE_INSTALL_PREFIX}/lib/security" CACHE PATH "Where PAM modules live") set(FU_PAMDIR "${CMAKE_INSTALL_PREFIX}/lib/security" CACHE PATH "Where PAM modules live")
configure_file(src/shared/buildconfig.h.in ${CMAKE_CURRENT_BINARY_DIR}/buildconfig.h @ONLY) configure_file(src/shared/buildconfig.h.in ${CMAKE_CURRENT_BINARY_DIR}/buildconfig.h @ONLY)
include_directories(${CMAKE_CURRENT_BINARY_DIR} src/shared) include_directories(${CMAKE_CURRENT_BINARY_DIR} src/shared)
@@ -47,35 +47,35 @@ add_compile_options(-Wall -Wextra -Wno-unused-parameter)
find_package(Qt6 6.5 REQUIRED COMPONENTS Core DBus Network) find_package(Qt6 6.5 REQUIRED COMPONENTS Core DBus Network)
# OpenCV 5 split the contour and transform helpers into "geometry". # OpenCV 5 split the contour and transform helpers into "geometry".
find_package(OpenCV REQUIRED COMPONENTS core) find_package(OpenCV REQUIRED COMPONENTS core)
set(PFU_OPENCV_MODULES core imgproc imgcodecs videoio objdetect dnn) set(FU_OPENCV_MODULES core imgproc imgcodecs videoio objdetect dnn)
if(OpenCV_VERSION_MAJOR GREATER_EQUAL 5) if(OpenCV_VERSION_MAJOR GREATER_EQUAL 5)
list(APPEND PFU_OPENCV_MODULES geometry) list(APPEND FU_OPENCV_MODULES geometry)
endif() endif()
find_package(OpenCV REQUIRED COMPONENTS ${PFU_OPENCV_MODULES}) find_package(OpenCV REQUIRED COMPONENTS ${FU_OPENCV_MODULES})
# The settings file reader, shared with the agent (which has no use for # The settings file reader, shared with the agent (which has no use for
# OpenCV). # OpenCV).
add_library(pfu_common STATIC src/core/keyvalue.cpp) add_library(fu_common STATIC src/core/keyvalue.cpp)
target_include_directories(pfu_common PUBLIC src/core) target_include_directories(fu_common PUBLIC src/core)
target_link_libraries(pfu_common PUBLIC Qt6::Core) target_link_libraries(fu_common PUBLIC Qt6::Core)
set_target_properties(pfu_common PROPERTIES POSITION_INDEPENDENT_CODE ON) set_target_properties(fu_common PROPERTIES POSITION_INDEPENDENT_CODE ON)
add_library(pfu_core STATIC add_library(fu_core STATIC
src/core/settings.cpp src/core/settings.cpp
src/core/camera.cpp src/core/camera.cpp
src/core/vision.cpp src/core/vision.cpp
src/core/liveness.cpp src/core/liveness.cpp
src/core/store.cpp src/core/store.cpp
) )
target_include_directories(pfu_core PUBLIC src/core ${OpenCV_INCLUDE_DIRS}) target_include_directories(fu_core PUBLIC src/core ${OpenCV_INCLUDE_DIRS})
target_link_libraries(pfu_core PUBLIC pfu_common Qt6::Core ${OpenCV_LIBS}) target_link_libraries(fu_core PUBLIC fu_common Qt6::Core ${OpenCV_LIBS})
set_target_properties(pfu_core PROPERTIES POSITION_INDEPENDENT_CODE ON) set_target_properties(fu_core PROPERTIES POSITION_INDEPENDENT_CODE ON)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# The daemon # The daemon
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
add_executable(plasma-face-unlockd add_executable(face-unlockd
src/daemon/job.h src/daemon/job.h
src/daemon/agentlink.cpp src/daemon/agentlink.cpp
src/daemon/main.cpp src/daemon/main.cpp
@@ -87,38 +87,40 @@ add_executable(plasma-face-unlockd
src/daemon/userstate.cpp src/daemon/userstate.cpp
src/daemon/system.cpp src/daemon/system.cpp
) )
target_link_libraries(plasma-face-unlockd PRIVATE pfu_core Qt6::DBus Qt6::Network) target_link_libraries(face-unlockd PRIVATE fu_core Qt6::DBus Qt6::Network)
install(TARGETS plasma-face-unlockd DESTINATION ${PFU_LIBEXECDIR}) install(TARGETS face-unlockd DESTINATION ${FU_LIBEXECDIR})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# The client the shell code uses # The client the shell code uses
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
add_executable(plasma-face-unlock-ctl src/ctl/main.cpp) add_executable(face-unlock-ctl src/ctl/main.cpp)
target_link_libraries(plasma-face-unlock-ctl PRIVATE Qt6::Core Qt6::Network) target_link_libraries(face-unlock-ctl PRIVATE Qt6::Core Qt6::Network)
install(TARGETS plasma-face-unlock-ctl DESTINATION ${PFU_LIBEXECDIR}) install(TARGETS face-unlock-ctl DESTINATION ${FU_LIBEXECDIR})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# The PAM module # The PAM module
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if(PFU_BUILD_PAM) # The PAM module, and the agent's own lock screen, which checks the password.
find_path(PAM_INCLUDE_DIR security/pam_modules.h REQUIRED) find_path(PAM_INCLUDE_DIR security/pam_modules.h REQUIRED)
find_library(PAM_LIBRARY pam REQUIRED) find_library(PAM_LIBRARY pam REQUIRED)
if(FU_BUILD_PAM)
find_package(PkgConfig REQUIRED) find_package(PkgConfig REQUIRED)
pkg_check_modules(SYSTEMD IMPORTED_TARGET libsystemd) pkg_check_modules(SYSTEMD IMPORTED_TARGET libsystemd)
add_library(pam_plasma_face_unlock MODULE src/pam/pam_plasma_face_unlock.c) add_library(pam_face_unlock MODULE src/pam/pam_face_unlock.c)
set_target_properties(pam_plasma_face_unlock PROPERTIES PREFIX "" C_VISIBILITY_PRESET hidden) set_target_properties(pam_face_unlock PROPERTIES PREFIX "" C_VISIBILITY_PRESET hidden)
target_include_directories(pam_plasma_face_unlock PRIVATE ${PAM_INCLUDE_DIR}) target_include_directories(pam_face_unlock PRIVATE ${PAM_INCLUDE_DIR})
target_link_libraries(pam_plasma_face_unlock PRIVATE ${PAM_LIBRARY}) target_link_libraries(pam_face_unlock PRIVATE ${PAM_LIBRARY})
if(SYSTEMD_FOUND) if(SYSTEMD_FOUND)
target_compile_definitions(pam_plasma_face_unlock PRIVATE HAVE_SYSTEMD=1) target_compile_definitions(pam_face_unlock PRIVATE HAVE_SYSTEMD=1)
target_link_libraries(pam_plasma_face_unlock PRIVATE PkgConfig::SYSTEMD) target_link_libraries(pam_face_unlock PRIVATE PkgConfig::SYSTEMD)
endif() endif()
install(TARGETS pam_plasma_face_unlock DESTINATION ${PFU_PAMDIR}) install(TARGETS pam_face_unlock DESTINATION ${FU_PAMDIR})
if(PFU_BUILD_TESTS) if(FU_BUILD_TESTS)
add_executable(pam_harness tests/pam_harness.c) add_executable(pam_harness tests/pam_harness.c)
target_include_directories(pam_harness PRIVATE ${PAM_INCLUDE_DIR}) target_include_directories(pam_harness PRIVATE ${PAM_INCLUDE_DIR})
target_link_libraries(pam_harness PRIVATE ${PAM_LIBRARY}) target_link_libraries(pam_harness PRIVATE ${PAM_LIBRARY})
@@ -129,7 +131,7 @@ endif()
# The agent # The agent
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if(PFU_BUILD_AGENT) if(FU_BUILD_AGENT)
find_package(Qt6 6.5 REQUIRED COMPONENTS Gui Quick WaylandClient) find_package(Qt6 6.5 REQUIRED COMPONENTS Gui Quick WaylandClient)
# The bubble needs the Wayland surface of its window, which only the # The bubble needs the Wayland surface of its window, which only the
# private API hands out. It is the same one Plasma itself uses for this. # private API hands out. It is the same one Plasma itself uses for this.
@@ -142,38 +144,48 @@ if(PFU_BUILD_AGENT)
find_package(LayerShellQt REQUIRED) find_package(LayerShellQt REQUIRED)
find_package(KF6I18n REQUIRED) find_package(KF6I18n REQUIRED)
qt_add_executable(plasma-face-unlock-agent qt_add_executable(face-unlock-agent
src/agent/main.cpp src/agent/main.cpp
src/agent/daemonclient.cpp src/agent/daemonclient.cpp
src/agent/userconfig.cpp src/agent/userconfig.cpp
src/agent/agentsocket.cpp src/agent/agentsocket.cpp
src/agent/bubblewindow.cpp src/agent/bubblewindow.cpp
src/agent/bubblecontroller.cpp src/agent/bubblecontroller.cpp
src/agent/bubbleservice.cpp
src/agent/lockcontroller.cpp src/agent/lockcontroller.cpp
src/agent/lockwatcher.cpp
src/agent/lockers.cpp
src/agent/inputwatcher.cpp src/agent/inputwatcher.cpp
src/agent/wayland.cpp
src/agent/sessionlock.cpp
src/agent/lockscreencontroller.cpp
src/agent/wallpaper.cpp
src/agent/locktext.cpp
src/agent/lockpreview.cpp
src/agent/enrollcontroller.cpp src/agent/enrollcontroller.cpp
) )
target_include_directories(plasma-face-unlock-agent PRIVATE src/agent) target_include_directories(face-unlock-agent PRIVATE src/agent)
if(LayerShellQt_VERSION VERSION_GREATER_EQUAL 6.6) if(LayerShellQt_VERSION VERSION_GREATER_EQUAL 6.6)
target_compile_definitions(plasma-face-unlock-agent PRIVATE PFU_LAYERSHELL_HAS_SCREEN) target_compile_definitions(face-unlock-agent PRIVATE FU_LAYERSHELL_HAS_SCREEN)
endif() endif()
qt6_generate_wayland_protocol_client_sources(plasma-face-unlock-agent qt6_generate_wayland_protocol_client_sources(face-unlock-agent
FILES ${CMAKE_CURRENT_SOURCE_DIR}/protocols/kde-lockscreen-overlay-v1.xml FILES ${CMAKE_CURRENT_SOURCE_DIR}/protocols/kde-lockscreen-overlay-v1.xml
${CMAKE_CURRENT_SOURCE_DIR}/protocols/ext-idle-notify-v1.xml) ${CMAKE_CURRENT_SOURCE_DIR}/protocols/ext-idle-notify-v1.xml
${CMAKE_CURRENT_SOURCE_DIR}/protocols/ext-session-lock-v1.xml)
# The QML files sit next to the module's qmldir in the resources, so they # The QML files sit next to the module's qmldir in the resources, so they
# see each other (and the Theme singleton) without importing anything. # see each other (and the Theme singleton) without importing anything.
# Under /qt/qml, which the engine searches, or an installed agent finds no # Under /qt/qml, which the engine searches, or an installed agent finds no
# types (the build directory only works by its PlasmaFaceUnlock/qmldir). # types (the build directory only works by its FaceUnlock/qmldir).
set(PFU_QML_FILES Theme FaceGlyph LockGlyph Checkmark Bubble TickRing Enroll PillButton) set(FU_QML_FILES Theme FaceGlyph LockGlyph Checkmark Bubble TickRing Enroll PillButton LockScreen LockChoice LockPreview)
foreach(f ${PFU_QML_FILES}) foreach(f ${FU_QML_FILES})
set_source_files_properties(src/agent/qml/${f}.qml PROPERTIES QT_RESOURCE_ALIAS ${f}.qml) set_source_files_properties(src/agent/qml/${f}.qml PROPERTIES QT_RESOURCE_ALIAS ${f}.qml)
endforeach() endforeach()
set_source_files_properties(src/agent/qml/Theme.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE) set_source_files_properties(src/agent/qml/Theme.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
qt_add_qml_module(plasma-face-unlock-agent qt_add_qml_module(face-unlock-agent
URI PlasmaFaceUnlock URI FaceUnlock
VERSION 1.0 VERSION 1.0
RESOURCE_PREFIX /qt/qml RESOURCE_PREFIX /qt/qml
SOURCES SOURCES
@@ -188,33 +200,60 @@ if(PFU_BUILD_AGENT)
src/agent/qml/TickRing.qml src/agent/qml/TickRing.qml
src/agent/qml/Enroll.qml src/agent/qml/Enroll.qml
src/agent/qml/PillButton.qml src/agent/qml/PillButton.qml
src/agent/qml/LockScreen.qml
src/agent/qml/LockChoice.qml
src/agent/qml/LockPreview.qml
) )
target_link_libraries(plasma-face-unlock-agent PRIVATE target_link_libraries(face-unlock-agent PRIVATE
Qt6::Gui Qt6::GuiPrivate Qt6::Quick Qt6::DBus Qt6::Network Qt6::Gui Qt6::GuiPrivate Qt6::Quick Qt6::DBus Qt6::Network
Qt6::WaylandClient Qt6::WaylandClientPrivate Qt6::WaylandClient Qt6::WaylandClientPrivate
LayerShellQt::Interface LayerShellQt::Interface
KF6::I18n KF6::I18nQml KF6::I18n KF6::I18nQml
pfu_common fu_common
${PAM_LIBRARY}
) )
install(TARGETS plasma-face-unlock-agent DESTINATION ${PFU_LIBEXECDIR}) target_include_directories(face-unlock-agent PRIVATE ${PAM_INCLUDE_DIR})
install(TARGETS face-unlock-agent DESTINATION ${FU_LIBEXECDIR})
endif() endif()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tests # Tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if(PFU_BUILD_TESTS) if(FU_BUILD_TESTS)
enable_testing() enable_testing()
add_executable(test_liveness tests/test_liveness.cpp) add_executable(test_liveness tests/test_liveness.cpp)
target_link_libraries(test_liveness PRIVATE pfu_core) target_link_libraries(test_liveness PRIVATE fu_core)
add_test(NAME liveness COMMAND test_liveness) add_test(NAME liveness COMMAND test_liveness)
add_executable(test_store tests/test_store.cpp) add_executable(test_store tests/test_store.cpp)
target_link_libraries(test_store PRIVATE pfu_core) target_link_libraries(test_store PRIVATE fu_core)
add_test(NAME store COMMAND test_store) add_test(NAME store COMMAND test_store)
add_executable(test_images tests/test_images.cpp) add_executable(test_images tests/test_images.cpp)
target_link_libraries(test_images PRIVATE pfu_core) target_link_libraries(test_images PRIVATE fu_core)
if(FU_BUILD_AGENT)
add_executable(test_lockscreen tests/test_lockscreen.cpp src/agent/lockscreencontroller.cpp)
target_include_directories(test_lockscreen PRIVATE src/agent ${PAM_INCLUDE_DIR})
target_link_libraries(test_lockscreen PRIVATE Qt6::Core KF6::I18n ${PAM_LIBRARY})
add_test(NAME lockscreen COMMAND test_lockscreen)
add_executable(test_wallpaper tests/test_wallpaper.cpp src/agent/wallpaper.cpp)
target_include_directories(test_wallpaper PRIVATE src/agent)
target_link_libraries(test_wallpaper PRIVATE Qt6::Core)
add_test(NAME wallpaper COMMAND test_wallpaper)
add_executable(test_lockpreview tests/test_lockpreview.cpp src/agent/lockpreview.cpp)
target_include_directories(test_lockpreview PRIVATE src/agent)
target_link_libraries(test_lockpreview PRIVATE Qt6::Gui KF6::I18n)
add_test(NAME lockpreview COMMAND test_lockpreview)
add_executable(test_lockers tests/test_lockers.cpp src/agent/lockers.cpp)
target_include_directories(test_lockers PRIVATE src/agent)
target_link_libraries(test_lockers PRIVATE Qt6::Core)
add_test(NAME lockers COMMAND test_lockers)
endif()
endif() endif()
+56 -45
View File
@@ -1,4 +1,4 @@
# plasma-face-unlock: build and install # face-unlock: build and install
# #
# The shell front end installs as it is, like middleclick-autoscroll. The rest # The shell front end installs as it is, like middleclick-autoscroll. The rest
# is compiled by CMake (the daemon, the agent, the PAM module, the client the # is compiled by CMake (the daemon, the agent, the PAM module, the client the
@@ -9,20 +9,22 @@
# Overridable so a packager can pass the version it is actually building # Overridable so a packager can pass the version it is actually building
# (`make VERSION=$pkgver`). The literal below is the fallback for builds # (`make VERSION=$pkgver`). The literal below is the fallback for builds
# straight from a checkout, and is what a release tag has to carry. # straight from a checkout, and is what a release tag has to carry.
VERSION ?= 1.0.1 VERSION ?= 2.0.0
PREFIX ?= /usr PREFIX ?= /usr
DESTDIR ?= DESTDIR ?=
BINDIR ?= $(PREFIX)/bin BINDIR ?= $(PREFIX)/bin
DATADIR ?= $(PREFIX)/share DATADIR ?= $(PREFIX)/share
LIBDIR ?= $(DATADIR)/plasma-face-unlock/lib LIBDIR ?= $(DATADIR)/face-unlock/lib
LIBEXECDIR ?= $(PREFIX)/lib/plasma-face-unlock LIBEXECDIR ?= $(PREFIX)/lib/face-unlock
MODELDIR ?= $(DATADIR)/plasma-face-unlock/models MODELDIR ?= $(DATADIR)/face-unlock/models
LOCALEDIR ?= $(DATADIR)/locale LOCALEDIR ?= $(DATADIR)/locale
MANDIR ?= $(DATADIR)/man MANDIR ?= $(DATADIR)/man
APPDIR ?= $(DATADIR)/applications APPDIR ?= $(DATADIR)/applications
POLKITDIR ?= $(DATADIR)/polkit-1/actions POLKITDIR ?= $(DATADIR)/polkit-1/actions
ICONDIR ?= $(DATADIR)/icons/hicolor/scalable/apps ICONDIR ?= $(DATADIR)/icons/hicolor/scalable/apps
GNOMEEXTDIR ?= $(DATADIR)/gnome-shell/extensions
GNOMEEXT := face-unlock@loonixtools.github.io
# Where systemd looks for units, asked of systemd itself for a normal install. # Where systemd looks for units, asked of systemd itself for a normal install.
# A build with a prefix of its own keeps them under that prefix. # A build with a prefix of its own keeps them under that prefix.
@@ -44,11 +46,14 @@ endif
BUILDDIR ?= build BUILDDIR ?= build
CMAKE ?= cmake CMAKE ?= cmake
# One compiler per core. A bare --parallel lets make start them all at once,
# and a few dozen Qt and OpenCV files at once can use up the memory.
JOBS ?= $(shell nproc 2>/dev/null || echo 2)
CMAKE_FLAGS ?= CMAKE_FLAGS ?=
LINGUAS := de LINGUAS := de es fr it ja ko nl pl pt_BR ru tr uk zh_CN
MOFILES := $(patsubst %,po/%.mo,$(LINGUAS)) MOFILES := $(patsubst %,po/%.mo,$(LINGUAS))
MANPAGE := doc/plasma-face-unlock.1 MANPAGE := doc/face-unlock.1
LIBS := $(wildcard src/lib/*.sh) LIBS := $(wildcard src/lib/*.sh)
@@ -85,13 +90,13 @@ native:
$(CMAKE) -S . -B $(BUILDDIR) \ $(CMAKE) -S . -B $(BUILDDIR) \
-DCMAKE_BUILD_TYPE=Release \ -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=$(PREFIX) \ -DCMAKE_INSTALL_PREFIX=$(PREFIX) \
-DPFU_VERSION=$(VERSION) \ -DFU_VERSION=$(VERSION) \
-DPFU_LIBEXECDIR=$(LIBEXECDIR) \ -DFU_LIBEXECDIR=$(LIBEXECDIR) \
-DPFU_MODELDIR=$(MODELDIR) \ -DFU_MODELDIR=$(MODELDIR) \
-DPFU_LOCALEDIR=$(LOCALEDIR) \ -DFU_LOCALEDIR=$(LOCALEDIR) \
-DPFU_PAMDIR=$(PAMDIR) \ -DFU_PAMDIR=$(PAMDIR) \
$(CMAKE_FLAGS) $(CMAKE_FLAGS)
$(CMAKE) --build $(BUILDDIR) --parallel $(CMAKE) --build $(BUILDDIR) --parallel $(JOBS)
models: $(addprefix models/,$(MODELS)) models: $(addprefix models/,$(MODELS))
@@ -108,7 +113,7 @@ else
@echo "msgfmt not found, skipping $@" @echo "msgfmt not found, skipping $@"
endif endif
$(MANPAGE): doc/plasma-face-unlock.1.scd $(MANPAGE): doc/face-unlock.1.scd
ifdef SCDOC ifdef SCDOC
$(SCDOC) < $< > $@ $(SCDOC) < $< > $@
else else
@@ -116,13 +121,15 @@ else
endif endif
# Syntax-check every shell file, and run shellcheck when it is available. # Syntax-check every shell file, and run shellcheck when it is available.
# SC2034 is off: the files share their variables, and shellcheck looks at one
# file at a time.
check: check:
@set -e; for f in src/plasma-face-unlock $(LIBS) tests/*.sh; do \ @set -e; for f in src/face-unlock $(LIBS) tests/*.sh; do \
bash -n "$$f" && echo "ok $$f"; \ bash -n "$$f" && echo "ok $$f"; \
done done
@if command -v shellcheck >/dev/null 2>&1; then \ @if command -v shellcheck >/dev/null 2>&1; then \
shellcheck -x -e SC1090,SC1091 src/plasma-face-unlock $(LIBS) tests/*.sh; \ shellcheck -x -e SC1090,SC1091,SC2034 src/face-unlock $(LIBS) tests/*.sh \
echo "ok shellcheck"; \ && echo "ok shellcheck"; \
else \ else \
echo "shellcheck not found, skipped"; \ echo "shellcheck not found, skipped"; \
fi fi
@@ -144,7 +151,7 @@ install: build
DESTDIR="$(DESTDIR)" $(CMAKE) --install $(BUILDDIR) DESTDIR="$(DESTDIR)" $(CMAKE) --install $(BUILDDIR)
# the command # the command
install -Dm755 src/plasma-face-unlock "$(DESTDIR)$(BINDIR)/plasma-face-unlock" install -Dm755 src/face-unlock "$(DESTDIR)$(BINDIR)/face-unlock"
install -d "$(DESTDIR)$(LIBDIR)" install -d "$(DESTDIR)$(LIBDIR)"
install -Dm644 -t "$(DESTDIR)$(LIBDIR)" $(LIBS) install -Dm644 -t "$(DESTDIR)$(LIBDIR)" $(LIBS)
sed -i -e 's|@VERSION@|$(VERSION)|g' \ sed -i -e 's|@VERSION@|$(VERSION)|g' \
@@ -152,7 +159,7 @@ install: build
-e 's|@LIBEXECDIR@|$(LIBEXECDIR)|g' \ -e 's|@LIBEXECDIR@|$(LIBEXECDIR)|g' \
-e 's|@LOCALEDIR@|$(LOCALEDIR)|g' \ -e 's|@LOCALEDIR@|$(LOCALEDIR)|g' \
-e 's|@PAMDIR@|$(PAMDIR)|g' \ -e 's|@PAMDIR@|$(PAMDIR)|g' \
"$(DESTDIR)$(BINDIR)/plasma-face-unlock" \ "$(DESTDIR)$(BINDIR)/face-unlock" \
"$(DESTDIR)$(LIBDIR)"/*.sh "$(DESTDIR)$(LIBDIR)"/*.sh
# the models # the models
@@ -161,50 +168,54 @@ install: build
done done
# the daemon's socket and service, the agent's user service # the daemon's socket and service, the agent's user service
install -Dm644 res/systemd/plasma-face-unlockd.socket "$(DESTDIR)$(SYSTEMUNITDIR)/plasma-face-unlockd.socket" install -Dm644 res/systemd/face-unlockd.socket "$(DESTDIR)$(SYSTEMUNITDIR)/face-unlockd.socket"
install -Dm644 res/systemd/plasma-face-unlockd.service "$(DESTDIR)$(SYSTEMUNITDIR)/plasma-face-unlockd.service" install -Dm644 res/systemd/face-unlockd.service "$(DESTDIR)$(SYSTEMUNITDIR)/face-unlockd.service"
install -Dm644 res/systemd/plasma-face-unlock-agent.service "$(DESTDIR)$(USERUNITDIR)/plasma-face-unlock-agent.service" install -Dm644 res/systemd/face-unlock-agent.service "$(DESTDIR)$(USERUNITDIR)/face-unlock-agent.service"
sed -i -e 's|@LIBEXECDIR@|$(LIBEXECDIR)|g' \ sed -i -e 's|@LIBEXECDIR@|$(LIBEXECDIR)|g' \
"$(DESTDIR)$(SYSTEMUNITDIR)/plasma-face-unlockd.service" \ "$(DESTDIR)$(SYSTEMUNITDIR)/face-unlockd.service" \
"$(DESTDIR)$(USERUNITDIR)/plasma-face-unlock-agent.service" "$(DESTDIR)$(USERUNITDIR)/face-unlock-agent.service"
# the desktop file KWin looks for before it lets the bubble above the # the desktop file KWin looks for before it lets the bubble above the
# lock screen, the polkit action, the icon # lock screen, the polkit action, the icon
install -Dm644 res/applications/io.github.loonixtools.plasma-face-unlock-agent.desktop \ install -Dm644 res/applications/io.github.loonixtools.face-unlock-agent.desktop \
"$(DESTDIR)$(APPDIR)/io.github.loonixtools.plasma-face-unlock-agent.desktop" "$(DESTDIR)$(APPDIR)/io.github.loonixtools.face-unlock-agent.desktop"
sed -i -e 's|@LIBEXECDIR@|$(LIBEXECDIR)|g' "$(DESTDIR)$(APPDIR)/io.github.loonixtools.plasma-face-unlock-agent.desktop" sed -i -e 's|@LIBEXECDIR@|$(LIBEXECDIR)|g' "$(DESTDIR)$(APPDIR)/io.github.loonixtools.face-unlock-agent.desktop"
install -Dm644 res/polkit/io.github.loonixtools.plasma-face-unlock.policy \ install -Dm644 res/polkit/io.github.loonixtools.face-unlock.policy \
"$(DESTDIR)$(POLKITDIR)/io.github.loonixtools.plasma-face-unlock.policy" "$(DESTDIR)$(POLKITDIR)/io.github.loonixtools.face-unlock.policy"
install -Dm644 res/plasma-face-unlock.svg "$(DESTDIR)$(ICONDIR)/plasma-face-unlock.svg" install -Dm644 res/face-unlock.svg "$(DESTDIR)$(ICONDIR)/face-unlock.svg"
# the GNOME Shell extension that draws the bubble on GNOME
install -Dm644 -t "$(DESTDIR)$(GNOMEEXTDIR)/$(GNOMEEXT)" res/gnome-shell/$(GNOMEEXT)/*
# translations # translations
@for l in $(LINGUAS); do \ @for l in $(LINGUAS); do \
if [ -f "po/$$l.mo" ]; then \ if [ -f "po/$$l.mo" ]; then \
install -Dm644 "po/$$l.mo" \ install -Dm644 "po/$$l.mo" \
"$(DESTDIR)$(LOCALEDIR)/$$l/LC_MESSAGES/plasma-face-unlock.mo"; \ "$(DESTDIR)$(LOCALEDIR)/$$l/LC_MESSAGES/face-unlock.mo"; \
fi; \ fi; \
done done
# documentation # documentation
@if [ -f $(MANPAGE) ]; then \ @if [ -f $(MANPAGE) ]; then \
install -Dm644 $(MANPAGE) "$(DESTDIR)$(MANDIR)/man1/plasma-face-unlock.1"; \ install -Dm644 $(MANPAGE) "$(DESTDIR)$(MANDIR)/man1/face-unlock.1"; \
fi fi
install -Dm644 README.md "$(DESTDIR)$(DATADIR)/doc/plasma-face-unlock/README.md" install -Dm644 README.md "$(DESTDIR)$(DATADIR)/doc/face-unlock/README.md"
uninstall: uninstall:
rm -f "$(DESTDIR)$(BINDIR)/plasma-face-unlock" rm -f "$(DESTDIR)$(BINDIR)/face-unlock"
rm -rf "$(DESTDIR)$(DATADIR)/plasma-face-unlock" rm -rf "$(DESTDIR)$(DATADIR)/face-unlock"
rm -rf "$(DESTDIR)$(LIBEXECDIR)" rm -rf "$(DESTDIR)$(LIBEXECDIR)"
rm -f "$(DESTDIR)$(PAMDIR)/pam_plasma_face_unlock.so" rm -f "$(DESTDIR)$(PAMDIR)/pam_face_unlock.so"
rm -f "$(DESTDIR)$(SYSTEMUNITDIR)/plasma-face-unlockd.socket" rm -f "$(DESTDIR)$(SYSTEMUNITDIR)/face-unlockd.socket"
rm -f "$(DESTDIR)$(SYSTEMUNITDIR)/plasma-face-unlockd.service" rm -f "$(DESTDIR)$(SYSTEMUNITDIR)/face-unlockd.service"
rm -f "$(DESTDIR)$(USERUNITDIR)/plasma-face-unlock-agent.service" rm -f "$(DESTDIR)$(USERUNITDIR)/face-unlock-agent.service"
rm -f "$(DESTDIR)$(APPDIR)/io.github.loonixtools.plasma-face-unlock-agent.desktop" rm -f "$(DESTDIR)$(APPDIR)/io.github.loonixtools.face-unlock-agent.desktop"
rm -f "$(DESTDIR)$(POLKITDIR)/io.github.loonixtools.plasma-face-unlock.policy" rm -f "$(DESTDIR)$(POLKITDIR)/io.github.loonixtools.face-unlock.policy"
rm -f "$(DESTDIR)$(ICONDIR)/plasma-face-unlock.svg" rm -f "$(DESTDIR)$(ICONDIR)/face-unlock.svg"
rm -f "$(DESTDIR)$(MANDIR)/man1/plasma-face-unlock.1" rm -rf "$(DESTDIR)$(GNOMEEXTDIR)/$(GNOMEEXT)"
rm -rf "$(DESTDIR)$(DATADIR)/doc/plasma-face-unlock" rm -f "$(DESTDIR)$(MANDIR)/man1/face-unlock.1"
@for l in $(LINGUAS); do rm -f "$(DESTDIR)$(LOCALEDIR)/$$l/LC_MESSAGES/plasma-face-unlock.mo"; done rm -rf "$(DESTDIR)$(DATADIR)/doc/face-unlock"
@for l in $(LINGUAS); do rm -f "$(DESTDIR)$(LOCALEDIR)/$$l/LC_MESSAGES/face-unlock.mo"; done
clean: clean:
rm -rf $(BUILDDIR) po/*.mo $(MANPAGE) rm -rf $(BUILDDIR) po/*.mo $(MANPAGE)
+90 -68
View File
@@ -1,24 +1,25 @@
<p align="center"> <p align="center">
<img width="200" src="res/plasma-face-unlock.svg" alt="plasma-face-unlock"> <img width="200" src="res/face-unlock.svg" alt="face-unlock">
</p> </p>
<h1 align="center">plasma-face-unlock</h1> <h1 align="center">face-unlock</h1>
<h3 align="center">Face ID for KDE Plasma.</h3> <h3 align="center">Face ID for Linux.</h3>
<p align="center"> <p align="center">
Look at the screen and it unlocks: the lock screen, sudo and admin prompts. A photo on a phone does not fool it. Unlock the lock screen, sudo and admin prompts with your face.<br>
On KDE Plasma, GNOME, Hyprland and Niri.
</p> </p>
<h5 align="center"> <h5 align="center">
<a href="#install">Install</a> | <a href="#install">Install</a> |
<a href="#how-to-use">How to use</a> | <a href="#how-to-use">How to use</a> |
<a href="#is-it-safe">Is it safe?</a> | <a href="#is-it-safe">Is it safe?</a> |
<a href="https://github.com/LoonixTools/plasma-face-unlock/issues">Report a bug</a> <a href="https://github.com/LoonixTools/face-unlock/issues">Report a bug</a>
</h5> </h5>
<p align="center"> <p align="center">
<a href="https://buymeacoffee.com/felitendo"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="48"></a> <a href="https://ko-fi.com/felitendo"><img src="https://storage.ko-fi.com/cdn/kofi5.png?v=6" alt="Buy me a coffee on Ko-fi" height="48"></a>
</p> </p>
<p align="center"> <p align="center">
@@ -26,122 +27,143 @@
</p> </p>
<p align="center"> <p align="center">
<img src="res/screenshots/bubble.png" alt="The bubble above the lock screen: looking, recognised, not recognised" width="720"> <img src="res/screenshots/bubble.png" alt="The bubble above the Plasma lock screen: looking, recognised, not recognised" width="720">
</p> </p>
Come back to your locked screen and look at it. A bubble drops down at the top, finds your face,
and you are in. It works for `sudo` and Plasma's admin prompts too, if you want. Everything runs on
your computer, and your face is saved as numbers, never as a picture.
## Install ## Install
**Arch, CachyOS, EndeavourOS, Manjaro** (AUR) <details>
<summary><b>Arch</b>, CachyOS, EndeavourOS, Manjaro</summary>
```bash ```bash
yay -S plasma-face-unlock yay -S face-unlock
``` ```
**Fedora** </details>
<details>
<summary><b>Fedora</b></summary>
```bash ```bash
sudo curl -fsSL -o /etc/yum.repos.d/plasma-face-unlock.repo \ sudo curl -fsSL -o /etc/yum.repos.d/face-unlock.repo \
https://loonixtools.github.io/plasma-face-unlock/plasma-face-unlock.repo https://loonixtools.github.io/face-unlock/face-unlock.repo
sudo dnf install plasma-face-unlock sudo dnf install face-unlock
``` ```
**Debian, Kubuntu** </details>
<details>
<summary><b>Debian</b>, Ubuntu</summary>
```bash ```bash
codename="$(sed -n 's/^VERSION_CODENAME=//p' /etc/os-release)" codename="$(sed -n 's/^VERSION_CODENAME=//p' /etc/os-release)"
sudo install -d -m 0755 /etc/apt/keyrings sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://loonixtools.github.io/plasma-face-unlock/KEY.gpg \ curl -fsSL https://loonixtools.github.io/face-unlock/KEY.gpg \
| sudo gpg --dearmor -o /etc/apt/keyrings/plasma-face-unlock.gpg | sudo gpg --dearmor -o /etc/apt/keyrings/face-unlock.gpg
echo "deb [signed-by=/etc/apt/keyrings/plasma-face-unlock.gpg] https://loonixtools.github.io/plasma-face-unlock/deb/$codename ./" \ echo "deb [signed-by=/etc/apt/keyrings/face-unlock.gpg] https://loonixtools.github.io/face-unlock/deb/$codename ./" \
| sudo tee /etc/apt/sources.list.d/plasma-face-unlock.list | sudo tee /etc/apt/sources.list.d/face-unlock.list
sudo apt update && sudo apt install plasma-face-unlock sudo apt update && sudo apt install face-unlock
``` ```
The packages are built for the current release of each. Updates then come with your normal system </details>
updates.
You need Plasma 6 on Wayland and a camera. Infrared cameras (the Windows Hello kind) work too. - **KDE Plasma:** works out of the box.
- **GNOME:** log out and back in once after installing.
- **Hyprland and Niri:** the bubble only shows on face-unlock's own lock screen.
hyprlock shows a line of text at the top instead. You also need a polkit
agent. If none runs, the menu offers to install one.
<details>
<summary>Hyprland and Niri: which lock screen?</summary>
<p align="center">
<img src="res/screenshots/lock-choice.png" alt="The window that asks which lock screen to use: face-unlock's with the bubble on the left, the user's own hyprlock with a line of text at the top on the right" width="560">
</p>
When you turn face unlock on, a window asks which lock screen you want. You
can change it later under **Settings**.
- **face-unlock's own:** the bubble, the time and your wallpaper. You lock with
`face-unlock lock`, and the menu shows where to put that.
- **Yours** (hyprlock, swaylock, gtklock or waylock): press Enter on the empty
password field to scan. hyprlock and swaylock also scan when you come back.
Hyprland without uwsm needs a line in its config. The menu shows it.
</details>
## How to use ## How to use
```bash ```bash
plasma-face-unlock face-unlock
``` ```
This opens a menu:
<p align="center"> <p align="center">
<img src="res/screenshots/menu.png" alt="The plasma-face-unlock menu in Konsole: face unlock on, one face, lock screen, sudo and admin prompts on" width="560"> <img src="res/screenshots/menu.png" alt="The face-unlock menu in Konsole: face unlock on, one face, lock screen, sudo and admin prompts on" width="560">
</p> </p>
Press **1** and follow the setup window: look at the camera, then turn your head slowly in a circle Press **1** and look at the camera. Then lock the screen and look at it.
until the ring is full. Then lock the screen and look at it. **2** adds another face, for example
with glasses, and **5** runs one test scan that shows what the camera sees. Try that first when
something does not work.
**4** opens the settings. The text at the bottom says what the selected one does: <details>
<summary>Settings</summary>
<p align="center"> <p align="center">
<img src="res/screenshots/settings.png" alt="The settings in Konsole, grouped into lock screen, password prompts, recognition and bubble, with the photo check explained at the bottom" width="680"> <img src="res/screenshots/settings.png" alt="The settings in Konsole, grouped into lock screen, password prompts, recognition and bubble, with the photo check explained at the bottom" width="680">
</p> </p>
Without the menu: `plasma-face-unlock enable`, `disable`, `setup [NAME]`, `faces`, `remove ID`, </details>
`test` and `status`.
## Is it safe? ## Is it safe?
It is a convenience, not a security upgrade. Face ID on a phone sees your face in 3D. A webcam only It is a convenience upgrade, not extra security. Your webcam only sees a flat picture, so
sees a flat picture. it cannot always differentiate your face from a good fake. But the tool does everything in its power to prevent that:
- You do not have to blink. A photo on a phone or tablet, or a glossy print, is still refused: - Photos and videos on a phone, tablet or glossy screen are caught.
it gives itself away by its reflection and its straight edges. - A matte printed photo is caught when the photo check is set to *strict*.
- A matte printed photo can get past that. Set the photo check to *strict* in the settings: then
the face has to blink or turn a little, and a photo can do neither.
- A video of you can still get in.
- Five failed tries in a row pause it for 15 minutes, or until you use your password.
- Only root can read your face data. Adding or deleting a face always needs your password.
- sudo and admin prompts never take a face over SSH.
If the computer guards something important, leave sudo and admin prompts off. But a video of you on a big matte screen could get in.
## How it works After five failed attempts, face unlock pauses for 15 minutes. Your face is kept as
numbers, not pictures, and only root can read them. sudo over SSH always asks
for the password.
## More
<details>
<summary>How it works</summary>
| | | | | |
|---|---| |---|---|
| `plasma-face-unlockd` | Runs as root when needed. It owns the camera and the face data and decides. | | `face-unlockd` | The root service. Owns the camera and the face data. |
| `plasma-face-unlock-agent` | Runs in your session. It watches the lock screen and draws the bubble. | | `face-unlock-agent` | Runs in your session. Watches the lock screen, draws the bubble. |
| `pam_plasma_face_unlock.so` | Lets sudo and admin prompts ask the daemon. No match: you type your password as usual. | | `pam_face_unlock.so` | Lets sudo and admin prompts ask the service. |
| `plasma-face-unlock` | The menu. | | `face-unlock` | The menu. |
Two small networks from the OpenCV model zoo find the face (YuNet) and turn it into numbers (SFace). Two small networks from the OpenCV model zoo run on the CPU: YuNet finds the face, SFace turns it
They run on the CPU in a few milliseconds. The lock screen is unlocked through logind, the same way into numbers. All details: `man face-unlock`.
`loginctl unlock-session` does it. `man plasma-face-unlock` has all the details.
## Build from source </details>
<details>
<summary>Build from source</summary>
```bash ```bash
make models # downloads the two networks and checks them make models
make make
make test make test
sudo make install sudo make install
``` ```
You need CMake, a C++20 compiler, Qt 6, LayerShellQt, KI18n, OpenCV 4.5.4 or newer (with DNN), Needs CMake, a C++20 compiler, Qt 6, LayerShellQt, KI18n, OpenCV 4.5.4+ (with DNN), Linux-PAM and
Linux-PAM and libsystemd. `scdoc` and `msgfmt` are optional (man page, translations). libsystemd.
[packaging/README.md](packaging/README.md) explains releases.
</details>
## Credits ## Credits
- [Glance](https://github.com/jonnyoo/glance) by Jonathan Zhou (MIT): face unlock for the Mac. The - [Glance](https://github.com/jonnyoo/glance) by Jonathan Zhou: the idea and the look of the bubble.
idea, the look of the bubble and the photo check come from there. The code here is new. - [YuNet](https://github.com/opencv/opencv_zoo/tree/main/models/face_detection_yunet) and
- [YuNet](https://github.com/opencv/opencv_zoo/tree/main/models/face_detection_yunet) (MIT) and [SFace](https://github.com/opencv/opencv_zoo/tree/main/models/face_recognition_sface) from the
[SFace](https://github.com/opencv/opencv_zoo/tree/main/models/face_recognition_sface) (Apache-2.0) OpenCV model zoo.
from the OpenCV model zoo.
## License
GPL-3.0-or-later. GPL-3.0-or-later.
@@ -1,19 +1,20 @@
plasma-face-unlock(1) face-unlock(1)
# NAME # NAME
plasma-face-unlock - face unlock for KDE Plasma face-unlock - face unlock for Plasma, GNOME, Hyprland and Niri
# SYNOPSIS # SYNOPSIS
*plasma-face-unlock* [_command_] *face-unlock* [_command_]
# DESCRIPTION # DESCRIPTION
Look at the screen and it unlocks, the way a phone does. The lock screen, sudo Look at the screen and it unlocks, the way a phone does. The lock screen, sudo
in a terminal and the admin password prompts of Plasma can all take a face in a terminal and the admin password prompts can all take a face instead of a
instead of a password. A photo of somebody on a phone or tablet does not get password, on KDE Plasma, GNOME, Hyprland and Niri (see *DESKTOPS*). A photo of
in, and with the strict photo check a printed one does not either. somebody on a phone or tablet does not get in, and with the strict photo check
a printed one does not either.
A bubble at the top of the screen shows what is going on: it drops down when A bubble at the top of the screen shows what is going on: it drops down when
the camera starts looking, the face in it looks around, and when it recognises the camera starts looking, the face in it looks around, and when it recognises
@@ -31,7 +32,9 @@ It is a convenience, not a security upgrade. See *HOW SAFE IS THIS*.
*enable* *enable*
Turn face unlock on for this user. Sets up a face first if there is none, Turn face unlock on for this user. Sets up a face first if there is none,
starts the lock screen agent, and turns sudo and admin prompts back on if starts the lock screen agent, and turns sudo and admin prompts back on if
they were on before. they were on before. On Hyprland and Niri it also puts the face into the
lock screen's password check (*Lock screens*), unless that was turned
off. On GNOME it switches on the extension that draws the bubble.
*disable* *disable*
Turn it off: the agent stops and sudo and the admin prompts go back to the Turn it off: the agent stops and sudo and the admin prompts go back to the
@@ -56,6 +59,12 @@ It is a convenience, not a security upgrade. See *HOW SAFE IS THIS*.
*status* *status*
What is on, and when the last unlock was. What is on, and when the last unlock was.
*lock*
Lock the screen. On Hyprland, Niri and other compositors whose lock
screen is a program of its own, with face-unlock's own lock screen,
which shows the bubble (see *DESKTOPS*). Elsewhere with the desktop's
lock screen. Returns once the screen is locked.
*-h*, *--help* *-h*, *--help*
Show a summary of the commands. Show a summary of the commands.
@@ -64,23 +73,23 @@ It is a convenience, not a security upgrade. See *HOW SAFE IS THIS*.
# THE PIECES # THE PIECES
*plasma-face-unlockd* *face-unlockd*
The daemon, running as root and started by its socket The daemon, running as root and started by its socket
(_plasma-face-unlockd.socket_). It is the only thing that opens the camera (_face-unlockd.socket_). It is the only thing that opens the camera
and the only thing that can read the face data. It exits after a minute and the only thing that can read the face data. It exits after a minute
with nothing to do. with nothing to do.
*plasma-face-unlock-agent* *face-unlock-agent*
Runs in the Plasma session as a user service Runs in the desktop session as a user service
(_plasma-face-unlock-agent.service_). It watches the lock screen, asks the (_face-unlock-agent.service_). It watches the lock screen, asks the
daemon to scan when somebody comes back, unlocks the session when the face daemon to scan when somebody comes back, unlocks the session when the face
matches, draws the bubble, and is the setup window. matches, draws the bubble, and is the setup window.
*pam_plasma_face_unlock.so* *pam_face_unlock.so*
The PAM module for sudo and admin prompts. It asks the daemon and turns The PAM module for sudo and admin prompts. It asks the daemon and turns
the answer into a PAM result. the answer into a PAM result.
*plasma-face-unlock-ctl* *face-unlock-ctl*
How this command talks to the daemon. How this command talks to the daemon.
# RECOGNITION # RECOGNITION
@@ -171,14 +180,16 @@ prompts off, or face unlock altogether.
Plasma's lock screen runs a fingerprint stack next to the password, but starts Plasma's lock screen runs a fingerprint stack next to the password, but starts
it once per lock, gives up for good after the first failure and labels it for it once per lock, gives up for good after the first failure and labels it for
fingerprints. So face unlock does not go through the lock screen's PAM at all. fingerprints. GNOME's, hyprlock and swaylock only ask their PAM stack once the
The agent watches for the screen to lock (org.freedesktop.ScreenSaver) and password is typed. So face unlock does not go through the lock screen's PAM at
scans when somebody comes back: all. The agent watches for the screen to lock (see *DESKTOPS*) and scans when
somebody comes back:
- on any key or mouse movement after the screen locked, once 1.5 seconds have - on any key or mouse movement after the screen locked, once 1.5 seconds have
passed (the key that locked it does not count). Enter on the empty password passed (the key that locked it does not count). Enter on the empty password
field is a key like any other. This works while a video or an app keeps the field is a key like any other. This works while a video or an app keeps the
screen on, too (ext_idle_notifier_v1, input notification); screen on, too (ext_idle_notifier_v1 with its input notification, and
Mutter's IdleMonitor on GNOME);
- when the machine wakes from sleep; - when the machine wakes from sleep;
- right after locking, if *Scan right after locking* is on. Off by - right after locking, if *Scan right after locking* is on. Off by
default: whoever locks their screen on purpose is usually still in front of default: whoever locks their screen on purpose is usually still in front of
@@ -188,24 +199,101 @@ After a scan that did not get anybody in, the next one waits for the person to
be still for two seconds and then touch something again, so typing the password be still for two seconds and then touch something again, so typing the password
does not start a scan with every key. does not start a scan with every key.
Unlocked through logind, the lock screen cuts off its own password prompt and When the face matches, the agent unlocks the session the way its lock screen
counts that as a wrong password. After a face unlock the daemon resets the wants it: through logind, as *loginctl unlock-session* does, on Plasma and
GNOME, by itself on its own lock screen, and with SIGUSR1 for hyprlock,
swaylock and gtklock after 4.0.0. Any other lock screen opens itself, through
PAM (see *DESKTOPS*).
Unlocked through logind, Plasma's lock screen cuts off its own password prompt
and counts that as a wrong password. After a face unlock the daemon resets the
failed logins of *pam_faillock*(8), as a correct password does, so face unlocks failed logins of *pam_faillock*(8), as a correct password does, so face unlocks
never lock the account. never lock the account.
The bubble is a layer-shell surface that KWin keeps above the lock screen On Plasma the bubble is a layer-shell surface that KWin keeps above the lock
(kde_lockscreen_overlay_v1). KWin only allows that for a program whose desktop screen (kde_lockscreen_overlay_v1). KWin only allows that for a program whose
file asks for it, which is desktop file asks for it, which is
_io.github.loonixtools.plasma-face-unlock-agent.desktop_. _io.github.loonixtools.face-unlock-agent.desktop_. How it gets there elsewhere
is under *DESKTOPS*.
# SUDO AND ADMIN PROMPTS # DESKTOPS
All of them on Wayland. sudo and admin prompts work the same everywhere, and
so does the bubble, above the lock screen too.
*KDE Plasma 6*
The lock is seen through org.freedesktop.ScreenSaver and logind, and
the bubble shows above the lock screen (kde_lockscreen_overlay_v1).
*GNOME*
The lock is seen through logind, where GNOME sets *LockedHint*. GNOME
lets no program draw above its windows, so a GNOME Shell extension
(_face-unlock@loonixtools.github.io_) draws the bubble, from what the
agent says on the session bus. *enable* switches it on; GNOME loads an
extension that was installed after the login at the next one.
*Hyprland*, *Niri* and other compositors with ext-session-lock
The lock screen is a program of the user's choice, and nothing but it
can open it. So the face goes into its password check, the way it goes
into sudo's: *Lock screens* under *Settings* puts the PAM module in
front of the stacks of hyprlock, swaylock, gtklock and waylock, where
installed (see *SUDO, ADMIN PROMPTS AND LOCK SCREENS*). Enter on the
empty password field starts a scan, and a match lets the lock screen
open itself. hyprlock asks PAM the moment it starts; that first time
is skipped, so a screen locked on purpose does not open again at once.
hyprlock, swaylock and gtklock (after 4.0.0) can also be opened from
outside: the agent finds them among the user's processes (niri also
sets *LockedHint*), scans when somebody comes back, and opens them with
SIGUSR1. It only does so once the lock screen handles SIGUSR1: before
it has locked, the signal would kill it. Those lock screens cover the
bubble. hyprlock shows it as a line of text instead: a label in
_~/.config/hypr/hyprlock.conf_ (a *source* line under a face-unlock
comment) reads what the agent writes, and SIGUSR2 makes hyprlock read
it again. gtklock shows the messages of the PAM module; swaylock and
waylock show none.
*enable* asks once in a window which way to go: face-unlock's lock
screen, or the user's own with that line of text. The window shows the
screen both ways, the user's own rebuilt from the config of hyprlock or
swaylock. *Which lock screen* under *Settings* opens it again.
For the bubble on the lock screen there is face-unlock's own: *lock*.
It shows the time, a password field (PAM service _face-unlock-lock_
when an administrator wrote one, else the distribution's password-auth,
common-auth or login) and the bubble, and a face opens it straight
away. Behind it is the picture on the desktop, as swaybg, awww (swww),
hyprpaper or wpaperd show it. *Wallpaper* under *Settings* puts another
picture there, one at random from a folder, or _none_; *Blur the
wallpaper* blurs it. If the agent dies while this screen is locked, the
compositor keeps it locked, and the agent takes the lock back when it
starts again. It needs Qt 6.10 or newer: with older Qt (Debian 13) the
menu does not offer it, and *lock* asks logind to lock.
Hyprland only starts the agent's user service under uwsm. Without it,
*enable* and *status* show what to add to its config.
Hyprland and Niri come without a polkit agent. One has to run for admin
prompts and for setting up a face, for example hyprpolkitagent. The menu warns
when none runs, and *p* installs one: hyprpolkitagent where the distribution
has it, else KDE's agent. It starts it too, now and with the session.
# SUDO, ADMIN PROMPTS AND LOCK SCREENS
Turned on under *Settings*, one line goes in front of the service's PAM stack: Turned on under *Settings*, one line goes in front of the service's PAM stack:
``` ```
-auth sufficient /usr/lib/security/pam_plasma_face_unlock.so -auth sufficient /usr/lib/security/pam_face_unlock.so
``` ```
For a lock screen (hyprlock, swaylock, gtklock, waylock) it ends in
*lockscreen*. The scan then counts as a lock screen's, and a lock screen less
than two seconds old gets none. Enter on the empty password field counts as a
wrong password for *pam_faillock*(8) before the face is tried; a match takes
that back, as a correct password does. A lock screen that checks the password
with *login* or *system-auth* directly is left alone: those also let people
log in.
A match lets the person in; anything else falls through to the password as if A match lets the person in; anything else falls through to the password as if
the line were not there. The dash makes PAM skip it quietly if the module ever the line were not there. The dash makes PAM skip it quietly if the module ever
goes missing, so sudo keeps working even when the package was removed without goes missing, so sudo keeps working even when the package was removed without
@@ -232,29 +320,42 @@ A laptop with its lid shut is not scanned (*Not when the lid is closed*).
# FILES # FILES
_~/.config/plasma-face-unlock/config_ _~/.config/face-unlock/config_
This user's settings: the lock screen, the bubble and its animation This user's settings: the lock screen, the bubble and its animation
speed. Written by the menu. speed. Written by the menu.
_/etc/plasma-face-unlock/config_ _/etc/face-unlock/config_
The system settings: camera, photo check, strictness, attention, scan The system settings: camera, photo check, strictness, attention, scan
length. Written by the menu through sudo. length. Written by the menu through sudo.
_/var/lib/plasma-face-unlock/users/<uid>.json_ _/var/lib/face-unlock/users/<uid>.json_
The face data: numbers, no pictures. Root only. The face data: numbers, no pictures. Root only.
_/var/lib/plasma-face-unlock/users/<uid>.state_ _/var/lib/face-unlock/users/<uid>.state_
Failed scans in a row, the pause they lead to, the last unlock. Failed scans in a row, the pause they lead to, the last unlock.
_/usr/share/plasma-face-unlock/models/_ _/usr/share/face-unlock/models/_
The two networks. The two networks.
_/run/plasma-face-unlock/socket_ _/run/face-unlock/socket_
The daemon's socket. The daemon's socket.
_$XDG_RUNTIME_DIR/plasma-face-unlock/agent.socket_ _$XDG_RUNTIME_DIR/face-unlock/agent.socket_
Where the daemon tells the agent about scans it did not start, for the Where the daemon tells the agent about scans it did not start, for the
bubble. bubble, and where *lock* asks it to lock.
_$XDG_RUNTIME_DIR/face-unlock/locked_
There while face-unlock's own lock screen is up.
_$XDG_RUNTIME_DIR/face-unlock/lock-text_
What hyprlock shows at the top when the user keeps their own lock
screen.
_~/.config/face-unlock/hyprlock.conf_
The label for that, which _~/.config/hypr/hyprlock.conf_ sources.
_/usr/share/gnome-shell/extensions/face-unlock@loonixtools.github.io/_
The GNOME Shell extension that draws the bubble on GNOME.
# ENVIRONMENT # ENVIRONMENT
@@ -263,17 +364,18 @@ _$XDG_RUNTIME_DIR/plasma-face-unlock/agent.socket_
# REQUIREMENTS # REQUIREMENTS
KDE Plasma 6 on Wayland, a camera, OpenCV 4.5.4 or newer with its DNN module, KDE Plasma 6, GNOME, Hyprland or Niri on Wayland, a camera, OpenCV 4.5.4 or
Qt 6, LayerShellQt, KI18n, systemd, polkit and Linux-PAM. newer with its DNN module, Qt 6, LayerShellQt, KI18n, systemd, polkit and
Linux-PAM.
# SEE ALSO # SEE ALSO
*loginctl*(1), *pam*(8), *polkit*(8), *systemctl*(1) *loginctl*(1), *pam*(8), *polkit*(8), *systemctl*(1), *swaylock*(1)
# AUTHORS # AUTHORS
Felitendo. Source and issue tracker at Felitendo. Source and issue tracker at
https://github.com/LoonixTools/plasma-face-unlock https://github.com/LoonixTools/face-unlock
The liveness model and the look of the bubble follow Glance by Jonathan Zhou The liveness model and the look of the bubble follow Glance by Jonathan Zhou
(https://github.com/jonnyoo/glance, MIT). The models are YuNet and SFace from (https://github.com/jonnyoo/glance, MIT). The models are YuNet and SFace from
+8 -8
View File
@@ -7,24 +7,24 @@ description of the layout, and two descriptions drift.
| | | | | |
|---|---| |---|---|
| `deb/control`, `deb/copyright` | metadata for the Debian binary package | | `deb/control`, `deb/copyright` | metadata for the Debian binary package |
| `rpm/plasma-face-unlock.spec` | the RPM spec | | `rpm/face-unlock.spec` | the RPM spec |
| `build-deb.sh`, `build-rpm.sh` | build one package into `dist/` | | `build-deb.sh`, `build-rpm.sh` | build one package into `dist/` |
| `check-version.sh` | refuses a tag that disagrees with the Makefile | | `check-version.sh` | refuses a tag that disagrees with the Makefile |
| `publish-repos.sh` | regenerates the APT and RPM repositories | | `publish-repos.sh` | regenerates the APT and RPM repositories |
| `pages/` | the landing page and the `.repo` file served from GitHub Pages | | `pages/` | the landing page and the `.repo` file served from GitHub Pages |
The AUR package lives in [Felitendo/PKGBUILDS](https://github.com/Felitendo/PKGBUILDS/tree/main/plasma-face-unlock). The AUR package lives in [Felitendo/PKGBUILDS](https://github.com/Felitendo/PKGBUILDS/tree/main/face-unlock).
Its CI notices a new GitHub release, updates the checksum and pushes to the AUR. Its CI notices a new GitHub release, updates the checksum and pushes to the AUR.
Unlike the shell-only LoonixTools, this one is compiled. The packages are per Unlike the shell-only LoonixTools, this one is compiled. The packages are per
architecture (amd64 and x86_64), and they need the Plasma 6 and Qt 6 architecture (amd64 and x86_64), and they need the Qt 6 and KDE Frameworks 6
development packages to build: Debian 13 (trixie) and current Fedora have development packages to build: Debian 13 (trixie) and current Fedora have
them. The Debian package's library dependencies are read off the binaries by them. The Debian package's library dependencies are read off the binaries by
`dpkg-shlibdeps`; RPM does the same on its own. `dpkg-shlibdeps`; RPM does the same on its own.
The program uses Qt's private API, so a package only fits the Qt it was built The program uses Qt's private API, so a package only fits the Qt it was built
against. The `.deb` is therefore built twice, in Debian 13 and in Ubuntu 26.04 against. The `.deb` is therefore built twice, in Debian 13 and in Ubuntu 26.04
(for Kubuntu), with a suffix on the version (`~deb13`, `~ubuntu26.04`), and each (for Ubuntu and Kubuntu), with a suffix on the version (`~deb13`, `~ubuntu26.04`), and each
gets an APT repository of its own: `deb/trixie` and `deb/resolute`. The RPM is gets an APT repository of its own: `deb/trixie` and `deb/resolute`. The RPM is
built on the current Fedora. built on the current Fedora.
@@ -34,7 +34,7 @@ checksums in the Makefile. The RPM spec and the PKGBUILD list them as sources
of their own, with the same checksums. of their own, with the same checksums.
Neither the deb nor the rpm switches anything on at install time. The daemon's Neither the deb nor the rpm switches anything on at install time. The daemon's
socket is enabled by `plasma-face-unlock` the first time somebody turns it on, socket is enabled by `face-unlock` the first time somebody turns it on,
the agent is a user service each user enables, and the PAM files are only the agent is a user service each user enables, and the PAM files are only
touched when somebody asks for sudo or admin prompts. Removing a package touched when somebody asks for sudo or admin prompts. Removing a package
disables the socket. disables the socket.
@@ -52,7 +52,7 @@ argument.
## Making a release ## Making a release
1. Bump `VERSION` in the Makefile. The compiled programs get it from there 1. Bump `VERSION` in the Makefile. The compiled programs get it from there
too (`-DPFU_VERSION`). too (`-DFU_VERSION`).
2. Add the release to `CHANGELOG.md`, in the format CLAUDE.md describes. 2. Add the release to `CHANGELOG.md`, in the format CLAUDE.md describes.
3. Commit, then `git tag vX.Y.Z && git push --tags`. 3. Commit, then `git tag vX.Y.Z && git push --tags`.
@@ -75,9 +75,9 @@ repositories. Nothing is pushed and no release is made.
```bash ```bash
gpg --batch --passphrase '' --quick-generate-key \ gpg --batch --passphrase '' --quick-generate-key \
'plasma-face-unlock repository <felitendoyt@gmail.com>' rsa4096 sign never 'face-unlock repository <felitendoyt@gmail.com>' rsa4096 sign never
gpg --armor --export-secret-keys 'plasma-face-unlock repository' \ gpg --armor --export-secret-keys 'face-unlock repository' \
| gh secret set GPG_PRIVATE_KEY | gh secret set GPG_PRIVATE_KEY
``` ```
+12 -2
View File
@@ -21,7 +21,7 @@ version="${1:-$(make -s -C "$here" version)}"
# The package depends on the exact Qt of the distribution it is built on, so # The package depends on the exact Qt of the distribution it is built on, so
# each one gets a build of its own, told apart by a suffix: ~deb13, ~ubuntu26.04. # each one gets a build of its own, told apart by a suffix: ~deb13, ~ubuntu26.04.
debversion="$version${DEB_SUFFIX:-}" debversion="$version${DEB_SUFFIX:-}"
name=plasma-face-unlock name=face-unlock
# A package without its man page or its translations is not a package this # A package without its man page or its translations is not a package this
# should be quietly willing to produce. # should be quietly willing to produce.
@@ -63,11 +63,21 @@ cat > "$root/DEBIAN/prerm" <<'SH'
#!/bin/sh #!/bin/sh
set -e set -e
if [ "$1" = remove ] && [ -d /run/systemd/system ]; then if [ "$1" = remove ] && [ -d /run/systemd/system ]; then
systemctl disable --now plasma-face-unlockd.socket plasma-face-unlockd.service >/dev/null 2>&1 || true systemctl disable --now face-unlockd.socket face-unlockd.service >/dev/null 2>&1 || true
fi fi
SH SH
chmod 755 "$root/DEBIAN/prerm" chmod 755 "$root/DEBIAN/prerm"
# Faces, settings and PAM lines of plasma-face-unlock, the old name.
cat > "$root/DEBIAN/postinst" <<'SH'
#!/bin/sh
set -e
if [ "$1" = configure ]; then
face-unlock --root migrate || true
fi
SH
chmod 755 "$root/DEBIAN/postinst"
( cd "$root" && find . -type f ! -path './DEBIAN/*' -printf '%P\0' \ ( cd "$root" && find . -type f ! -path './DEBIAN/*' -printf '%P\0' \
| LC_ALL=C sort -z | xargs -0 md5sum > DEBIAN/md5sums ) | LC_ALL=C sort -z | xargs -0 md5sum > DEBIAN/md5sums )
+1 -1
View File
@@ -14,7 +14,7 @@ set -euo pipefail
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
version="${1:-$(make -s -C "$here" version)}" version="${1:-$(make -s -C "$here" version)}"
name=plasma-face-unlock name=face-unlock
command -v rpmbuild > /dev/null || { echo "$0: rpmbuild is not installed" >&2; exit 1; } command -v rpmbuild > /dev/null || { echo "$0: rpmbuild is not installed" >&2; exit 1; }
+13 -8
View File
@@ -1,17 +1,22 @@
Package: plasma-face-unlock Package: face-unlock
Version: @VERSION@ Version: @VERSION@
Section: admin Section: admin
Priority: optional Priority: optional
Architecture: @ARCH@ Architecture: @ARCH@
Maintainer: Felitendo <felitendoyt@gmail.com> Maintainer: Felitendo <felitendoyt@gmail.com>
Depends: @DEPENDS@, bash (>= 4.2), coreutils, grep, sed, mawk | gawk, systemd, polkitd | policykit-1, qml6-module-qtquick, qml6-module-qtquick-shapes, qml6-module-qtquick-effects, qml6-module-qtquick-window, qml6-module-qtqml-workerscript Depends: @DEPENDS@, bash (>= 4.2), coreutils, grep, sed, mawk | gawk, systemd, polkitd | policykit-1, qml6-module-qtquick, qml6-module-qtquick-shapes, qml6-module-qtquick-effects, qml6-module-qtquick-window, qml6-module-qtqml-workerscript
Recommends: gettext-base, kscreenlocker Recommends: gettext-base
Homepage: https://github.com/LoonixTools/plasma-face-unlock Replaces: plasma-face-unlock
Description: face unlock for KDE Plasma Conflicts: plasma-face-unlock
Homepage: https://github.com/LoonixTools/face-unlock
Description: face unlock for Plasma, GNOME, Hyprland and Niri
Look at the screen and it unlocks, the way a phone does. The lock screen, Look at the screen and it unlocks, the way a phone does. The lock screen,
sudo in a terminal and the admin password prompts of Plasma can take a face sudo in a terminal and the admin password prompts can take a face instead
instead of a password. A bubble at the top of the screen, above the lock of a password, on KDE Plasma, GNOME, Hyprland and Niri. A bubble at the top
screen too, shows the face being looked for, recognised or refused. of the screen shows the face being looked for, recognised or refused.
.
This package was called plasma-face-unlock before. It takes over its faces
and settings.
. .
A photo on a phone, a tablet or a glossy print is refused by its A photo on a phone, a tablet or a glossy print is refused by its
reflection and its straight edges. The strict photo check also wants a sign reflection and its straight edges. The strict photo check also wants a sign
@@ -21,5 +26,5 @@ Description: face unlock for KDE Plasma
through. through.
. .
Everything runs on the machine. Faces are stored as numbers readable only by Everything runs on the machine. Faces are stored as numbers readable only by
root, never as pictures. Run "plasma-face-unlock disable" before removing root, never as pictures. Run "face-unlock disable" before removing
this package, so that sudo and polkit go back to the password alone. this package, so that sudo and polkit go back to the password alone.
+4 -4
View File
@@ -1,17 +1,17 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: plasma-face-unlock Upstream-Name: face-unlock
Source: https://github.com/LoonixTools/plasma-face-unlock Source: https://github.com/LoonixTools/face-unlock
Files: * Files: *
Copyright: 2026 Felitendo Copyright: 2026 Felitendo
License: GPL-3+ License: GPL-3+
Files: usr/share/plasma-face-unlock/models/face_detection_yunet_2023mar.onnx Files: usr/share/face-unlock/models/face_detection_yunet_2023mar.onnx
Copyright: 2021-2023 Shiqi Yu, Wei Wu and the YuNet authors Copyright: 2021-2023 Shiqi Yu, Wei Wu and the YuNet authors
License: MIT License: MIT
Comment: https://github.com/opencv/opencv_zoo/tree/main/models/face_detection_yunet Comment: https://github.com/opencv/opencv_zoo/tree/main/models/face_detection_yunet
Files: usr/share/plasma-face-unlock/models/face_recognition_sface_2021dec.onnx Files: usr/share/face-unlock/models/face_recognition_sface_2021dec.onnx
Copyright: 2021 Yaoyao Zhong and Weihong Deng Copyright: 2021 Yaoyao Zhong and Weihong Deng
License: Apache-2.0 License: Apache-2.0
Comment: https://github.com/opencv/opencv_zoo/tree/main/models/face_recognition_sface Comment: https://github.com/opencv/opencv_zoo/tree/main/models/face_recognition_sface
@@ -1,5 +1,5 @@
[plasma-face-unlock] [face-unlock]
name=plasma-face-unlock name=face-unlock
baseurl=@BASEURL@/rpm baseurl=@BASEURL@/rpm
enabled=1 enabled=1
gpgcheck=1 gpgcheck=1
+22 -22
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>plasma-face-unlock</title> <title>face-unlock</title>
<style> <style>
:root { :root {
--bg: #ffffff; --bg: #ffffff;
@@ -62,52 +62,52 @@
</head> </head>
<body> <body>
<h1>plasma-face-unlock</h1> <h1>face-unlock</h1>
<p class="lead"> <p class="lead">
Face ID for KDE Plasma: look at the screen and it unlocks. The lock screen, Face ID for Linux: look at the screen and it unlocks. The lock screen, sudo
sudo and the admin prompts can take a face instead of a password, and a photo and the admin prompts can take a face instead of a password, on KDE Plasma,
of somebody is not enough. GNOME, Hyprland and Niri. A photo of somebody is not enough.
</p> </p>
<p> <p>
This page is the package repository. Set it up once and every new version This page is the package repository. Set it up once and every new version
arrives with the rest of your system updates. The arrives with the rest of your system updates. The
<a href="https://github.com/LoonixTools/plasma-face-unlock">source and the <a href="https://github.com/LoonixTools/face-unlock">source and the
documentation</a> are on GitHub. documentation</a> are on GitHub.
</p> </p>
<h2>Debian 13</h2> <h2>Debian 13</h2>
<pre><code>sudo install -d -m 0755 /etc/apt/keyrings <pre><code>sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL @BASEURL@/KEY.gpg \ curl -fsSL @BASEURL@/KEY.gpg \
| sudo gpg --dearmor -o /etc/apt/keyrings/plasma-face-unlock.gpg | sudo gpg --dearmor -o /etc/apt/keyrings/face-unlock.gpg
echo "deb [signed-by=/etc/apt/keyrings/plasma-face-unlock.gpg] @BASEURL@/deb/trixie ./" \ echo "deb [signed-by=/etc/apt/keyrings/face-unlock.gpg] @BASEURL@/deb/trixie ./" \
| sudo tee /etc/apt/sources.list.d/plasma-face-unlock.list | sudo tee /etc/apt/sources.list.d/face-unlock.list
sudo apt update sudo apt update
sudo apt install plasma-face-unlock</code></pre> sudo apt install face-unlock</code></pre>
<h2>Kubuntu 26.04</h2> <h2>Ubuntu 26.04</h2>
<pre><code>sudo install -d -m 0755 /etc/apt/keyrings <pre><code>sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL @BASEURL@/KEY.gpg \ curl -fsSL @BASEURL@/KEY.gpg \
| sudo gpg --dearmor -o /etc/apt/keyrings/plasma-face-unlock.gpg | sudo gpg --dearmor -o /etc/apt/keyrings/face-unlock.gpg
echo "deb [signed-by=/etc/apt/keyrings/plasma-face-unlock.gpg] @BASEURL@/deb/resolute ./" \ echo "deb [signed-by=/etc/apt/keyrings/face-unlock.gpg] @BASEURL@/deb/resolute ./" \
| sudo tee /etc/apt/sources.list.d/plasma-face-unlock.list | sudo tee /etc/apt/sources.list.d/face-unlock.list
sudo apt update sudo apt update
sudo apt install plasma-face-unlock</code></pre> sudo apt install face-unlock</code></pre>
<h2>Fedora 44 (KDE Plasma)</h2> <h2>Fedora 44</h2>
<pre><code>sudo curl -fsSL -o /etc/yum.repos.d/plasma-face-unlock.repo \ <pre><code>sudo curl -fsSL -o /etc/yum.repos.d/face-unlock.repo \
@BASEURL@/plasma-face-unlock.repo @BASEURL@/face-unlock.repo
sudo dnf install plasma-face-unlock</code></pre> sudo dnf install face-unlock</code></pre>
<h2>Arch, CachyOS, EndeavourOS, Manjaro</h2> <h2>Arch, CachyOS, EndeavourOS, Manjaro</h2>
<pre><code>paru -S plasma-face-unlock</code></pre> <pre><code>paru -S face-unlock</code></pre>
<h2>Then, once</h2> <h2>Then, once</h2>
<pre><code>plasma-face-unlock</code></pre> <pre><code>face-unlock</code></pre>
<p> <p>
Press 1. It sets up your face (look at the camera, move your head in a Press 1. It sets up your face (look at the camera, move your head in a
circle) and turns face unlock on. sudo and the admin prompts are off until circle) and turns face unlock on. sudo and the admin prompts are off until
you switch them on under Settings. Run <code>plasma-face-unlock disable</code> you switch them on under Settings. Run <code>face-unlock disable</code>
before removing the package: it takes face unlock back out of sudo and before removing the package: it takes face unlock back out of sudo and
polkit. polkit.
</p> </p>
+5 -5
View File
@@ -19,7 +19,7 @@ pages="$(cd -- "$1" && pwd)"
incoming="$(cd -- "$2" && pwd)" incoming="$(cd -- "$2" && pwd)"
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
base_url="${PFU_REPO_URL:-https://loonixtools.github.io/plasma-face-unlock}" base_url="${FU_REPO_URL:-https://loonixtools.github.io/face-unlock}"
keyid="$(gpg --list-secret-keys --with-colons | awk -F: '/^sec:/ { print $5; exit }')" keyid="$(gpg --list-secret-keys --with-colons | awk -F: '/^sec:/ { print $5; exit }')"
[[ -n $keyid ]] || { echo "$0: no secret key in the keyring" >&2; exit 1; } [[ -n $keyid ]] || { echo "$0: no secret key in the keyring" >&2; exit 1; }
@@ -40,8 +40,8 @@ for suite in trixie:deb13 resolute:ubuntu26.04; do
dpkg-scanpackages --multiversion . > Packages dpkg-scanpackages --multiversion . > Packages
gzip -9kf Packages gzip -9kf Packages
apt-ftparchive \ apt-ftparchive \
-o APT::FTPArchive::Release::Origin=plasma-face-unlock \ -o APT::FTPArchive::Release::Origin=face-unlock \
-o APT::FTPArchive::Release::Label=plasma-face-unlock \ -o APT::FTPArchive::Release::Label=face-unlock \
-o APT::FTPArchive::Release::Suite="$codename" \ -o APT::FTPArchive::Release::Suite="$codename" \
-o APT::FTPArchive::Release::Codename="$codename" \ -o APT::FTPArchive::Release::Codename="$codename" \
-o APT::FTPArchive::Release::Architectures=amd64 \ -o APT::FTPArchive::Release::Architectures=amd64 \
@@ -65,8 +65,8 @@ done
gpg --armor --export "$keyid" > "$pages/KEY.gpg" gpg --armor --export "$keyid" > "$pages/KEY.gpg"
sed "s|@BASEURL@|$base_url|g" "$here/packaging/pages/index.html" > "$pages/index.html" sed "s|@BASEURL@|$base_url|g" "$here/packaging/pages/index.html" > "$pages/index.html"
sed "s|@BASEURL@|$base_url|g" "$here/packaging/pages/plasma-face-unlock.repo" \ sed "s|@BASEURL@|$base_url|g" "$here/packaging/pages/face-unlock.repo" \
> "$pages/plasma-face-unlock.repo" > "$pages/face-unlock.repo"
# Pages would otherwise hand the whole directory to Jekyll, which drops every # Pages would otherwise hand the whole directory to Jekyll, which drops every
# file whose name starts with an underscore and can rewrite the rest. # file whose name starts with an underscore and can rewrite the rest.
@@ -1,16 +1,16 @@
# Built with `packaging/build-rpm.sh`, which passes the version in rather than # Built with `packaging/build-rpm.sh`, which passes the version in rather than
# editing this file: the Makefile is where the version is written down. # editing this file: the Makefile is where the version is written down.
%global upstream_version %{?_version}%{!?_version:1.0.1} %global upstream_version %{?_version}%{!?_version:2.0.0}
Name: plasma-face-unlock Name: face-unlock
Version: %{upstream_version} Version: %{upstream_version}
Release: 1%{?dist} Release: 1%{?dist}
Summary: Face unlock for KDE Plasma Summary: Face unlock for Plasma, GNOME, Hyprland and Niri
# The program is GPL; the two networks it ships are MIT (YuNet) and # The program is GPL; the two networks it ships are MIT (YuNet) and
# Apache-2.0 (SFace). # Apache-2.0 (SFace).
License: GPL-3.0-or-later AND MIT AND Apache-2.0 License: GPL-3.0-or-later AND MIT AND Apache-2.0
URL: https://github.com/LoonixTools/plasma-face-unlock URL: https://github.com/LoonixTools/face-unlock
Source0: %{name}-%{version}.tar.gz Source0: %{name}-%{version}.tar.gz
Source1: https://github.com/opencv/opencv_zoo/raw/main/models/face_detection_yunet/face_detection_yunet_2023mar.onnx Source1: https://github.com/opencv/opencv_zoo/raw/main/models/face_detection_yunet/face_detection_yunet_2023mar.onnx
Source2: https://github.com/opencv/opencv_zoo/raw/main/models/face_recognition_sface/face_recognition_sface_2021dec.onnx Source2: https://github.com/opencv/opencv_zoo/raw/main/models/face_recognition_sface/face_recognition_sface_2021dec.onnx
@@ -45,13 +45,18 @@ Requires: polkit
Requires: systemd Requires: systemd
Requires: qt6-qtdeclarative Requires: qt6-qtdeclarative
Recommends: /usr/bin/gettext Recommends: /usr/bin/gettext
Recommends: kscreenlocker # The old name.
Obsoletes: plasma-face-unlock < 2
Provides: plasma-face-unlock = %{version}-%{release}
%description %description
Look at the screen and it unlocks, the way a phone does. The lock screen, sudo Look at the screen and it unlocks, the way a phone does. The lock screen, sudo
in a terminal and the admin password prompts of Plasma can take a face instead in a terminal and the admin password prompts can take a face instead of a
of a password. A bubble at the top of the screen, above the lock screen too, password, on KDE Plasma, GNOME, Hyprland and Niri. A bubble at the top of the
shows the face being looked for, recognised or refused. screen shows the face being looked for, recognised or refused.
This package was called plasma-face-unlock before. It takes over its faces and
settings.
A photo on a phone, a tablet or a glossy print is refused by its reflection and A photo on a phone, a tablet or a glossy print is refused by its reflection and
its straight edges. The strict photo check also wants a sign of life (a blink, its straight edges. The strict photo check also wants a sign of life (a blink,
@@ -59,7 +64,7 @@ or the nose moving the way a real nose does when the head turns), which stops a
printed photo too. It is a convenience, not a security upgrade: a webcam sees a printed photo too. It is a convenience, not a security upgrade: a webcam sees a
flat picture, and a video of the person can get through. flat picture, and a video of the person can get through.
Run "plasma-face-unlock disable" before removing this package, so that sudo Run "face-unlock disable" before removing this package, so that sudo
and polkit go back to the password alone. and polkit go back to the password alone.
%prep %prep
@@ -78,10 +83,14 @@ make install DESTDIR=%{buildroot} PREFIX=%{_prefix} VERSION=%{upstream_version}
%find_lang %{name} %find_lang %{name}
%preun %preun
%systemd_preun plasma-face-unlockd.socket plasma-face-unlockd.service %systemd_preun face-unlockd.socket face-unlockd.service
%postun %postun
%systemd_postun plasma-face-unlockd.socket plasma-face-unlockd.service %systemd_postun face-unlockd.socket face-unlockd.service
# After the old package is gone: its faces, settings and PAM lines.
%posttrans
%{_bindir}/face-unlock --root migrate || :
%files -f %{name}.lang %files -f %{name}.lang
%license LICENSE %license LICENSE
@@ -89,13 +98,14 @@ make install DESTDIR=%{buildroot} PREFIX=%{_prefix} VERSION=%{upstream_version}
%{_bindir}/%{name} %{_bindir}/%{name}
%{_prefix}/lib/%{name}/ %{_prefix}/lib/%{name}/
%{_datadir}/%{name}/ %{_datadir}/%{name}/
%{_libdir}/security/pam_plasma_face_unlock.so %{_libdir}/security/pam_face_unlock.so
%{_unitdir}/plasma-face-unlockd.socket %{_unitdir}/face-unlockd.socket
%{_unitdir}/plasma-face-unlockd.service %{_unitdir}/face-unlockd.service
%{_userunitdir}/plasma-face-unlock-agent.service %{_userunitdir}/face-unlock-agent.service
%{_datadir}/applications/io.github.loonixtools.plasma-face-unlock-agent.desktop %{_datadir}/applications/io.github.loonixtools.face-unlock-agent.desktop
%{_datadir}/polkit-1/actions/io.github.loonixtools.plasma-face-unlock.policy %{_datadir}/polkit-1/actions/io.github.loonixtools.face-unlock.policy
%{_datadir}/icons/hicolor/scalable/apps/plasma-face-unlock.svg %{_datadir}/icons/hicolor/scalable/apps/face-unlock.svg
%{_datadir}/gnome-shell/extensions/face-unlock@loonixtools.github.io/
%{_mandir}/man1/%{name}.1* %{_mandir}/man1/%{name}.1*
%changelog %changelog
+497 -177
View File
File diff suppressed because it is too large. Load diff
+1283
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1293
View File
File diff suppressed because it is too large. Load diff
+1276
View File
File diff suppressed because it is too large. Load diff
+1254
View File
File diff suppressed because it is too large. Load diff
+1249
View File
File diff suppressed because it is too large. Load diff
+1272
View File
File diff suppressed because it is too large. Load diff
+1259
View File
File diff suppressed because it is too large. Load diff
+1275
View File
File diff suppressed because it is too large. Load diff
+1270
View File
File diff suppressed because it is too large. Load diff
+1265
View File
File diff suppressed because it is too large. Load diff
+1268
View File
File diff suppressed because it is too large. Load diff
+14 -14
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# #
# Collects every translatable string into po/plasma-face-unlock.pot and brings # Collects every translatable string into po/face-unlock.pot and brings
# the translations up to date with it. One catalog serves all four parts: # the translations up to date with it. One catalog serves all four parts:
# the shell code (pfu_msg), the agent (i18n in C++ and QML) and the PAM module # the shell code (fu_msg), the agent (i18n in C++ and QML) and the PAM module
# (dgettext), so a word is translated once wherever it shows up. # (dgettext), so a word is translated once wherever it shows up.
# #
# The settings labels and headings live in an array in menu.sh and reach # The settings labels and headings live in an array in menu.sh and reach
@@ -18,23 +18,23 @@ trap 'rm -rf -- "$tmp"' EXIT
version="$(make -s version)" version="$(make -s version)"
# The labels, as calls xgettext understands, pointing back at menu.sh. # The labels, as calls xgettext understands, pointing back at menu.sh.
awk -F'|' '/^\t"(user|sys|pam)\|/ { sub(/"$/, "", $5); print "pfu_msg \"" $5 "\"" } awk -F'|' '/^\t"(user|sys|pam)\|/ { sub(/"$/, "", $5); print "fu_msg \"" $5 "\"" }
/^\t"group\|/ { sub(/"$/, "", $2); print "pfu_msg \"" $2 "\"" }' src/lib/menu.sh > "$tmp/settings.sh" /^\t"group\|/ { sub(/"$/, "", $2); print "fu_msg \"" $2 "\"" }' src/lib/menu.sh > "$tmp/settings.sh"
common=(--from-code=UTF-8 --add-comments=TRANSLATORS --package-name=plasma-face-unlock --package-version="$version" common=(--from-code=UTF-8 --add-comments=TRANSLATORS --package-name=face-unlock --package-version="$version"
--msgid-bugs-address=https://github.com/LoonixTools/plasma-face-unlock/issues) --msgid-bugs-address=https://github.com/LoonixTools/face-unlock/issues)
xgettext "${common[@]}" -L Shell -k --keyword=pfu_msg --keyword=pfu_msg_into:2 --keyword=pfu_msg_in:2 \ xgettext "${common[@]}" -L Shell -k --keyword=fu_msg --keyword=fu_msg_into:2 --keyword=fu_msg_in:2 \
-o "$tmp/shell.pot" src/plasma-face-unlock src/lib/*.sh "$tmp/settings.sh" -o "$tmp/shell.pot" src/face-unlock src/lib/*.sh "$tmp/settings.sh"
sed -i "s|#: $tmp/settings.sh:[0-9]*|#: src/lib/menu.sh|" "$tmp/shell.pot" sed -i "s|$tmp/settings.sh:[0-9]*|src/lib/menu.sh|g" "$tmp/shell.pot"
xgettext "${common[@]}" -L C++ --keyword=i18n --keyword=_ -o "$tmp/native.pot" src/agent/*.cpp src/pam/*.c xgettext "${common[@]}" -L C++ --keyword=i18n --keyword=_ -o "$tmp/native.pot" src/agent/*.cpp src/pam/*.c
xgettext "${common[@]}" -L JavaScript --keyword=i18n -o "$tmp/qml.pot" src/agent/qml/*.qml xgettext "${common[@]}" -L JavaScript --keyword=i18n -o "$tmp/qml.pot" src/agent/qml/*.qml
msgcat --use-first -o po/plasma-face-unlock.pot "$tmp/shell.pot" "$tmp/native.pot" "$tmp/qml.pot" msgcat --use-first -o po/face-unlock.pot "$tmp/shell.pot" "$tmp/native.pot" "$tmp/qml.pot"
sed -i -e '1i # Translation template for plasma-face-unlock.\n# Copyright (C) 2026 Felitendo\n# This file is distributed under the same license as plasma-face-unlock.' -e '1,4d' \ sed -i -e '1i # Translation template for face-unlock.\n# Copyright (C) 2026 Felitendo\n# This file is distributed under the same license as face-unlock.' -e '1,4d' \
po/plasma-face-unlock.pot po/face-unlock.pot
for po in po/*.po; do for po in po/*.po; do
msgmerge --quiet --update --backup=none --no-fuzzy-matching "$po" po/plasma-face-unlock.pot msgmerge --quiet --update --backup=none --no-fuzzy-matching "$po" po/face-unlock.pot
done done
echo "po/plasma-face-unlock.pot: $(grep -c '^msgid' po/plasma-face-unlock.pot) strings" echo "po/face-unlock.pot: $(grep -c '^msgid' po/face-unlock.pot) strings"
+1199
View File
File diff suppressed because it is too large. Load diff
+328
View File
@@ -0,0 +1,328 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="ext_session_lock_v1">
<copyright>
Copyright 2021 Isaac Freund
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
</copyright>
<description summary="secure session locking with arbitrary graphics">
This protocol allows for a privileged Wayland client to lock the session
and display arbitrary graphics while the session is locked.
The compositor may choose to restrict this protocol to a special client
launched by the compositor itself or expose it to all privileged clients,
this is compositor policy.
The client is responsible for performing authentication and informing the
compositor when the session should be unlocked. If the client dies while
the session is locked the session remains locked, possibly permanently
depending on compositor policy.
The key words "must", "must not", "required", "shall", "shall not",
"should", "should not", "recommended", "may", and "optional" in this
document are to be interpreted as described in IETF RFC 2119.
Warning! The protocol described in this file is currently in the
testing phase. Backward compatible changes may be added together with
the corresponding interface version bump. Backward incompatible changes
can only be done by creating a new major version of the extension.
</description>
<interface name="ext_session_lock_manager_v1" version="1">
<description summary="used to lock the session">
This interface is used to request that the session be locked.
</description>
<request name="destroy" type="destructor">
<description summary="destroy the session lock manager object">
This informs the compositor that the session lock manager object will
no longer be used. Existing objects created through this interface
remain valid.
</description>
</request>
<request name="lock">
<description summary="attempt to lock the session">
This request creates a session lock and asks the compositor to lock the
session. The compositor will send either the ext_session_lock_v1.locked
or ext_session_lock_v1.finished event on the created object in
response to this request.
</description>
<arg name="id" type="new_id" interface="ext_session_lock_v1"/>
</request>
</interface>
<interface name="ext_session_lock_v1" version="1">
<description summary="manage lock state and create lock surfaces">
In response to the creation of this object the compositor must send
either the locked or finished event.
The locked event indicates that the session is locked. This means
that the compositor must stop rendering and providing input to normal
clients. Instead the compositor must blank all outputs with an opaque
color such that their normal content is fully hidden.
The only surfaces that should be rendered while the session is locked
are the lock surfaces created through this interface and optionally,
at the compositor's discretion, special privileged surfaces such as
input methods or portions of desktop shell UIs.
The locked event must not be sent until a new "locked" frame (either
from a session lock surface or the compositor blanking the output) has
been presented on all outputs and no security sensitive normal/unlocked
content is possibly visible.
The finished event should be sent immediately on creation of this
object if the compositor decides that the locked event will not be sent.
The compositor may wait for the client to create and render session lock
surfaces before sending the locked event to avoid displaying intermediate
blank frames. However, it must impose a reasonable time limit if
waiting and send the locked event as soon as the hard requirements
described above can be met if the time limit expires. Clients should
immediately create lock surfaces for all outputs on creation of this
object to make this possible.
This behavior of the locked event is required in order to prevent
possible race conditions with clients that wish to suspend the system
or similar after locking the session. Without these semantics, clients
triggering a suspend after receiving the locked event would race with
the first "locked" frame being presented and normal/unlocked frames
might be briefly visible as the system is resumed if the suspend
operation wins the race.
If the client dies while the session is locked, the compositor must not
unlock the session in response. It is acceptable for the session to be
permanently locked if this happens. The compositor may choose to continue
to display the lock surfaces the client had mapped before it died or
alternatively fall back to a solid color, this is compositor policy.
Compositors may also allow a secure way to recover the session, the
details of this are compositor policy. Compositors may allow a new
client to create a ext_session_lock_v1 object and take responsibility
for unlocking the session, they may even start a new lock client
instance automatically.
</description>
<enum name="error">
<entry name="invalid_destroy" value="0"
summary="attempted to destroy session lock while locked"/>
<entry name="invalid_unlock" value="1"
summary="unlock requested but locked event was never sent"/>
<entry name="role" value="2"
summary="given wl_surface already has a role"/>
<entry name="duplicate_output" value="3"
summary="given output already has a lock surface"/>
<entry name="already_constructed" value="4"
summary="given wl_surface has a buffer attached or committed"/>
</enum>
<request name="destroy" type="destructor">
<description summary="destroy the session lock">
This informs the compositor that the lock object will no longer be
used. Existing objects created through this interface remain valid.
After this request is made, lock surfaces created through this object
should be destroyed by the client as they will no longer be used by
the compositor.
It is a protocol error to make this request if the locked event was
sent, the unlock_and_destroy request must be used instead.
</description>
</request>
<event name="locked">
<description summary="session successfully locked">
This client is now responsible for displaying graphics while the
session is locked and deciding when to unlock the session.
The locked event must not be sent until a new "locked" frame has been
presented on all outputs and no security sensitive normal/unlocked
content is possibly visible.
If this event is sent, making the destroy request is a protocol error,
the lock object must be destroyed using the unlock_and_destroy request.
</description>
</event>
<event name="finished">
<description summary="the session lock object should be destroyed">
The compositor has decided that the session lock should be destroyed
as it will no longer be used by the compositor. Exactly when this
event is sent is compositor policy, but it must never be sent more
than once for a given session lock object.
This might be sent because there is already another ext_session_lock_v1
object held by a client, or the compositor has decided to deny the
request to lock the session for some other reason. This might also
be sent because the compositor implements some alternative, secure
way to authenticate and unlock the session.
The finished event should be sent immediately on creation of this
object if the compositor decides that the locked event will not
be sent.
If the locked event is sent on creation of this object the finished
event may still be sent at some later time in this object's
lifetime. This is compositor policy.
Upon receiving this event, the client should make either the destroy
request or the unlock_and_destroy request, depending on whether or
not the locked event was received on this object.
</description>
</event>
<request name="get_lock_surface">
<description summary="create a lock surface for a given output">
The client is expected to create lock surfaces for all outputs
currently present and any new outputs as they are advertised. These
won't be displayed by the compositor unless the lock is successful
and the locked event is sent.
Providing a wl_surface which already has a role or already has a buffer
attached or committed is a protocol error, as is attaching/committing
a buffer before the first ext_session_lock_surface_v1.configure event.
Attempting to create more than one lock surface for a given output
is a duplicate_output protocol error.
</description>
<arg name="id" type="new_id" interface="ext_session_lock_surface_v1"/>
<arg name="surface" type="object" interface="wl_surface"/>
<arg name="output" type="object" interface="wl_output"/>
</request>
<request name="unlock_and_destroy" type="destructor">
<description summary="unlock the session, destroying the object">
This request indicates that the session should be unlocked, for
example because the user has entered their password and it has been
verified by the client.
This request also informs the compositor that the lock object will
no longer be used and should be destroyed. Existing objects created
through this interface remain valid.
After this request is made, lock surfaces created through this object
should be destroyed by the client as they will no longer be used by
the compositor.
It is a protocol error to make this request if the locked event has
not been sent. In that case, the lock object must be destroyed using
the destroy request.
Note that a correct client that wishes to exit directly after unlocking
the session must use the wl_display.sync request to ensure the server
receives and processes the unlock_and_destroy request. Otherwise
there is no guarantee that the server has unlocked the session due
to the asynchronous nature of the Wayland protocol. For example,
the server might terminate the client with a protocol error before
it processes the unlock_and_destroy request.
</description>
</request>
</interface>
<interface name="ext_session_lock_surface_v1" version="1">
<description summary="a surface displayed while the session is locked">
The client may use lock surfaces to display a screensaver, render a
dialog to enter a password and unlock the session, or however else it
sees fit.
On binding this interface the compositor will immediately send the
first configure event. After making the ack_configure request in
response to this event the client should attach and commit the first
buffer. Committing the surface before acking the first configure is a
protocol error. Committing the surface with a null buffer at any time
is a protocol error.
The compositor is free to handle keyboard/pointer focus for lock
surfaces however it chooses. A reasonable way to do this would be to
give the first lock surface created keyboard focus and change keyboard
focus if the user clicks on other surfaces.
</description>
<enum name="error">
<entry name="commit_before_first_ack" value="0"
summary="surface committed before first ack_configure request"/>
<entry name="null_buffer" value="1"
summary="surface committed with a null buffer"/>
<entry name="dimensions_mismatch" value="2"
summary="failed to match ack'd width/height"/>
<entry name="invalid_serial" value="3"
summary="serial provided in ack_configure is invalid"/>
</enum>
<request name="destroy" type="destructor">
<description summary="destroy the lock surface object">
This informs the compositor that the lock surface object will no
longer be used.
It is recommended for a lock client to destroy lock surfaces if
their corresponding wl_output global is removed.
If a lock surface on an active output is destroyed before the
ext_session_lock_v1.unlock_and_destroy event is sent, the compositor
must fall back to rendering a solid color.
</description>
</request>
<request name="ack_configure">
<description summary="ack a configure event">
When a configure event is received, if a client commits the surface
in response to the configure event, then the client must make an
ack_configure request sometime before the commit request, passing
along the serial of the configure event.
If the client receives multiple configure events before it can
respond to one, it only has to ack the last configure event.
A client is not required to commit immediately after sending an
ack_configure request - it may even ack_configure several times
before its next surface commit.
A client may send multiple ack_configure requests before committing,
but only the last request sent before a commit indicates which
configure event the client really is responding to.
Sending an ack_configure request consumes the configure event
referenced by the given serial, as well as all older configure events
sent on this object.
It is a protocol error to issue multiple ack_configure requests
referencing the same configure event or to issue an ack_configure
request referencing a configure event older than the last configure
event acked for a given lock surface.
</description>
<arg name="serial" type="uint" summary="serial from the configure event"/>
</request>
<event name="configure">
<description summary="the client should resize its surface">
This event is sent once on binding the interface and may be sent again
at the compositor's discretion, for example if output geometry changes.
The width and height are in surface-local coordinates and are exact
requirements. Failing to match these surface dimensions in the next
commit after acking a configure is a protocol error.
</description>
<arg name="serial" type="uint" summary="serial for use in ack_configure"/>
<arg name="width" type="uint"/>
<arg name="height" type="uint"/>
</event>
</interface>
</protocol>
@@ -0,0 +1,36 @@
[Desktop Entry]
Type=Application
Name=Face Unlock
Name[de]=Gesichtsentsperrung
Name[fr]=Déverrouillage facial
Name[es]=Desbloqueo facial
Name[it]=Sblocco con il volto
Name[pt_BR]=Desbloqueio facial
Name[nl]=Gezichtsontgrendeling
Name[pl]=Odblokowywanie twarzą
Name[ru]=Разблокировка по лицу
Name[uk]=Розблокування обличчям
Name[tr]=Yüzle kilit açma
Name[zh_CN]=人脸解锁
Name[ja]=顔認証
Name[ko]=얼굴 잠금 해제
Comment=Unlocks the lock screen, sudo and admin prompts with your face
Comment[de]=Entsperrt Sperrbildschirm, sudo und Admin-Abfragen mit deinem Gesicht
Comment[fr]=Déverrouille l'écran de verrouillage, sudo et les demandes d'administration avec votre visage
Comment[es]=Desbloquea la pantalla de bloqueo, sudo y las solicitudes de administrador con tu cara
Comment[it]=Sblocca la schermata di blocco, sudo e le richieste di amministratore con il tuo volto
Comment[pt_BR]=Desbloqueia a tela de bloqueio, o sudo e os pedidos de administrador com o seu rosto
Comment[nl]=Ontgrendelt het vergrendelscherm, sudo en beheerdersvragen met je gezicht
Comment[pl]=Odblokowuje twarzą ekran blokady, sudo i okna administratora
Comment[ru]=Снимает блокировку экрана, sudo и запросы администратора по вашему лицу
Comment[uk]=Розблоковує екран блокування, sudo і запити адміністратора вашим обличчям
Comment[tr]=Kilit ekranını, sudo'yu ve yönetici istemlerini yüzünüzle açar
Comment[zh_CN]=用你的脸解锁锁屏、sudo 和管理员授权
Comment[ja]=顔でロック画面、sudo、管理者の認証を解除します
Comment[ko]=얼굴로 잠금 화면, sudo, 관리자 인증 창의 잠금을 해제합니다
Exec=@LIBEXECDIR@/face-unlock-agent
Icon=face-unlock
NoDisplay=true
# KWin only lets a program show above the lock screen when its desktop file
# asks for it by name. This is that file.
X-KDE-Wayland-Interfaces=kde_lockscreen_overlay_v1
@@ -1,13 +0,0 @@
[Desktop Entry]
Type=Application
Name=Plasma Face Unlock
Name[de]=Plasma-Gesichtsentsperrung
Comment=Unlocks the lock screen, sudo and admin prompts with your face
Comment[de]=Entsperrt Sperrbildschirm, sudo und Admin-Abfragen mit deinem Gesicht
Exec=@LIBEXECDIR@/plasma-face-unlock-agent
Icon=plasma-face-unlock
NoDisplay=true
OnlyShowIn=KDE;
# KWin only lets a program show above the lock screen when its desktop file
# asks for it by name. This is that file.
X-KDE-Wayland-Interfaces=kde_lockscreen_overlay_v1
File renamed without changes.
@@ -0,0 +1,926 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// face-unlock's bubble on GNOME.
//
// GNOME lets no program draw above its windows, and nothing at all above its
// lock screen. An extension runs inside GNOME Shell and may. This one draws
// the bubble the agent draws everywhere else, with the same shapes and the
// same timing: a port of src/agent/qml (Bubble, FaceGlyph, LockGlyph,
// Checkmark). What to show comes from the agent over the session bus.
import Clutter from 'gi://Clutter';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import Pango from 'gi://Pango';
import PangoCairo from 'gi://PangoCairo';
import St from 'gi://St';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
const BUS_NAME = 'io.github.loonixtools.FaceUnlock';
const OBJECT_PATH = '/io/github/loonixtools/FaceUnlock';
const BubbleProxy = Gio.DBusProxy.makeProxyWrapper(`
<node>
<interface name="io.github.loonixtools.FaceUnlock.Bubble">
<method name="GetState"><arg type="a{sv}" direction="out"/></method>
<signal name="StateChanged"><arg type="a{sv}"/></signal>
</interface>
</node>`);
// Theme.qml
const Theme = {
success: [0x30, 0xd1, 0x58],
failure: [0xff, 0x45, 0x3a],
panel: [0, 0, 0],
textPrimary: [0xff, 0xff, 0xff],
textSecondary: [0x94, 0x94, 0x94],
textDetail: [0xbd, 0xbd, 0xbd],
closedWidth: 80,
closedHeight: 24,
openWidth: 180,
openHeight: 180,
openRadius: 48,
minimalWidth: 150,
minimalHeight: 40,
topGap: 6,
slideInDuration: 280,
expandDelay: 150,
growDuration: 460,
shrinkDuration: 280,
slideOutDelay: 150,
slideOutDuration: 240,
};
const WIDTH = 420;
const HEIGHT = 300;
// Qt's easing curves, as the QML uses them.
const Ease = {
linear: p => p,
outQuad: p => 1 - (1 - p) * (1 - p),
inOutQuad: p => p < 0.5 ? 2 * p * p : 1 - (-2 * p + 2) ** 2 / 2,
outCubic: p => 1 - (1 - p) ** 3,
inCubic: p => p ** 3,
inOutSine: p => -(Math.cos(Math.PI * p) - 1) / 2,
outBack: s => p => {
const q = p - 1;
return q * q * ((s + 1) * q + s) + 1;
},
};
const OUT_BACK = Ease.outBack(1.70158);
function now() {
return GLib.get_monotonic_time() / 1000;
}
function smooth(from, to, p) {
const x = Math.max(0, Math.min(1, (p - from) / (to - from)));
return x * x * (3 - 2 * x);
}
// A value that eases to a new target from wherever it is, like a QML
// Behavior. Colours are arrays and ease per channel.
class Tween {
constructor(value) {
this._from = value;
this._to = value;
this._start = 0;
this._duration = 0;
this._ease = Ease.linear;
}
set(to, duration, ease, at) {
if (this._same(to, this._to))
return;
this._from = this.get(at);
this._to = to;
this._start = at;
this._duration = duration;
this._ease = ease;
}
jump(value) {
this._from = this._to = value;
this._duration = 0;
}
get(at) {
const p = this._duration > 0 ? Math.min(1, (at - this._start) / this._duration) : 1;
const e = this._ease(p);
if (Array.isArray(this._to))
return this._to.map((t, i) => this._from[i] + (t - this._from[i]) * e);
return this._from + (this._to - this._from) * e;
}
busy(at) {
return this._duration > 0 && at - this._start < this._duration;
}
_same(a, b) {
return Array.isArray(a) ? a.every((v, i) => v === b[i]) : a === b;
}
}
// A run of steps from 0, like a QML SequentialAnimation of NumberAnimations:
// [target, duration, easing]...
function sequence(steps, start, at, pace) {
if (start === null)
return [0, false];
let t = at - start;
let from = 0;
for (const [to, duration, ease] of steps) {
const d = duration * pace;
if (t < d)
return [from + (to - from) * ease(t / d), true];
t -= d;
from = to;
}
return [from, false];
}
function rgba(cr, color, alpha = 1) {
cr.setSourceRGBA(color[0] / 255, color[1] / 255, color[2] / 255, alpha);
}
// A quadratic curve in cairo, which only knows cubic ones.
function quadTo(cr, x0, y0, cx, cy, x, y) {
cr.curveTo(x0 + 2 / 3 * (cx - x0), y0 + 2 / 3 * (cy - y0),
x + 2 / 3 * (cx - x), y + 2 / 3 * (cy - y), x, y);
}
function polyline(cr, points) {
if (points.length < 2)
return;
cr.moveTo(points[0][0], points[0][1]);
for (let i = 1; i < points.length; ++i)
cr.lineTo(points[i][0], points[i][1]);
}
function roundedRect(cr, x, y, w, h, r) {
r = Math.max(0, Math.min(r, w / 2, h / 2));
cr.newSubPath();
cr.arc(x + w - r, y + r, r, -Math.PI / 2, 0);
cr.arc(x + w - r, y + h - r, r, 0, Math.PI / 2);
cr.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI);
cr.arc(x + r, y + r, r, Math.PI, 3 * Math.PI / 2);
cr.closePath();
}
// Draws what draw() draws into its own layer, at the given opacity and
// scaled about the middle of the square it sits in.
function layer(cr, opacity, scale, cx, cy, draw) {
if (opacity <= 0.001)
return;
cr.save();
cr.pushGroup();
cr.translate(cx, cy);
cr.scale(scale, scale);
cr.translate(-cx, -cy);
draw();
cr.popGroupToSource();
cr.paintWithAlpha(Math.min(1, opacity));
cr.restore();
}
// LockGlyph.qml: a padlock that opens.
class LockGlyph {
constructor() {
this._openness = new Tween(0);
this.open = false;
this.pace = 1;
}
setOpen(open, at) {
if (open === this.open)
return;
this.open = open;
if (open) {
this._openness.jump(0);
this._openness.set(1, 560 * this.pace, Ease.linear, at);
} else {
this._openness.jump(0);
}
}
busy(at) {
return this._openness.busy(at);
}
draw(cr, x, y, size, color, at) {
const u = size / 100;
const openness = this._openness.get(at);
const lift = 12 * smooth(0, 0.3, openness);
const p = Math.max(0, Math.min(1, (openness - 0.22) / 0.78));
const s = 1.4;
const swing = 180 * (1 + (s + 1) * (p - 1) ** 3 + s * (p - 1) ** 2);
const beat = 1 + 0.14 * Math.sin(Math.PI * smooth(0.55, 1, openness));
cr.save();
cr.translate(x + size / 2, y + size / 2);
cr.scale(beat, beat);
cr.translate(-size / 2, -size / 2);
rgba(cr, color);
roundedRect(cr, 14 * u, 46 * u, 72 * u, 50 * u, 12 * u);
cr.fill();
const right = 68.5;
const radius = 18.5;
const top = 28 - lift;
const narrow = Math.cos(swing * Math.PI / 180);
const points = [];
const add = (px, py) => points.push([(right + (px - right) * narrow) * u, py * u]);
add(31.5, 56 - 18 * smooth(0, 0.3, openness));
add(31.5, top);
for (let i = 1; i < 24; ++i) {
const a = Math.PI + Math.PI * i / 24;
add(50 + radius * Math.cos(a), top + radius * Math.sin(a));
}
add(right, top);
add(right, 56);
cr.setLineWidth(11 * u);
cr.setLineCap(1 /* round */);
cr.setLineJoin(1 /* round */);
polyline(cr, points);
cr.stroke();
cr.restore();
}
}
// FaceGlyph.qml: brackets round a face that looks around, then the rings and
// a tick, or a red shake of the head.
class FaceGlyph {
constructor(lineWidth = null) {
this.lineWidth = lineWidth;
this.color = Theme.textPrimary;
this.pace = 1;
this.mode = 'idle';
this.onSettled = null;
this._lookStart = 0;
this._lookFrozen = 0;
this._lookAmount = new Tween(0);
this._smile = new Tween(1);
this._bracket = new Tween(1);
this._bracketColor = new Tween(Theme.textPrimary);
this._shown = new Tween(1);
this._lockOpacity = new Tween(0);
this._lockScale = new Tween(0.7);
this._lock = new LockGlyph();
this._successStart = null;
this._shakeStart = null;
this._settle = 0;
}
_looking() {
return this.mode === 'scanning';
}
_lookAngle(at) {
if (!this._looking())
return this._lookFrozen;
const period = 2000 * this.pace;
return 2 * Math.PI * (((at - this._lookStart) % period) / period);
}
_breathe(at) {
if (!this._looking())
return 0;
const half = 700 * this.pace;
const p = ((at - this._lookStart) % (2 * half)) / half;
return p < 1 ? Ease.inOutSine(p) : 1 - Ease.inOutSine(p - 1);
}
setMode(mode, at) {
if (mode === this.mode)
return;
const wasLooking = this._looking();
if (wasLooking)
this._lookFrozen = this._lookAngle(at);
this.mode = mode;
const pace = this.pace;
if (this._looking() && !wasLooking)
this._lookStart = at;
this._lookAmount.set(this._looking() ? 1 : 0, 350 * pace, Ease.inOutQuad, at);
this._smile.set(mode === 'failure' ? 0 : 1, 220 * pace, Ease.outQuad, at);
this._bracket.set(mode === 'tracking' ? 0.9 : 1, 260 * pace, OUT_BACK, at);
this._bracketColor.set(this._colorFor(mode), 220 * pace, Ease.linear, at);
this._shown.set(mode === 'lockout' ? 0 : 1, 180 * pace, Ease.linear, at);
this._lockOpacity.set(mode === 'lockout' ? 1 : 0, 200 * pace, Ease.linear, at);
this._lockScale.set(mode === 'lockout' ? 1 : 0.7, 260 * pace, OUT_BACK, at);
this._successStart = null;
if (this._settle) {
GLib.source_remove(this._settle);
this._settle = 0;
}
if (mode === 'failure') {
this._shakeStart = at;
this.onSettled?.(false);
} else if (mode === 'success') {
this._successStart = at;
this._settle = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 850 * pace, () => {
this._settle = 0;
this.onSettled?.(true);
return GLib.SOURCE_REMOVE;
});
}
}
_colorFor(mode) {
if (mode === 'success')
return Theme.success;
if (mode === 'failure')
return Theme.failure;
return this.color;
}
destroy() {
if (this._settle)
GLib.source_remove(this._settle);
this._settle = 0;
}
busy(at) {
return this._looking() || this._lookAmount.busy(at) || this._smile.busy(at) ||
this._bracket.busy(at) || this._bracketColor.busy(at) || this._shown.busy(at) ||
this._lockOpacity.busy(at) || this._lockScale.busy(at) ||
(this._successStart !== null && at - this._successStart < 900 * this.pace) ||
sequence(FaceGlyph.SHAKE, this._shakeStart, at, this.pace)[1];
}
draw(cr, x, y, size, at) {
const u = size / 100;
const lw = this.lineWidth ?? size * 0.055;
const pace = this.pace;
const color = this._bracketColor.get(at);
const lookAmount = this._lookAmount.get(at);
const angle = this._lookAngle(at);
const yaw = 0.34 * Math.cos(angle) * lookAmount;
const pitch = 0.2 * Math.sin(angle) * lookAmount;
const fx = (px, py, pz) => (50 + px * Math.cos(yaw) + pz * Math.sin(yaw)) * u;
const fy = (px, py, pz) => {
const depth = pz * Math.cos(yaw) - px * Math.sin(yaw);
return (50 + py * Math.cos(pitch) - depth * Math.sin(pitch)) * u;
};
const t = this._successStart === null ? 0 : Math.min(1, (at - this._successStart) / (850 * pace));
const tickStart = this._successStart === null ? null : this._successStart + 470 * pace;
const tick = tickStart === null || at < tickStart ? 0 : Ease.outQuad(Math.min(1, (at - tickStart) / (260 * pace)));
const [shake] = sequence(FaceGlyph.SHAKE, this._shakeStart, at, pace);
const faceGone = smooth(0, 0.22, t);
const ringsIn = smooth(0.04, 0.26, t);
const turn = FaceGlyph.turned(Math.min(1, t / 0.7));
const ringSize = 40 * (0.7 + 0.3 * ringsIn);
const ringA = FaceGlyph.ring(0, -360 * turn, 90 * turn, ringSize);
const ringB = FaceGlyph.ring(70 + 360 * turn, 30 + 360 * turn, -90 * turn, ringSize);
const ringBShown = 1 - smooth(0.36, 0.58, t);
const ringABack = 1 - 0.6 * FaceGlyph.tilt(ringA);
const ringBBack = 1 - 0.6 * FaceGlyph.tilt(ringB);
const glow = 1 - smooth(0.42, 0.75, t);
const onScreen = p => [(50 + p[0]) * u, (50 + p[1]) * u];
const bracketScale = this._bracket.get(at) - 0.04 * this._breathe(at);
const c = 50 * u;
cr.save();
cr.translate(x + shake * u, y);
cr.setLineCap(1);
cr.setLineJoin(1);
// Brackets, giving way to the rings
layer(cr, 1 - faceGone, 1 - 0.15 * faceGone, c, c, () => {
cr.translate(c, c);
cr.scale(bracketScale, bracketScale);
cr.translate(-c, -c);
rgba(cr, color);
cr.setLineWidth(lw);
const corner = (sx, sy, lx, ly, qx, qy, cx, cy, ex, ey) => {
cr.moveTo(sx * u, sy * u);
cr.lineTo(lx * u, ly * u);
quadTo(cr, lx * u, ly * u, cx * u, cy * u, qx * u, qy * u);
cr.lineTo(ex * u, ey * u);
};
corner(6, 30, 6, 19, 19, 6, 6, 6, 30, 6);
corner(70, 6, 81, 6, 94, 19, 94, 6, 94, 30);
corner(94, 70, 94, 81, 81, 94, 94, 94, 70, 94);
corner(30, 94, 19, 94, 6, 81, 6, 94, 6, 70);
cr.stroke();
});
// The rings: a soft glow under them, their far halves dimmed, the
// near halves on top.
if (t > 0) {
layer(cr, ringsIn, 1, c, c, () => {
if (glow > 0) {
// MultiEffect's blur, drawn as wider and fainter strokes.
for (let i = 0; i < 6; ++i) {
cr.setLineWidth(lw * 1.6 + i * lw * 0.9);
const a = 0.8 * glow * 0.16;
rgba(cr, color, a);
polyline(cr, [...ringA.map(onScreen), onScreen(ringA[0])]);
cr.stroke();
rgba(cr, color, a * ringBShown);
polyline(cr, [...ringB.map(onScreen), onScreen(ringB[0])]);
cr.stroke();
}
}
cr.setLineWidth(lw);
const half = (points, front) => FaceGlyph.half(points, front).map(onScreen);
rgba(cr, color, ringABack);
polyline(cr, half(ringA, false));
cr.stroke();
rgba(cr, color, ringBBack * ringBShown);
polyline(cr, half(ringB, false));
cr.stroke();
rgba(cr, color, ringBShown);
polyline(cr, half(ringB, true));
cr.stroke();
rgba(cr, color);
polyline(cr, half(ringA, true));
cr.stroke();
});
}
// The face itself
layer(cr, this._shown.get(at) * (1 - faceGone), 1 - 0.2 * faceGone, c, c, () => {
rgba(cr, color);
cr.setLineWidth(lw);
const smile = this._smile.get(at);
cr.moveTo(fx(-16, -14, 26), fy(-16, -14, 26));
cr.lineTo(fx(-16, -5, 26), fy(-16, -5, 26));
cr.moveTo(fx(16, -14, 26), fy(16, -14, 26));
cr.lineTo(fx(16, -5, 26), fy(16, -5, 26));
cr.moveTo(fx(0, -13, 29), fy(0, -13, 29));
cr.lineTo(fx(0, 6, 37), fy(0, 6, 37));
quadTo(cr, fx(0, 6, 37), fy(0, 6, 37), fx(0, 11, 35), fy(0, 11, 35), fx(-5, 11, 31), fy(-5, 11, 31));
cr.moveTo(fx(-15, 20, 21), fy(-15, 20, 21));
quadTo(cr, fx(-15, 20, 21), fy(-15, 20, 21), fx(0, 20 + 11 * smile, 24), fy(0, 20 + 11 * smile, 24),
fx(15, 20, 21), fy(15, 20, 21));
cr.stroke();
});
// The tick, drawn inside the ring as it comes to rest
if (tick > 0.001) {
layer(cr, Math.min(1, tick * 8), 0.75, c, c, () => {
cr.translate(1.5 * u, 0);
rgba(cr, Theme.success);
cr.setLineWidth(lw / 0.75);
const a = [28 * u, 52 * u];
const b = [43 * u, 67 * u];
const e = [73 * u, 35 * u];
const lerp = (p, q, k) => [p[0] + (q[0] - p[0]) * k, p[1] + (q[1] - p[1]) * k];
const split = 0.33;
polyline(cr, tick <= split ? [a, lerp(a, b, tick / split)]
: [a, b, lerp(b, e, (tick - split) / (1 - split))]);
cr.stroke();
});
}
// A lock instead of the face, after too many tries
const lockSize = size * 0.42;
layer(cr, this._lockOpacity.get(at), this._lockScale.get(at), c, c, () => {
this._lock.draw(cr, c - lockSize / 2, c - lockSize / 2, lockSize, this.color, at);
});
cr.restore();
}
// How far the rings have turned at p: speeding up from rest, fastest a
// quarter of the way in, then settling softly, facing forward.
static turned(p) {
return p * p * (10 - p * (20 - p * (15 - 4 * p)));
}
// A ring turned in 3D (degrees about x, then y, then z).
static ring(ax, ay, az, radius) {
const d = Math.PI / 180;
const ca = Math.cos(ax * d), sa = Math.sin(ax * d);
const cb = Math.cos(ay * d), sb = Math.sin(ay * d);
const cg = Math.cos(az * d), sg = Math.sin(az * d);
const points = [];
for (let i = 0; i < 64; ++i) {
const phi = 2 * Math.PI * i / 64;
let px = radius * Math.cos(phi);
let py = radius * Math.sin(phi) * ca;
let pz = radius * Math.sin(phi) * sa;
const x1 = px * cb + pz * sb;
pz = pz * cb - px * sb;
px = x1;
const x2 = px * cg - py * sg;
py = px * sg + py * cg;
px = x2;
points.push([px, py, pz]);
}
return points;
}
static tilt(points) {
let most = 0;
for (const p of points)
most = Math.max(most, Math.abs(p[2]));
return most / Math.max(1, Math.hypot(points[0][0], points[0][1], points[0][2]));
}
// The half of a ring in front of the middle, or behind it, in one piece
// and reaching one point into the other half so the two meet.
static half(points, front) {
const n = points.length;
const inFront = i => points[(i + n) % n][2] >= -1e-6;
let start = -1;
for (let i = 0; i < n; ++i) {
if (inFront(i) === front && inFront(i - 1) !== front) {
start = i;
break;
}
}
const out = [];
if (start < 0) {
if (inFront(0) === front) {
for (let i = 0; i <= n; ++i)
out.push(points[i % n]);
}
return out;
}
out.push(points[(start + n - 1) % n]);
let i = start;
while (inFront(i) === front && i < start + n) {
out.push(points[i % n]);
++i;
}
out.push(points[i % n]);
return out;
}
}
FaceGlyph.SHAKE = [
[-9, 55, Ease.outQuad], [8, 70, Ease.inOutQuad], [-6, 65, Ease.inOutQuad],
[4, 60, Ease.inOutQuad], [-2, 55, Ease.inOutQuad], [0, 50, Ease.outQuad],
];
const PILL_SHAKE = [
[-10, 55, Ease.outQuad], [9, 70, Ease.linear], [-6, 65, Ease.linear],
[4, 60, Ease.linear], [0, 55, Ease.outQuad],
];
// Bubble.qml: the island that slides down, opens up, shows the face and
// closes again.
class Bubble {
constructor() {
this.actor = new St.DrawingArea({reactive: false, visible: false});
this.actor.connect('repaint', area => this._repaint(area));
this._timeline = new Clutter.Timeline({actor: this.actor, duration: 1000, repeat_count: -1});
this._timeline.connect('new-frame', () => this._frame());
this.phase = 'hidden';
this.message = '';
this.faceSeen = false;
this.style = 'full';
this.pace = 1;
this._shownPhase = 'idle';
this._positioned = false;
this._expanded = false;
this._opening = false;
this._timers = {};
this._width = new Tween(Theme.closedWidth);
this._height = new Tween(Theme.closedHeight);
this._y = new Tween(-Theme.closedHeight - 20);
this._shadow = new Tween(0);
this._fullOpacity = new Tween(0);
this._smallOpacity = new Tween(0);
this._glyphY = new Tween(40);
this._messageOpacity = new Tween(0);
this._pulseStart = null;
this._settle = new Tween(0);
this._pillShakeStart = null;
this._face = new FaceGlyph();
this._smallFace = new FaceGlyph(2.4);
this._smallFace.onSettled = ok => {
if (!ok)
this._pillShakeStart = now();
};
this._lock = new LockGlyph();
}
destroy() {
for (const id of Object.values(this._timers))
GLib.source_remove(id);
this._timers = {};
this._face.destroy();
this._smallFace.destroy();
this._timeline.stop();
this.actor.destroy();
}
setState(state) {
const at = now();
const wasOpen = this.phase !== 'hidden';
this.phase = state.phase ?? 'hidden';
this.message = state.message ?? '';
this.faceSeen = !!state.faceSeen;
this.style = state.style === 'minimal' ? 'minimal' : 'full';
const pace = state.pace ?? 1;
if (pace !== this.pace) {
this.pace = pace;
this._face.pace = this._smallFace.pace = this._lock.pace = pace;
}
if (this.phase !== 'hidden')
this._shownPhase = this.phase;
const glyphMode = this._shownPhase === 'scanning'
? this.faceSeen ? 'tracking' : 'scanning'
: this._shownPhase;
this._face.setMode(glyphMode, at);
this._smallFace.setMode(glyphMode === 'lockout' ? 'failure' : glyphMode, at);
this._lock.setOpen(this._shownPhase === 'success', at);
const hasMessage = this.message.length > 0;
this._glyphY.set(hasMessage ? 28 : 40, 220 * this.pace, Ease.outCubic, at);
this._messageOpacity.set(hasMessage ? 1 : 0, 200 * this.pace, Ease.linear, at);
this._updatePulse(at);
const wantOpen = this.phase !== 'hidden';
if (wantOpen !== wasOpen)
this._choreograph(wantOpen, at);
this._wake();
}
_timer(name, ms, callback) {
if (this._timers[name])
GLib.source_remove(this._timers[name]);
this._timers[name] = GLib.timeout_add(GLib.PRIORITY_DEFAULT, Math.max(0, Math.round(ms)), () => {
delete this._timers[name];
callback(now());
this._wake();
return GLib.SOURCE_REMOVE;
});
}
_cancel(name) {
if (this._timers[name]) {
GLib.source_remove(this._timers[name]);
delete this._timers[name];
}
}
_choreograph(wantOpen, at) {
if (wantOpen) {
this._opening = true;
this._cancel('slideOut');
this._cancel('closeDone');
this._setPositioned(true, at);
if (!this._expanded)
this._timer('expand', Theme.expandDelay * this.pace, t => this._setExpanded(true, t));
this.actor.show();
} else {
this._opening = false;
this._cancel('expand');
this._setExpanded(false, at);
this._timer('slideOut', Theme.slideOutDelay * this.pace, t => {
this._setPositioned(false, t);
this._timer('closeDone', Theme.slideOutDuration * this.pace + 60, () => {
this.actor.hide();
this._timeline.stop();
});
});
}
}
_setPositioned(positioned, at) {
this._positioned = positioned;
this._y.set(positioned ? Theme.topGap : -Theme.closedHeight - 20,
(this._opening ? Theme.slideInDuration : Theme.slideOutDuration) * this.pace,
this._opening ? Ease.outCubic : Ease.inCubic, at);
}
_setExpanded(expanded, at) {
this._expanded = expanded;
const minimal = this.style === 'minimal';
const w = expanded ? minimal ? Theme.minimalWidth : Theme.openWidth : Theme.closedWidth;
const h = expanded ? minimal ? Theme.minimalHeight : Theme.openHeight : Theme.closedHeight;
const duration = (this._opening ? Theme.growDuration : Theme.shrinkDuration) * this.pace;
// Growing overshoots sideways more than down, so the bottom edge does
// not sag.
this._width.set(w, duration, this._opening ? Ease.outBack(1.6) : Ease.outCubic, at);
this._height.set(h, duration, this._opening ? Ease.outBack(0.9) : Ease.outCubic, at);
this._shadow.set(expanded ? 0.35 : 0, (this._opening ? 200 : 120) * this.pace, Ease.linear, at);
this._fullOpacity.set(expanded ? 1 : 0, (this._opening ? 260 : 120) * this.pace, Ease.linear, at);
this._smallOpacity.set(expanded ? 1 : 0, (this._opening ? 200 : 120) * this.pace, Ease.linear, at);
this._updatePulse(at);
}
// The breathing while it scans: 1500 ms a breath, settling when it stops.
_updatePulse(at) {
const running = this.phase === 'scanning' && this._expanded;
if (running && this._pulseStart === null) {
this._pulseStart = at;
} else if (!running && this._pulseStart !== null) {
this._settle.jump(this._pulse(at));
this._settle.set(0, 200 * this.pace, Ease.outQuad, at);
this._pulseStart = null;
}
}
_pulse(at) {
if (this._pulseStart === null)
return this._settle.get(at);
const p = this.pace;
let t = (at - this._pulseStart) % (1500 * p);
if (t < 600 * p)
return 0;
t -= 600 * p;
if (t < 400 * p)
return Ease.inOutQuad(t / (400 * p));
t -= 400 * p;
if (t < 50 * p)
return 1;
t -= 50 * p;
if (t < 400 * p)
return 1 - Ease.inOutQuad(t / (400 * p));
return 0;
}
_wake() {
if (!this.actor.visible)
return;
if (!this._timeline.is_playing())
this._timeline.start();
this.actor.queue_repaint();
}
_frame() {
const at = now();
this.actor.queue_repaint();
const busy = this.phase !== 'hidden' || Object.keys(this._timers).length > 0 ||
this._width.busy(at) || this._y.busy(at) || this._face.busy(at) || this._smallFace.busy(at);
if (!busy)
this._timeline.stop();
}
_repaint(area) {
const cr = area.get_context();
const scale = St.ThemeContext.get_for_stage(global.stage).scale_factor;
const at = now();
cr.save();
cr.setOperator(0 /* clear */);
cr.paint();
cr.restore();
cr.scale(scale, scale);
try {
this._draw(cr, at);
} catch (e) {
logError(e, 'face-unlock bubble');
}
cr.$dispose();
}
_draw(cr, at) {
const minimal = this.style === 'minimal';
const w = this._width.get(at);
const h = this._height.get(at);
const [pillShake] = sequence(PILL_SHAKE, this._pillShakeStart, at, this.pace);
const x = (WIDTH - w) / 2 + pillShake;
const y = this._y.get(at);
const radius = minimal ? h / 2 : Math.min(Theme.openRadius, h / 2);
// The shadow: MultiEffect's soft one, as rings that fade outwards.
const shadow = this._shadow.get(at);
if (shadow > 0.001) {
for (let i = 8; i >= 1; --i) {
const grow = i * 2;
rgba(cr, [0, 0, 0], shadow * 0.18 * (1 - i / 9));
roundedRect(cr, x - grow, y + 3 - grow, w + 2 * grow, h + 2 * grow, radius + grow);
cr.fill();
}
}
rgba(cr, Theme.panel);
roundedRect(cr, x, y, w, h, radius);
cr.fill();
const pulse = this._pulse(at);
if (!minimal) {
// The face and a line of text, laid out at the open size and
// scaled with the island.
const fit = Math.min(w / Theme.openWidth, h / Theme.openHeight);
const ox = x + w / 2 - Theme.openWidth / 2;
const oy = y + h / 2 - Theme.openHeight / 2;
const cx = ox + Theme.openWidth / 2;
const cy = oy + Theme.openHeight / 2;
layer(cr, this._fullOpacity.get(at), fit, cx, cy, () => {
layer(cr, 1 - 0.35 * pulse, 1 - 0.03 * pulse, cx, cy, () => {
this._face.draw(cr, ox + 40, oy + this._glyphY.get(at), 100, at);
this._drawMessage(cr, ox, oy, at);
});
});
} else {
layer(cr, this._smallOpacity.get(at), 1, x + w / 2, y + h / 2, () => {
const failed = this._shownPhase === 'failure' || this._shownPhase === 'lockout';
this._lock.draw(cr, x + 16, y + (h - 18) / 2, 18, failed ? Theme.failure : Theme.textPrimary, at);
const fx = x + w - 14 - 24;
const fy = y + (h - 24) / 2;
layer(cr, 1 - 0.35 * pulse, 1 - 0.03 * pulse, fx + 12, fy + 12, () => {
this._smallFace.draw(cr, fx, fy, 24, at);
});
});
}
}
_drawMessage(cr, ox, oy, at) {
const opacity = this._messageOpacity.get(at);
if (opacity <= 0.001 || !this.message)
return;
const layout = PangoCairo.create_layout(cr);
const font = Pango.FontDescription.from_string(Bubble._fontName());
font.set_absolute_size(13 * Pango.SCALE);
font.set_weight(Pango.Weight.MEDIUM);
layout.set_font_description(font);
layout.set_width((Theme.openWidth - 32) * Pango.SCALE);
layout.set_ellipsize(Pango.EllipsizeMode.END);
layout.set_alignment(Pango.Alignment.CENTER);
layout.set_text(this.message, -1);
const [, logical] = layout.get_pixel_extents();
const failed = this._shownPhase === 'failure' || this._shownPhase === 'lockout';
rgba(cr, failed ? Theme.textDetail : Theme.textSecondary, opacity);
cr.moveTo(ox + 16, oy + Theme.openHeight - 20 - logical.height);
PangoCairo.show_layout(cr, layout);
}
static _fontName() {
if (!Bubble._font) {
const name = new Gio.Settings({schema_id: 'org.gnome.desktop.interface'}).get_string('font-name');
Bubble._font = name.replace(/\s+[\d.]+$/, '');
}
return Bubble._font;
}
}
export default class FaceUnlockExtension extends Extension {
enable() {
this._bubble = new Bubble();
const ui = Main.layoutManager.uiGroup;
ui.add_child(this._bubble.actor);
// Above everything, the lock screen and its dialogs included.
this._raise = ui.connect('child-added', () => ui.set_child_above_sibling(this._bubble.actor, null));
this._monitors = Main.layoutManager.connect('monitors-changed', () => this._place());
this._scale = St.ThemeContext.get_for_stage(global.stage).connect('notify::scale-factor', () => this._place());
this._place();
this._watch = Gio.bus_watch_name(Gio.BusType.SESSION, BUS_NAME, Gio.BusNameWatcherFlags.NONE,
() => this._connect(), () => this._disconnect());
}
disable() {
Gio.bus_unwatch_name(this._watch);
this._disconnect();
Main.layoutManager.uiGroup.disconnect(this._raise);
Main.layoutManager.disconnect(this._monitors);
St.ThemeContext.get_for_stage(global.stage).disconnect(this._scale);
this._bubble.destroy();
this._bubble = null;
}
_place() {
const monitor = Main.layoutManager.primaryMonitor;
if (!monitor)
return;
const scale = St.ThemeContext.get_for_stage(global.stage).scale_factor;
this._bubble.actor.set_size(WIDTH * scale, HEIGHT * scale);
this._bubble.actor.set_position(monitor.x + Math.round((monitor.width - WIDTH * scale) / 2), monitor.y);
Main.layoutManager.uiGroup.set_child_above_sibling(this._bubble.actor, null);
}
_connect() {
this._disconnect();
const unpack = state => {
const out = {};
for (const [key, value] of Object.entries(state))
out[key] = value instanceof GLib.Variant ? value.recursiveUnpack() : value;
return out;
};
this._proxy = new BubbleProxy(Gio.DBus.session, BUS_NAME, OBJECT_PATH, (proxy, error) => {
if (error) {
logError(error, 'face-unlock');
return;
}
this._signal = proxy.connectSignal('StateChanged', (p, sender, [state]) => {
this._place();
this._bubble?.setState(unpack(state));
});
proxy.GetStateRemote(([state], getError) => {
if (!getError)
this._bubble?.setState(unpack(state));
});
}, null, Gio.DBusProxyFlags.DO_NOT_AUTO_START);
}
_disconnect() {
if (this._proxy && this._signal)
this._proxy.disconnectSignal(this._signal);
this._proxy = null;
this._signal = null;
this._bubble?.setState({phase: 'hidden'});
}
}
@@ -0,0 +1,8 @@
{
"uuid": "face-unlock@loonixtools.github.io",
"name": "Face Unlock",
"description": "The bubble of face-unlock: shows what the camera is doing, on the lock screen too.",
"shell-version": ["45", "46", "47", "48", "49", "50"],
"session-modes": ["user", "unlock-dialog"],
"url": "https://github.com/LoonixTools/face-unlock"
}
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<policyconfig>
<vendor>LoonixTools</vendor>
<vendor_url>https://github.com/LoonixTools/face-unlock</vendor_url>
<icon_name>face-unlock</icon_name>
<!-- A face is a way in, so adding, changing or deleting one takes the
password, the way a phone asks for its code before it sets up a face.
The daemon refuses to let a face answer this particular question. -->
<action id="io.github.loonixtools.face-unlock.manage">
<description>Change the faces that can unlock your account</description>
<description xml:lang="de">Gesichter ändern, die dein Konto entsperren können</description>
<description xml:lang="fr">Modifier les visages qui peuvent déverrouiller votre compte</description>
<description xml:lang="es">Cambiar las caras que pueden desbloquear tu cuenta</description>
<description xml:lang="it">Modificare i volti che possono sbloccare il tuo account</description>
<description xml:lang="pt_BR">Alterar os rostos que podem desbloquear sua conta</description>
<description xml:lang="nl">De gezichten wijzigen die je account kunnen ontgrendelen</description>
<description xml:lang="pl">Zmień twarze, które mogą odblokować twoje konto</description>
<description xml:lang="ru">Изменить лица, которые могут разблокировать вашу учётную запись</description>
<description xml:lang="uk">Змінити обличчя, які можуть розблокувати ваш обліковий запис</description>
<description xml:lang="tr">Hesabınızın kilidini açabilen yüzleri değiştir</description>
<description xml:lang="zh_CN">更改可以解锁你账户的人脸</description>
<description xml:lang="ja">アカウントのロックを解除できる顔を変更</description>
<description xml:lang="ko">계정 잠금을 해제할 수 있는 얼굴 변경</description>
<message>Authentication is required to change the faces that can unlock your account.</message>
<message xml:lang="de">Zum Ändern der Gesichter, die dein Konto entsperren können, ist eine Legitimierung erforderlich.</message>
<message xml:lang="fr">Une authentification est nécessaire pour modifier les visages qui peuvent déverrouiller votre compte.</message>
<message xml:lang="es">Se necesita autenticación para cambiar las caras que pueden desbloquear tu cuenta.</message>
<message xml:lang="it">È richiesta l'autenticazione per modificare i volti che possono sbloccare il tuo account.</message>
<message xml:lang="pt_BR">É necessária autenticação para alterar os rostos que podem desbloquear sua conta.</message>
<message xml:lang="nl">Authenticatie is vereist om de gezichten te wijzigen die je account kunnen ontgrendelen.</message>
<message xml:lang="pl">Wymagane jest uwierzytelnienie, aby zmienić twarze, które mogą odblokować twoje konto.</message>
<message xml:lang="ru">Для изменения лиц, которые могут разблокировать вашу учётную запись, требуется аутентификация.</message>
<message xml:lang="uk">Для зміни облич, які можуть розблокувати ваш обліковий запис, потрібна автентифікація.</message>
<message xml:lang="tr">Hesabınızın kilidini açabilen yüzleri değiştirmek için kimlik doğrulaması gerekiyor.</message>
<message xml:lang="zh_CN">更改可以解锁你账户的人脸需要身份验证。</message>
<message xml:lang="ja">アカウントのロックを解除できる顔を変更するには認証が必要です。</message>
<message xml:lang="ko">계정 잠금을 해제할 수 있는 얼굴을 변경하려면 인증이 필요합니다.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>no</allow_inactive>
<allow_active>auth_self_keep</allow_active>
</defaults>
</action>
</policyconfig>
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<policyconfig>
<vendor>LoonixTools</vendor>
<vendor_url>https://github.com/LoonixTools/plasma-face-unlock</vendor_url>
<icon_name>plasma-face-unlock</icon_name>
<!-- A face is a way in, so adding, changing or deleting one takes the
password, the way a phone asks for its code before it sets up a face.
The daemon refuses to let a face answer this particular question. -->
<action id="io.github.loonixtools.plasma-face-unlock.manage">
<description>Change the faces that can unlock your account</description>
<description xml:lang="de">Gesichter ändern, die dein Konto entsperren können</description>
<message>Authentication is required to change the faces that can unlock your account.</message>
<message xml:lang="de">Zum Ändern der Gesichter, die dein Konto entsperren können, ist eine Legitimierung erforderlich.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>no</allow_inactive>
<allow_active>auth_self_keep</allow_active>
</defaults>
</action>
</policyconfig>
Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=Face unlock (lock screen and bubble)
Documentation=man:face-unlock(1)
PartOf=graphical-session.target
After=graphical-session.target
# Wayland only: the bubble is a layer-shell surface, and the lock screen is
# watched through the compositor.
ConditionEnvironment=WAYLAND_DISPLAY
[Service]
ExecStart=@LIBEXECDIR@/face-unlock-agent
Restart=on-failure
RestartSec=3
Slice=session.slice
[Install]
WantedBy=graphical-session.target
@@ -1,14 +1,14 @@
[Unit] [Unit]
Description=Face unlock for KDE Plasma Description=Face unlock
Documentation=man:plasma-face-unlock(1) Documentation=man:face-unlock(1)
Requires=plasma-face-unlockd.socket Requires=face-unlockd.socket
After=plasma-face-unlockd.socket After=face-unlockd.socket
[Service] [Service]
Type=simple Type=simple
# Started by the socket, and gone again after a minute with nothing to do. # Started by the socket, and gone again after a minute with nothing to do.
ExecStart=@LIBEXECDIR@/plasma-face-unlockd ExecStart=@LIBEXECDIR@/face-unlockd
StateDirectory=plasma-face-unlock StateDirectory=face-unlock
StateDirectoryMode=0700 StateDirectoryMode=0700
UMask=0077 UMask=0077
@@ -1,11 +1,11 @@
[Unit] [Unit]
Description=Face unlock for KDE Plasma (socket) Description=Face unlock (socket)
Documentation=man:plasma-face-unlock(1) Documentation=man:face-unlock(1)
[Socket] [Socket]
# Everybody may connect. Who may ask for what is decided per request, by the # Everybody may connect. Who may ask for what is decided per request, by the
# daemon, from the credentials the kernel reports for the other end. # daemon, from the credentials the kernel reports for the other end.
ListenStream=/run/plasma-face-unlock/socket ListenStream=/run/face-unlock/socket
SocketMode=0666 SocketMode=0666
DirectoryMode=0755 DirectoryMode=0755
RemoveOnStop=yes RemoveOnStop=yes
@@ -1,17 +0,0 @@
[Unit]
Description=Face unlock for KDE Plasma (lock screen and bubble)
Documentation=man:plasma-face-unlock(1)
PartOf=graphical-session.target
After=graphical-session.target
# Plasma on Wayland. The bubble is a layer-shell surface, and there is no such
# thing on X11.
ConditionEnvironment=WAYLAND_DISPLAY
[Service]
ExecStart=@LIBEXECDIR@/plasma-face-unlock-agent
Restart=on-failure
RestartSec=3
Slice=session.slice
[Install]
WantedBy=graphical-session.target
+45 -3
View File
@@ -26,7 +26,7 @@ AgentSocket::AgentSocket(QObject *parent)
continue; continue;
} }
auto buffer = std::make_shared<QByteArray>(); auto buffer = std::make_shared<QByteArray>();
connect(socket, &QLocalSocket::readyRead, this, [this, socket, buffer] { connect(socket, &QLocalSocket::readyRead, this, [this, socket, buffer, cred] {
*buffer += socket->readAll(); *buffer += socket->readAll();
if (buffer->size() > 64 * 1024) { if (buffer->size() > 64 * 1024) {
socket->abort(); socket->abort();
@@ -36,8 +36,12 @@ AgentSocket::AgentSocket(QObject *parent)
while ((nl = buffer->indexOf('\n')) >= 0) { while ((nl = buffer->indexOf('\n')) >= 0) {
const QJsonObject o = QJsonDocument::fromJson(buffer->left(nl)).object(); const QJsonObject o = QJsonDocument::fromJson(buffer->left(nl)).object();
buffer->remove(0, nl + 1); buffer->remove(0, nl + 1);
if (o.value(u"event").toString() == u"scan") { const QString what = o.value(u"event").toString();
if (what == u"scan") {
Q_EMIT scanEvent(o); Q_EMIT scanEvent(o);
} else if (what == u"lock" && cred.uid == ::getuid()) {
m_waiting.append(socket);
Q_EMIT lockRequested();
} }
} }
}); });
@@ -48,7 +52,45 @@ AgentSocket::AgentSocket(QObject *parent)
QString AgentSocket::path() QString AgentSocket::path()
{ {
return QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation) + QStringLiteral("/plasma-face-unlock/agent.socket"); return QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation) + QStringLiteral("/face-unlock/agent.socket");
}
bool AgentSocket::running()
{
QLocalSocket probe;
probe.connectToServer(path());
return probe.waitForConnected(500);
}
bool AgentSocket::requestLock()
{
QLocalSocket socket;
socket.connectToServer(path());
if (!socket.waitForConnected(500)) {
return false;
}
socket.write(QJsonDocument(QJsonObject{{QStringLiteral("event"), QStringLiteral("lock")}}).toJson(QJsonDocument::Compact) + '\n');
if (!socket.waitForBytesWritten(500)) {
return false;
}
// The answer comes once the compositor has the lock, so that an idle
// daemon that locks before sleep does not suspend an unlocked screen.
QByteArray answer;
while (!answer.contains('\n') && socket.waitForReadyRead(5000)) {
answer += socket.readAll();
}
return QJsonDocument::fromJson(answer.left(answer.indexOf('\n'))).object().value(u"event").toString() == u"locked";
}
void AgentSocket::confirmLock()
{
for (const QPointer<QLocalSocket> &socket : std::as_const(m_waiting)) {
if (socket) {
socket->write(QJsonDocument(QJsonObject{{QStringLiteral("event"), QStringLiteral("locked")}}).toJson(QJsonDocument::Compact) + '\n');
socket->flush();
}
}
m_waiting.clear();
} }
bool AgentSocket::listen() bool AgentSocket::listen()
+16 -2
View File
@@ -2,18 +2,21 @@
// //
// Where the daemon tells this session about scans it did not ask for: sudo in // Where the daemon tells this session about scans it did not ask for: sudo in
// a terminal, an admin prompt, a test from the menu. The daemon connects to // a terminal, an admin prompt, a test from the menu. The daemon connects to
// $XDG_RUNTIME_DIR/plasma-face-unlock/agent.socket when such a scan starts, // $XDG_RUNTIME_DIR/face-unlock/agent.socket when such a scan starts,
// so neither side has to keep a connection open (and the daemon can exit when // so neither side has to keep a connection open (and the daemon can exit when
// it is idle). // it is idle).
// //
// Only root and this user may talk here, and all they can do is make the // Only root and this user may talk here, and all they can do is make the
// bubble move. // bubble move, or (this user) ask for face-unlock's own lock screen.
#pragma once #pragma once
#include <QJsonObject> #include <QJsonObject>
#include <QList>
#include <QLocalServer> #include <QLocalServer>
#include <QLocalSocket>
#include <QObject> #include <QObject>
#include <QPointer>
class AgentSocket : public QObject class AgentSocket : public QObject
{ {
@@ -22,11 +25,22 @@ public:
explicit AgentSocket(QObject *parent = nullptr); explicit AgentSocket(QObject *parent = nullptr);
bool listen(); bool listen();
// Whether another agent already listens here. Hyprland without a systemd
// session starts the agent from its own config, and that must not make
// two of them.
static bool running();
// Ask the running agent to lock the screen, and wait until it is. False
// when there is no agent or the lock did not come.
static bool requestLock();
// Tell everybody waiting in requestLock() that the screen is locked.
void confirmLock();
static QString path(); static QString path();
Q_SIGNALS: Q_SIGNALS:
void scanEvent(const QJsonObject &event); void scanEvent(const QJsonObject &event);
void lockRequested();
private: private:
QLocalServer m_server; QLocalServer m_server;
QList<QPointer<QLocalSocket>> m_waiting;
}; };
+39
View File
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "bubbleservice.h"
#include "bubblecontroller.h"
#include <QDBusConnection>
#include <QDBusError>
BubbleService::BubbleService(BubbleController *bubble, QObject *parent)
: QObject(parent)
, m_bubble(bubble)
{
const auto changed = [this] {
Q_EMIT StateChanged(GetState());
};
connect(bubble, &BubbleController::phaseChanged, this, changed);
connect(bubble, &BubbleController::messageChanged, this, changed);
connect(bubble, &BubbleController::faceSeenChanged, this, changed);
connect(bubble, &BubbleController::styleChanged, this, changed);
connect(bubble, &BubbleController::paceChanged, this, changed);
QDBusConnection bus = QDBusConnection::sessionBus();
if (!bus.registerService(QStringLiteral("io.github.loonixtools.FaceUnlock"))
|| !bus.registerObject(QStringLiteral("/io/github/loonixtools/FaceUnlock"), this, QDBusConnection::ExportScriptableContents)) {
qWarning("cannot offer the bubble on the session bus: %s", qPrintable(bus.lastError().message()));
}
}
QVariantMap BubbleService::GetState() const
{
return {
{QStringLiteral("phase"), m_bubble->phase()},
{QStringLiteral("message"), m_bubble->message()},
{QStringLiteral("faceSeen"), m_bubble->faceSeen()},
{QStringLiteral("style"), m_bubble->style()},
{QStringLiteral("pace"), m_bubble->pace()},
};
}
+30
View File
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The bubble's state on the session bus, for GNOME. GNOME lets no program
// draw above its windows or its lock screen; only a GNOME Shell extension
// may. face-unlock's extension (res/gnome-shell) draws the bubble from what
// this says: io.github.loonixtools.FaceUnlock, /io/github/loonixtools/FaceUnlock.
#pragma once
#include <QObject>
#include <QVariantMap>
class BubbleController;
class BubbleService : public QObject
{
Q_OBJECT
Q_CLASSINFO("D-Bus Interface", "io.github.loonixtools.FaceUnlock.Bubble")
public:
explicit BubbleService(BubbleController *bubble, QObject *parent = nullptr);
// phase, message, faceSeen, style, pace: what BubbleController has.
Q_SCRIPTABLE QVariantMap GetState() const;
Q_SIGNALS:
Q_SCRIPTABLE void StateChanged(const QVariantMap &state);
private:
BubbleController *m_bubble;
};
+4 -3
View File
@@ -74,13 +74,13 @@ void BubbleWindow::create()
// faint box with sharp corners around the bubble. An on-screen // faint box with sharp corners around the bubble. An on-screen
// display only fades, which a clear window does not show. // display only fades, which a clear window does not show.
layer->setScope(QStringLiteral("on-screen-display")); layer->setScope(QStringLiteral("on-screen-display"));
#ifdef PFU_LAYERSHELL_HAS_SCREEN #ifdef FU_LAYERSHELL_HAS_SCREEN
layer->setScreen(QGuiApplication::primaryScreen()); layer->setScreen(QGuiApplication::primaryScreen());
#endif #endif
} }
m_view->setInitialProperties({{QStringLiteral("bubble"), QVariant::fromValue(m_controller)}}); m_view->setInitialProperties({{QStringLiteral("bubble"), QVariant::fromValue(m_controller)}});
m_view->loadFromModule(QStringLiteral("PlasmaFaceUnlock"), QStringLiteral("Bubble")); m_view->loadFromModule(QStringLiteral("FaceUnlock"), QStringLiteral("Bubble"));
if (m_view->status() == QQuickView::Error) { if (m_view->status() == QQuickView::Error) {
for (const QQmlError &e : m_view->errors()) { for (const QQmlError &e : m_view->errors()) {
qWarning("%s", qPrintable(e.toString())); qWarning("%s", qPrintable(e.toString()));
@@ -101,7 +101,8 @@ void BubbleWindow::allowOverLockscreen()
return; return;
} }
if (!m_overlay->isActive()) { if (!m_overlay->isActive()) {
if (!warned) { // Only KWin has the protocol. Anywhere else there is nothing to fix.
if (!warned && qEnvironmentVariable("XDG_CURRENT_DESKTOP").split(u':').contains(u"KDE")) {
warned = true; warned = true;
qWarning("KWin does not let this program show above the lock screen; is its desktop file installed?"); qWarning("KWin does not let this program show above the lock screen; is its desktop file installed?");
} }
+5
View File
@@ -11,6 +11,11 @@
// file lists the interface, which the one installed with this does. The // file lists the interface, which the one installed with this does. The
// request has to be made for every new surface role, before it is mapped, so // request has to be made for every new surface role, before it is mapped, so
// it is repeated each time the window is shown again. // it is repeated each time the window is shown again.
//
// Other compositors have no such thing: on Hyprland and Niri the lock screen
// covers the bubble, which shows the tick once it is gone. GNOME has no
// layer-shell at all; there face-unlock's GNOME Shell extension draws the
// bubble (BubbleService).
#pragma once #pragma once
+2 -2
View File
@@ -38,8 +38,8 @@ DaemonRequest::~DaemonRequest()
QString DaemonRequest::socketPath() QString DaemonRequest::socketPath()
{ {
const QByteArray env = qgetenv("PFU_SOCKET"); const QByteArray env = qgetenv("FU_SOCKET");
return env.isEmpty() ? QStringLiteral(PFU_SOCKET) : QString::fromLocal8Bit(env); return env.isEmpty() ? QStringLiteral(FU_SOCKET) : QString::fromLocal8Bit(env);
} }
void DaemonRequest::send(const QJsonObject &message) void DaemonRequest::send(const QJsonObject &message)
+5
View File
@@ -191,6 +191,11 @@ void EnrollController::onFinished(const QJsonObject &result)
} }
if (reason == u"denied") { if (reason == u"denied") {
m_error = i18n("A face can only be added with your password."); m_error = i18n("A face can only be added with your password.");
// Hyprland, Niri and the like bring no polkit agent, so no password window shows.
const QStringList desktops = qEnvironmentVariable("XDG_CURRENT_DESKTOP").split(u':');
if (!desktops.contains(u"KDE") && !desktops.contains(u"GNOME")) {
m_error += u' ' + i18n("No password window? Start a polkit agent, for example hyprpolkitagent.");
}
} else if (reason == u"camera") { } else if (reason == u"camera") {
m_error = i18n("The camera could not be used: %1", result.value(u"message").toString()); m_error = i18n("The camera could not be used: %1", result.value(u"message").toString());
} else if (reason == u"models") { } else if (reason == u"models") {
+144 -2
View File
@@ -2,10 +2,15 @@
#include "inputwatcher.h" #include "inputwatcher.h"
#include <QDBusConnection>
#include <QDBusConnectionInterface>
#include <QDBusMessage>
#include <QDBusPendingCallWatcher>
#include <QGuiApplication> #include <QGuiApplication>
#include <QWaylandClientExtensionTemplate> #include <QWaylandClientExtensionTemplate>
#include <QtGui/qguiapplication_platform.h> #include <QtGui/qguiapplication_platform.h>
#include <algorithm>
#include <functional> #include <functional>
#include "qwayland-ext-idle-notify-v1.h" #include "qwayland-ext-idle-notify-v1.h"
@@ -54,6 +59,111 @@ private:
bool m_fired = false; bool m_fired = false;
}; };
namespace
{
const QString MutterService = QStringLiteral("org.gnome.Mutter.IdleMonitor");
const QString MutterPath = QStringLiteral("/org/gnome/Mutter/IdleMonitor/Core");
} // namespace
// GNOME's way: a watch that fires once nothing was touched for the calm,
// then one that fires at the next input. Each fires once and is gone.
class MutterIdle : public QObject
{
Q_OBJECT
public:
MutterIdle(std::function<void()> input, QObject *parent)
: QObject(parent)
, m_input(std::move(input))
{
QDBusConnection::sessionBus().connect(MutterService, MutterPath, MutterService, QStringLiteral("WatchFired"), this, SLOT(onFired(uint)));
}
~MutterIdle() override
{
stop();
}
void watch(int calmMs)
{
stop();
const int generation = m_generation;
if (calmMs <= 0) {
addWatch(QStringLiteral("AddUserActiveWatch"), {}, generation, &m_active);
return;
}
// Already calm for that long (the scan itself took a while): the
// idle watch would wait for the next calm, so go straight on.
call(QStringLiteral("GetIdletime"), {}, [this, generation, calmMs](const QDBusMessage &reply) {
if (generation != m_generation) {
return;
}
if (reply.arguments().value(0).toULongLong() >= quint64(calmMs)) {
addWatch(QStringLiteral("AddUserActiveWatch"), {}, generation, &m_active);
} else {
addWatch(QStringLiteral("AddIdleWatch"), {QVariant::fromValue(quint64(calmMs))}, generation, &m_idle);
}
});
}
void stop()
{
++m_generation;
remove(m_idle);
remove(m_active);
m_idle = m_active = 0;
}
private Q_SLOTS:
void onFired(uint id)
{
if (id != 0 && id == m_idle) {
remove(m_idle);
m_idle = 0;
addWatch(QStringLiteral("AddUserActiveWatch"), {}, m_generation, &m_active);
} else if (id != 0 && id == m_active) {
m_active = 0;
m_input();
}
}
private:
template<typename Done>
void call(const QString &method, const QVariantList &args, Done done)
{
QDBusMessage msg = QDBusMessage::createMethodCall(MutterService, MutterPath, MutterService, method);
msg.setArguments(args);
auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::sessionBus().asyncCall(msg), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [watcher, done] {
watcher->deleteLater();
done(watcher->reply());
});
}
void addWatch(const QString &method, const QVariantList &args, int generation, uint *slot)
{
call(method, args, [this, generation, slot](const QDBusMessage &reply) {
const uint id = reply.arguments().value(0).toUInt();
if (generation != m_generation) {
// Stopped in the meantime.
remove(id);
return;
}
*slot = id;
});
}
void remove(uint id)
{
if (id != 0) {
call(QStringLiteral("RemoveWatch"), {QVariant::fromValue(id)}, [](const QDBusMessage &) { });
}
}
std::function<void()> m_input;
int m_generation = 0;
uint m_idle = 0;
uint m_active = 0;
};
InputWatcher::InputWatcher(QObject *parent) InputWatcher::InputWatcher(QObject *parent)
: QObject(parent) : QObject(parent)
, m_notifier(std::make_unique<IdleNotifier>()) , m_notifier(std::make_unique<IdleNotifier>())
@@ -64,12 +174,29 @@ InputWatcher::~InputWatcher() = default;
void InputWatcher::watch(int calmMs) void InputWatcher::watch(int calmMs)
{ {
m_notification.reset(); stop();
m_watching = true;
m_calmMs = calmMs;
m_calm.start();
auto *wayland = qGuiApp->nativeInterface<QNativeInterface::QWaylandApplication>(); auto *wayland = qGuiApp->nativeInterface<QNativeInterface::QWaylandApplication>();
if (!m_notifier->isActive() || !wayland || !wayland->seat()) { if (!m_notifier->isActive() || !wayland || !wayland->seat()) {
qWarning("no ext_idle_notifier_v1, so no telling when somebody comes back"); if (!m_mutter && QDBusConnection::sessionBus().interface()->isServiceRegistered(MutterService)) {
m_mutter = new MutterIdle(
[this] {
QMetaObject::invokeMethod(this, &InputWatcher::input, Qt::QueuedConnection);
},
this);
}
if (m_mutter) {
m_mutter->watch(calmMs);
} else {
qWarning("no ext_idle_notifier_v1 and no Mutter IdleMonitor, so no telling when somebody comes back");
}
return; return;
} }
// Hyprland never says resumed for a timeout of 0. A tenth of a second of
// calm first changes nothing anywhere.
calmMs = std::max(calmMs, 100);
// Version 1 has only the notification that inhibitors hold back. // Version 1 has only the notification that inhibitors hold back.
::ext_idle_notification_v1 *object = m_notifier->QWaylandClientExtension::version() >= 2 ::ext_idle_notification_v1 *object = m_notifier->QWaylandClientExtension::version() >= 2
? m_notifier->get_input_idle_notification(uint32_t(calmMs), wayland->seat()) ? m_notifier->get_input_idle_notification(uint32_t(calmMs), wayland->seat())
@@ -81,7 +208,22 @@ void InputWatcher::watch(int calmMs)
}); });
} }
void InputWatcher::noteInput()
{
if (m_watching && m_calm.elapsed() >= m_calmMs) {
m_watching = false;
QMetaObject::invokeMethod(this, &InputWatcher::input, Qt::QueuedConnection);
}
m_calm.restart();
}
void InputWatcher::stop() void InputWatcher::stop()
{ {
m_watching = false;
m_notification.reset(); m_notification.reset();
if (m_mutter) {
m_mutter->stop();
}
} }
#include "inputwatcher.moc"
+11 -1
View File
@@ -5,16 +5,19 @@
// From ext_idle_notifier_v1, like KIdleTime, but with the input notification // From ext_idle_notifier_v1, like KIdleTime, but with the input notification
// of version 2. KIdleTime's own honours idle inhibitors: with a video playing // of version 2. KIdleTime's own honours idle inhibitors: with a video playing
// or an app keeping the screen on, no key reached it and the lock screen never // or an app keeping the screen on, no key reached it and the lock screen never
// scanned. // scanned. GNOME has no ext_idle_notifier_v1; there it is Mutter's own
// IdleMonitor on the session bus, which knows nothing of inhibitors either.
#pragma once #pragma once
#include <QElapsedTimer>
#include <QObject> #include <QObject>
#include <memory> #include <memory>
class IdleNotifier; class IdleNotifier;
class IdleNotification; class IdleNotification;
class MutterIdle;
class InputWatcher : public QObject class InputWatcher : public QObject
{ {
@@ -27,6 +30,9 @@ public:
// calmMs. 0: the next input. Replaces what was watched before. // calmMs. 0: the next input. Replaces what was watched before.
void watch(int calmMs); void watch(int calmMs);
void stop(); void stop();
// A key or the pointer on face-unlock's own lock screen, which sees them
// itself. Counts like input the compositor reports.
void noteInput();
Q_SIGNALS: Q_SIGNALS:
void input(); void input();
@@ -34,4 +40,8 @@ Q_SIGNALS:
private: private:
std::unique_ptr<IdleNotifier> m_notifier; std::unique_ptr<IdleNotifier> m_notifier;
std::unique_ptr<IdleNotification> m_notification; std::unique_ptr<IdleNotification> m_notification;
MutterIdle *m_mutter = nullptr;
bool m_watching = false;
int m_calmMs = 0;
QElapsedTimer m_calm;
}; };
+32 -60
View File
@@ -4,14 +4,11 @@
#include "bubblecontroller.h" #include "bubblecontroller.h"
#include "daemonclient.h" #include "daemonclient.h"
#include "lockscreencontroller.h"
#include "userconfig.h" #include "userconfig.h"
#include <QDBusConnection> #include <QDBusConnection>
#include <QDBusMessage>
#include <QDBusPendingCallWatcher>
#include <QDBusPendingReply>
#include <QJsonObject> #include <QJsonObject>
#include <QProcess>
namespace namespace
{ {
@@ -27,24 +24,32 @@ constexpr int CalmBeforeRetryMs = 2000;
constexpr int ScanAfterWakeMs = 1000; constexpr int ScanAfterWakeMs = 1000;
} // namespace } // namespace
LockController::LockController(BubbleController *bubble, UserConfig *config, QObject *parent) LockController::LockController(BubbleController *bubble, UserConfig *config, SessionLock *lock, LockScreenController *screen, QObject *parent)
: QObject(parent) : QObject(parent)
, m_bubble(bubble) , m_bubble(bubble)
, m_config(config) , m_config(config)
, m_screen(screen)
{ {
if (lock) {
m_lock.setOwnLock(lock);
}
if (screen) {
connect(screen, &LockScreenController::input, &m_input, &InputWatcher::noteInput);
}
m_armTimer.setSingleShot(true); m_armTimer.setSingleShot(true);
connect(&m_armTimer, &QTimer::timeout, this, [this] { connect(&m_armTimer, &QTimer::timeout, this, [this] {
arm(0); arm(0);
}); });
connect(&m_input, &InputWatcher::input, this, &LockController::onResume); connect(&m_input, &InputWatcher::input, this, &LockController::onResume);
connect(&m_lock, &LockWatcher::lockedChanged, this, &LockController::onLockedChanged);
QDBusConnection session = QDBusConnection::sessionBus(); // A lock screen that is only starting cannot be opened yet: the scan
session.connect(QStringLiteral("org.freedesktop.ScreenSaver"), // that was due when it locked follows once it can.
QStringLiteral("/ScreenSaver"), connect(&m_lock, &LockWatcher::unlockable, this, [this] {
QStringLiteral("org.freedesktop.ScreenSaver"), if (m_lockScanDue) {
QStringLiteral("ActiveChanged"), m_lockScanDue = false;
this, startScan(QStringLiteral("lock"));
SLOT(onActiveChanged(bool))); }
});
QDBusConnection::systemBus().connect(QStringLiteral("org.freedesktop.login1"), QDBusConnection::systemBus().connect(QStringLiteral("org.freedesktop.login1"),
QStringLiteral("/org/freedesktop/login1"), QStringLiteral("/org/freedesktop/login1"),
@@ -52,29 +57,16 @@ LockController::LockController(BubbleController *bubble, UserConfig *config, QOb
QStringLiteral("PrepareForSleep"), QStringLiteral("PrepareForSleep"),
this, this,
SLOT(onPrepareForSleep(bool))); SLOT(onPrepareForSleep(bool)));
// Started while the screen is already locked (the agent restarted).
const QDBusMessage get = QDBusMessage::createMethodCall(QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("/ScreenSaver"),
QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("GetActive"));
auto *watcher = new QDBusPendingCallWatcher(session.asyncCall(get), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher] {
watcher->deleteLater();
const QDBusPendingReply<bool> reply = *watcher;
if (reply.isValid() && reply.value()) {
onActiveChanged(true);
}
});
} }
void LockController::onActiveChanged(bool active) void LockController::onLockedChanged(bool locked)
{ {
if (active == m_locked) { if (locked == m_locked) {
return; return;
} }
if (!active) { if (!locked) {
m_locked = false; m_locked = false;
m_lockScanDue = false;
m_armTimer.stop(); m_armTimer.stop();
m_input.stop(); m_input.stop();
if (m_scan) { if (m_scan) {
@@ -93,7 +85,11 @@ void LockController::onActiveChanged(bool active)
return; return;
} }
if (!m_config->lockScreen()) { const bool face = m_config->enabled() && m_config->lockScreen();
if (m_screen) {
m_screen->setFaceUnlock(face);
}
if (!face) {
return; return;
} }
m_locked = true; m_locked = true;
@@ -103,6 +99,7 @@ void LockController::onActiveChanged(bool active)
if (m_config->scanOnLock()) { if (m_config->scanOnLock()) {
QTimer::singleShot(400, this, [this] { QTimer::singleShot(400, this, [this] {
m_lockScanDue = m_locked && !m_lock.canUnlock();
startScan(QStringLiteral("lock")); startScan(QStringLiteral("lock"));
}); });
} else { } else {
@@ -155,7 +152,9 @@ void LockController::warmUp()
void LockController::startScan(const QString &why) void LockController::startScan(const QString &why)
{ {
if (!m_locked || m_scan || m_stopped) { // A lock screen this cannot open (gtklock, waylock, a shell's own) asks
// for the face itself, through PAM, when Enter is pressed.
if (!m_locked || m_scan || m_stopped || !m_lock.canUnlock()) {
return; return;
} }
qInfo("scanning (%s)", qPrintable(why)); qInfo("scanning (%s)", qPrintable(why));
@@ -189,7 +188,7 @@ void LockController::onScanFinished(const QJsonObject &result)
// moment to go, and the bubble stays above the desktop, so the rings // moment to go, and the bubble stays above the desktop, so the rings
// and the tick play on over it. // and the tick play on over it.
m_bubble->succeeded(); m_bubble->succeeded();
unlock(); m_lock.unlock();
return; return;
} }
@@ -207,30 +206,3 @@ void LockController::onScanFinished(const QJsonObject &result)
} }
arm(CalmBeforeRetryMs); arm(CalmBeforeRetryMs);
} }
void LockController::unlock()
{
if (!m_locked) {
return;
}
// "auto" is the caller's own session, or for a program outside any
// session (this one runs as a user service) the session on the display.
const QDBusMessage call = QDBusMessage::createMethodCall(QStringLiteral("org.freedesktop.login1"),
QStringLiteral("/org/freedesktop/login1/session/auto"),
QStringLiteral("org.freedesktop.login1.Session"),
QStringLiteral("Unlock"));
auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::systemBus().asyncCall(call), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [watcher] {
watcher->deleteLater();
const QDBusPendingReply<> reply = *watcher;
if (reply.isError()) {
qWarning("logind would not unlock: %s; trying loginctl", qPrintable(reply.error().message()));
const QString id = qEnvironmentVariable("XDG_SESSION_ID");
QStringList args{QStringLiteral("unlock-session")};
if (!id.isEmpty()) {
args << id;
}
QProcess::startDetached(QStringLiteral("loginctl"), args);
}
});
}
+15 -11
View File
@@ -4,22 +4,20 @@
// //
// When the screen locks, this waits for somebody to come back: a key, the // When the screen locks, this waits for somebody to come back: a key, the
// mouse, the lid opening, the machine waking from sleep. Then it scans, and // mouse, the lid opening, the machine waking from sleep. Then it scans, and
// when the face matches it asks logind to unlock the session, which is the // when the face matches it unlocks the session the way that desktop's lock
// same request `loginctl unlock-session` makes and which Plasma's screen // screen wants it (see LockWatcher).
// locker has always honoured.
// //
// Why not a PAM module in the lock screen, like fingerprints? Plasma runs its // Why not a PAM module in the lock screen, like fingerprints? Plasma runs its
// fingerprint stack in parallel with the password, but only starts it once per // fingerprint stack in parallel with the password, but only starts it once per
// lock, gives up for good the first time it fails, and labels it "scan your // lock, gives up for good the first time it fails, and labels it "scan your
// fingerprint". Doing it from here means scanning again every time somebody // fingerprint". GNOME's, hyprlock's and swaylock's only ask once the password
// sits back down, no wrong label, and a bubble that knows what is going on. // is typed. Doing it from here means scanning again every time somebody sits
// It does not lower the bar either: any program running as this user can // back down, no wrong label, and a bubble that knows what is going on.
// already unlock this user's session through logind. Face data and the
// decision stay with the daemon, which runs as root.
#pragma once #pragma once
#include "inputwatcher.h" #include "inputwatcher.h"
#include "lockwatcher.h"
#include <QElapsedTimer> #include <QElapsedTimer>
#include <QObject> #include <QObject>
@@ -28,13 +26,16 @@
class BubbleController; class BubbleController;
class DaemonRequest; class DaemonRequest;
class LockScreenController;
class SessionLock;
class UserConfig; class UserConfig;
class LockController : public QObject class LockController : public QObject
{ {
Q_OBJECT Q_OBJECT
public: public:
LockController(BubbleController *bubble, UserConfig *config, QObject *parent = nullptr); // lock and screen are face-unlock's own lock screen, where there is one.
LockController(BubbleController *bubble, UserConfig *config, SessionLock *lock, LockScreenController *screen, QObject *parent = nullptr);
bool locked() const bool locked() const
{ {
@@ -42,7 +43,7 @@ public:
} }
private Q_SLOTS: private Q_SLOTS:
void onActiveChanged(bool active); void onLockedChanged(bool locked);
void onPrepareForSleep(bool sleeping); void onPrepareForSleep(bool sleeping);
private: private:
@@ -51,15 +52,18 @@ private:
void onResume(); void onResume();
void startScan(const QString &why); void startScan(const QString &why);
void onScanFinished(const QJsonObject &result); void onScanFinished(const QJsonObject &result);
void unlock();
void warmUp(); void warmUp();
BubbleController *m_bubble; BubbleController *m_bubble;
UserConfig *m_config; UserConfig *m_config;
LockScreenController *m_screen;
bool m_locked = false; bool m_locked = false;
bool m_stopped = false; bool m_stopped = false;
// Scan right after locking, once the lock screen can be opened.
bool m_lockScanDue = false;
QElapsedTimer m_lockedFor; QElapsedTimer m_lockedFor;
QPointer<DaemonRequest> m_scan; QPointer<DaemonRequest> m_scan;
QTimer m_armTimer; QTimer m_armTimer;
InputWatcher m_input; InputWatcher m_input;
LockWatcher m_lock;
}; };
+127
View File
@@ -0,0 +1,127 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "lockers.h"
#include <QFile>
#include <QString>
#include <algorithm>
#include <csignal>
#include <dirent.h>
#include <sys/stat.h>
#include <unistd.h>
namespace
{
bool isLocker(const QByteArray &name)
{
return name == "hyprlock" || name == "swaylock" || name == "gtklock";
}
QByteArray readFile(const QString &path)
{
QFile f(path);
return f.open(QIODevice::ReadOnly) ? f.readAll() : QByteArray();
}
pid_t parentOf(pid_t pid)
{
// The fields after the name in brackets, which can hold anything.
const QByteArray stat = readFile(QStringLiteral("/proc/%1/stat").arg(pid));
const qsizetype close = stat.lastIndexOf(')');
return close < 0 ? 0 : pid_t(stat.mid(close + 2).split(' ').value(1).toInt());
}
// The lock screens of this user on this display. Another session of the same
// user has a display of its own, and its lock screen is left alone. A child
// of a lock screen (swaylock and gtklock check the password in one) is left
// out: killed by the signal before its parent unlocks, it would take the
// parent down with it, and the screen would stay locked.
QList<pid_t> findAll()
{
QList<pid_t> found;
DIR *proc = ::opendir("/proc");
if (!proc) {
return found;
}
QByteArray display = qgetenv("WAYLAND_DISPLAY");
if (display.isEmpty()) {
display = "wayland-0";
}
const uid_t me = ::getuid();
while (dirent *entry = ::readdir(proc)) {
const pid_t pid = pid_t(atoi(entry->d_name));
struct stat st;
if (pid <= 0 || ::fstatat(::dirfd(proc), entry->d_name, &st, 0) != 0 || st.st_uid != me) {
continue;
}
const QString dir = QStringLiteral("/proc/%1/").arg(pid);
if (!isLocker(readFile(dir + QStringLiteral("comm")).trimmed())) {
continue;
}
bool sameDisplay = true;
for (const QByteArray &var : readFile(dir + QStringLiteral("environ")).split('\0')) {
if (var.startsWith("WAYLAND_DISPLAY=")) {
sameDisplay = var.mid(16) == display;
break;
}
}
if (sameDisplay) {
found.append(pid);
}
}
::closedir(proc);
QList<pid_t> top;
for (const pid_t pid : std::as_const(found)) {
if (!found.contains(parentOf(pid))) {
top.append(pid);
}
}
return top;
}
// Whether pid catches sig, from the mask in /proc/<pid>/status.
bool catches(pid_t pid, int sig)
{
for (const QByteArray &line : readFile(QStringLiteral("/proc/%1/status").arg(pid)).split('\n')) {
if (line.startsWith("SigCgt:")) {
bool ok = false;
const qulonglong mask = line.mid(7).trimmed().toULongLong(&ok, 16);
return ok && (mask >> (sig - 1)) & 1;
}
}
return false;
}
} // namespace
QList<pid_t> Lockers::find(const QByteArray &name)
{
QList<pid_t> found;
for (const pid_t pid : findAll()) {
if (name.isEmpty() || readFile(QStringLiteral("/proc/%1/comm").arg(pid)).trimmed() == name) {
found.append(pid);
}
}
return found;
}
bool Lockers::ready(int sig)
{
const QList<pid_t> lockers = find();
return std::any_of(lockers.cbegin(), lockers.cend(), [sig](pid_t pid) {
return catches(pid, sig);
});
}
int Lockers::signal(int sig, const QByteArray &name)
{
int waiting = 0;
for (const pid_t pid : find(name)) {
if (catches(pid, sig)) {
::kill(pid, sig);
} else {
++waiting;
}
}
return waiting;
}
+26
View File
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The lock screens that are programs of their own and take signals from
// outside: hyprlock, swaylock and gtklock open on SIGUSR1, hyprlock reads
// its labels again on SIGUSR2.
#pragma once
#include <QByteArray>
#include <QList>
#include <sys/types.h>
namespace Lockers
{
// The ones of this user on this display, or only those called name.
QList<pid_t> find(const QByteArray &name = {});
// Sends sig to each that is ready for it, and says how many were not. A
// lock screen only handles its signals once the screen is locked: before
// that, the signal kills it, and the compositor keeps the screen locked with
// nobody to open it.
int signal(int sig, const QByteArray &name = {});
// Whether one of them handles sig: gtklock only opens on SIGUSR1 since 2025,
// and none does before it has locked.
bool ready(int sig);
}
+647
View File
@@ -0,0 +1,647 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "lockpreview.h"
#include <KLocalizedString>
#include <QDateTime>
#include <QDir>
#include <QElapsedTimer>
#include <QFile>
#include <QFileInfo>
#include <QHash>
#include <QProcess>
#include <QRegularExpression>
#include <QPointF>
#include <QStandardPaths>
#include <QTime>
#include <QVariantMap>
#include <algorithm>
#include <pwd.h>
#include <unistd.h>
namespace
{
QString readFile(const QString &path)
{
QFile f(path);
return f.open(QIODevice::ReadOnly | QIODevice::Text) ? QString::fromUtf8(f.readAll()) : QString();
}
QString expandHome(const QString &path)
{
return path == u"~" ? QDir::homePath() : path.startsWith(u"~/") ? QDir::homePath() + path.mid(1) : path;
}
QString configHome()
{
return QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation);
}
// The first file of name under the user's and then the system's config
// directories, as hyprlock and swaylock look for theirs.
QString findConfig(const QString &name)
{
QStringList dirs{configHome()};
dirs += QStandardPaths::standardLocations(QStandardPaths::GenericConfigLocation);
for (const QString &dir : std::as_const(dirs)) {
if (QFileInfo::exists(dir + u'/' + name)) {
return dir + u'/' + name;
}
}
return {};
}
bool truthy(const QString &value)
{
return value == u"1" || value.compare(u"true", Qt::CaseInsensitive) == 0 || value.compare(u"yes", Qt::CaseInsensitive) == 0
|| value.compare(u"on", Qt::CaseInsensitive) == 0;
}
// ---------------------------------------------------------------------------
// hyprlang, as far as a lock screen's config needs it
struct Section {
QString name;
QHash<QString, QString> values;
};
// Sections, $variables, source = (with ~ and wildcards, relative to the
// file), and # comments, where ## stands for a #.
class Hyprlang
{
public:
void parse(const QString &path, int depth = 0);
QList<Section> sections;
private:
QString expand(const QString &value) const;
QHash<QString, QString> m_vars;
QList<qsizetype> m_open;
};
QString stripComment(const QString &line)
{
QString out;
for (qsizetype i = 0; i < line.size(); ++i) {
if (line.at(i) == u'#') {
if (i + 1 < line.size() && line.at(i + 1) == u'#') {
out += u'#';
++i;
continue;
}
break;
}
out += line.at(i);
}
return out.trimmed();
}
void Hyprlang::parse(const QString &path, int depth)
{
if (depth > 8) {
return;
}
const QString dir = QFileInfo(path).absolutePath();
const QStringList lines = readFile(path).split(u'\n');
for (const QString &raw : lines) {
const QString line = stripComment(raw);
if (line.isEmpty()) {
continue;
}
if (line == u"}") {
if (!m_open.isEmpty()) {
m_open.removeLast();
}
continue;
}
if (line.endsWith(u'{')) {
sections.append({line.chopped(1).trimmed(), {}});
m_open.append(sections.size() - 1);
continue;
}
const qsizetype eq = line.indexOf(u'=');
if (eq < 0) {
continue;
}
const QString key = line.left(eq).trimmed();
const QString value = expand(line.mid(eq + 1).trimmed());
if (key.startsWith(u'$')) {
m_vars.insert(key.mid(1), value);
} else if (key == u"source" && m_open.isEmpty()) {
const QFileInfo pattern(QDir(dir).absoluteFilePath(expandHome(value)));
const QStringList matches = QDir(pattern.absolutePath()).entryList({pattern.fileName()}, QDir::Files, QDir::Name);
for (const QString &match : matches) {
parse(pattern.absolutePath() + u'/' + match, depth + 1);
}
} else if (!m_open.isEmpty()) {
sections[m_open.last()].values.insert(key, value);
}
}
}
QString Hyprlang::expand(const QString &value) const
{
if (!value.contains(u'$') || m_vars.isEmpty()) {
return value;
}
// Longest first, so $font does not eat the start of $fontsize.
QStringList names = m_vars.keys();
std::sort(names.begin(), names.end(), [](const QString &a, const QString &b) {
return a.size() > b.size();
});
QString out = value;
for (const QString &name : std::as_const(names)) {
out.replace(u'$' + name, m_vars.value(name));
}
return out;
}
// ---------------------------------------------------------------------------
// Values
// "rgba(a, b) rgba(c) 45deg": the colours and the angle.
QVariantMap gradient(const QString &value)
{
QVariantList colors;
double angle = 0;
QString token;
int depth = 0;
const auto flush = [&] {
const QString t = token.trimmed();
token.clear();
if (t.endsWith(u"deg")) {
angle = t.chopped(3).toDouble();
} else if (const QColor c = LockPreview::color(t); c.isValid()) {
colors.append(c);
}
};
for (const QChar ch : value) {
if (ch == u'(') {
++depth;
} else if (ch == u')') {
--depth;
}
if (ch.isSpace() && depth == 0) {
flush();
} else {
token += ch;
}
}
flush();
return {{QStringLiteral("colors"), colors}, {QStringLiteral("angle"), angle}};
}
// "x, y", each in pixels or percent of the screen.
QPointF layout(const QString &value, const QSizeF &screen)
{
const QStringList parts = value.split(u',');
const auto one = [](const QString &part, qreal whole) {
const QString s = part.trimmed();
return s.endsWith(u'%') ? s.chopped(1).toDouble() / 100.0 * whole : s.toDouble();
};
return {one(parts.value(0), screen.width()), one(parts.value(1), screen.height())};
}
// A Pango font description ("Noto Sans Light 12") as family, weight and
// slant.
void font(const QString &description, QVariantMap &into)
{
static const QHash<QString, int> weights{
{QStringLiteral("thin"), 100}, {QStringLiteral("ultra-light"), 200}, {QStringLiteral("extra-light"), 200},
{QStringLiteral("ultralight"), 200}, {QStringLiteral("extralight"), 200}, {QStringLiteral("light"), 300},
{QStringLiteral("semi-light"), 350}, {QStringLiteral("book"), 380}, {QStringLiteral("regular"), 400},
{QStringLiteral("normal"), 400}, {QStringLiteral("medium"), 500}, {QStringLiteral("semi-bold"), 600},
{QStringLiteral("semibold"), 600}, {QStringLiteral("demi-bold"), 600}, {QStringLiteral("demibold"), 600},
{QStringLiteral("bold"), 700}, {QStringLiteral("ultra-bold"), 800}, {QStringLiteral("extra-bold"), 800},
{QStringLiteral("extrabold"), 800}, {QStringLiteral("heavy"), 900}, {QStringLiteral("black"), 900},
};
QStringList words = description.section(u',', 0, 0).split(u' ', Qt::SkipEmptyParts);
int weight = 400;
bool italic = false;
while (words.size() > 1) {
const QString word = words.last().toLower();
bool number = false;
word.toDouble(&number);
if (number) {
words.removeLast();
} else if (word == u"italic" || word == u"oblique") {
italic = true;
words.removeLast();
} else if (weights.contains(word)) {
weight = weights.value(word);
words.removeLast();
} else {
break;
}
}
into.insert(QStringLiteral("family"), words.isEmpty() ? QStringLiteral("Sans") : words.join(u' '));
into.insert(QStringLiteral("weight"), weight);
into.insert(QStringLiteral("italic"), italic);
}
// Pango markup as Qt's StyledText: span becomes font, b and i, the rest of
// Pango's tags go and their text stays.
QString styled(const QString &markup)
{
static const QRegularExpression tag(QStringLiteral("<(/?)([a-zA-Z]+)([^>]*)>"));
static const QRegularExpression attribute(QStringLiteral("([a-z_]+)\\s*=\\s*[\"']([^\"']*)[\"']"));
QString out;
QStringList closers;
qsizetype last = 0;
auto it = tag.globalMatch(markup);
while (it.hasNext()) {
const auto m = it.next();
out += markup.mid(last, m.capturedStart() - last);
last = m.capturedEnd();
const bool closing = !m.captured(1).isEmpty();
const QString name = m.captured(2).toLower();
if (name == u"span") {
if (closing) {
out += closers.isEmpty() ? QString() : closers.takeLast();
continue;
}
QString open;
QString close;
QString fontAttrs;
auto attrs = attribute.globalMatch(m.captured(3));
while (attrs.hasNext()) {
const auto a = attrs.next();
const QString key = a.captured(1);
QString value = a.captured(2);
if (key == u"foreground" || key == u"fgcolor" || key == u"color") {
// Pango's #RRGGBBAA is Qt's #AARRGGBB.
if (value.startsWith(u'#') && value.size() == 9) {
value = u'#' + value.mid(7, 2) + value.mid(1, 6);
}
fontAttrs += QStringLiteral(" color=\"%1\"").arg(value);
} else if (key == u"font_family" || key == u"face") {
fontAttrs += QStringLiteral(" face=\"%1\"").arg(value);
} else if ((key == u"weight" || key == u"font_weight") && (value.contains(u"bold") || value.contains(u"heavy") || value.toInt() >= 600)) {
open += QStringLiteral("<b>");
close.prepend(QStringLiteral("</b>"));
} else if ((key == u"style" || key == u"font_style") && value == u"italic") {
open += QStringLiteral("<i>");
close.prepend(QStringLiteral("</i>"));
}
}
if (!fontAttrs.isEmpty()) {
open.prepend(QStringLiteral("<font%1>").arg(fontAttrs));
close += QStringLiteral("</font>");
}
out += open;
closers.append(close);
} else if (name == u"b" || name == u"i" || name == u"u") {
out += m.captured(0);
}
}
out += markup.mid(last);
out.replace(u'\n', QStringLiteral("<br>"));
return out;
}
// What a command prints, or nothing when it takes too long.
QString run(const QString &command, QElapsedTimer &budget)
{
// A config full of slow commands (weather, music) must not keep the
// window from opening.
if (budget.elapsed() > 2000) {
return {};
}
QProcess process;
process.start(QStringLiteral("/bin/sh"), {QStringLiteral("-c"), command});
if (!process.waitForFinished(700)) {
process.kill();
process.waitForFinished(100);
return {};
}
return QString::fromUtf8(process.readAllStandardOutput());
}
// A label's text as hyprlock would show it right after locking.
QString labelText(QString text, bool trim, QElapsedTimer &budget)
{
const passwd *pw = getpwuid(getuid());
text.replace(QStringLiteral("$DESC"), pw ? QString::fromLocal8Bit(pw->pw_gecos) : QString());
text.replace(QStringLiteral("$USER"), pw ? QString::fromLocal8Bit(pw->pw_name) : QString());
text.replace(QStringLiteral("<br/>"), QStringLiteral("\n"));
const QTime now = QTime::currentTime();
text.replace(QStringLiteral("$TIME12"), now.toString(QStringLiteral("hh:mm AP")));
text.replace(QStringLiteral("$TIME"), now.toString(QStringLiteral("HH:mm")));
static const QRegularExpression layoutVar(QStringLiteral("\\$LAYOUT(\\[([^\\]]*)\\])?"));
auto layouts = layoutVar.globalMatch(text);
while (layouts.hasNext()) {
const auto m = layouts.next();
const QString first = m.captured(2).section(u',', 0, 0).trimmed();
text.replace(m.captured(0), first.isEmpty() ? QStringLiteral("en") : first);
}
// What only a lock screen that runs knows: nothing has failed yet.
static const QRegularExpression runtime(QStringLiteral("\\$(ATTEMPTS(\\[[^\\]]*\\])?|PAMFAIL|FPRINTFAIL|FPRINTPROMPT|FAIL)"));
text.remove(runtime);
text.replace(QStringLiteral("$PAMPROMPT"), QStringLiteral("Password:"));
if (text.startsWith(u"cmd[") && text.contains(u']')) {
const QString command = text.mid(text.indexOf(u']') + 1);
// face-unlock's own line, as it reads during a scan.
text = command.contains(u"face-unlock/lock-text") ? i18n("Looking for your face…").toHtmlEscaped() : run(command, budget);
}
return trim ? text.trimmed() : text;
}
void place(const Section &s, const QString &position, const QSizeF &screen, QVariantMap &into)
{
const QPointF pos = layout(s.values.value(QStringLiteral("position"), position), screen);
into.insert(QStringLiteral("x"), pos.x());
into.insert(QStringLiteral("y"), pos.y());
into.insert(QStringLiteral("halign"), s.values.value(QStringLiteral("halign"), QStringLiteral("center")));
into.insert(QStringLiteral("valign"), s.values.value(QStringLiteral("valign"), QStringLiteral("center")));
into.insert(QStringLiteral("rotate"), s.values.value(QStringLiteral("rotate"), QStringLiteral("0")).toDouble());
}
QColor colorOr(const QString &value, const QColor &fallback)
{
const QColor c = LockPreview::color(value);
return c.isValid() ? c : fallback;
}
// swaylock's colours: RRGGBB or RRGGBBAA, without a #.
QColor hexColor(const QString &value, const QColor &fallback)
{
QString v = value.trimmed();
if (v.startsWith(u'#')) {
v = v.mid(1);
}
bool ok = false;
const uint n = v.toUInt(&ok, 16);
if (!ok || (v.size() != 6 && v.size() != 8)) {
return fallback;
}
return v.size() == 6 ? QColor((n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff) : QColor((n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff);
}
QVariantMap wallpaperOnly(const QUrl &wallpaper)
{
return {{QStringLiteral("type"), QStringLiteral("background")},
{QStringLiteral("color"), QColor(0x11, 0x11, 0x11)},
{QStringLiteral("source"), wallpaper},
{QStringLiteral("fill"), QStringLiteral("fill")},
{QStringLiteral("blur"), 0.0},
{QStringLiteral("dim"), 0.0}};
}
} // namespace
QColor LockPreview::color(const QString &value)
{
const QString v = value.trimmed();
static const QRegularExpression call(QStringLiteral("^(rgba?)\\((.*)\\)$"));
const auto m = call.match(v);
if (m.hasMatch()) {
const QString inner = m.captured(2).trimmed();
const QStringList parts = inner.split(u',');
if (parts.size() >= 3) {
QColor c(parts.at(0).trimmed().toInt(), parts.at(1).trimmed().toInt(), parts.at(2).trimmed().toInt());
if (parts.size() >= 4) {
c.setAlphaF(std::clamp(parts.at(3).trimmed().toDouble(), 0.0, 1.0));
}
return c;
}
return hexColor(inner, {});
}
bool ok = false;
const qulonglong n = v.startsWith(u"0x", Qt::CaseInsensitive) ? v.mid(2).toULongLong(&ok, 16) : v.toULongLong(&ok, 10);
if (!ok) {
return {};
}
return QColor(int((n >> 16) & 0xff), int((n >> 8) & 0xff), int(n & 0xff), int((n >> 24) & 0xff));
}
QString LockPreview::locker()
{
static const QStringList names{QStringLiteral("hyprlock"), QStringLiteral("swaylock"), QStringLiteral("gtklock"), QStringLiteral("waylock")};
// Where a lock screen is started from: the idle daemons first, then the
// keys. The first one named there, outside comments.
static const QStringList places{QStringLiteral("hypr/hypridle.conf"), QStringLiteral("swayidle/config"), QStringLiteral("niri/config.kdl"),
QStringLiteral("hypr/hyprland.lua"), QStringLiteral("hypr/hyprland.conf")};
for (const QString &place : places) {
for (const QString &line : readFile(configHome() + u'/' + place).split(u'\n')) {
const QString code = line.trimmed();
if (code.startsWith(u'#') || code.startsWith(u"//") || code.startsWith(u"--")) {
continue;
}
qsizetype first = -1;
QString found;
for (const QString &name : names) {
const qsizetype at = code.indexOf(name);
if (at >= 0 && (first < 0 || at < first)) {
first = at;
found = name;
}
}
if (!found.isEmpty()) {
return found;
}
}
}
// Started some other way (a script of its own): one that is set up.
if (!QStandardPaths::findExecutable(QStringLiteral("hyprlock")).isEmpty() && !findConfig(QStringLiteral("hypr/hyprlock.conf")).isEmpty()) {
return QStringLiteral("hyprlock");
}
for (const QString &name : names) {
if (!QStandardPaths::findExecutable(name).isEmpty()) {
return name;
}
}
return {};
}
QVariantList LockPreview::hyprlock(const QString &configPath, const QSizeF &screen, const QString &output, const QUrl &wallpaper)
{
Hyprlang config;
config.parse(configPath);
bool trim = true;
for (const Section &s : std::as_const(config.sections)) {
if (s.name == u"general" && s.values.contains(QStringLiteral("text_trim"))) {
trim = truthy(s.values.value(QStringLiteral("text_trim")));
}
}
QElapsedTimer budget;
budget.start();
QList<QPair<int, QVariantMap>> placed;
bool ourLine = false;
bool background = false;
for (const Section &s : std::as_const(config.sections)) {
const auto value = [&s](const char *key, const char *fallback) {
return s.values.value(QString::fromLatin1(key), QString::fromLatin1(fallback));
};
const QString monitor = value("monitor", "");
if (!monitor.isEmpty() && monitor != output && !monitor.startsWith(u"desc:")) {
continue;
}
QVariantMap w;
int z = value("zindex", "0").toInt();
if (s.name == u"background") {
background = true;
z = value("zindex", "-1").toInt();
const QString path = value("path", "");
const int passes = value("blur_passes", "0").toInt();
w = wallpaperOnly(path == u"screenshot" ? wallpaper : path.isEmpty() ? QUrl() : QUrl::fromLocalFile(expandHome(path)));
w.insert(QStringLiteral("color"), colorOr(value("color", ""), QColor(0x11, 0x11, 0x11)));
w.insert(QStringLiteral("blur"), passes > 0 ? std::min(1.0, passes * value("blur_size", "8").toDouble() / 24.0) : 0.0);
// hyprlock's blur also darkens, by its brightness.
w.insert(QStringLiteral("dim"), passes > 0 ? std::clamp(1.0 - value("brightness", "0.8172").toDouble(), 0.0, 1.0) : 0.0);
} else if (s.name == u"label") {
const QString text = value("text", "Sample Text");
ourLine = ourLine || text.contains(u"face-unlock/lock-text");
w.insert(QStringLiteral("type"), QStringLiteral("label"));
w.insert(QStringLiteral("text"), styled(labelText(text, trim, budget)));
font(value("font_family", "Sans"), w);
w.insert(QStringLiteral("size"), value("font_size", "16").toDouble());
w.insert(QStringLiteral("color"), colorOr(value("color", ""), Qt::white));
w.insert(QStringLiteral("align"), value("text_align", "center"));
w.insert(QStringLiteral("shadow"), value("shadow_passes", "0").toInt() > 0);
w.insert(QStringLiteral("shadowSize"), value("shadow_size", "3").toDouble());
w.insert(QStringLiteral("shadowColor"), colorOr(value("shadow_color", ""), Qt::black));
place(s, QStringLiteral("0,0"), screen, w);
} else if (s.name == u"input-field") {
const QPointF size = layout(value("size", "400,90"), screen);
w.insert(QStringLiteral("type"), QStringLiteral("input"));
w.insert(QStringLiteral("width"), size.x());
w.insert(QStringLiteral("height"), size.y());
w.insert(QStringLiteral("thickness"), value("outline_thickness", "4").toDouble());
w.insert(QStringLiteral("outer"), gradient(value("outer_color", "0xFF111111")));
w.insert(QStringLiteral("inner"), colorOr(value("inner_color", ""), QColor(0xdd, 0xdd, 0xdd)));
w.insert(QStringLiteral("fontColor"), colorOr(value("font_color", ""), Qt::black));
font(value("font_family", "Sans"), w);
w.insert(QStringLiteral("placeholder"), styled(labelText(value("placeholder_text", "<i>Input Password</i>"), trim, budget)));
w.insert(QStringLiteral("rounding"), value("rounding", "-1").toDouble());
place(s, QStringLiteral("0,0"), screen, w);
} else if (s.name == u"shape") {
const QPointF size = layout(value("size", "100,100"), screen);
w.insert(QStringLiteral("type"), QStringLiteral("shape"));
w.insert(QStringLiteral("width"), size.x());
w.insert(QStringLiteral("height"), size.y());
w.insert(QStringLiteral("color"), colorOr(value("color", ""), QColor(0x11, 0x11, 0x11)));
w.insert(QStringLiteral("rounding"), value("rounding", "0").toDouble());
w.insert(QStringLiteral("borderSize"), value("border_size", "0").toDouble());
w.insert(QStringLiteral("border"), gradient(value("border_color", "0xFF00CFE6")));
place(s, QStringLiteral("0,0"), screen, w);
} else if (s.name == u"image") {
const QString path = value("path", "");
if (path.isEmpty()) {
continue;
}
w.insert(QStringLiteral("type"), QStringLiteral("image"));
w.insert(QStringLiteral("source"), QUrl::fromLocalFile(expandHome(path)));
w.insert(QStringLiteral("size"), value("size", "150").toDouble());
w.insert(QStringLiteral("rounding"), value("rounding", "-1").toDouble());
w.insert(QStringLiteral("borderSize"), value("border_size", "4").toDouble());
w.insert(QStringLiteral("border"), gradient(value("border_color", "0xFFDDDDDD")));
place(s, QStringLiteral("0,0"), screen, w);
} else {
continue;
}
placed.append({z, w});
}
if (!background) {
QVariantMap w = wallpaperOnly({});
placed.append({-1, w});
}
// face-unlock's line, as the menu puts it in (fu_hyprlock_text_on),
// for those who have not picked it yet.
if (!ourLine) {
QVariantMap w{{QStringLiteral("type"), QStringLiteral("label")},
{QStringLiteral("text"), i18n("Looking for your face…").toHtmlEscaped()},
{QStringLiteral("size"), 20.0},
{QStringLiteral("color"), QColor(255, 255, 255, 242)},
{QStringLiteral("align"), QStringLiteral("center")},
{QStringLiteral("shadow"), true},
{QStringLiteral("shadowSize"), 3.0},
{QStringLiteral("shadowColor"), QColor(Qt::black)},
{QStringLiteral("x"), 0.0},
{QStringLiteral("y"), -48.0},
{QStringLiteral("halign"), QStringLiteral("center")},
{QStringLiteral("valign"), QStringLiteral("top")},
{QStringLiteral("rotate"), 0.0}};
font(QStringLiteral("Sans"), w);
placed.append({10, w});
}
std::stable_sort(placed.begin(), placed.end(), [](const auto &a, const auto &b) {
return a.first < b.first;
});
QVariantList widgets;
for (const auto &p : std::as_const(placed)) {
widgets.append(p.second);
}
return widgets;
}
QVariantList LockPreview::swaylock(const QString &configPath, const QUrl &wallpaper)
{
// One option per line, as on the command line without the dashes.
QHash<QString, QString> o;
for (const QString &raw : readFile(configPath).split(u'\n')) {
const QString line = raw.trimmed();
if (line.isEmpty() || line.startsWith(u'#')) {
continue;
}
o.insert(line.section(u'=', 0, 0).trimmed(), line.contains(u'=') ? line.section(u'=', 1).trimmed() : QString());
}
// An image may name its output first: [[<output>]:]<path>.
QString image = o.value(QStringLiteral("image"));
if (image.contains(u':') && !image.startsWith(u'/') && !image.startsWith(u'~')) {
image = image.section(u':', 1);
}
QVariantMap bg = wallpaperOnly(o.contains(QStringLiteral("screenshots")) ? wallpaper
: image.isEmpty() ? QUrl()
: QUrl::fromLocalFile(expandHome(image)));
bg.insert(QStringLiteral("color"), hexColor(o.value(QStringLiteral("color")), Qt::white));
bg.insert(QStringLiteral("fill"), o.value(QStringLiteral("scaling"), QStringLiteral("fill")));
// swaylock-effects: effect-blur=<radius>x<times>.
const QString blur = o.value(QStringLiteral("effect-blur"));
if (!blur.isEmpty()) {
bg.insert(QStringLiteral("blur"), std::min(1.0, blur.section(u'x', 0, 0).toDouble() * blur.section(u'x', 1, 1).toDouble() / 24.0));
}
QVariantList widgets{bg};
// The ring shows only while typing, unless it is asked to stay.
const bool clock = o.contains(QStringLiteral("clock"));
if (o.contains(QStringLiteral("indicator")) || o.contains(QStringLiteral("indicator-idle-visible")) || clock) {
const QDateTime now = QDateTime::currentDateTime();
widgets.append(QVariantMap{
{QStringLiteral("type"), QStringLiteral("ring")},
{QStringLiteral("radius"), o.value(QStringLiteral("indicator-radius"), QStringLiteral("50")).toDouble()},
{QStringLiteral("thickness"), o.value(QStringLiteral("indicator-thickness"), QStringLiteral("10")).toDouble()},
{QStringLiteral("inside"), hexColor(o.value(QStringLiteral("inside-color")), QColor(0, 0, 0, 0xc0))},
{QStringLiteral("ring"), hexColor(o.value(QStringLiteral("ring-color")), QColor(0x33, 0x7d, 0x00))},
{QStringLiteral("textColor"), hexColor(o.value(QStringLiteral("text-color")), QColor(0xe5, 0xa4, 0x45))},
{QStringLiteral("text"), clock ? now.toString(QStringLiteral("HH:mm")) + u'\n' + now.toString(QStringLiteral("yyyy-MM-dd")) : QString()},
});
}
return widgets;
}
QVariantList LockPreview::widgets(const QString &locker, const QSizeF &screen, const QString &output, const QUrl &wallpaper)
{
if (locker == u"hyprlock") {
const QString path = findConfig(QStringLiteral("hypr/hyprlock.conf"));
if (!path.isEmpty()) {
return hyprlock(path, screen, output, wallpaper);
}
} else if (locker == u"swaylock") {
QString path = findConfig(QStringLiteral("swaylock/config"));
if (path.isEmpty() && QFileInfo::exists(QDir::homePath() + QStringLiteral("/.swaylock/config"))) {
path = QDir::homePath() + QStringLiteral("/.swaylock/config");
}
return swaylock(path, wallpaper);
}
// Something this does not know how to draw: the desktop's picture.
return {wallpaperOnly(wallpaper)};
}
+37
View File
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The user's own lock screen, rebuilt for its picture in the window that asks
// which lock screen to use (LockChoice.qml): what hyprlock draws from its
// config, with face-unlock's line at the top, or swaylock's background and
// ring. A screenshot would take locking the screen. LockPreview.qml draws
// what this returns.
//
// hyprlock places a widget from the corner or middle its halign and valign
// name, with y upwards, and takes font sizes as points. Colours and sizes
// here are hyprlang's: rgba(r, g, b, a), rgba(RRGGBBAA), 0xAARRGGBB, and
// "x, y" in pixels or percent of the screen.
#pragma once
#include <QColor>
#include <QSizeF>
#include <QString>
#include <QUrl>
#include <QVariantList>
namespace LockPreview
{
// The lock screen the user starts: the one their idle daemon or key runs,
// else one that is installed. hyprlock, swaylock, another name, or empty.
QString locker();
// That lock screen's widgets, bottom first, as maps for LockPreview.qml.
// output names the screen, for widgets meant for one monitor; wallpaper is
// the desktop's picture, for a background that is a screenshot.
QVariantList widgets(const QString &locker, const QSizeF &screen, const QString &output, const QUrl &wallpaper);
// The pieces, apart for the tests.
QVariantList hyprlock(const QString &configPath, const QSizeF &screen, const QString &output, const QUrl &wallpaper);
QVariantList swaylock(const QString &configPath, const QUrl &wallpaper);
QColor color(const QString &value);
}
+197
View File
@@ -0,0 +1,197 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "lockscreencontroller.h"
#include <KLocalizedString>
#include <QCoreApplication>
#include <QFile>
#include <QPointer>
#include <security/pam_appl.h>
#include <cstring>
#include <pwd.h>
#include <thread>
#include <unistd.h>
namespace
{
struct Conversation {
QByteArray password;
};
// Answers every hidden question with the password and every other one with
// nothing, as a lock screen that only asks for the password has to.
int converse(int count, const pam_message **messages, pam_response **responses, void *data)
{
auto *conversation = static_cast<Conversation *>(data);
auto *answers = static_cast<pam_response *>(calloc(size_t(count), sizeof(pam_response)));
if (!answers) {
return PAM_BUF_ERR;
}
for (int i = 0; i < count; ++i) {
if (messages[i]->msg_style == PAM_PROMPT_ECHO_OFF) {
answers[i].resp = strdup(conversation->password.constData());
} else if (messages[i]->msg_style == PAM_PROMPT_ECHO_ON) {
answers[i].resp = strdup("");
}
}
*responses = answers;
return PAM_SUCCESS;
}
QByteArray serviceName()
{
static const char *const services[] = {"face-unlock-lock", "password-auth", "common-auth", "login"};
static const char *const dirs[] = {"/etc/pam.d/", "/usr/lib/pam.d/", "/usr/share/pam.d/"};
for (const char *service : services) {
for (const char *dir : dirs) {
if (QFile::exists(QString::fromLatin1(dir) + QString::fromLatin1(service))) {
return service;
}
}
}
return "login";
}
bool checkPassword(const QByteArray &user, const QByteArray &password)
{
Conversation conversation{password};
const pam_conv conv{converse, &conversation};
pam_handle_t *pamh = nullptr;
// For development only, like the daemon's --socket: a directory of PAM
// files of its own, so a test never counts as a wrong password for the
// real account.
const QByteArray confdir = qgetenv("FU_PAM_CONFDIR");
int rc = confdir.isEmpty() ? pam_start(serviceName().constData(), user.constData(), &conv, &pamh)
: pam_start_confdir("face-unlock-lock", user.constData(), &conv, confdir.constData(), &pamh);
if (rc == PAM_SUCCESS) {
rc = pam_authenticate(pamh, 0);
if (rc == PAM_SUCCESS) {
pam_setcred(pamh, PAM_REFRESH_CRED);
}
}
pam_end(pamh, rc);
std::memset(conversation.password.data(), 0, size_t(conversation.password.size()));
return rc == PAM_SUCCESS;
}
passwd *me()
{
return getpwuid(getuid());
}
} // namespace
LockScreenController::LockScreenController(QObject *parent)
: QObject(parent)
{
}
LockScreenController::~LockScreenController()
{
clearPassword();
}
void LockScreenController::setPassword(const QString &password)
{
if (m_password == password) {
return;
}
m_password = password;
Q_EMIT passwordChanged();
if (!m_message.isEmpty()) {
m_message.clear();
Q_EMIT messageChanged();
}
}
void LockScreenController::setFaceUnlock(bool on)
{
if (m_faceUnlock != on) {
m_faceUnlock = on;
Q_EMIT faceUnlockChanged();
}
}
void LockScreenController::setBlur(bool blur)
{
if (m_blur != blur) {
m_blur = blur;
Q_EMIT blurChanged();
}
}
QString LockScreenController::userName() const
{
const passwd *pw = me();
if (!pw) {
return {};
}
// The full name from the comment field, else the login name.
const QString full = QString::fromLocal8Bit(pw->pw_gecos).section(u',', 0, 0).trimmed();
return full.isEmpty() ? QString::fromLocal8Bit(pw->pw_name) : full;
}
void LockScreenController::submit()
{
if (m_busy || m_password.isEmpty()) {
return;
}
const passwd *pw = me();
if (!pw) {
return;
}
m_busy = true;
Q_EMIT busyChanged();
const QByteArray user(pw->pw_name);
QByteArray password = m_password.toUtf8();
QPointer<LockScreenController> self(this);
std::thread([self, user, password]() mutable {
const bool ok = checkPassword(user, password);
std::memset(password.data(), 0, size_t(password.size()));
QMetaObject::invokeMethod(qApp, [self, ok] {
if (self) {
self->finish(ok);
}
});
}).detach();
}
void LockScreenController::finish(bool ok)
{
m_busy = false;
Q_EMIT busyChanged();
clearPassword();
Q_EMIT passwordChanged();
if (ok) {
Q_EMIT authenticated();
return;
}
m_message = i18n("Wrong password");
Q_EMIT messageChanged();
Q_EMIT rejected();
}
void LockScreenController::activity()
{
Q_EMIT input();
}
void LockScreenController::clearPassword()
{
// What is still in memory of it, as far as a QString lets that happen.
m_password.fill(u' ');
m_password.clear();
}
void LockScreenController::reset()
{
clearPassword();
Q_EMIT passwordChanged();
if (!m_message.isEmpty()) {
m_message.clear();
Q_EMIT messageChanged();
}
}
+86
View File
@@ -0,0 +1,86 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The own lock screen's side of things (see SessionLock): the password,
// whether it is being checked, what to say under it.
//
// The password is checked with PAM in a thread of its own, the way swaylock
// and hyprlock do it. The service is face-unlock-lock when an administrator
// has written one, else the distribution's plain password stack:
// password-auth (Fedora), common-auth (Debian, Ubuntu) or login. Not the face
// module: the face has its own way in here, the agent.
#pragma once
#include <QObject>
#include <QString>
class LockScreenController : public QObject
{
Q_OBJECT
Q_PROPERTY(QString password READ password WRITE setPassword NOTIFY passwordChanged)
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
// After a wrong password, until the next key.
Q_PROPERTY(QString message READ message NOTIFY messageChanged)
// Whether the face is looked for, which changes what the screen says.
Q_PROPERTY(bool faceUnlock READ faceUnlock NOTIFY faceUnlockChanged)
Q_PROPERTY(QString userName READ userName CONSTANT)
// Whether to blur the picture behind it.
Q_PROPERTY(bool blur READ blur NOTIFY blurChanged)
public:
explicit LockScreenController(QObject *parent = nullptr);
~LockScreenController() override;
QString password() const
{
return m_password;
}
void setPassword(const QString &password);
bool busy() const
{
return m_busy;
}
QString message() const
{
return m_message;
}
bool faceUnlock() const
{
return m_faceUnlock;
}
void setFaceUnlock(bool on);
QString userName() const;
bool blur() const
{
return m_blur;
}
void setBlur(bool blur);
// Check the password typed so far.
Q_INVOKABLE void submit();
// A key or the pointer on the lock screen.
Q_INVOKABLE void activity();
// A fresh lock: no password, nothing said.
void reset();
Q_SIGNALS:
void passwordChanged();
void busyChanged();
void messageChanged();
void faceUnlockChanged();
void blurChanged();
void authenticated();
// A wrong password, for the field to shake.
void rejected();
void input();
private:
void finish(bool ok);
void clearPassword();
QString m_password;
QString m_message;
bool m_busy = false;
bool m_faceUnlock = true;
bool m_blur = false;
};
+86
View File
@@ -0,0 +1,86 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "locktext.h"
#include "bubblecontroller.h"
#include "lockers.h"
#include "userconfig.h"
#include <KLocalizedString>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#include <QStandardPaths>
#include <QTimer>
#include <csignal>
LockText::LockText(BubbleController *bubble, UserConfig *config, QObject *parent)
: QObject(parent)
, m_bubble(bubble)
, m_config(config)
{
connect(m_bubble, &BubbleController::phaseChanged, this, &LockText::update);
connect(m_bubble, &BubbleController::messageChanged, this, &LockText::update);
connect(m_config, &UserConfig::changed, this, &LockText::update);
// Nothing left over from an agent before this one.
write({});
}
LockText::~LockText()
{
QFile::remove(path());
}
QString LockText::path()
{
return QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation) + QStringLiteral("/face-unlock/lock-text");
}
QString LockText::text(const QString &phase, const QString &message)
{
if (phase == u"scanning") {
return message.isEmpty() ? i18n("Looking for your face…") : message;
}
if (phase == u"success") {
return i18n("Face recognized");
}
// failure and lockout say why; hidden says nothing.
return phase == u"hidden" ? QString() : message;
}
void LockText::update()
{
const bool on = m_config->lockScreenStyle() == u"yours";
const QString now = on ? text(m_bubble->phase(), m_bubble->message()) : QString();
if (now != m_text) {
write(now);
// Left alone by those who did not pick it: their hyprlock has no
// label to read it.
m_tries = 0;
refresh();
}
}
void LockText::refresh()
{
// A hyprlock that is only starting reads the file once it is up, but
// may have read it just before this changed: asked again then.
if (Lockers::signal(SIGUSR2, "hyprlock") > 0 && ++m_tries < 20) {
QTimer::singleShot(250, this, &LockText::refresh);
}
}
void LockText::write(const QString &text)
{
m_text = text;
QDir().mkpath(QFileInfo(path()).absolutePath());
QSaveFile file(path());
// hyprlock reads labels as Pango markup.
if (file.open(QIODevice::WriteOnly)) {
file.write(text.toHtmlEscaped().toUtf8());
file.commit();
}
}
+37
View File
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The bubble as a line of text, for a lock screen that covers it: hyprlock.
// When the user keeps their own lock screen (LockScreenStyle=yours), the
// menu puts a label into hyprlock's config that shows the file at path()
// (see src/lib/system.sh). This writes what the bubble shows into it, and
// SIGUSR2 makes hyprlock read it again.
#pragma once
#include <QObject>
#include <QString>
class BubbleController;
class UserConfig;
class LockText : public QObject
{
Q_OBJECT
public:
LockText(BubbleController *bubble, UserConfig *config, QObject *parent = nullptr);
~LockText() override;
static QString path();
// The line for a phase and message of the bubble.
static QString text(const QString &phase, const QString &message);
private:
void update();
void refresh();
void write(const QString &text);
BubbleController *m_bubble;
UserConfig *m_config;
QString m_text;
int m_tries = 0;
};
+220
View File
@@ -0,0 +1,220 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "lockwatcher.h"
#include "lockers.h"
#include "sessionlock.h"
#include "wayland.h"
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDBusObjectPath>
#include <QDBusPendingCallWatcher>
#include <QDBusPendingReply>
#include <QDBusVariant>
#include <QProcess>
#include <csignal>
#include <unistd.h>
namespace
{
const QString Login1 = QStringLiteral("org.freedesktop.login1");
const QString SessionInterface = QStringLiteral("org.freedesktop.login1.Session");
const QString Properties = QStringLiteral("org.freedesktop.DBus.Properties");
constexpr int PollMs = 1000;
} // namespace
LockWatcher::LockWatcher(QObject *parent)
: QObject(parent)
{
QDBusConnection session = QDBusConnection::sessionBus();
session.connect(QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("/ScreenSaver"),
QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("ActiveChanged"),
this,
SLOT(onScreenSaver(bool)));
// Started while the screen is already locked (the agent restarted).
QDBusMessage get = QDBusMessage::createMethodCall(QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("/ScreenSaver"),
QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("GetActive"));
get.setAutoStartService(false);
auto *watcher = new QDBusPendingCallWatcher(session.asyncCall(get), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher] {
watcher->deleteLater();
const QDBusPendingReply<bool> reply = *watcher;
if (reply.isValid() && reply.value()) {
onScreenSaver(true);
}
});
findSession();
m_ownLockers = Wayland::hasGlobal("ext_session_lock_manager_v1");
if (m_ownLockers) {
connect(&m_poll, &QTimer::timeout, this, &LockWatcher::pollLockers);
m_poll.start(PollMs);
// Not from here: nobody is connected yet to hear about a lock screen
// that is already up.
QTimer::singleShot(0, this, &LockWatcher::pollLockers);
}
}
void LockWatcher::onScreenSaver(bool active)
{
m_screenSaver = active;
update();
}
void LockWatcher::findSession()
{
// Niri and some others hand their session on to user services. Without
// it, "auto" is the session on the display.
const QString id = qEnvironmentVariable("XDG_SESSION_ID");
if (!id.isEmpty()) {
QDBusMessage call = QDBusMessage::createMethodCall(Login1,
QStringLiteral("/org/freedesktop/login1"),
QStringLiteral("org.freedesktop.login1.Manager"),
QStringLiteral("GetSession"));
call << id;
auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::systemBus().asyncCall(call), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher] {
watcher->deleteLater();
const QDBusPendingReply<QDBusObjectPath> reply = *watcher;
watchSession(reply.isValid() ? reply.value().path() : QStringLiteral("/org/freedesktop/login1/session/auto"));
});
return;
}
QDBusMessage call = QDBusMessage::createMethodCall(Login1, QStringLiteral("/org/freedesktop/login1/session/auto"), Properties, QStringLiteral("Get"));
call << SessionInterface << QStringLiteral("Id");
auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::systemBus().asyncCall(call), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher] {
watcher->deleteLater();
const QDBusPendingReply<QDBusVariant> reply = *watcher;
if (!reply.isValid()) {
qWarning("logind knows no session for this agent: %s", qPrintable(reply.error().message()));
return;
}
// The signals come from the session's real path, not from "auto".
QDBusMessage call = QDBusMessage::createMethodCall(Login1,
QStringLiteral("/org/freedesktop/login1"),
QStringLiteral("org.freedesktop.login1.Manager"),
QStringLiteral("GetSession"));
call << reply.value().variant().toString();
auto *next = new QDBusPendingCallWatcher(QDBusConnection::systemBus().asyncCall(call), this);
connect(next, &QDBusPendingCallWatcher::finished, this, [this, next] {
next->deleteLater();
const QDBusPendingReply<QDBusObjectPath> path = *next;
if (path.isValid()) {
watchSession(path.value().path());
}
});
});
}
void LockWatcher::watchSession(const QString &path)
{
m_session = path;
QDBusConnection::systemBus().connect(Login1,
path,
Properties,
QStringLiteral("PropertiesChanged"),
this,
SLOT(onSessionProperties(QString, QVariantMap, QStringList)));
readLockedHint();
}
void LockWatcher::readLockedHint()
{
QDBusMessage call = QDBusMessage::createMethodCall(Login1, m_session, Properties, QStringLiteral("Get"));
call << SessionInterface << QStringLiteral("LockedHint");
auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::systemBus().asyncCall(call), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher] {
watcher->deleteLater();
const QDBusPendingReply<QDBusVariant> reply = *watcher;
if (reply.isValid()) {
m_lockedHint = reply.value().variant().toBool();
update();
}
});
}
void LockWatcher::onSessionProperties(const QString &interface, const QVariantMap &changed, const QStringList &invalidated)
{
if (interface != SessionInterface) {
return;
}
if (changed.contains(QStringLiteral("LockedHint"))) {
m_lockedHint = changed.value(QStringLiteral("LockedHint")).toBool();
update();
} else if (invalidated.contains(QStringLiteral("LockedHint"))) {
readLockedHint();
}
}
void LockWatcher::pollLockers()
{
m_lockerRunning = !Lockers::find().isEmpty();
const bool ready = Lockers::ready(SIGUSR1);
const bool became = ready && !m_lockerReady;
m_lockerReady = ready;
update();
if (became) {
Q_EMIT unlockable();
}
}
void LockWatcher::setOwnLock(SessionLock *lock)
{
m_own = lock;
connect(lock, &SessionLock::lockedChanged, this, &LockWatcher::update);
}
bool LockWatcher::canUnlock() const
{
return (m_own && m_own->isLocked()) || !m_ownLockers || m_lockerReady;
}
void LockWatcher::update()
{
const bool locked = m_screenSaver || m_lockedHint || m_lockerRunning || (m_own && m_own->isLocked());
if (locked != m_locked) {
m_locked = locked;
Q_EMIT lockedChanged(locked);
}
}
void LockWatcher::unlock()
{
if (m_own && m_own->isLocked()) {
m_own->unlock();
return;
}
// Looked up again rather than taken from the last poll: a second is
// long enough for a pid to belong to something else.
Lockers::signal(SIGUSR1);
const QDBusMessage call = QDBusMessage::createMethodCall(Login1,
m_session.isEmpty() ? QStringLiteral("/org/freedesktop/login1/session/auto") : m_session,
SessionInterface,
QStringLiteral("Unlock"));
auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::systemBus().asyncCall(call), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [watcher] {
watcher->deleteLater();
const QDBusPendingReply<> reply = *watcher;
if (reply.isError()) {
qWarning("logind would not unlock: %s; trying loginctl", qPrintable(reply.error().message()));
const QString id = qEnvironmentVariable("XDG_SESSION_ID");
QStringList args{QStringLiteral("unlock-session")};
if (!id.isEmpty()) {
args << id;
}
QProcess::startDetached(QStringLiteral("loginctl"), args);
}
});
}
+77
View File
@@ -0,0 +1,77 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Whether the screen is locked, and how to unlock it, on each desktop.
//
// Plasma org.freedesktop.ScreenSaver and logind's LockedHint. Unlocked
// through logind.
// GNOME logind's LockedHint. Unlocked through logind.
// Niri logind's LockedHint, which niri sets for any lock screen.
// Hyprland sets no LockedHint, so the lock screen is looked for among this
// user's processes (hyprlock, swaylock, gtklock) once a second.
// Only on compositors where the lock screen is a program of its
// own (ext-session-lock).
//
// hyprlock, swaylock and gtklock do not listen to logind. They unlock on
// SIGUSR1.
// face-unlock's own lock screen (SessionLock) is simply told to. Any other
// lock screen of that kind only opens itself: it gets the face through the
// PAM module in its own stack (see src/lib/pam.sh), when Enter is pressed.
//
// None of this lowers the bar: any program running as this user can already
// ask logind to unlock the session, or send its own lock screen a signal. The
// face data and the decision stay with the daemon, which runs as root.
#pragma once
#include <QObject>
#include <QTimer>
#include <QVariantMap>
#include <sys/types.h>
class SessionLock;
class LockWatcher : public QObject
{
Q_OBJECT
public:
explicit LockWatcher(QObject *parent = nullptr);
bool locked() const
{
return m_locked;
}
// Whether unlock() can open the lock screen that is up: always where
// logind unlocks (Plasma, GNOME), elsewhere only hyprlock, swaylock and
// gtklock, once they take SIGUSR1.
bool canUnlock() const;
void unlock();
void setOwnLock(SessionLock *lock);
Q_SIGNALS:
void lockedChanged(bool locked);
// canUnlock() came true: the lock screen that is up now takes SIGUSR1.
void unlockable();
private Q_SLOTS:
void onScreenSaver(bool active);
void onSessionProperties(const QString &interface, const QVariantMap &changed, const QStringList &invalidated);
private:
void findSession();
void watchSession(const QString &path);
void readLockedHint();
void pollLockers();
void update();
bool m_screenSaver = false;
bool m_lockedHint = false;
bool m_lockerRunning = false;
bool m_lockerReady = false;
bool m_locked = false;
// The compositor's lock screen is a program of its own.
bool m_ownLockers = false;
SessionLock *m_own = nullptr;
QString m_session;
QTimer m_poll;
};
+257 -10
View File
@@ -1,19 +1,34 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
// //
// plasma-face-unlock-agent: the part in the user's session. // face-unlock-agent: the part in the user's session.
// //
// (no arguments) stay in the background: unlock the lock screen by face, // (no arguments) stay in the background: unlock the lock screen by face,
// and show the bubble for every scan // and show the bubble for every scan
// --enroll open the window that sets up a face // --enroll open the window that sets up a face
// --lock lock the screen: with face-unlock's own lock screen
// where the lock screen is a program of its own (Hyprland,
// Niri), else through logind with the desktop's
// --demo play the bubble's animations once, for trying out a // --demo play the bubble's animations once, for trying out a
// style (--bubble-style minimal) and for screenshots // style (--bubble-style minimal) and for screenshots
// --choose-lock-screen
// open the window that asks which lock screen to use
// where it is a program of its own, and print the answer
// --has-own-lock for the menu: exits 0 when this build has face-unlock's
// own lock screen (see SessionLock::built)
#include "agentsocket.h" #include "agentsocket.h"
#include "bubblecontroller.h" #include "bubblecontroller.h"
#include "bubbleservice.h"
#include "bubblewindow.h" #include "bubblewindow.h"
#include "enrollcontroller.h" #include "enrollcontroller.h"
#include "lockcontroller.h" #include "lockcontroller.h"
#include "lockpreview.h"
#include "lockscreencontroller.h"
#include "locktext.h"
#include "sessionlock.h"
#include "userconfig.h" #include "userconfig.h"
#include "wallpaper.h"
#include "wayland.h"
#include "buildconfig.h" #include "buildconfig.h"
@@ -21,11 +36,20 @@
#include <KLocalizedString> #include <KLocalizedString>
#include <QCommandLineParser> #include <QCommandLineParser>
#include <QDBusConnection>
#include <QDBusMessage>
#include <QFile>
#include <QGuiApplication> #include <QGuiApplication>
#include <QQmlApplicationEngine> #include <QQmlApplicationEngine>
#include <QQmlEngine> #include <QQmlEngine>
#include <QScreen>
#include <QTimer> #include <QTimer>
#include <QWindow>
#include <cstdio>
#include <cstring>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h> #include <unistd.h>
namespace namespace
@@ -39,7 +63,7 @@ int runEnroll(QGuiApplication &app, const QString &name)
QQmlApplicationEngine engine; QQmlApplicationEngine engine;
KLocalization::setupLocalizedContext(&engine); KLocalization::setupLocalizedContext(&engine);
engine.setInitialProperties({{QStringLiteral("controller"), QVariant::fromValue(&controller)}}); engine.setInitialProperties({{QStringLiteral("controller"), QVariant::fromValue(&controller)}});
engine.loadFromModule(QStringLiteral("PlasmaFaceUnlock"), QStringLiteral("Enroll")); engine.loadFromModule(QStringLiteral("FaceUnlock"), QStringLiteral("Enroll"));
if (engine.rootObjects().isEmpty()) { if (engine.rootObjects().isEmpty()) {
return 2; return 2;
} }
@@ -53,6 +77,142 @@ int runEnroll(QGuiApplication &app, const QString &name)
return controller.state() == u"done" ? 0 : code; return controller.state() == u"done" ? 0 : code;
} }
// The window of --choose-lock-screen. Prints "own" or "yours" for the menu,
// which carries it out.
int runChooseLockScreen(QGuiApplication &app)
{
app.setQuitOnLastWindowClosed(true);
UserConfig config;
// Both choices show this screen as it would look: face-unlock's lock
// screen as it is, with a scan going on, and the user's own rebuilt.
const QScreen *screen = QGuiApplication::primaryScreen();
const QString output = screen ? screen->name() : QString();
const QSize size = screen ? screen->size() : QSize(1920, 1080);
const QUrl desktop = Wallpaper::pick(Wallpaper::forLock({}), output);
const QUrl ownWallpaper = config.lockWallpaper().isEmpty() ? desktop : Wallpaper::pick(Wallpaper::forLock(config.lockWallpaper()), output);
const QString locker = LockPreview::locker();
LockScreenController lock;
lock.setBlur(config.lockBlur());
BubbleController bubble(&config);
bubble.scanStarted();
// A scan that goes on: the bubble closes by itself after a while.
QTimer rescan;
QObject::connect(&rescan, &QTimer::timeout, &bubble, &BubbleController::scanStarted);
rescan.start(20000);
QQmlApplicationEngine engine;
KLocalization::setupLocalizedContext(&engine);
engine.setInitialProperties({{QStringLiteral("current"), config.lockScreenStyle()},
{QStringLiteral("locker"), locker},
{QStringLiteral("widgets"), LockPreview::widgets(locker, size, output, desktop)},
{QStringLiteral("screenSize"), size},
{QStringLiteral("ownWallpaper"), ownWallpaper},
{QStringLiteral("lock"), QVariant::fromValue(&lock)},
{QStringLiteral("bubble"), QVariant::fromValue(&bubble)}});
engine.loadFromModule(QStringLiteral("FaceUnlock"), QStringLiteral("LockChoice"));
auto *window = engine.rootObjects().isEmpty() ? nullptr : qobject_cast<QWindow *>(engine.rootObjects().constFirst());
if (!window) {
return 2;
}
// Shown only now, at the size its texts need (see LockChoice.qml).
window->show();
app.exec();
const QString answer = window->property("answer").toString();
if (answer.isEmpty()) {
return 1;
}
std::printf("%s\n", qPrintable(answer));
return 0;
}
// Where --lock tells the command that ran it that the screen is locked, when
// it forked to stay behind as the lock screen. -1: it did not.
int readyFd = -1;
void signalReady(bool ok)
{
if (readyFd >= 0) {
if (ok) {
[[maybe_unused]] const ssize_t n = write(readyFd, "1", 1);
}
close(readyFd);
readyFd = -1;
}
}
// Whether an agent listens on its socket. Asked before Qt is up, to decide
// whether to fork.
bool agentListening()
{
const char *runtime = getenv("XDG_RUNTIME_DIR");
if (!runtime) {
return false;
}
sockaddr_un addr{};
addr.sun_family = AF_UNIX;
const int len = snprintf(addr.sun_path, sizeof(addr.sun_path), "%s/face-unlock/agent.socket", runtime);
if (len <= 0 || size_t(len) >= sizeof(addr.sun_path)) {
return false;
}
const int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
const bool ok = fd >= 0 && connect(fd, reinterpret_cast<sockaddr *>(&addr), sizeof(addr)) == 0;
if (fd >= 0) {
close(fd);
}
return ok;
}
// With no agent running, --lock stays behind as the lock screen until it is
// unlocked. The command that asked for it returns as soon as the screen is
// locked, the way swaylock -f does: an idle daemon locking before sleep
// waits for that, and not a moment longer.
void forkForLock(int argc, char **argv)
{
bool lock = false;
for (int i = 1; i < argc; ++i) {
lock = lock || std::strcmp(argv[i], "--lock") == 0;
}
int fds[2];
if (!lock || agentListening() || pipe(fds) != 0) {
return;
}
const pid_t pid = fork();
if (pid < 0) {
close(fds[0]);
close(fds[1]);
return;
}
if (pid > 0) {
close(fds[1]);
char c = 0;
const bool locked = read(fds[0], &c, 1) == 1;
_exit(locked ? 0 : 1);
}
close(fds[0]);
setsid();
readyFd = fds[1];
}
// Plasma and GNOME lock with their own lock screen when logind asks them to,
// as `loginctl lock-session` does.
int lockThroughLogind()
{
const QString id = qEnvironmentVariable("XDG_SESSION_ID");
QDBusMessage call = QDBusMessage::createMethodCall(QStringLiteral("org.freedesktop.login1"),
QStringLiteral("/org/freedesktop/login1"),
QStringLiteral("org.freedesktop.login1.Manager"),
QStringLiteral("LockSession"));
call << (id.isEmpty() ? QStringLiteral("auto") : id);
const QDBusMessage reply = QDBusConnection::systemBus().call(call);
if (reply.type() == QDBusMessage::ErrorMessage) {
qWarning("logind would not lock: %s", qPrintable(reply.errorMessage()));
return 1;
}
return 0;
}
// The whole life of a bubble, twice: a face that is recognised after a blink, // The whole life of a bubble, twice: a face that is recognised after a blink,
// then one that is not. // then one that is not.
void scheduleDemo(BubbleController *bubble) void scheduleDemo(BubbleController *bubble)
@@ -75,24 +235,34 @@ void scheduleDemo(BubbleController *bubble)
int main(int argc, char **argv) int main(int argc, char **argv)
{ {
if (argc == 2 && std::strcmp(argv[1], "--has-own-lock") == 0) {
return SessionLock::built ? 0 : 1;
}
forkForLock(argc, argv);
QGuiApplication app(argc, argv); QGuiApplication app(argc, argv);
app.setApplicationName(QStringLiteral("plasma-face-unlock-agent")); app.setApplicationName(QStringLiteral("face-unlock-agent"));
app.setApplicationVersion(QStringLiteral(PFU_VERSION)); app.setApplicationVersion(QStringLiteral(FU_VERSION));
app.setDesktopFileName(QStringLiteral("io.github.loonixtools.plasma-face-unlock-agent")); app.setDesktopFileName(QStringLiteral("io.github.loonixtools.face-unlock-agent"));
KLocalizedString::setApplicationDomain(PFU_NAME); KLocalizedString::setApplicationDomain(FU_NAME);
QCommandLineParser parser; QCommandLineParser parser;
parser.setApplicationDescription(i18n("Face unlock for KDE Plasma")); parser.setApplicationDescription(i18n("Face unlock for the lock screen, sudo and admin prompts"));
parser.addHelpOption(); parser.addHelpOption();
parser.addVersionOption(); parser.addVersionOption();
const QCommandLineOption enrollOpt(QStringLiteral("enroll"), i18n("Set up a face.")); const QCommandLineOption enrollOpt(QStringLiteral("enroll"), i18n("Set up a face."));
const QCommandLineOption nameOpt(QStringLiteral("name"), i18n("What to call the new face."), QStringLiteral("name")); const QCommandLineOption nameOpt(QStringLiteral("name"), i18n("What to call the new face."), QStringLiteral("name"));
const QCommandLineOption lockOpt(QStringLiteral("lock"), i18n("Lock the screen."));
const QCommandLineOption demoOpt(QStringLiteral("demo"), i18n("Play the bubble's animations once.")); const QCommandLineOption demoOpt(QStringLiteral("demo"), i18n("Play the bubble's animations once."));
// Not "--style": QGuiApplication takes that one for itself. // Not "--style": QGuiApplication takes that one for itself.
const QCommandLineOption styleOpt(QStringLiteral("bubble-style"), i18n("Bubble style for the demo: full or minimal."), QStringLiteral("style")); const QCommandLineOption styleOpt(QStringLiteral("bubble-style"), i18n("Bubble style for the demo: full or minimal."), QStringLiteral("style"));
parser.addOptions({enrollOpt, nameOpt, demoOpt, styleOpt}); const QCommandLineOption chooseOpt(QStringLiteral("choose-lock-screen"), i18n("Ask which lock screen to use, and print the answer."));
parser.addOptions({enrollOpt, nameOpt, lockOpt, demoOpt, styleOpt, chooseOpt});
parser.process(app); parser.process(app);
if (parser.isSet(chooseOpt)) {
return runChooseLockScreen(app);
}
if (parser.isSet(enrollOpt)) { if (parser.isSet(enrollOpt)) {
QString name = parser.value(nameOpt); QString name = parser.value(nameOpt);
if (name.isEmpty()) { if (name.isEmpty()) {
@@ -101,6 +271,13 @@ int main(int argc, char **argv)
return runEnroll(app, name); return runEnroll(app, name);
} }
const bool lockNow = parser.isSet(lockOpt);
if (lockNow && !SessionLock::available()) {
const int rc = lockThroughLogind();
signalReady(rc == 0);
return rc;
}
app.setQuitOnLastWindowClosed(false); app.setQuitOnLastWindowClosed(false);
QQmlEngine engine; QQmlEngine engine;
KLocalization::setupLocalizedContext(&engine); KLocalization::setupLocalizedContext(&engine);
@@ -110,17 +287,87 @@ int main(int argc, char **argv)
config.overrideStyle(parser.value(styleOpt)); config.overrideStyle(parser.value(styleOpt));
} }
BubbleController bubble(&config); BubbleController bubble(&config);
BubbleWindow window(&engine, &bubble); // The bubble floats above everything as a layer-shell surface. GNOME has
// none, and a normal window cannot stay on top or pick its place: there
// face-unlock's GNOME Shell extension draws it, from what this tells it.
std::unique_ptr<BubbleWindow> window;
if (Wayland::hasGlobal("zwlr_layer_shell_v1")) {
window = std::make_unique<BubbleWindow>(&engine, &bubble);
}
if (parser.isSet(demoOpt)) { if (parser.isSet(demoOpt)) {
if (!window) {
qWarning("this desktop has no layer-shell; on GNOME the extension draws the bubble");
return 1;
}
scheduleDemo(&bubble); scheduleDemo(&bubble);
return app.exec(); return app.exec();
} }
AgentSocket socket; AgentSocket socket;
if (AgentSocket::running()) {
if (lockNow) {
return AgentSocket::requestLock() ? 0 : 1;
}
qInfo("an agent is already running in this session");
return 0;
}
socket.listen(); socket.listen();
QObject::connect(&socket, &AgentSocket::scanEvent, &bubble, &BubbleController::daemonEvent); QObject::connect(&socket, &AgentSocket::scanEvent, &bubble, &BubbleController::daemonEvent);
std::unique_ptr<BubbleService> service;
if (!window) {
service = std::make_unique<BubbleService>(&bubble);
}
LockController lock(&bubble, &config); LockScreenController screen;
std::unique_ptr<SessionLock> sessionLock;
if (SessionLock::available()) {
sessionLock = std::make_unique<SessionLock>(&engine, &bubble, &screen, &config);
SessionLock *lock = sessionLock.get();
QObject::connect(&socket, &AgentSocket::lockRequested, lock, [lock, &socket] {
lock->lock();
if (lock->isConfirmed()) {
socket.confirmLock();
}
});
QObject::connect(lock, &SessionLock::confirmed, &socket, &AgentSocket::confirmLock);
}
LockController lock(&bubble, &config, sessionLock.get(), &screen);
// hyprlock covers the bubble: it can show it as a line of text instead.
std::unique_ptr<LockText> text;
if (sessionLock && !lockNow) {
text = std::make_unique<LockText>(&bubble, &config);
}
if (lockNow) {
// No agent was running, so this one is only here for the lock. It
// goes once the lock is gone and the bubble has finished.
QObject::connect(sessionLock.get(), &SessionLock::lockedChanged, &app, [&bubble](bool locked) {
if (locked) {
return;
}
if (!bubble.shown()) {
QCoreApplication::quit();
}
QObject::connect(&bubble, &BubbleController::shownChanged, qApp, [&bubble] {
if (!bubble.shown()) {
QCoreApplication::quit();
}
});
QTimer::singleShot(5000, qApp, &QCoreApplication::quit);
});
QObject::connect(sessionLock.get(), &SessionLock::confirmed, &app, [] {
signalReady(true);
});
sessionLock->lock();
if (!sessionLock->isLocked()) {
signalReady(false);
return 1;
}
} else if (sessionLock && QFile::exists(SessionLock::markerPath())) {
// The last agent went away with the screen locked, and the compositor
// kept it locked. Take the lock back, so it can be opened again.
sessionLock->lock();
}
return app.exec(); return app.exec();
} }
+8 -3
View File
@@ -6,17 +6,22 @@
import QtQuick import QtQuick
import QtQuick.Window import QtQuick.Window
import PlasmaFaceUnlock import FaceUnlock
Window { Window {
id: window id: window
required property var controller required property var controller
// One fixed size: the layout needs no more room, and tiling compositors
// (Hyprland, Niri) float a window that cannot be resized instead of
// stretching it over half the screen.
width: 520 width: 520
height: 680 height: 680
minimumWidth: 460 minimumWidth: width
minimumHeight: 620 maximumWidth: width
minimumHeight: height
maximumHeight: height
visible: true visible: true
color: Theme.panel color: Theme.panel
title: i18n("Set up Face Unlock") title: i18n("Set up Face Unlock")
+264
View File
@@ -0,0 +1,264 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Where the lock screen is a program of the user's (Hyprland, Niri): lock
// with face-unlock's own, with the bubble, or keep that program, which then
// shows a line of text (hyprlock). Each choice shows the user's screen as it
// would look: face-unlock's lock screen live, and their own rebuilt from its
// config (see LockPreview). The pick goes back to the menu, which carries
// it out.
import QtQuick
import QtQuick.Effects
import QtQuick.Window
import FaceUnlock
Window {
id: window
// "own", "yours", or empty when there is none yet.
required property string current
// The user's lock screen (see LockPreview::locker) and what it shows.
required property string locker
required property var widgets
// The screen the pictures show, and the parts of face-unlock's own.
required property size screenSize
required property url ownWallpaper
required property var lock
required property var bubble
// What was picked, empty when the window was closed.
property string answer: ""
property string selected: current
function pick() {
if (selected.length > 0) {
answer = selected
close()
}
}
// One fixed size, so tiling compositors float it (see Enroll). The
// height follows the texts, whose length depends on the language. It
// is taken from them rather than from the Row and Column, which only lay
// out once the window shows, and the agent shows it after that.
readonly property int fitHeight: Math.ceil(36 + heading.height + 10 + intro.height + 28 + Math.max(own.needed, yours.needed) + 94)
width: 800
height: fitHeight
minimumWidth: 800
maximumWidth: 800
minimumHeight: fitHeight
maximumHeight: fitHeight
visible: false
color: Theme.panel
title: i18n("Choose your lock screen")
Component {
id: ownScene
LockScreen {
lock: window.lock
bubble: window.bubble
primary: true
wallpaper: window.ownWallpaper
interactive: false
}
}
Component {
id: yoursScene
LockPreview {
widgets: window.widgets
}
}
// One of the two: the screen, a radio button with the name, what it means.
component Choice: Rectangle {
id: card
required property string style
required property Component scene
required property string name
required property string about
readonly property bool chosen: window.selected === style
// The height its text needs; both take the larger one.
readonly property real needed: body.y + body.height + 20
width: 364
radius: 18
color: area.containsMouse && !chosen ? Qt.lighter(Theme.surface, 1.25) : Theme.surface
border.width: 2
border.color: chosen ? Theme.accent : "transparent"
Behavior on border.color { ColorAnimation { duration: 150 } }
Behavior on color { ColorAnimation { duration: 120 } }
// The whole screen, made small: filling the picture, cut at the
// sides or at the top and bottom.
Item {
id: picture
x: 14
y: 14
width: parent.width - 28
height: Math.round(width * 10 / 16)
clip: true
layer.enabled: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: mask
}
Loader {
sourceComponent: card.scene
width: window.screenSize.width
height: window.screenSize.height
transformOrigin: Item.TopLeft
scale: Math.max(picture.width / width, picture.height / height)
x: (picture.width - width * scale) / 2
y: (picture.height - height * scale) / 2
}
}
Rectangle {
id: mask
width: picture.width
height: picture.height
radius: 10
visible: false
layer.enabled: true
}
// A radio button: a ring, filled when picked.
Rectangle {
id: radio
x: 18
y: title.y + 3
width: 18
height: 18
radius: 9
color: "transparent"
border.width: 2
border.color: card.chosen ? Theme.accent : Theme.textSecondary
Rectangle {
anchors.centerIn: parent
width: 8
height: 8
radius: 4
color: Theme.accent
visible: card.chosen
}
}
Text {
id: title
anchors.top: picture.bottom
anchors.topMargin: 16
x: radio.x + radio.width + 10
width: parent.width - x - 18
wrapMode: Text.WordWrap
color: Theme.textPrimary
font.pixelSize: 16
font.weight: Font.DemiBold
text: card.name
}
Text {
id: body
anchors.top: title.bottom
anchors.topMargin: 8
x: 18
width: parent.width - 36
wrapMode: Text.WordWrap
color: Theme.textDetail
font.pixelSize: 13
lineHeight: 1.15
text: card.about
}
MouseArea {
id: area
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: window.selected = card.style
onDoubleClicked: {
window.selected = card.style
window.pick()
}
}
}
Column {
id: head
anchors.top: parent.top
anchors.topMargin: 36
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - 96
spacing: 10
Text {
id: heading
width: parent.width
horizontalAlignment: Text.AlignHCenter
color: Theme.textPrimary
font.pixelSize: 26
font.weight: Font.Bold
text: i18n("Your lock screen")
}
Text {
id: intro
width: parent.width
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
color: Theme.textSecondary
font.pixelSize: 13
font.weight: Font.Medium
lineHeight: 1.15
text: i18n("Your desktop leaves the lock screen to a program of your choice. Pick how face unlock shows up there. You can change it later under Settings.")
}
}
Row {
id: cards
anchors.top: head.bottom
anchors.topMargin: 28
anchors.horizontalCenter: parent.horizontalCenter
spacing: 24
focus: true
Keys.onLeftPressed: window.selected = "own"
Keys.onRightPressed: window.selected = "yours"
Keys.onReturnPressed: window.pick()
Keys.onEnterPressed: window.pick()
Keys.onEscapePressed: window.close()
Choice {
id: own
height: Math.max(own.needed, yours.needed)
style: "own"
scene: ownScene
name: i18n("face-unlock's lock screen")
about: i18n("The bubble at the top, the time and your wallpaper. Your shortcut and idle lock then run face-unlock lock.")
}
Choice {
id: yours
height: own.height
style: "yours"
scene: yoursScene
name: i18n("Keep your lock screen")
about: window.locker === "hyprlock"
? i18n("It stays as it is. hyprlock shows a line of text at the top instead of the bubble. Enter on the empty password field scans.")
: i18n("It stays as it is, without the bubble. Enter on the empty password field scans.")
}
}
Row {
anchors.bottom: parent.bottom
anchors.bottomMargin: 32
anchors.horizontalCenter: parent.horizontalCenter
spacing: 12
PillButton {
primary: false
text: i18n("Cancel")
onClicked: window.close()
}
PillButton {
enabled: window.selected.length > 0
text: i18n("Continue")
onClicked: window.pick()
}
}
}
+249
View File
@@ -0,0 +1,249 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The user's own lock screen, drawn from what LockPreview (C++) read out of
// its config, at the size of the screen. Whoever shows it scales it down.
// Placement as in hyprlock: from the side or middle halign and valign name,
// x to the right and y upwards. One Repeater per kind of widget, stacked in
// the order hyprlock draws them.
import QtQuick
import QtQuick.Effects
Item {
id: root
required property var widgets
readonly property var numbered: widgets.map((w, i) => Object.assign({order: i}, w))
function only(type) {
return numbered.filter(w => w.type === type)
}
function alignX(halign, size, offset) {
return halign === "center" ? (width - size) / 2 + offset
: halign === "right" ? width - size + offset
: offset
}
function alignY(valign, size, offset) {
const up = valign === "center" ? (height - size) / 2 + offset
: valign === "top" ? height - size + offset
: offset
return height - up - size
}
clip: true
Repeater {
model: root.only("background")
Item {
id: back
required property var modelData
readonly property var w: modelData
z: w.order
width: root.width
height: root.height
Rectangle {
anchors.fill: parent
color: back.w.color
}
Image {
// Past the edges when blurred: the blur would pull in the
// nothing beyond them and darken the rim.
anchors.fill: parent
anchors.margins: back.w.blur > 0 ? -Math.round(64 * back.w.blur) : 0
visible: back.w.fill !== "solid_color"
source: back.w.source
asynchronous: true
sourceSize: Qt.size(root.width, root.height)
fillMode: back.w.fill === "fit" ? Image.PreserveAspectFit
: back.w.fill === "stretch" ? Image.Stretch
: back.w.fill === "center" ? Image.Pad
: back.w.fill === "tile" ? Image.Tile
: Image.PreserveAspectCrop
layer.enabled: back.w.blur > 0
layer.effect: MultiEffect {
blurEnabled: true
blur: 1
blurMax: Math.round(64 * back.w.blur)
}
}
Rectangle {
anchors.fill: parent
color: "black"
opacity: back.w.dim
}
}
}
Repeater {
model: root.only("label")
Text {
id: line
required property var modelData
readonly property var w: modelData
z: w.order
x: root.alignX(w.halign, width, w.x)
y: root.alignY(w.valign, height, w.y)
rotation: w.rotate
text: w.text
textFormat: Text.StyledText
color: w.color
font.family: w.family
font.weight: w.weight
font.italic: w.italic
font.pointSize: w.size
horizontalAlignment: w.align === "left" ? Text.AlignLeft : w.align === "right" ? Text.AlignRight : Text.AlignHCenter
layer.enabled: w.shadow
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: line.w.shadowColor
shadowBlur: Math.min(1, line.w.shadowSize / 8)
shadowHorizontalOffset: 0
shadowVerticalOffset: 0
}
}
}
Repeater {
model: root.only("input")
// The outline is the outer colour around the inner one. A gradient
// outline needs an opaque inside, which leaves it showing only at
// the edge.
Rectangle {
id: field
required property var modelData
readonly property var w: modelData
readonly property var outer: w.outer.colors
readonly property bool blend: outer.length > 1 && w.inner.a >= 0.99
z: w.order
x: root.alignX(w.halign, width, w.x)
y: root.alignY(w.valign, height, w.y)
rotation: w.rotate
width: w.width
height: w.height
radius: w.rounding < 0 ? height / 2 : Math.min(w.rounding, height / 2)
color: blend ? "transparent" : w.inner
border.width: blend ? 0 : w.thickness
border.color: outer.length > 0 ? outer[0] : "transparent"
gradient: blend ? outline : null
Gradient {
id: outline
orientation: Math.abs(Math.cos(field.w.outer.angle * Math.PI / 180)) >= Math.abs(Math.sin(field.w.outer.angle * Math.PI / 180))
? Gradient.Horizontal : Gradient.Vertical
GradientStop { position: 0; color: field.outer.length > 0 ? field.outer[0] : "transparent" }
GradientStop { position: 1; color: field.outer.length > 0 ? field.outer[field.outer.length - 1] : "transparent" }
}
Rectangle {
visible: field.blend
anchors.fill: parent
anchors.margins: field.w.thickness
radius: Math.max(0, field.radius - field.w.thickness)
color: field.w.inner
}
Text {
anchors.centerIn: parent
text: field.w.placeholder
textFormat: Text.StyledText
color: field.w.fontColor
font.family: field.w.family
font.weight: field.w.weight
font.italic: field.w.italic
font.pointSize: Math.max(1, field.height / 4)
}
}
}
Repeater {
model: root.only("shape")
Rectangle {
required property var modelData
readonly property var w: modelData
z: w.order
x: root.alignX(w.halign, width, w.x)
y: root.alignY(w.valign, height, w.y)
rotation: w.rotate
width: w.width
height: w.height
radius: w.rounding < 0 ? Math.min(width, height) / 2 : Math.min(w.rounding, Math.min(width, height) / 2)
color: w.color
border.width: w.borderSize
border.color: w.border.colors.length > 0 ? w.border.colors[0] : "transparent"
}
}
Repeater {
model: root.only("image")
Item {
id: frame
required property var modelData
readonly property var w: modelData
readonly property real round: w.rounding < 0 ? width / 2 : Math.min(w.rounding, width / 2)
z: w.order
x: root.alignX(w.halign, width, w.x)
y: root.alignY(w.valign, height, w.y)
rotation: w.rotate
width: w.size + 2 * w.borderSize
height: width
Rectangle {
anchors.fill: parent
radius: frame.round
color: frame.w.border.colors.length > 0 ? frame.w.border.colors[0] : "transparent"
}
Image {
id: photo
anchors.fill: parent
anchors.margins: frame.w.borderSize
source: frame.w.source
fillMode: Image.PreserveAspectCrop
asynchronous: true
layer.enabled: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: photoMask
}
}
Rectangle {
id: photoMask
width: photo.width
height: photo.height
radius: Math.max(0, frame.round - frame.w.borderSize)
visible: false
layer.enabled: true
}
}
}
Repeater {
model: root.only("ring")
// swaylock's indicator, in the middle.
Rectangle {
id: circle
required property var modelData
readonly property var w: modelData
z: w.order
anchors.centerIn: parent
width: 2 * w.radius + 2 * w.thickness
height: width
radius: width / 2
color: w.inside
border.width: w.thickness
border.color: w.ring
Text {
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
text: circle.w.text
color: circle.w.textColor
font.pixelSize: circle.w.radius / 3
}
}
}
}
+213
View File
@@ -0,0 +1,213 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// face-unlock's own lock screen, one per screen (see SessionLock): the time,
// the password, and on the main screen the bubble at the top, the same one
// that shows over the desktop. Behind it the desktop's picture, or the one
// the user set (see Wallpaper).
import QtQuick
import QtQuick.Effects
Item {
id: root
required property var lock
required property var bubble
required property bool primary
required property url wallpaper
// Off for the picture in the window that asks which lock screen to use:
// nothing to type into, no keys taken.
property bool interactive: true
property date now: new Date()
Timer {
interval: 1000
running: true
repeat: true
onTriggered: root.now = new Date()
}
Rectangle {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0; color: "#11161D" }
GradientStop { position: 0.6; color: "#000000" }
}
}
// Loaded aside, so a big picture does not hold up the lock, and faded in.
Item {
anchors.fill: parent
opacity: picture.status === Image.Ready ? 1 : 0
visible: opacity > 0
Behavior on opacity {
NumberAnimation { duration: 200 }
}
Image {
id: picture
// Past the edges when blurred: the blur would pull in the nothing
// beyond them and darken the rim.
anchors.fill: parent
anchors.margins: root.lock.blur ? -64 : 0
source: root.wallpaper
fillMode: Image.PreserveAspectCrop
sourceSize: Qt.size(root.width, root.height)
asynchronous: true
cache: false
layer.enabled: root.lock.blur
layer.effect: MultiEffect {
blurEnabled: true
blur: 1
blurMax: 64
}
}
// Dimmed a little, so the time and the password read on any picture.
Rectangle {
anchors.fill: parent
color: "#000000"
opacity: 0.35
}
}
// Any touch counts as somebody being back, and a click anywhere puts the
// keys into the password.
MouseArea {
anchors.fill: parent
enabled: root.interactive
hoverEnabled: true
acceptedButtons: Qt.AllButtons
onPositionChanged: root.lock.activity()
onPressed: {
root.lock.activity()
field.forceActiveFocus()
}
}
// Below the open bubble, which hangs from the top edge.
Column {
anchors.horizontalCenter: parent.horizontalCenter
y: Math.max(Theme.topGap + Theme.openHeight + 24, Math.round(parent.height * 0.2))
spacing: 2
Text {
anchors.horizontalCenter: parent.horizontalCenter
color: Theme.textPrimary
font.pixelSize: Math.max(56, Math.min(120, Math.round(root.height * 0.11)))
font.weight: Font.Light
text: Qt.formatTime(root.now, Qt.locale().timeFormat(Locale.ShortFormat))
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
color: Theme.textDetail
font.pixelSize: 18
font.weight: Font.Medium
text: Qt.formatDate(root.now, Qt.locale().dateFormat(Locale.LongFormat))
}
}
Column {
anchors.horizontalCenter: parent.horizontalCenter
y: Math.round(parent.height * 0.62)
spacing: 12
Text {
anchors.horizontalCenter: parent.horizontalCenter
color: Theme.textPrimary
font.pixelSize: 17
font.weight: Font.DemiBold
text: root.lock.userName
}
Rectangle {
id: pill
anchors.horizontalCenter: parent.horizontalCenter
width: 280
height: 44
radius: height / 2
color: Theme.surfaceRaised
opacity: root.lock.busy ? 0.6 : 1
border.width: field.activeFocus ? 1 : 0
border.color: Qt.rgba(1, 1, 1, 0.18)
property real shake: 0
transform: Translate { x: pill.shake }
SequentialAnimation {
id: shakeAnimation
NumberAnimation { target: pill; property: "shake"; to: -10; duration: 55; easing.type: Easing.OutQuad }
NumberAnimation { target: pill; property: "shake"; to: 9; duration: 70 }
NumberAnimation { target: pill; property: "shake"; to: -6; duration: 65 }
NumberAnimation { target: pill; property: "shake"; to: 4; duration: 60 }
NumberAnimation { target: pill; property: "shake"; to: 0; duration: 55; easing.type: Easing.OutQuad }
}
TextInput {
id: field
anchors.fill: parent
anchors.leftMargin: 20
anchors.rightMargin: 20
verticalAlignment: TextInput.AlignVCenter
horizontalAlignment: TextInput.AlignHCenter
echoMode: TextInput.Password
passwordCharacter: "●"
color: Theme.textPrimary
selectionColor: Theme.accent
font.pixelSize: 15
clip: true
focus: root.interactive
enabled: root.interactive
// The dots show how far it is; a blinking bar in the middle
// of the empty field only cuts the word in it in two.
cursorDelegate: Item {}
readOnly: root.lock.busy
text: root.lock.password
onTextEdited: root.lock.password = text
onAccepted: root.lock.submit()
Keys.onPressed: event => {
root.lock.activity()
event.accepted = false
}
Component.onCompleted: {
if (root.interactive) {
forceActiveFocus()
}
}
}
Text {
anchors.centerIn: parent
color: Theme.textSecondary
font.pixelSize: 15
text: i18n("Password")
visible: field.text.length === 0
}
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
width: 360
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
color: root.lock.message.length > 0 ? Theme.failure : Theme.textDetail
font.pixelSize: 13
font.weight: Font.Medium
text: root.lock.message.length > 0 ? root.lock.message
: root.lock.faceUnlock ? i18n("Look at the camera or type your password")
: i18n("Type your password")
}
}
Connections {
target: root.lock
function onRejected() {
shakeAnimation.restart()
}
}
// The bubble, where it shows over the desktop too.
Bubble {
visible: root.primary
anchors.horizontalCenter: parent.horizontalCenter
y: 0
bubble: root.bubble
}
}
+296
View File
@@ -0,0 +1,296 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "sessionlock.h"
#include "bubblecontroller.h"
#include "lockscreencontroller.h"
#include "userconfig.h"
#include "wallpaper.h"
#include "wayland.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QGuiApplication>
#include <QQuickView>
#include <QScreen>
#include <QStandardPaths>
#include <QUrl>
#include <QtWaylandClient/private/qwaylanddisplay_p.h>
#include <QtWaylandClient/private/qwaylandintegration_p.h>
#include <QtWaylandClient/private/qwaylandscreen_p.h>
#include <QtWaylandClient/private/qwaylandshellintegration_p.h>
#include <QtWaylandClient/private/qwaylandshellsurface_p.h>
#include <QtWaylandClient/private/qwaylandsurface_p.h>
#include <QtWaylandClient/private/qwaylandwindow_p.h>
#include "qwayland-ext-session-lock-v1.h"
// The manager, and through it the role every lock window gets.
class LockIntegration : public QtWaylandClient::QWaylandShellIntegrationTemplate<LockIntegration>, public QtWayland::ext_session_lock_manager_v1
{
public:
LockIntegration()
: QWaylandShellIntegrationTemplate<LockIntegration>(1)
{
}
~LockIntegration() override
{
if (object()) {
destroy();
}
}
QtWaylandClient::QWaylandShellSurface *createShellSurface(QtWaylandClient::QWaylandWindow *window) override;
// The lock the surfaces belong to.
::ext_session_lock_v1 *current = nullptr;
};
// One screen's lock surface. Like LayerShellQt's layer surface: the size
// comes from the compositor, and nothing is drawn before it has sent one.
class LockSurface : public QtWaylandClient::QWaylandShellSurface, public QtWayland::ext_session_lock_surface_v1
{
public:
LockSurface(::ext_session_lock_v1 *lock, QtWaylandClient::QWaylandWindow *window)
: QWaylandShellSurface(window)
{
init(ext_session_lock_v1_get_lock_surface(lock, window->waylandSurface()->object(), window->waylandScreen()->output()));
}
~LockSurface() override
{
destroy();
}
bool isExposed() const override
{
return m_configured;
}
#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0)
// Qt commits a new role at once, as xdg-shell wants. A lock surface must
// not be committed before it has acknowledged its first size. Older Qt
// cannot be stopped from that (see SessionLock::built).
bool commitSurfaceRole() const override
{
return false;
}
#endif
void applyConfigure() override
{
window()->resizeFromApplyConfigure(m_size);
}
protected:
void ext_session_lock_surface_v1_configure(uint32_t serial, uint32_t width, uint32_t height) override
{
ack_configure(serial);
m_size = QSize(int(width), int(height));
if (!m_configured) {
m_configured = true;
applyConfigure();
#if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0)
window()->updateExposure();
#else
window()->sendRecursiveExposeEvent();
#endif
} else {
window()->applyConfigureWhenPossible();
}
}
private:
QSize m_size;
bool m_configured = false;
};
QtWaylandClient::QWaylandShellSurface *LockIntegration::createShellSurface(QtWaylandClient::QWaylandWindow *window)
{
return new LockSurface(current, window);
}
class Lock : public QtWayland::ext_session_lock_v1
{
public:
Lock(::ext_session_lock_v1 *object, SessionLock *owner)
: QtWayland::ext_session_lock_v1(object)
, m_owner(owner)
{
}
protected:
// Queued: not from inside the Wayland event, which the answer to it might
// destroy.
void ext_session_lock_v1_locked() override
{
QMetaObject::invokeMethod(m_owner, &SessionLock::onLocked, Qt::QueuedConnection);
}
void ext_session_lock_v1_finished() override
{
QMetaObject::invokeMethod(m_owner, &SessionLock::onFinished, Qt::QueuedConnection);
}
private:
SessionLock *m_owner;
};
SessionLock::SessionLock(QQmlEngine *engine, BubbleController *bubble, LockScreenController *screen, UserConfig *config, QObject *parent)
: QObject(parent)
, m_engine(engine)
, m_bubble(bubble)
, m_screen(screen)
, m_config(config)
{
connect(qGuiApp, &QGuiApplication::screenAdded, this, [this](QScreen *s) {
if (m_lock) {
addScreen(s);
}
});
connect(qGuiApp, &QGuiApplication::screenRemoved, this, &SessionLock::removeScreen);
connect(m_screen, &LockScreenController::authenticated, this, &SessionLock::unlock);
}
SessionLock::~SessionLock()
{
// Leaving with the lock held keeps the session locked, which is the
// point. The windows go, the lock stays.
qDeleteAll(m_views);
delete m_lock;
delete m_integration;
}
bool SessionLock::available()
{
return built && Wayland::hasGlobal("ext_session_lock_manager_v1");
}
QString SessionLock::markerPath()
{
return QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation) + QStringLiteral("/face-unlock/locked");
}
void SessionLock::lock()
{
if (m_lock) {
return;
}
auto *qpa = QtWaylandClient::QWaylandIntegration::instance();
if (!m_integration && qpa) {
m_integration = new LockIntegration;
if (!m_integration->initialize(qpa->display())) {
delete m_integration;
m_integration = nullptr;
}
}
if (!m_integration) {
qWarning("the compositor has no ext_session_lock_manager_v1, so this cannot lock the screen");
return;
}
m_confirmed = false;
m_unlockWanted = false;
m_lock = new Lock(m_integration->lock(), this);
m_integration->current = m_lock->object();
m_screen->reset();
m_pictures = Wallpaper::forLock(m_config->lockWallpaper());
m_screen->setBlur(m_config->lockBlur());
for (QScreen *s : QGuiApplication::screens()) {
addScreen(s);
}
QDir().mkpath(QFileInfo(markerPath()).absolutePath());
QFile marker(markerPath());
if (!marker.open(QIODevice::WriteOnly)) {
qWarning("cannot write %s", qPrintable(markerPath()));
}
Q_EMIT lockedChanged(true);
}
void SessionLock::addScreen(QScreen *screen)
{
if (m_views.contains(screen) || !dynamic_cast<QtWaylandClient::QWaylandScreen *>(screen->handle())) {
return;
}
auto *view = new QQuickView(m_engine, nullptr);
view->setColor(Qt::black);
view->setScreen(screen);
view->setResizeMode(QQuickView::SizeRootObjectToView);
view->resize(screen->size());
view->setInitialProperties({{QStringLiteral("lock"), QVariant::fromValue(m_screen)},
{QStringLiteral("bubble"), QVariant::fromValue(m_bubble)},
{QStringLiteral("wallpaper"), Wallpaper::pick(m_pictures, screen->name())},
{QStringLiteral("primary"), screen == QGuiApplication::primaryScreen()}});
view->loadFromModule(QStringLiteral("FaceUnlock"), QStringLiteral("LockScreen"));
for (const QQmlError &e : view->errors()) {
qWarning("%s", qPrintable(e.toString()));
}
view->create();
if (auto *wayland = dynamic_cast<QtWaylandClient::QWaylandWindow *>(view->handle())) {
wayland->setShellIntegration(m_integration);
}
view->show();
view->requestActivate();
m_views.insert(screen, view);
}
void SessionLock::removeScreen(QScreen *screen)
{
if (auto view = m_views.take(screen)) {
delete view;
}
}
void SessionLock::onLocked()
{
if (!m_lock) {
return;
}
m_confirmed = true;
Q_EMIT confirmed();
if (m_unlockWanted) {
unlock();
}
}
void SessionLock::onFinished()
{
if (!m_lock) {
return;
}
// Refused (another lock screen is up) or ended by the compositor.
if (m_confirmed) {
m_lock->unlock_and_destroy();
} else {
qWarning("the compositor did not let this lock the screen; is another lock screen running?");
m_lock->destroy();
}
release();
}
void SessionLock::unlock()
{
if (!m_lock) {
return;
}
// Unlocking before the compositor has confirmed the lock is a protocol
// error. It follows as soon as it has.
if (!m_confirmed) {
m_unlockWanted = true;
return;
}
m_lock->unlock_and_destroy();
release();
}
void SessionLock::release()
{
qDeleteAll(m_views);
m_views.clear();
delete m_lock;
m_lock = nullptr;
m_confirmed = false;
m_integration->current = nullptr;
QFile::remove(markerPath());
// Out to the compositor now: an agent started only for this lock quits
// straight after.
Wayland::sync();
m_screen->reset();
Q_EMIT lockedChanged(false);
}
+91
View File
@@ -0,0 +1,91 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// face-unlock's own lock screen, for whoever wants it on a compositor whose
// lock screen is a program of its own (ext-session-lock: Hyprland, Niri,
// Sway and others): `face-unlock lock`.
//
// Any other lock screen there covers the bubble, and only opens itself (it
// gets the face through PAM, see src/lib/pam.sh). This one is the lock
// screen, so the bubble shows on it and a face opens it straight away. The
// picture behind it is the user's choice (LockWallpaper).
//
// Qt knows no ext-session-lock, so each screen's window gets the lock surface
// role through Qt's shell integration, the way LayerShellQt gives windows the
// layer-shell role. If this program goes away while locked, the compositor
// keeps the session locked: that is the protocol's promise.
#pragma once
#include <QHash>
#include <QObject>
#include <QPointer>
#include <QUrl>
#include <QtGlobal>
class BubbleController;
class LockScreenController;
class UserConfig;
class QQmlEngine;
class QQuickView;
class QScreen;
class LockIntegration;
class Lock;
class SessionLock : public QObject
{
Q_OBJECT
public:
SessionLock(QQmlEngine *engine, BubbleController *bubble, LockScreenController *screen, UserConfig *config, QObject *parent = nullptr);
~SessionLock() override;
// Whether this build has it. Qt before 6.10 commits a window's surface
// before a lock surface may be committed, which the protocol forbids.
static constexpr bool built = QT_VERSION >= QT_VERSION_CHECK(6, 10, 0);
// Whether it can lock here: built, and the compositor offers
// ext-session-lock.
static bool available();
// From the lock request until the lock is gone again.
bool isLocked() const
{
return m_lock != nullptr;
}
// The compositor has confirmed the lock: nothing unlocked is on screen.
bool isConfirmed() const
{
return m_confirmed;
}
// A file that says the screen is locked, so that an agent started again
// after a crash locks again (see main.cpp).
static QString markerPath();
public Q_SLOTS:
void lock();
void unlock();
Q_SIGNALS:
void lockedChanged(bool locked);
void confirmed();
private:
friend class Lock;
void onLocked();
void onFinished();
void addScreen(QScreen *screen);
void removeScreen(QScreen *screen);
void release();
QQmlEngine *m_engine;
BubbleController *m_bubble;
LockScreenController *m_screen;
UserConfig *m_config;
LockIntegration *m_integration = nullptr;
Lock *m_lock = nullptr;
bool m_confirmed = false;
bool m_unlockWanted = false;
QHash<QScreen *, QPointer<QQuickView>> m_views;
// The pictures behind it, by output (see Wallpaper).
QHash<QString, QUrl> m_pictures;
};
+5 -1
View File
@@ -23,12 +23,16 @@ UserConfig::UserConfig(QObject *parent)
QString UserConfig::path() QString UserConfig::path()
{ {
return QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation) + QStringLiteral("/plasma-face-unlock/config"); return QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation) + QStringLiteral("/face-unlock/config");
} }
void UserConfig::load() void UserConfig::load()
{ {
const KeyValueFile kv = KeyValueFile::load(path()); const KeyValueFile kv = KeyValueFile::load(path());
m_enabled = kv.boolean(QStringLiteral("Enabled"), false);
m_lockWallpaper = kv.value(QStringLiteral("LockWallpaper"));
m_lockBlur = kv.boolean(QStringLiteral("LockBlur"), false);
m_lockScreenStyle = kv.value(QStringLiteral("LockScreenStyle"));
m_lockScreen = kv.boolean(QStringLiteral("LockScreen"), true); m_lockScreen = kv.boolean(QStringLiteral("LockScreen"), true);
m_scanOnWake = kv.boolean(QStringLiteral("ScanOnWake"), true); m_scanOnWake = kv.boolean(QStringLiteral("ScanOnWake"), true);
m_scanOnLock = kv.boolean(QStringLiteral("ScanOnLock"), false); m_scanOnLock = kv.boolean(QStringLiteral("ScanOnLock"), false);
+29 -1
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
// //
// ~/.config/plasma-face-unlock/config: what this user wants from the agent. // ~/.config/face-unlock/config: what this user wants from the agent.
// Written by the menu, read here, and read again whenever it changes. // Written by the menu, read here, and read again whenever it changes.
#pragma once #pragma once
@@ -17,6 +17,30 @@ class UserConfig : public QObject
public: public:
explicit UserConfig(QObject *parent = nullptr); explicit UserConfig(QObject *parent = nullptr);
// Face unlock turned on at all. The agent's service only runs when it is,
// but the own lock screen (--lock) runs either way.
bool enabled() const
{
return m_enabled;
}
// The picture behind the own lock screen (see Wallpaper): empty for the
// desktop's, "none", a file, or a folder to take one from at random.
QString lockWallpaper() const
{
return m_lockWallpaper;
}
// Where the lock screen is a program of the user's (Hyprland, Niri):
// "own" to lock with face-unlock's, "yours" to keep that program, which
// then shows the bubble as a line of text (see LockText). Empty until
// the user picked one.
QString lockScreenStyle() const
{
return m_lockScreenStyle;
}
bool lockBlur() const
{
return m_lockBlur;
}
// Unlock the lock screen by face. // Unlock the lock screen by face.
bool lockScreen() const bool lockScreen() const
{ {
@@ -72,6 +96,10 @@ private:
void load(); void load();
QFileSystemWatcher m_watcher; QFileSystemWatcher m_watcher;
bool m_enabled = false;
QString m_lockWallpaper;
QString m_lockScreenStyle;
bool m_lockBlur = false;
bool m_lockScreen = true; bool m_lockScreen = true;
bool m_scanOnWake = true; bool m_scanOnWake = true;
bool m_scanOnLock = false; bool m_scanOnLock = false;
+180
View File
@@ -0,0 +1,180 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "wallpaper.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QProcess>
#include <QRandomGenerator>
#include <QRegularExpression>
#include <unistd.h>
namespace
{
// A picture, or one at random from a folder.
QUrl picture(const QString &setting)
{
QString path = setting;
if (path.startsWith(u"~/")) {
path = QDir::homePath() + path.mid(1);
}
const QFileInfo info(path);
if (setting.isEmpty() || !info.exists()) {
return {};
}
if (info.isFile()) {
return QUrl::fromLocalFile(info.absoluteFilePath());
}
const QStringList pictures = QDir(path).entryList({QStringLiteral("*.jpg"), QStringLiteral("*.jpeg"), QStringLiteral("*.png"), QStringLiteral("*.webp")},
QDir::Files | QDir::Readable);
if (pictures.isEmpty()) {
return {};
}
return QUrl::fromLocalFile(QDir(path).absoluteFilePath(pictures.at(QRandomGenerator::global()->bounded(int(pictures.size())))));
}
// What a command prints, or nothing when it is not there or hangs.
QString run(const QString &program, const QStringList &args)
{
QProcess process;
process.start(program, args);
if (!process.waitForFinished(1000) || process.exitStatus() != QProcess::NormalExit || process.exitCode() != 0) {
process.kill();
process.waitForFinished(100);
return {};
}
return QString::fromLocal8Bit(process.readAllStandardOutput());
}
// The pictures the running wallpaper programs of this user show.
QHash<QString, QString> desktop()
{
QHash<QString, QString> found;
const uint me = getuid();
const QStringList pids = QDir(QStringLiteral("/proc")).entryList({QStringLiteral("[0-9]*")}, QDir::Dirs);
for (const QString &pid : pids) {
const QString dir = QStringLiteral("/proc/") + pid;
if (QFileInfo(dir).ownerId() != me) {
continue;
}
QFile comm(dir + QStringLiteral("/comm"));
if (!comm.open(QIODevice::ReadOnly)) {
continue;
}
const QByteArray name = comm.readAll().trimmed();
if (name == "swaybg") {
QFile cmdline(dir + QStringLiteral("/cmdline"));
if (cmdline.open(QIODevice::ReadOnly)) {
QStringList args;
for (const QByteArray &arg : cmdline.readAll().split('\0')) {
args << QString::fromLocal8Bit(arg);
}
// Relative to where it was started.
const QDir cwd(QFileInfo(dir + QStringLiteral("/cwd")).symLinkTarget());
QHash<QString, QString> shown = Wallpaper::fromSwaybg(args.mid(1));
for (QString &path : shown) {
path = cwd.absoluteFilePath(path);
}
found.insert(shown);
}
} else if (name == "awww-daemon" || name == "swww-daemon") {
found.insert(Wallpaper::fromAwww(run(QString::fromLatin1(name.left(4)), {QStringLiteral("query")})));
} else if (name == "hyprpaper") {
found.insert(Wallpaper::fromList(run(QStringLiteral("hyprctl"), {QStringLiteral("hyprpaper"), QStringLiteral("listactive")})));
} else if (name == "wpaperd") {
found.insert(Wallpaper::fromList(run(QStringLiteral("wpaperctl"), {QStringLiteral("all-wallpapers")})));
}
}
return found;
}
} // namespace
QHash<QString, QString> Wallpaper::fromSwaybg(const QStringList &args)
{
QHash<QString, QString> found;
QString output;
for (int i = 0; i < args.size(); ++i) {
const QString &arg = args.at(i);
const bool hasNext = i + 1 < args.size();
if ((arg == u"-o" || arg == u"--output") && hasNext) {
output = args.at(++i);
} else if (arg.startsWith(u"--output=")) {
output = arg.mid(9);
} else if ((arg == u"-i" || arg == u"--image") && hasNext) {
found.insert(output == u"*" ? QString() : output, args.at(++i));
} else if (arg.startsWith(u"--image=")) {
found.insert(output == u"*" ? QString() : output, arg.mid(8));
}
}
return found;
}
QHash<QString, QString> Wallpaper::fromAwww(const QString &text)
{
// "eDP-1: 1920x1080, scale: 1, currently displaying: image: /a/b.jpg",
// with a namespace in front on awww. A plain colour is no picture.
static const QRegularExpression line(QStringLiteral("([^\\s:]+): \\d+x\\d+,.*currently displaying: image: (.+)$"), QRegularExpression::MultilineOption);
QHash<QString, QString> found;
auto it = line.globalMatch(text);
while (it.hasNext()) {
const auto match = it.next();
found.insert(match.captured(1), match.captured(2).trimmed());
}
return found;
}
QHash<QString, QString> Wallpaper::fromList(const QString &text)
{
// "eDP-1: /a/b.jpg", on older hyprpaper "eDP-1 = /a/b.jpg".
static const QRegularExpression line(QStringLiteral("^([^\\s:=]+)(?:: | = )(/.+)$"), QRegularExpression::MultilineOption);
QHash<QString, QString> found;
auto it = line.globalMatch(text);
while (it.hasNext()) {
const auto match = it.next();
found.insert(match.captured(1), match.captured(2).trimmed());
}
return found;
}
QHash<QString, QUrl> Wallpaper::forLock(const QString &setting)
{
QHash<QString, QUrl> pictures;
if (setting.compare(u"none", Qt::CaseInsensitive) == 0) {
return pictures;
}
if (!setting.isEmpty()) {
const QUrl url = picture(setting);
if (!url.isEmpty()) {
pictures.insert(QString(), url);
}
return pictures;
}
const QHash<QString, QString> shown = desktop();
for (auto it = shown.cbegin(); it != shown.cend(); ++it) {
const QUrl url = picture(it.value());
if (!url.isEmpty()) {
pictures.insert(it.key(), url);
}
}
return pictures;
}
QUrl Wallpaper::pick(const QHash<QString, QUrl> &pictures, const QString &output)
{
if (pictures.isEmpty()) {
return {};
}
if (pictures.contains(output)) {
return pictures.value(output);
}
if (pictures.contains(QString())) {
return pictures.value(QString());
}
// Named differently than here: some picture of the desktop is still
// better than none.
QStringList outputs = pictures.keys();
outputs.sort();
return pictures.value(outputs.first());
}
+30
View File
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The picture behind face-unlock's own lock screen. By default the one on
// the desktop. Hyprland, niri and the like leave the wallpaper to a program
// of its own, so it comes from whichever of the common ones runs: swaybg,
// awww (once swww), hyprpaper or wpaperd, each for every output.
#pragma once
#include <QHash>
#include <QString>
#include <QStringList>
#include <QUrl>
namespace Wallpaper
{
// The pictures for a lock by output name, "" for every other output.
// setting is LockWallpaper: empty for the desktop's, "none", a picture, or a
// folder to take one from at random.
QHash<QString, QUrl> forLock(const QString &setting);
// The one for this output.
QUrl pick(const QHash<QString, QUrl> &pictures, const QString &output);
// What the programs tell, by output name. Apart for the tests.
QHash<QString, QString> fromSwaybg(const QStringList &args);
// `awww query` and `swww query`.
QHash<QString, QString> fromAwww(const QString &text);
// `hyprctl hyprpaper listactive` and `wpaperctl all-wallpapers`.
QHash<QString, QString> fromList(const QString &text);
}
+69
View File
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "wayland.h"
#include <QByteArray>
#include <QGuiApplication>
#include <QSet>
#include <wayland-client.h>
namespace
{
void onGlobal(void *data, wl_registry *, uint32_t, const char *interface, uint32_t)
{
static_cast<QSet<QByteArray> *>(data)->insert(QByteArray(interface));
}
void onGlobalRemove(void *, wl_registry *, uint32_t)
{
}
const wl_registry_listener listener = {onGlobal, onGlobalRemove};
wl_display *display()
{
auto *app = qGuiApp ? qGuiApp->nativeInterface<QNativeInterface::QWaylandApplication>() : nullptr;
return app ? app->display() : nullptr;
}
const QSet<QByteArray> &globals()
{
static QSet<QByteArray> names;
static bool asked = false;
if (asked) {
return names;
}
asked = true;
wl_display *d = display();
if (!d) {
return names;
}
wl_event_queue *queue = wl_display_create_queue(d);
auto *wrapped = static_cast<wl_display *>(wl_proxy_create_wrapper(d));
wl_proxy_set_queue(reinterpret_cast<wl_proxy *>(wrapped), queue);
wl_registry *registry = wl_display_get_registry(wrapped);
wl_registry_add_listener(registry, &listener, &names);
wl_display_roundtrip_queue(d, queue);
wl_registry_destroy(registry);
wl_proxy_wrapper_destroy(wrapped);
wl_event_queue_destroy(queue);
return names;
}
} // namespace
bool Wayland::hasGlobal(const char *interface)
{
return globals().contains(QByteArray(interface));
}
void Wayland::sync()
{
// On a queue of its own, like above: Qt reads the default one.
if (wl_display *d = display()) {
wl_event_queue *queue = wl_display_create_queue(d);
wl_display_roundtrip_queue(d, queue);
wl_event_queue_destroy(queue);
}
}
+18
View File
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// What the compositor offers. Not every desktop has everything: GNOME has no
// layer-shell (so no bubble) and no ext-idle-notify, and only compositors
// whose lock screen is a program of its own have ext-session-lock.
#pragma once
namespace Wayland
{
// Whether the compositor announces this interface, for example
// "zwlr_layer_shell_v1". Asked once, on an event queue of its own, so Qt's
// own handling of the connection is not disturbed.
bool hasGlobal(const char *interface);
// Wait until the compositor has handled everything sent so far.
void sync();
} // namespace Wayland
+1 -1
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
// //
// The system settings, /etc/plasma-face-unlock/config. // The system settings, /etc/face-unlock/config.
// //
// These are the ones that decide how hard it is to get in: which camera, how // These are the ones that decide how hard it is to get in: which camera, how
// strict the match is, whether a photo is checked for. They belong to root // strict the match is, whether a photo is checked for. They belong to root
+1 -1
View File
@@ -2,7 +2,7 @@
// //
// The face data. // The face data.
// //
// One file per user under /var/lib/plasma-face-unlock/users, named after the // One file per user under /var/lib/face-unlock/users, named after the
// numeric user id so a rename cannot hand one person's faces to another. Only // numeric user id so a rename cannot hand one person's faces to another. Only
// root can read or write the directory. There are no pictures in it: every // root can read or write the directory. There are no pictures in it: every
// sample is the 128 numbers the recognizer made of one frame, and the frame // sample is the 128 numbers the recognizer made of one frame, and the frame
+4 -4
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
// //
// plasma-face-unlock-ctl: the shell code's way to the daemon. // face-unlock-ctl: the shell code's way to the daemon.
// //
// bash has no Unix sockets, so this sends one request and prints every // bash has no Unix sockets, so this sends one request and prints every
// message that comes back as one line of tab separated key=value pairs: // message that comes back as one line of tab separated key=value pairs:
@@ -68,7 +68,7 @@ void printObject(const QJsonObject &o, const QString &event)
int usage() int usage()
{ {
std::fprintf(stderr, std::fprintf(stderr,
"usage: plasma-face-unlock-ctl [--socket PATH] COMMAND\n" "usage: face-unlock-ctl [--socket PATH] COMMAND\n"
" hello | status | cameras | list | watch | unlocked\n" " hello | status | cameras | list | watch | unlocked\n"
" verify [USER] [PURPOSE] | test\n" " verify [USER] [PURPOSE] | test\n"
" remove ID | rename ID NAME | enable ID | disable ID | clear\n"); " remove ID | rename ID NAME | enable ID | disable ID | clear\n");
@@ -81,12 +81,12 @@ int main(int argc, char **argv)
QCoreApplication app(argc, argv); QCoreApplication app(argc, argv);
QStringList args = app.arguments().mid(1); QStringList args = app.arguments().mid(1);
QString socketPath = QStringLiteral(PFU_SOCKET); QString socketPath = QStringLiteral(FU_SOCKET);
if (args.size() >= 2 && args.first() == u"--socket") { if (args.size() >= 2 && args.first() == u"--socket") {
socketPath = args.at(1); socketPath = args.at(1);
args = args.mid(2); args = args.mid(2);
} }
if (const QByteArray env = qgetenv("PFU_SOCKET"); !env.isEmpty()) { if (const QByteArray env = qgetenv("FU_SOCKET"); !env.isEmpty()) {
socketPath = QString::fromLocal8Bit(env); socketPath = QString::fromLocal8Bit(env);
} }
if (args.isEmpty()) { if (args.isEmpty()) {
+1 -1
View File
@@ -25,7 +25,7 @@ AgentLink::AgentLink(uid_t uid, QObject *parent)
: QObject(parent) : QObject(parent)
, m_uid(uid) , m_uid(uid)
{ {
const QString dir = QStringLiteral("/run/user/%1/plasma-face-unlock").arg(uid); const QString dir = QStringLiteral("/run/user/%1/face-unlock").arg(uid);
const QString path = dir + QStringLiteral("/agent.socket"); const QString path = dir + QStringLiteral("/agent.socket");
if (!ownedBy(dir, uid, false) || !ownedBy(path, uid, true)) { if (!ownedBy(dir, uid, false) || !ownedBy(path, uid, true)) {
// No agent in that session, or not one of this user's making. // No agent in that session, or not one of this user's making.
+1 -1
View File
@@ -3,7 +3,7 @@
// Telling a user's session about a scan it did not start itself (sudo, an // Telling a user's session about a scan it did not start itself (sudo, an
// admin prompt, a test from the menu), so the bubble can show it. // admin prompt, a test from the menu), so the bubble can show it.
// //
// The agent listens on /run/user/UID/plasma-face-unlock/agent.socket. That is // The agent listens on /run/user/UID/face-unlock/agent.socket. That is
// a place the user controls, so nothing is taken for granted: the socket has // a place the user controls, so nothing is taken for granted: the socket has
// to belong to the user, and so does the process that answers on it, checked // to belong to the user, and so does the process that answers on it, checked
// by the kernel before a single byte is written. What is written is only // by the kernel before a single byte is written. What is written is only
+8 -8
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
// //
// plasma-face-unlockd: the part that runs as root. // face-unlockd: the part that runs as root.
// //
// It owns the camera and the face data, and it is the only thing that does. // It owns the camera and the face data, and it is the only thing that does.
// Everything else (the lock screen agent, sudo, the setup window, the menu) // Everything else (the lock screen agent, sudo, the setup window, the menu)
@@ -66,21 +66,21 @@ int main(int argc, char **argv)
umask(077); umask(077);
QCoreApplication app(argc, argv); QCoreApplication app(argc, argv);
app.setApplicationName(QStringLiteral("plasma-face-unlockd")); app.setApplicationName(QStringLiteral("face-unlockd"));
app.setApplicationVersion(QStringLiteral(PFU_VERSION)); app.setApplicationVersion(QStringLiteral(FU_VERSION));
qSetMessagePattern(QStringLiteral("%{if-warning}warning: %{endif}%{if-critical}error: %{endif}%{message}")); qSetMessagePattern(QStringLiteral("%{if-warning}warning: %{endif}%{if-critical}error: %{endif}%{message}"));
QCommandLineParser parser; QCommandLineParser parser;
parser.setApplicationDescription(QStringLiteral("Face unlock daemon for KDE Plasma")); parser.setApplicationDescription(QStringLiteral("Face unlock daemon"));
parser.addHelpOption(); parser.addHelpOption();
parser.addVersionOption(); parser.addVersionOption();
const QCommandLineOption socketOpt(QStringLiteral("socket"), QStringLiteral("Listen here instead of the system socket."), QStringLiteral("path"), const QCommandLineOption socketOpt(QStringLiteral("socket"), QStringLiteral("Listen here instead of the system socket."), QStringLiteral("path"),
QStringLiteral(PFU_SOCKET)); QStringLiteral(FU_SOCKET));
const QCommandLineOption stateOpt(QStringLiteral("state-dir"), QStringLiteral("Where face data is kept."), QStringLiteral("dir"), const QCommandLineOption stateOpt(QStringLiteral("state-dir"), QStringLiteral("Where face data is kept."), QStringLiteral("dir"),
QStringLiteral(PFU_STATEDIR)); QStringLiteral(FU_STATEDIR));
const QCommandLineOption configOpt(QStringLiteral("config"), QStringLiteral("The settings file."), QStringLiteral("file"), QStringLiteral(PFU_CONFIG)); const QCommandLineOption configOpt(QStringLiteral("config"), QStringLiteral("The settings file."), QStringLiteral("file"), QStringLiteral(FU_CONFIG));
const QCommandLineOption modelOpt(QStringLiteral("models"), QStringLiteral("Where the networks are."), QStringLiteral("dir"), const QCommandLineOption modelOpt(QStringLiteral("models"), QStringLiteral("Where the networks are."), QStringLiteral("dir"),
QStringLiteral(PFU_MODELDIR)); QStringLiteral(FU_MODELDIR));
const QCommandLineOption idleOpt(QStringLiteral("idle-exit"), QStringLiteral("Exit after this many idle seconds (0: never)."), QStringLiteral("seconds")); const QCommandLineOption idleOpt(QStringLiteral("idle-exit"), QStringLiteral("Exit after this many idle seconds (0: never)."), QStringLiteral("seconds"));
parser.addOptions({socketOpt, stateOpt, configOpt, modelOpt, idleOpt}); parser.addOptions({socketOpt, stateOpt, configOpt, modelOpt, idleOpt});
parser.process(app); parser.process(app);
+4 -4
View File
@@ -4,9 +4,9 @@
// //
// Adding a face is adding a way in, so it asks for the password first, the // Adding a face is adding a way in, so it asks for the password first, the
// same way a phone asks for its code before it sets up a face. The question // same way a phone asks for its code before it sets up a face. The question
// goes to the polkit agent of the person's own session (on Plasma, the // goes to the polkit agent of the person's own session (the desktop's usual
// familiar password dialog), which is why the daemon never sees the // password dialog), which is why the daemon never sees the password.
// password. // Hyprland and Niri have none of their own: one has to be running there.
#pragma once #pragma once
@@ -17,7 +17,7 @@
namespace Polkit namespace Polkit
{ {
inline constexpr char ManageAction[] = "io.github.loonixtools.plasma-face-unlock.manage"; inline constexpr char ManageAction[] = "io.github.loonixtools.face-unlock.manage";
// Calls done(true) when the process may go ahead. Interactive: this can take // Calls done(true) when the process may go ahead. Interactive: this can take
// as long as it takes somebody to type a password. // as long as it takes somebody to type a password.
+12 -3
View File
@@ -282,7 +282,7 @@ void Server::onRequest(Client *client, const QJsonObject &request)
client->role = cmd; client->role = cmd;
if (cmd == u"hello") { if (cmd == u"hello") {
reply(client, {{QStringLiteral("event"), QStringLiteral("result")}, {QStringLiteral("ok"), true}, {QStringLiteral("version"), QStringLiteral(PFU_VERSION)}}); reply(client, {{QStringLiteral("event"), QStringLiteral("result")}, {QStringLiteral("ok"), true}, {QStringLiteral("version"), QStringLiteral(FU_VERSION)}});
} else if (cmd == u"status") { } else if (cmd == u"status") {
handleStatus(client); handleStatus(client);
} else if (cmd == u"cameras") { } else if (cmd == u"cameras") {
@@ -344,7 +344,7 @@ void Server::handleStatus(Client *client)
reply(client, reply(client,
{{QStringLiteral("event"), QStringLiteral("result")}, {{QStringLiteral("event"), QStringLiteral("result")},
{QStringLiteral("ok"), true}, {QStringLiteral("ok"), true},
{QStringLiteral("version"), QStringLiteral(PFU_VERSION)}, {QStringLiteral("version"), QStringLiteral(FU_VERSION)},
{QStringLiteral("user"), System::nameOf(uid)}, {QStringLiteral("user"), System::nameOf(uid)},
{QStringLiteral("faces"), enabled}, {QStringLiteral("faces"), enabled},
{QStringLiteral("identities"), int(faces.size())}, {QStringLiteral("identities"), int(faces.size())},
@@ -468,7 +468,7 @@ void Server::handleVerify(Client *client, const QJsonObject &request)
} }
QString purpose = request.value(u"purpose").toString(); QString purpose = request.value(u"purpose").toString();
static const QStringList purposes = {QStringLiteral("unlock"), QStringLiteral("sudo"), QStringLiteral("polkit"), QStringLiteral("test")}; static const QStringList purposes = {QStringLiteral("unlock"), QStringLiteral("sudo"), QStringLiteral("polkit"), QStringLiteral("lockscreen"), QStringLiteral("test")};
if (!purposes.contains(purpose)) { if (!purposes.contains(purpose)) {
purpose = QStringLiteral("other"); purpose = QStringLiteral("other");
} }
@@ -579,6 +579,15 @@ void Server::onJobDone()
state.lastUnlock = now; state.lastUnlock = now;
state.lastPurpose = scan->purpose(); state.lastPurpose = scan->purpose();
// A lock screen that asks for the face through PAM only asks
// again after a password, so Enter on the empty field is how a
// scan starts there, and pam_faillock counts it as a wrong
// password. The face was right: taken back, as a correct
// password would.
if (geteuid() == 0 && scan->purpose() == u"lockscreen") {
System::resetFailedLogins(scan->uid());
}
// Learn from a confident match, the way Face ID keeps up with a // Learn from a confident match, the way Face ID keeps up with a
// beard growing in. Only well clear of the threshold, so the // beard growing in. Only well clear of the threshold, so the
// samples cannot creep towards somebody else one borderline // samples cannot creep towards somebody else one borderline
+266
View File
@@ -0,0 +1,266 @@
#!/usr/bin/env bash
#
# face-unlock: face unlock for Plasma, GNOME, Hyprland and Niri
#
# Look at the screen and it unlocks, the way a phone does. The lock screen,
# sudo in a terminal and the admin password prompts can all take a face
# instead of a password, with a check that it is a face and not a photo of one.
#
# This is the front end: the menu and the commands. The camera, the face data
# and the decision are the daemon's (face-unlockd, running as root),
# the lock screen and the bubble at the top of the screen are the agent's
# (face-unlock-agent, in the session), and sudo and polkit reach the
# daemon through a PAM module. See the man page for how the pieces fit.
#
# Copyright (C) 2026 Felitendo
# SPDX-License-Identifier: GPL-3.0-or-later
set -uo pipefail
FU_SELF="$(readlink -f "${BASH_SOURCE[0]}")"
# Run as root (through sudo from the menu), only what was installed counts.
# An environment that points the libraries somewhere else is ignored then.
if [[ $EUID -eq 0 ]]; then
unset FU_LIBDIR FU_LIBEXECDIR FU_LOCALEDIR FU_PAMDIR FU_CTL FU_AGENT FU_PAM_MODULE FU_SYSCONFIG \
FU_PAM_ETC_DIR FU_PAM_VENDOR_DIRS FU_OLD_SYSDIR FU_OLD_STATEDIR FU_STATEDIR FU_SYSTEMD_ETC
fi
FU_LIBDIR="${FU_LIBDIR:-@LIBDIR@}"
for _mod in common config daemon pam system migrate menu; do
# shellcheck source=/dev/null
if ! source "$FU_LIBDIR/$_mod.sh"; then
printf 'face-unlock: cannot load %s/%s.sh\n' "$FU_LIBDIR" "$_mod" >&2
exit 14
fi
done
unset _mod
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
# The daemon is started by its socket. Enabling the socket is the one thing
# that needs root once per machine.
fu_ensure_service() {
fu_status_load && return 0
if ! fu_socket_enabled; then
fu_say " $(fu_msg "Face unlock's service has to be switched on once for this computer. That needs your password.")"
fu_root socket-enable || return 1
sleep 0.3
fi
fu_status_load && return 0
fu_bad "$(fu_reason_text unreachable)"
fu_note "$(fu_msg "Check it with: systemctl status %s" "$FU_UNIT_SOCKET")"
return 1
}
fu_do_setup() {
local name="${1:-}" rc
fu_ensure_service || return 1
if [[ -z ${WAYLAND_DISPLAY:-} && -z ${DISPLAY:-} ]]; then
fu_bad "$(fu_msg "Setting up a face needs the camera picture on screen. Run this inside your desktop session.")"
return 1
fi
fu_say " $(fu_msg "The setup window is open. Follow it there.")"
if [[ -n $name ]]; then
"$FU_AGENT" --enroll --name "$name" > /dev/null 2>&1
else
"$FU_AGENT" --enroll > /dev/null 2>&1
fi
rc=$?
if (( rc == 0 )); then
fu_ok "$(fu_msg "The face is set up.")"
fu_config_load
if [[ $CFG_ENABLED != yes ]]; then
fu_note "$(fu_msg "Face unlock is still off. Turn it on with [1] or \`%s enable\`." "$FU_NAME")"
fi
return 0
fi
fu_note "$(fu_msg "No face was added.")"
return 1
}
fu_do_enable() {
fu_ensure_service || return 1
fu_faces_load
if (( ${#FU_FACE_IDS[@]} == 0 )); then
fu_say " $(fu_msg "First, set up your face.")"
fu_do_setup || return 1
fi
fu_config_set Enabled yes || { fu_bad "$(fu_msg "Could not save the setting.")"; return 1; }
fu_config_load
if fu_agent_available; then
fu_agent_enable || fu_bad "$(fu_msg "Could not start the lock screen agent.")"
fu_agent_autostarts || fu_agent_hint
else
fu_note "$(fu_msg "No systemd user session, so the lock screen agent was not started.")"
fi
# What was on the last time face unlock was on.
[[ $CFG_SUDO == yes ]] && ! fu_pam_enabled sudo && fu_root pam-enable sudo
[[ $CFG_POLKIT == yes ]] && ! fu_pam_enabled polkit-1 && fu_root pam-enable polkit-1
# The lock screens of Hyprland, Niri and the like only open themselves:
# the face goes into their password check. On unless turned off.
if fu_pam_lockers_here && [[ $CFG_LOCKERS == yes ]] && ! fu_pam_lockers_enabled; then
fu_root pam-enable lockscreens
fi
case "$FU_DESKTOP" in
gnome)
fu_gnome_extension_enable
case $? in
1) fu_bad "$(fu_msg "Could not switch on the GNOME extension that shows the bubble.")" ;;
2) fu_note "$(fu_msg "Log out and back in once: then GNOME shows the bubble too.")" ;;
esac
;;
esac
# Where the lock screen is a program of its own: face-unlock's, with the
# bubble, or that program with a line of text. Asked once. Without
# face-unlock's own (older Qt) there is nothing to ask.
if [[ $FU_DESKTOP != plasma && $FU_DESKTOP != gnome && -z $(fu_config_get LockScreenStyle) ]]; then
if ! fu_own_lock_here; then
fu_lock_style_set yours
elif [[ -n ${WAYLAND_DISPLAY:-} ]]; then
fu_say " $(fu_msg "Pick your lock screen in the window.")"
fu_lock_choose || fu_note "$(fu_msg "No lock screen picked. You can do it later under Settings.")"
fi
fi
if fu_pam_lockers_here && fu_pam_lockers_enabled && [[ $(fu_config_get LockScreenStyle) != own ]]; then
fu_note "$(fu_msg "The lock screen (%s) takes your face too. If it does not scan when you come back, press Enter on the empty password field." "$(fu_pam_lockers_list)")"
fi
fu_ok "$(fu_msg "Face unlock is on. Lock the screen and look at it to try.")"
if [[ $CFG_SUDO != yes && $CFG_POLKIT != yes ]]; then
fu_note "$(fu_msg "It can do sudo and admin prompts too. See Settings.")"
fi
}
fu_do_disable() {
fu_config_set Enabled no || { fu_bad "$(fu_msg "Could not save the setting.")"; return 1; }
fu_agent_available && fu_agent_disable
[[ $FU_DESKTOP == gnome ]] && fu_gnome_extension_disable
local service
for service in "${FU_PAM_SERVICES[@]}" "${FU_PAM_LOCKERS[@]}"; do
if fu_pam_enabled "$service"; then
fu_root pam-disable "$service" || true
fi
done
fu_ok "$(fu_msg "Face unlock is off. Your faces are kept; delete them under Faces.")"
}
fu_do_status() {
fu_head " $FU_PRETTY"
fu_ui_status
printf '\n'
}
fu_do_faces() {
local i state
fu_status_load || { fu_bad "$(fu_reason_text unreachable)"; return 1; }
fu_faces_load
if (( ${#FU_FACE_IDS[@]} == 0 )); then
fu_note "$(fu_msg "No face is set up yet.")"
return 0
fi
for i in "${!FU_FACE_IDS[@]}"; do
if [[ ${FU_FACE_ON[i]} == true ]]; then state="$(fu_msg "on")"; else state="$(fu_msg "off")"; fi
printf ' %s %-24s %-4s %s\n' "${FU_FACE_IDS[i]}" "${FU_FACE_NAMES[i]}" "$state" \
"$(fu_msg "%s samples, %s learned" "${FU_FACE_SAMPLES[i]}" "${FU_FACE_LEARNED[i]}")"
done
}
fu_do_remove() {
local id="${1:-}" line
[[ -n $id ]] || { fu_bad "$(fu_msg "Which face? See \`%s faces\` for the ids." "$FU_NAME")"; return 1; }
line="$(fu_ctl remove "$id" | tail -n1)"
_fu_fields "$line"
if [[ ${FU_F[ok]:-} == true ]]; then
fu_ok "$(fu_msg "Deleted.")"
else
fu_bad "$(fu_reason_text "${FU_F[reason]:-unreachable}")"
return 1
fi
}
fu_do_help() {
cat <<- EOF
$FU_PRETTY $FU_VERSION
$(fu_msg "Usage: face-unlock [command]")
$(fu_msg "Commands:")
enable $(fu_msg "Turn face unlock on")
disable $(fu_msg "Turn it off (faces are kept)")
setup [NAME] $(fu_msg "Add a face")
faces $(fu_msg "List the faces")
remove ID $(fu_msg "Delete a face")
test $(fu_msg "Look at the camera and see what it sees")
lock $(fu_msg "Lock the screen")
status $(fu_msg "Show what is on")
-h, --help $(fu_msg "Show this help")
-V, --version $(fu_msg "Show the version")
$(fu_msg "Without a command an interactive menu is shown.")
EOF
}
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
main() {
local cmd="${1:-}"
[[ $# -gt 0 ]] && shift
case "$cmd" in
--root) fu_root_verb "$@"; return ;;
esac
# Face data and settings are per user; root has neither a lock screen
# nor a face of its own to set up.
if [[ $EUID -eq 0 && -z ${FU_ALLOW_ROOT:-} ]]; then
fu_bad "$(fu_msg "Run this as your own user, not as root. It asks for your password when it needs to.")"
exit 2
fi
case "$cmd" in
''|enable|disable|setup|add|enroll|faces|list|remove|delete|test|try) fu_migrate ;;
esac
case "$cmd" in
enable) fu_do_enable ;;
disable) fu_do_disable ;;
setup|add|enroll) fu_do_setup "$@" ;;
faces|list) fu_do_faces ;;
remove|delete) fu_do_remove "$@" ;;
test|try) fu_ensure_service && fu_test ;;
status) fu_do_status ;;
lock) exec "$FU_AGENT" --lock ;;
-h|--help|help) fu_do_help ;;
-V|--version) printf '%s %s\n' "$FU_NAME" "$FU_VERSION" ;;
'') fu_ui_menu ;;
*)
fu_bad "$(fu_msg "Unknown command: %s" "$cmd")"
printf '\n'
fu_do_help
exit 1
;;
esac
}
main "$@"
+83 -70
View File
@@ -4,42 +4,53 @@
# #
# The shell side never touches the camera or the face data. Those belong to # The shell side never touches the camera or the face data. Those belong to
# the daemon, and everything here that needs them asks it through # the daemon, and everything here that needs them asks it through
# plasma-face-unlock-ctl. What this side does write is the user's own settings # face-unlock-ctl. What this side does write is the user's own settings
# file, and (through sudo, and only when asked) the system settings and the # file, and (through sudo, and only when asked) the system settings and the
# PAM files of sudo and polkit. # PAM files of sudo and polkit.
PFU_VERSION="@VERSION@" FU_VERSION="@VERSION@"
PFU_NAME="plasma-face-unlock" FU_NAME="face-unlock"
PFU_PRETTY="Plasma Face Unlock" FU_PRETTY="Face Unlock"
PFU_LIBDIR="${PFU_LIBDIR:-@LIBDIR@}" FU_LIBDIR="${FU_LIBDIR:-@LIBDIR@}"
PFU_LIBEXECDIR="${PFU_LIBEXECDIR:-@LIBEXECDIR@}" FU_LIBEXECDIR="${FU_LIBEXECDIR:-@LIBEXECDIR@}"
PFU_LOCALEDIR="${PFU_LOCALEDIR:-@LOCALEDIR@}" FU_LOCALEDIR="${FU_LOCALEDIR:-@LOCALEDIR@}"
PFU_PAMDIR="${PFU_PAMDIR:-@PAMDIR@}" FU_PAMDIR="${FU_PAMDIR:-@PAMDIR@}"
PFU_CTL="${PFU_CTL:-$PFU_LIBEXECDIR/plasma-face-unlock-ctl}" FU_CTL="${FU_CTL:-$FU_LIBEXECDIR/face-unlock-ctl}"
PFU_AGENT="${PFU_AGENT:-$PFU_LIBEXECDIR/plasma-face-unlock-agent}" FU_AGENT="${FU_AGENT:-$FU_LIBEXECDIR/face-unlock-agent}"
PFU_PAM_MODULE="${PFU_PAM_MODULE:-$PFU_PAMDIR/pam_plasma_face_unlock.so}" FU_PAM_MODULE="${FU_PAM_MODULE:-$FU_PAMDIR/pam_face_unlock.so}"
PFU_XDG_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}" FU_XDG_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}"
PFU_CONFDIR="${PFU_CONFDIR:-${PFU_XDG_CONFIG}/${PFU_NAME}}" FU_CONFDIR="${FU_CONFDIR:-${FU_XDG_CONFIG}/${FU_NAME}}"
PFU_CONFIG="${PFU_CONFIG:-${PFU_CONFDIR}/config}" FU_CONFIG="${FU_CONFIG:-${FU_CONFDIR}/config}"
# The system settings. Only root writes them; see system.sh. # The system settings. Only root writes them; see system.sh.
PFU_SYSCONFIG="${PFU_SYSCONFIG:-/etc/${PFU_NAME}/config}" FU_SYSCONFIG="${FU_SYSCONFIG:-/etc/${FU_NAME}/config}"
PFU_UNIT_SOCKET="plasma-face-unlockd.socket" FU_UNIT_SOCKET="face-unlockd.socket"
PFU_UNIT_AGENT="plasma-face-unlock-agent.service" FU_UNIT_AGENT="face-unlock-agent.service"
# The desktop this runs in: plasma, gnome, hyprland, niri or other. All but
# the first two have a lock screen that is a program of its own, and there
# face-unlock's own one (`face-unlock lock`) is on offer.
case ":${XDG_CURRENT_DESKTOP:-}:" in
*:KDE:*) FU_DESKTOP=plasma ;;
*:GNOME:*) FU_DESKTOP=gnome ;;
*:Hyprland:*) FU_DESKTOP=hyprland ;;
*:niri:*) FU_DESKTOP=niri ;;
*) FU_DESKTOP=other ;;
esac
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Translations # Translations
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
export TEXTDOMAIN="plasma-face-unlock" export TEXTDOMAIN="face-unlock"
export TEXTDOMAINDIR="${PFU_LOCALEDIR}" export TEXTDOMAINDIR="${FU_LOCALEDIR}"
pfu_ui_locale() { fu_ui_locale() {
local l="${PFU_UI_LOCALE:-}" local l="${FU_UI_LOCALE:-}"
if [[ -z $l ]]; then if [[ -z $l ]]; then
l="${LC_ALL:-}" l="${LC_ALL:-}"
@@ -63,53 +74,53 @@ pfu_ui_locale() {
# Every gettext lookup is a fork and the settings screen redraws a screenful of # Every gettext lookup is a fork and the settings screen redraws a screenful of
# labels per keypress, so results are memoized. # labels per keypress, so results are memoized.
declare -A PFU_MSG_CACHE=() declare -A FU_MSG_CACHE=()
PFU_MSG_RESULT='' FU_MSG_RESULT=''
# pfu_msg_into <locale> <msgid> # fu_msg_into <locale> <msgid>
# Plain lookup with the result in PFU_MSG_RESULT and no printf formatting, for # Plain lookup with the result in FU_MSG_RESULT and no printf formatting, for
# callers that would otherwise pay a fork per label per frame. # callers that would otherwise pay a fork per label per frame.
pfu_msg_into() { fu_msg_into() {
local locale="$1" msgid="$2" cachekey local locale="$1" msgid="$2" cachekey
cachekey="${locale}"$'\x1f'"${msgid}" cachekey="${locale}"$'\x1f'"${msgid}"
if [[ -n ${PFU_MSG_CACHE[$cachekey]+set} ]]; then if [[ -n ${FU_MSG_CACHE[$cachekey]+set} ]]; then
PFU_MSG_RESULT="${PFU_MSG_CACHE[$cachekey]}" FU_MSG_RESULT="${FU_MSG_CACHE[$cachekey]}"
return 0 return 0
fi fi
PFU_MSG_RESULT="$(LC_ALL="$locale" LANGUAGE="${locale%%.*}" gettext -- "$msgid" 2>/dev/null)" FU_MSG_RESULT="$(LC_ALL="$locale" LANGUAGE="${locale%%.*}" gettext -- "$msgid" 2>/dev/null)"
[[ -n $PFU_MSG_RESULT ]] || PFU_MSG_RESULT="$msgid" [[ -n $FU_MSG_RESULT ]] || FU_MSG_RESULT="$msgid"
PFU_MSG_CACHE[$cachekey]="$PFU_MSG_RESULT" FU_MSG_CACHE[$cachekey]="$FU_MSG_RESULT"
return 0 return 0
} }
# pfu_msg_in <locale> <msgid> [printf args...] # fu_msg_in <locale> <msgid> [printf args...]
pfu_msg_in() { fu_msg_in() {
local locale="$1" msgid="$2" local locale="$1" msgid="$2"
shift 2 shift 2
pfu_msg_into "$locale" "$msgid" fu_msg_into "$locale" "$msgid"
# With no arguments the message is plain text, not a format string. Feeding # With no arguments the message is plain text, not a format string. Feeding
# it to printf anyway would turn a literal percent sign in a translation # it to printf anyway would turn a literal percent sign in a translation
# into an invalid conversion. # into an invalid conversion.
if (( $# == 0 )); then if (( $# == 0 )); then
printf '%s' "$PFU_MSG_RESULT" printf '%s' "$FU_MSG_RESULT"
return return
fi fi
# shellcheck disable=SC2059 # the format string is the translated message # shellcheck disable=SC2059 # the format string is the translated message
printf -- "$PFU_MSG_RESULT" "$@" printf -- "$FU_MSG_RESULT" "$@"
} }
PFU_LOCALE_CACHED='' FU_LOCALE_CACHED=''
# pfu_msg <msgid> [printf args...] # fu_msg <msgid> [printf args...]
pfu_msg() { fu_msg() {
[[ -n $PFU_LOCALE_CACHED ]] || PFU_LOCALE_CACHED="$(pfu_ui_locale)" [[ -n $FU_LOCALE_CACHED ]] || FU_LOCALE_CACHED="$(fu_ui_locale)"
pfu_msg_in "$PFU_LOCALE_CACHED" "$@" fu_msg_in "$FU_LOCALE_CACHED" "$@"
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -119,45 +130,47 @@ pfu_msg() {
# Decided once, while stdout is still whatever the process was started with: # Decided once, while stdout is still whatever the process was started with:
# testing -t 1 at the point of use is wrong for anything called through $(...), # testing -t 1 at the point of use is wrong for anything called through $(...),
# which sees a pipe and would conclude nobody is watching. # which sees a pipe and would conclude nobody is watching.
PFU_INTERACTIVE='' FU_INTERACTIVE=''
[[ -t 1 ]] && PFU_INTERACTIVE=1 [[ -t 1 ]] && FU_INTERACTIVE=1
if [[ -n $PFU_INTERACTIVE && -z ${NO_COLOR:-} ]]; then if [[ -n $FU_INTERACTIVE && -z ${NO_COLOR:-} ]]; then
PFU_C_RESET=$'\033[0m' FU_C_RESET=$'\033[0m'
PFU_C_BOLD=$'\033[1m' FU_C_BOLD=$'\033[1m'
PFU_C_DIM=$'\033[2m' FU_C_DIM=$'\033[2m'
PFU_C_BLUE=$'\033[38;2;52;153;255m' FU_C_BLUE=$'\033[38;2;52;153;255m'
PFU_C_GREEN=$'\033[32m' FU_C_GREEN=$'\033[32m'
PFU_C_YELLOW=$'\033[33m' FU_C_YELLOW=$'\033[33m'
PFU_C_RED=$'\033[31m' FU_C_RED=$'\033[31m'
else else
PFU_C_RESET='' PFU_C_BOLD='' PFU_C_DIM='' PFU_C_BLUE='' FU_C_RESET='' FU_C_BOLD='' FU_C_DIM='' FU_C_BLUE=''
PFU_C_GREEN='' PFU_C_YELLOW='' PFU_C_RED='' FU_C_GREEN='' FU_C_YELLOW='' FU_C_RED=''
fi fi
# What pfu_ok, pfu_bad and pfu_note printed. The menu redraws straight after an # What fu_ok, fu_bad and fu_note printed. The menu redraws straight after an
# action, which wipes the screen, so it shows these again under the new frame # action, which wipes the screen, so it shows these again under the new frame
# rather than holding everything up for a key press. # rather than holding everything up for a key press.
PFU_UI_NOTICES=() FU_UI_NOTICES=()
pfu_say() { printf '%s\n' "$*"; } fu_say() { printf '%s\n' "$*"; }
pfu_head() { printf '\n%s%s%s\n\n' "$PFU_C_BOLD$PFU_C_BLUE" "$*" "$PFU_C_RESET"; } fu_head() { printf '\n%s%s%s\n\n' "$FU_C_BOLD$FU_C_BLUE" "$*" "$FU_C_RESET"; }
pfu_ok() { PFU_UI_NOTICES+=("$PFU_C_GREEN✔$PFU_C_RESET $*"); printf '%s✔%s %s\n' "$PFU_C_GREEN" "$PFU_C_RESET" "$*"; } fu_ok() { FU_UI_NOTICES+=("$FU_C_GREEN✔$FU_C_RESET $*"); printf '%s✔%s %s\n' "$FU_C_GREEN" "$FU_C_RESET" "$*"; }
pfu_bad() { PFU_UI_NOTICES+=("$PFU_C_RED✘$PFU_C_RESET $*"); printf '%s✘%s %s\n' "$PFU_C_RED" "$PFU_C_RESET" "$*" >&2; } fu_bad() { FU_UI_NOTICES+=("$FU_C_RED✘$FU_C_RESET $*"); printf '%s✘%s %s\n' "$FU_C_RED" "$FU_C_RESET" "$*" >&2; }
pfu_note() { PFU_UI_NOTICES+=("$PFU_C_DIM•$PFU_C_RESET $*"); printf '%s•%s %s\n' "$PFU_C_DIM" "$PFU_C_RESET" "$*"; } fu_note() { FU_UI_NOTICES+=("$FU_C_DIM•$FU_C_RESET $*"); printf '%s•%s %s\n' "$FU_C_DIM" "$FU_C_RESET" "$*"; }
# A line of config to copy, under a note.
fu_code() { FU_UI_NOTICES+=(" $*"); printf ' %s\n' "$*"; }
pfu_have() { command -v "$1" > /dev/null 2>&1; } fu_have() { command -v "$1" > /dev/null 2>&1; }
# Human-readable "x minutes ago" for a unix timestamp. 0 or empty yields the # Human-readable "x minutes ago" for a unix timestamp. 0 or empty yields the
# translated "never". # translated "never".
# #
# Written with [[ ]] and a variable for each number on purpose: xgettext reads # Written with [[ ]] and a variable for each number on purpose: xgettext reads
# the < of an (( )) as a redirection and loses every string after it. # the < of an (( )) as a redirection and loses every string after it.
pfu_time_ago() { fu_time_ago() {
local ts="$1" now delta n local ts="$1" now delta n
if [[ ! $ts =~ ^[0-9]+$ || $ts -eq 0 ]]; then if [[ ! $ts =~ ^[0-9]+$ || $ts -eq 0 ]]; then
pfu_msg "never" fu_msg "never"
printf '\n' printf '\n'
return return
fi fi
@@ -167,22 +180,22 @@ pfu_time_ago() {
[[ $delta -lt 0 ]] && delta=0 [[ $delta -lt 0 ]] && delta=0
if [[ $delta -lt 60 ]]; then if [[ $delta -lt 60 ]]; then
pfu_msg "just now" fu_msg "just now"
elif [[ $delta -lt 120 ]]; then elif [[ $delta -lt 120 ]]; then
pfu_msg "1 minute ago" fu_msg "1 minute ago"
elif [[ $delta -lt 3600 ]]; then elif [[ $delta -lt 3600 ]]; then
n=$(( delta / 60 )) n=$(( delta / 60 ))
pfu_msg "%d minutes ago" "$n" fu_msg "%d minutes ago" "$n"
elif [[ $delta -lt 7200 ]]; then elif [[ $delta -lt 7200 ]]; then
pfu_msg "1 hour ago" fu_msg "1 hour ago"
elif [[ $delta -lt 86400 ]]; then elif [[ $delta -lt 86400 ]]; then
n=$(( delta / 3600 )) n=$(( delta / 3600 ))
pfu_msg "%d hours ago" "$n" fu_msg "%d hours ago" "$n"
elif [[ $delta -lt 172800 ]]; then elif [[ $delta -lt 172800 ]]; then
pfu_msg "1 day ago" fu_msg "1 day ago"
else else
n=$(( delta / 86400 )) n=$(( delta / 86400 ))
pfu_msg "%d days ago" "$n" fu_msg "%d days ago" "$n"
fi fi
printf '\n' printf '\n'
} }
+36 -34
View File
@@ -2,9 +2,9 @@
# #
# Reading and writing the settings files. # Reading and writing the settings files.
# #
# Two of them, in the same format: ~/.config/plasma-face-unlock/config for # Two of them, in the same format: ~/.config/face-unlock/config for
# what this user wants from the lock screen and the bubble, and # what this user wants from the lock screen and the bubble, and
# /etc/plasma-face-unlock/config for what the daemon does (which camera, how # /etc/face-unlock/config for what the daemon does (which camera, how
# strict), which only root writes. Neither is meant to be edited by hand: # strict), which only root writes. Neither is meant to be edited by hand:
# every option is in the menu. # every option is in the menu.
# #
@@ -12,25 +12,25 @@
# comments. The daemon and the agent read them with the same rules (see # comments. The daemon and the agent read them with the same rules (see
# src/core/keyvalue.cpp). # src/core/keyvalue.cpp).
declare -A PFU_KV_CACHE=() declare -A FU_KV_CACHE=()
# _pfu_kv_lookup <file> <Key> [default] # _fu_kv_lookup <file> <Key> [default]
# Result in PFU_KV_VALUE. Assigning rather than printing matters on the # Result in FU_KV_VALUE. Assigning rather than printing matters on the
# settings screen, which reads every key on every frame: a command # settings screen, which reads every key on every frame: a command
# substitution there is a fork, and forks are the whole cost of a redraw. # substitution there is a fork, and forks are the whole cost of a redraw.
PFU_KV_VALUE='' FU_KV_VALUE=''
_pfu_kv_lookup() { _fu_kv_lookup() {
local file="$1" key="$2" default="${3:-}" val='' line content local file="$1" key="$2" default="${3:-}" val='' line content
PFU_KV_VALUE="$default" FU_KV_VALUE="$default"
[[ -r $file ]] || return 0 [[ -r $file ]] || return 0
if [[ -n ${PFU_KV_CACHE[$file]+set} ]]; then if [[ -n ${FU_KV_CACHE[$file]+set} ]]; then
content="${PFU_KV_CACHE[$file]}" content="${FU_KV_CACHE[$file]}"
else else
content="$(< "$file")" content="$(< "$file")"
PFU_KV_CACHE[$file]="$content" FU_KV_CACHE[$file]="$content"
fi fi
while IFS= read -r line; do while IFS= read -r line; do
@@ -45,19 +45,19 @@ _pfu_kv_lookup() {
val="${val%\"}" val="${val%\"}"
val="${val#\"}" val="${val#\"}"
[[ -n $val ]] && PFU_KV_VALUE="$val" [[ -n $val ]] && FU_KV_VALUE="$val"
return 0 return 0
} }
pfu_is_true() { fu_is_true() {
case "${1,,}" in case "${1,,}" in
yes|y|true|1|on|enabled) return 0 ;; yes|y|true|1|on|enabled) return 0 ;;
*) return 1 ;; *) return 1 ;;
esac esac
} }
# pfu_kv_set <file> <Key> <Value> <header line> # fu_kv_set <file> <Key> <Value> <header line>
pfu_kv_set() { fu_kv_set() {
local file="$1" key="$2" value="$3" header="$4" tmp local file="$1" key="$2" value="$3" header="$4" tmp
if [[ ! -e $file ]]; then if [[ ! -e $file ]]; then
@@ -65,7 +65,8 @@ pfu_kv_set() {
{ {
printf '# %s\n' "$header" printf '# %s\n' "$header"
printf '#\n' printf '#\n'
printf '# Written by `%s`. Nothing here needs editing by hand:\n' "$PFU_NAME" # shellcheck disable=SC2016 # the backticks are text
printf '# Written by `%s`. Nothing here needs editing by hand:\n' "$FU_NAME"
printf '# every option is in the menu.\n' printf '# every option is in the menu.\n'
} > "$file" || return 1 } > "$file" || return 1
fi fi
@@ -91,42 +92,43 @@ pfu_kv_set() {
# Replaced, not rewritten in place: the agent watches the directory and # Replaced, not rewritten in place: the agent watches the directory and
# picks up the new file the moment it lands. # picks up the new file the moment it lands.
mv -f "$tmp" "$file" mv -f "$tmp" "$file"
unset 'PFU_KV_CACHE[$file]' unset 'FU_KV_CACHE[$file]'
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# This user's settings # This user's settings
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
pfu_config_get() { fu_config_get() {
_pfu_kv_lookup "$PFU_CONFIG" "$@" _fu_kv_lookup "$FU_CONFIG" "$@"
printf '%s\n' "$PFU_KV_VALUE" printf '%s\n' "$FU_KV_VALUE"
} }
pfu_config_set() { fu_config_set() {
pfu_kv_set "$PFU_CONFIG" "$1" "$2" "$PFU_PRETTY" fu_kv_set "$FU_CONFIG" "$1" "$2" "$FU_PRETTY"
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# The system settings (read by anybody, written by root) # The system settings (read by anybody, written by root)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
pfu_sys_get() { fu_sys_get() {
_pfu_kv_lookup "$PFU_SYSCONFIG" "$@" _fu_kv_lookup "$FU_SYSCONFIG" "$@"
printf '%s\n' "$PFU_KV_VALUE" printf '%s\n' "$FU_KV_VALUE"
} }
# pfu_config_load # fu_config_load
# Everything the menu shows, resolved once per screen. # Everything the menu shows, resolved once per screen.
pfu_config_load() { fu_config_load() {
PFU_KV_CACHE=() FU_KV_CACHE=()
_pfu_kv_lookup "$PFU_CONFIG" Enabled no; CFG_ENABLED=no; pfu_is_true "$PFU_KV_VALUE" && CFG_ENABLED=yes _fu_kv_lookup "$FU_CONFIG" Enabled no; CFG_ENABLED=no; fu_is_true "$FU_KV_VALUE" && CFG_ENABLED=yes
_pfu_kv_lookup "$PFU_CONFIG" LockScreen yes; CFG_LOCK=no; pfu_is_true "$PFU_KV_VALUE" && CFG_LOCK=yes _fu_kv_lookup "$FU_CONFIG" LockScreen yes; CFG_LOCK=no; fu_is_true "$FU_KV_VALUE" && CFG_LOCK=yes
_pfu_kv_lookup "$PFU_CONFIG" Sudo no; CFG_SUDO=no; pfu_is_true "$PFU_KV_VALUE" && CFG_SUDO=yes _fu_kv_lookup "$FU_CONFIG" Sudo no; CFG_SUDO=no; fu_is_true "$FU_KV_VALUE" && CFG_SUDO=yes
_pfu_kv_lookup "$PFU_CONFIG" Polkit no; CFG_POLKIT=no; pfu_is_true "$PFU_KV_VALUE" && CFG_POLKIT=yes _fu_kv_lookup "$FU_CONFIG" Polkit no; CFG_POLKIT=no; fu_is_true "$FU_KV_VALUE" && CFG_POLKIT=yes
_fu_kv_lookup "$FU_CONFIG" LockScreens yes; CFG_LOCKERS=no; fu_is_true "$FU_KV_VALUE" && CFG_LOCKERS=yes
_pfu_kv_lookup "$PFU_SYSCONFIG" Liveness light; CFG_LIVENESS="$PFU_KV_VALUE" _fu_kv_lookup "$FU_SYSCONFIG" Liveness light; CFG_LIVENESS="$FU_KV_VALUE"
_pfu_kv_lookup "$PFU_SYSCONFIG" Camera auto; CFG_CAMERA="$PFU_KV_VALUE" _fu_kv_lookup "$FU_SYSCONFIG" Camera auto; CFG_CAMERA="$FU_KV_VALUE"
return 0 return 0
} }
+110 -103
View File
@@ -2,184 +2,191 @@
# #
# Asking the daemon. # Asking the daemon.
# #
# Through plasma-face-unlock-ctl, which prints every message as a line of tab # Through face-unlock-ctl, which prints every message as a line of tab
# separated key=value pairs (see src/ctl/main.cpp). What comes back is parsed # separated key=value pairs (see src/ctl/main.cpp). What comes back is parsed
# into plain variables and arrays here, so the rest of the shell code never # into plain variables and arrays here, so the rest of the shell code never
# sees the wire format. # sees the wire format.
# _pfu_fields <line> # _fu_fields <line>
# Splits one line of ctl output into the associative array PFU_F. # Splits one line of ctl output into the associative array FU_F.
declare -A PFU_F=() declare -A FU_F=()
_pfu_fields() { _fu_fields() {
local line="$1" field local line="$1" field
local -a parts local -a parts
PFU_F=() FU_F=()
IFS=$'\t' read -r -a parts <<< "$line" IFS=$'\t' read -r -a parts <<< "$line"
for field in "${parts[@]}"; do for field in "${parts[@]}"; do
PFU_F["${field%%=*}"]="${field#*=}" FU_F["${field%%=*}"]="${field#*=}"
done done
} }
pfu_ctl() { fu_ctl() {
"$PFU_CTL" "$@" "$FU_CTL" "$@"
} }
# pfu_status_load # fu_status_load
# PFU_ST_REACHABLE is yes when the daemon answered at all. Everything else # FU_ST_REACHABLE is yes when the daemon answered at all. Everything else
# is only filled in then. # is only filled in then.
pfu_status_load() { fu_status_load() {
local out local out
PFU_ST_REACHABLE=no FU_ST_REACHABLE=no
PFU_ST_FACES=0 FU_ST_FACES=0
PFU_ST_LOCKOUT=0 FU_ST_LOCKOUT=0
PFU_ST_LAST=0 FU_ST_LAST=0
PFU_ST_PURPOSE='' FU_ST_PURPOSE=''
PFU_ST_CAMERA='' FU_ST_CAMERA=''
PFU_ST_CAMERA_NAME='' FU_ST_CAMERA_NAME=''
PFU_ST_CAMERA_PRESENT=no FU_ST_CAMERA_PRESENT=no
PFU_ST_MODELS=no FU_ST_MODELS=no
out="$(pfu_ctl status 2>/dev/null | tail -n1)" out="$(fu_ctl status 2>/dev/null | tail -n1)"
_pfu_fields "$out" _fu_fields "$out"
[[ ${PFU_F[ok]:-} == true ]] || return 1 [[ ${FU_F[ok]:-} == true ]] || return 1
PFU_ST_REACHABLE=yes FU_ST_REACHABLE=yes
PFU_ST_FACES="${PFU_F[faces]:-0}" FU_ST_FACES="${FU_F[faces]:-0}"
PFU_ST_LOCKOUT="${PFU_F[lockout]:-0}" FU_ST_LOCKOUT="${FU_F[lockout]:-0}"
PFU_ST_LAST="${PFU_F[lastUnlock]:-0}" FU_ST_LAST="${FU_F[lastUnlock]:-0}"
PFU_ST_PURPOSE="${PFU_F[lastPurpose]:-}" FU_ST_PURPOSE="${FU_F[lastPurpose]:-}"
PFU_ST_CAMERA="${PFU_F[cameraPath]:-}" FU_ST_CAMERA="${FU_F[cameraPath]:-}"
PFU_ST_CAMERA_NAME="${PFU_F[cameraName]:-}" FU_ST_CAMERA_NAME="${FU_F[cameraName]:-}"
PFU_ST_CAMERA_PRESENT="${PFU_F[cameraPresent]:-false}" FU_ST_CAMERA_PRESENT="${FU_F[cameraPresent]:-false}"
PFU_ST_MODELS="${PFU_F[models]:-false}" FU_ST_MODELS="${FU_F[models]:-false}"
return 0 return 0
} }
# pfu_faces_load # fu_faces_load
# The caller's faces, into parallel arrays. # The caller's faces, into parallel arrays.
pfu_faces_load() { fu_faces_load() {
local line local line
PFU_FACE_IDS=() PFU_FACE_NAMES=() PFU_FACE_ON=() PFU_FACE_SAMPLES=() PFU_FACE_LEARNED=() PFU_FACE_CREATED=() FU_FACE_IDS=() FU_FACE_NAMES=() FU_FACE_ON=() FU_FACE_SAMPLES=() FU_FACE_LEARNED=() FU_FACE_CREATED=()
while IFS= read -r line; do while IFS= read -r line; do
_pfu_fields "$line" _fu_fields "$line"
[[ ${PFU_F[event]:-} == item ]] || continue [[ ${FU_F[event]:-} == item ]] || continue
PFU_FACE_IDS+=("${PFU_F[id]}") FU_FACE_IDS+=("${FU_F[id]}")
PFU_FACE_NAMES+=("${PFU_F[name]}") FU_FACE_NAMES+=("${FU_F[name]}")
PFU_FACE_ON+=("${PFU_F[enabled]}") FU_FACE_ON+=("${FU_F[enabled]}")
PFU_FACE_SAMPLES+=("${PFU_F[samples]:-0}") FU_FACE_SAMPLES+=("${FU_F[samples]:-0}")
PFU_FACE_LEARNED+=("${PFU_F[adaptive]:-0}") FU_FACE_LEARNED+=("${FU_F[adaptive]:-0}")
PFU_FACE_CREATED+=("${PFU_F[created]:-0}") FU_FACE_CREATED+=("${FU_F[created]:-0}")
done < <(pfu_ctl list 2>/dev/null) done < <(fu_ctl list 2>/dev/null)
} }
# pfu_cameras_load # fu_cameras_load
pfu_cameras_load() { fu_cameras_load() {
local line local line
PFU_CAM_PATHS=() PFU_CAM_NAMES=() PFU_CAM_IR=() FU_CAM_PATHS=() FU_CAM_NAMES=() FU_CAM_IR=()
PFU_CAM_AUTO='' FU_CAM_AUTO=''
while IFS= read -r line; do while IFS= read -r line; do
_pfu_fields "$line" _fu_fields "$line"
case "${PFU_F[event]:-}" in case "${FU_F[event]:-}" in
item) item)
PFU_CAM_PATHS+=("${PFU_F[path]}") FU_CAM_PATHS+=("${FU_F[path]}")
PFU_CAM_NAMES+=("${PFU_F[name]}") FU_CAM_NAMES+=("${FU_F[name]}")
PFU_CAM_IR+=("${PFU_F[infrared]}") FU_CAM_IR+=("${FU_F[infrared]}")
;; ;;
result) result)
PFU_CAM_AUTO="${PFU_F[auto]:-}" FU_CAM_AUTO="${FU_F[auto]:-}"
;; ;;
esac esac
done < <(pfu_ctl cameras 2>/dev/null) done < <(fu_ctl cameras 2>/dev/null)
} }
# pfu_reason_text <reason> # fu_reason_text <reason>
# What a daemon answer means, for people. # What a daemon answer means, for people.
pfu_reason_text() { fu_reason_text() {
case "$1" in case "$1" in
mismatch) pfu_msg "The face did not match." ;; mismatch) fu_msg "The face did not match." ;;
spoof) pfu_msg "That looked like a photo or a screen." ;; spoof) fu_msg "That looked like a photo or a screen." ;;
liveness) pfu_msg "The face matched, but did not blink or move." ;; liveness) fu_msg "The face matched, but did not blink or move." ;;
attention) pfu_msg "The face was not looking at the screen." ;; attention) fu_msg "The face was not looking at the screen." ;;
quality) pfu_msg "The picture was too dark, too blurry or too far away." ;; quality) fu_msg "The picture was too dark, too blurry or too far away." ;;
no-face) pfu_msg "No face in view." ;; no-face) fu_msg "No face in view." ;;
lockout) pfu_msg "Face unlock is paused after too many tries. Unlock once with your password." ;; lockout) fu_msg "Face unlock is paused after too many tries. Unlock once with your password." ;;
not-enrolled) pfu_msg "No face is set up yet." ;; not-enrolled) fu_msg "No face is set up yet." ;;
camera) pfu_msg "The camera could not be used." ;; camera) fu_msg "The camera could not be used." ;;
models) pfu_msg "The recognition models are missing. Reinstall the package." ;; models) fu_msg "The recognition models are missing. Reinstall the package." ;;
lid-closed) pfu_msg "The lid is closed." ;; lid-closed) fu_msg "The lid is closed." ;;
busy) pfu_msg "The camera is busy with another scan." ;; busy) fu_msg "The camera is busy with another scan." ;;
unreachable) pfu_msg "The face unlock service is not running." ;; unreachable) fu_msg "The face unlock service is not running." ;;
denied) pfu_msg "Not allowed." ;; denied)
*) pfu_msg "Something went wrong (%s)." "$1" ;; fu_msg "Not allowed."
# Hyprland, Niri and the like bring no polkit agent, so no password window shows.
if [[ $FU_DESKTOP != plasma && $FU_DESKTOP != gnome ]]; then
printf ' '
fu_msg "No password window? Start a polkit agent, for example hyprpolkitagent."
fi
;;
*) fu_msg "Something went wrong (%s)." "$1" ;;
esac esac
printf '\n' printf '\n'
} }
# pfu_test # fu_test
# One scan, with everything the checks see on one line that keeps updating. # One scan, with everything the checks see on one line that keeps updating.
# The bubble shows it too, if the agent is running. # The bubble shows it too, if the agent is running.
pfu_test() { fu_test() {
local line started=0 shown='' score='-' matched=0 local line started=0 shown='' score='-' matched=0
pfu_say " $(pfu_msg "Look at the camera. Blink once, or turn your head a little.")" fu_say " $(fu_msg "Look at the camera. Blink once, or turn your head a little.")"
printf '\n' printf '\n'
while IFS= read -r line; do while IFS= read -r line; do
_pfu_fields "$line" _fu_fields "$line"
case "${PFU_F[event]:-}" in case "${FU_F[event]:-}" in
started) started)
started=1 started=1
;; ;;
face) face)
printf '\r\033[K %s\n' "$(pfu_msg "Face found.")" printf '\r\033[K %s\n' "$(fu_msg "Face found.")"
;; ;;
hint) hint)
case "${PFU_F[hint]}" in case "${FU_F[hint]}" in
blink) printf '\r\033[K %s\n' "$(pfu_msg "Recognised. Now blink once, or turn your head a little.")" ;; blink) printf '\r\033[K %s\n' "$(fu_msg "Recognised. Now blink once, or turn your head a little.")" ;;
look) printf '\r\033[K %s\n' "$(pfu_msg "Look at the screen.")" ;; look) printf '\r\033[K %s\n' "$(fu_msg "Look at the screen.")" ;;
closer) printf '\r\033[K %s\n' "$(pfu_msg "Move closer to the camera.")" ;; closer) printf '\r\033[K %s\n' "$(fu_msg "Move closer to the camera.")" ;;
light) printf '\r\033[K %s\n' "$(pfu_msg "It is too dark to see your face.")" ;; light) printf '\r\033[K %s\n' "$(fu_msg "It is too dark to see your face.")" ;;
esac esac
;; ;;
frame) frame)
[[ -n ${PFU_F[score]:-} ]] && score="${PFU_F[score]}" [[ -n ${FU_F[score]:-} ]] && score="${FU_F[score]}"
matched="${PFU_F[matched]:-0}" matched="${FU_F[matched]:-0}"
# match, turn of the head, eyes, and how close each photo check is # match, turn of the head, eyes, and how close each photo check is
# to firing, as numbers for anybody tuning it. # to firing, as numbers for anybody tuning it.
printf -v shown ' %s %-6s %s %5s° %s %-5s %s %-4s %s %-4s %s %-4s %s %-4s' \ printf -v shown ' %s %-6s %s %5s° %s %-5s %s %-4s %s %-4s %s %-4s %s %-4s' \
"$(pfu_msg "match")" "$score" "$(pfu_msg "turn")" "${PFU_F[yaw]:-0}" \ "$(fu_msg "match")" "$score" "$(fu_msg "turn")" "${FU_F[yaw]:-0}" \
"$(pfu_msg "eyes")" "${PFU_F[eyes]:-0}" \ "$(fu_msg "eyes")" "${FU_F[eyes]:-0}" \
"$(pfu_msg "depth")" "${PFU_F[depth]:-0}" "$(pfu_msg "blink")" "${PFU_F[blink]:-0}" \ "$(fu_msg "depth")" "${FU_F[depth]:-0}" "$(fu_msg "blink")" "${FU_F[blink]:-0}" \
"$(pfu_msg "glare")" "${PFU_F[glare]:-0}" "$(pfu_msg "edge")" "${PFU_F[device]:-0}" "$(fu_msg "glare")" "${FU_F[glare]:-0}" "$(fu_msg "edge")" "${FU_F[device]:-0}"
[[ -n $PFU_INTERACTIVE ]] && printf '\r\033[K%s%s%s' "$PFU_C_DIM" "$shown" "$PFU_C_RESET" [[ -n $FU_INTERACTIVE ]] && printf '\r\033[K%s%s%s' "$FU_C_DIM" "$shown" "$FU_C_RESET"
;; ;;
result) result)
printf '\r\033[K' printf '\r\033[K'
if [[ ${PFU_F[ok]} == true ]]; then if [[ ${FU_F[ok]} == true ]]; then
local how='' local how=''
case "${PFU_F[liveness]:-}" in case "${FU_F[liveness]:-}" in
blink) how="$(pfu_msg "blink")" ;; blink) how="$(fu_msg "blink")" ;;
depth) how="$(pfu_msg "head turn")" ;; depth) how="$(fu_msg "head turn")" ;;
esac esac
pfu_ok "$(pfu_msg "Recognised as %s (match %s) in %s ms." "${PFU_F[name]}" "${PFU_F[score]}" "${PFU_F[ms]}")" fu_ok "$(fu_msg "Recognised as %s (match %s) in %s ms." "${FU_F[name]}" "${FU_F[score]}" "${FU_F[ms]}")"
[[ -n $how ]] && pfu_say " $(pfu_msg "Sign of life: %s" "$how")" [[ -n $how ]] && fu_say " $(fu_msg "Sign of life: %s" "$how")"
else else
pfu_bad "$(pfu_reason_text "${PFU_F[reason]}")" fu_bad "$(fu_reason_text "${FU_F[reason]}")"
[[ -n ${PFU_F[message]:-} ]] && pfu_say " ${PFU_F[message]}" [[ -n ${FU_F[message]:-} ]] && fu_say " ${FU_F[message]}"
if [[ -n ${PFU_F[lockout]:-} ]]; then if [[ -n ${FU_F[lockout]:-} ]]; then
pfu_note "$(pfu_msg "That was too many tries. Face unlock is paused until you unlock with your password.")" fu_note "$(fu_msg "That was too many tries. Face unlock is paused until you unlock with your password.")"
fi fi
fi fi
(( started )) || true (( started )) || true
return 0 return 0
;; ;;
esac esac
done < <(pfu_ctl test 2>/dev/null) done < <(fu_ctl test 2>/dev/null)
printf '\n' printf '\n'
pfu_bad "$(pfu_reason_text unreachable)" fu_bad "$(fu_reason_text unreachable)"
return 1 return 1
} }
+406 -269
View File
@@ -14,39 +14,39 @@
# character, so the line discipline eats it instead of delivering it, and a # character, so the line discipline eats it instead of delivering it, and a
# backspace typed while the interface was between reads simply vanishes. # backspace typed while the interface was between reads simply vanishes.
# Holding non-canonical mode for the whole interface removes the gap. # Holding non-canonical mode for the whole interface removes the gap.
PFU_TERM_SAVED='' FU_TERM_SAVED=''
pfu_ui_term_raw() { fu_ui_term_raw() {
pfu_have stty || return 0 fu_have stty || return 0
[[ -t 0 ]] || return 0 [[ -t 0 ]] || return 0
[[ -n $PFU_TERM_SAVED ]] && return 0 [[ -n $FU_TERM_SAVED ]] && return 0
PFU_TERM_SAVED="$(stty -g 2>/dev/null)" || { PFU_TERM_SAVED=''; return 0; } FU_TERM_SAVED="$(stty -g 2>/dev/null)" || { FU_TERM_SAVED=''; return 0; }
stty -icanon -echo min 1 time 0 2>/dev/null || true stty -icanon -echo min 1 time 0 2>/dev/null || true
} }
pfu_ui_term_restore() { fu_ui_term_restore() {
[[ -n $PFU_TERM_SAVED ]] || return 0 [[ -n $FU_TERM_SAVED ]] || return 0
stty "$PFU_TERM_SAVED" 2>/dev/null || true stty "$FU_TERM_SAVED" 2>/dev/null || true
PFU_TERM_SAVED='' FU_TERM_SAVED=''
} }
# Runs an action with the terminal handed back to normal line mode, so anything # Runs an action with the terminal handed back to normal line mode, so anything
# it prints or prompts for (sudo's password prompt, above all) behaves the way # it prints or prompts for (sudo's password prompt, above all) behaves the way
# a program expects. # a program expects.
pfu_ui_cooked() { fu_ui_cooked() {
pfu_ui_term_restore fu_ui_term_restore
"$@" "$@"
local rc=$? local rc=$?
pfu_ui_term_raw fu_ui_term_raw
return $rc return $rc
} }
# pfu_read_key # fu_read_key
# One keypress, resolved to a symbolic name. Arrow keys arrive as ESC [ A, so # One keypress, resolved to a symbolic name. Arrow keys arrive as ESC [ A, so
# the tail of the sequence is consumed here rather than being mistaken for # the tail of the sequence is consumed here rather than being mistaken for
# three separate presses. # three separate presses.
pfu_read_key() { fu_read_key() {
local k rest local k rest
IFS= read -rsn1 k || return 1 IFS= read -rsn1 k || return 1
@@ -72,26 +72,26 @@ pfu_read_key() {
esac esac
} }
# pfu_ui_read_line <initial> # fu_ui_read_line <initial>
# A minimal line editor built on pfu_read_key, with the result in # A minimal line editor built on fu_read_key, with the result in
# PFU_LINE_RESULT. This exists instead of bash's own `read -r` because mixing # FU_LINE_RESULT. This exists instead of bash's own `read -r` because mixing
# line mode into a single-key interface breaks it: after one cooked-mode read # line mode into a single-key interface breaks it: after one cooked-mode read
# the following `read -sn1` stops receiving keystrokes entirely. # the following `read -sn1` stops receiving keystrokes entirely.
PFU_LINE_RESULT='' FU_LINE_RESULT=''
pfu_ui_read_line() { fu_ui_read_line() {
local buf="${1:-}" key local buf="${1:-}" key
PFU_LINE_RESULT='' FU_LINE_RESULT=''
printf '%s' "$buf" printf '%s' "$buf"
while true; do while true; do
key="$(pfu_read_key)" || { printf '\n'; return 1; } key="$(fu_read_key)" || { printf '\n'; return 1; }
case "$key" in case "$key" in
enter) enter)
printf '\n' printf '\n'
PFU_LINE_RESULT="$buf" FU_LINE_RESULT="$buf"
return 0 return 0
;; ;;
escape) escape)
@@ -118,75 +118,90 @@ pfu_ui_read_line() {
done done
} }
# _pfu_ui_take_notices # _fu_ui_take_notices
# The messages the last action left, as lines for under a frame, in # The messages the last action left, as lines for under a frame, in
# PFU_UI_NOTICE_TEXT. Each is shown once. # FU_UI_NOTICE_TEXT. Each is shown once.
PFU_UI_NOTICE_TEXT='' FU_UI_NOTICE_TEXT=''
_pfu_ui_take_notices() { _fu_ui_take_notices() {
local n local n
PFU_UI_NOTICE_TEXT='' FU_UI_NOTICE_TEXT=''
(( ${#PFU_UI_NOTICES[@]} )) || return 0 (( ${#FU_UI_NOTICES[@]} )) || return 0
PFU_UI_NOTICE_TEXT=$'\n' FU_UI_NOTICE_TEXT=$'\n'
for n in "${PFU_UI_NOTICES[@]}"; do for n in "${FU_UI_NOTICES[@]}"; do
PFU_UI_NOTICE_TEXT+=" $n"$'\n' FU_UI_NOTICE_TEXT+=" $n"$'\n'
done done
PFU_UI_NOTICES=() FU_UI_NOTICES=()
} }
# pfu_ui_confirm <question> # fu_ui_confirm <question>
pfu_ui_confirm() { fu_ui_confirm() {
local key local key hint yes
printf '\n %s %s ' "$1" "$(pfu_msg "[y/N]")" # TRANSLATORS: the letter after "[" is the key for yes. y works too.
key="$(pfu_read_key)" || return 1 hint="$(fu_msg "[y/N]")"
yes="${hint:1:1}"
printf '\n %s %s ' "$1" "$hint"
key="$(fu_read_key)" || return 1
printf '%s\n' "$key" printf '%s\n' "$key"
[[ $key == [yYjJ] ]] [[ ${key,,} == y || ${key,,} == "${yes,,}" ]]
} }
# _pfu_row <label> <value> # _fu_width <text>
# The columns the text takes, into FU_WIDTH. ${#s} counts characters, but
# Chinese, Japanese and Korean ones take two columns each.
FU_WIDTH=0
_fu_width() {
local wide="${1//[^ -〿぀-ヿ㐀-䶿一-鿿가-힯豈-﫿＀-⦆]/}"
FU_WIDTH=$(( ${#1} + ${#wide} ))
}
# _fu_row <label> <value>
# printf's %-28s pads by bytes, so a label containing "ü" comes out one column # printf's %-28s pads by bytes, so a label containing "ü" comes out one column
# short. ${#s} counts characters in a UTF-8 locale, so the padding is computed # short. The padding is computed here instead.
# here instead. _fu_row() {
_pfu_row() {
local label="$1" value="$2" pad local label="$1" value="$2" pad
pad=$(( 30 - ${#label} )) _fu_width "$label"
pad=$(( 30 - FU_WIDTH ))
(( pad < 0 )) && pad=0 (( pad < 0 )) && pad=0
printf ' %s%*s %s\n' "$label" "$pad" '' "$value" printf ' %s%*s %s\n' "$label" "$pad" '' "$value"
} }
_pfu_onoff() { _fu_onoff() {
if [[ $1 == yes ]]; then if [[ $1 == yes ]]; then
printf '%s%s%s' "$PFU_C_GREEN" "$(pfu_msg "ON")" "$PFU_C_RESET" printf '%s%s%s' "$FU_C_GREEN" "$(fu_msg "ON")" "$FU_C_RESET"
else else
printf '%s%s%s' "$PFU_C_DIM" "$(pfu_msg "OFF")" "$PFU_C_RESET" printf '%s%s%s' "$FU_C_DIM" "$(fu_msg "OFF")" "$FU_C_RESET"
fi fi
} }
_pfu_small_onoff() { _fu_small_onoff() {
if [[ $1 == yes ]]; then if [[ $1 == yes ]]; then
printf '%s%s%s' "$PFU_C_GREEN" "$(pfu_msg "on")" "$PFU_C_RESET" printf '%s%s%s' "$FU_C_GREEN" "$(fu_msg "on")" "$FU_C_RESET"
else else
printf '%s%s%s' "$PFU_C_DIM" "$(pfu_msg "off")" "$PFU_C_RESET" printf '%s%s%s' "$FU_C_DIM" "$(fu_msg "off")" "$FU_C_RESET"
fi fi
} }
# pfu_value_label <Key> <value> # fu_value_label <Key> <value>
# How a setting's value reads in the menu. # How a setting's value reads in the menu.
pfu_value_label() { fu_value_label() {
case "$1:$2" in case "$1:$2" in
Liveness:heavy) pfu_msg "strict" ;; Liveness:heavy) fu_msg "strict" ;;
Liveness:light) pfu_msg "basic" ;; Liveness:light) fu_msg "basic" ;;
Liveness:off) pfu_msg "off" ;; Liveness:off) fu_msg "off" ;;
Strictness:normal) pfu_msg "normal" ;; Strictness:normal) fu_msg "normal" ;;
Strictness:strict) pfu_msg "strict" ;; Strictness:strict) fu_msg "strict" ;;
Strictness:relaxed) pfu_msg "relaxed" ;; Strictness:relaxed) fu_msg "relaxed" ;;
BubbleStyle:full) pfu_msg "island with the face" ;; BubbleStyle:full) fu_msg "island with the face" ;;
BubbleStyle:minimal) pfu_msg "small pill with a lock" ;; BubbleStyle:minimal) fu_msg "small pill with a lock" ;;
AnimationSpeed:normal) pfu_msg "normal" ;; AnimationSpeed:normal) fu_msg "normal" ;;
AnimationSpeed:fast) pfu_msg "fast" ;; AnimationSpeed:fast) fu_msg "fast" ;;
AnimationSpeed:slow) pfu_msg "slow" ;; AnimationSpeed:slow) fu_msg "slow" ;;
ScanSeconds:*) pfu_msg "%s seconds" "$2" ;; ScanSeconds:*) fu_msg "%s seconds" "$2" ;;
Camera:auto) pfu_msg "automatic" ;; Camera:auto) fu_msg "automatic" ;;
LockScreenStyle:own) fu_msg "face-unlock's" ;;
LockScreenStyle:*) fu_msg "yours" ;;
*) printf '%s' "$2" ;; *) printf '%s' "$2" ;;
esac esac
} }
@@ -195,76 +210,105 @@ pfu_value_label() {
# Status # Status
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# pfu_ui_status _fu_polkit_warning() {
fu_polkit_agent_missing || return 0
printf '\n %s%s%s\n' "$FU_C_YELLOW" "$(fu_msg "No polkit agent is running, so no password window can open. Adding a face and the admin prompts need one.")" "$FU_C_RESET"
}
# fu_ui_status
# Shared by the `status` subcommand and the menu header. # Shared by the `status` subcommand and the menu header.
pfu_ui_status() { fu_ui_status() {
local names='' i camera local names='' i camera agent=yes
pfu_config_load fu_config_load
pfu_status_load fu_status_load
_pfu_row "$(pfu_msg "Face unlock")" "$(_pfu_onoff "$CFG_ENABLED")" _fu_row "$(fu_msg "Face unlock")" "$(_fu_onoff "$CFG_ENABLED")"
printf '\n' printf '\n'
if [[ $PFU_ST_REACHABLE != yes ]]; then if [[ $FU_ST_REACHABLE != yes ]]; then
_pfu_row "$(pfu_msg "Service")" "${PFU_C_YELLOW}$(pfu_msg "not running")${PFU_C_RESET}" _fu_row "$(fu_msg "Service")" "${FU_C_YELLOW}$(fu_msg "not running")${FU_C_RESET}"
if [[ $CFG_ENABLED == yes ]]; then if [[ $CFG_ENABLED == yes ]]; then
printf '\n %s%s%s\n' "$PFU_C_DIM" "$(pfu_msg "Turning face unlock on again starts it.")" "$PFU_C_RESET" printf '\n %s%s%s\n' "$FU_C_DIM" "$(fu_msg "Turning face unlock on again starts it.")" "$FU_C_RESET"
fi fi
_fu_polkit_warning
return 0 return 0
fi fi
pfu_faces_load fu_faces_load
for i in "${!PFU_FACE_NAMES[@]}"; do for i in "${!FU_FACE_NAMES[@]}"; do
[[ ${PFU_FACE_ON[i]} == true ]] || continue [[ ${FU_FACE_ON[i]} == true ]] || continue
names="${names:+$names, }${PFU_FACE_NAMES[i]}" names="${names:+$names, }${FU_FACE_NAMES[i]}"
done done
if (( ${#PFU_FACE_IDS[@]} == 0 )); then if (( ${#FU_FACE_IDS[@]} == 0 )); then
_pfu_row "$(pfu_msg "Faces")" "${PFU_C_YELLOW}$(pfu_msg "none set up yet")${PFU_C_RESET}" _fu_row "$(fu_msg "Faces")" "${FU_C_YELLOW}$(fu_msg "none set up yet")${FU_C_RESET}"
else else
_pfu_row "$(pfu_msg "Faces")" "${PFU_ST_FACES} ${PFU_C_DIM}(${names:-$(pfu_msg "all turned off")})${PFU_C_RESET}" _fu_row "$(fu_msg "Faces")" "${FU_ST_FACES} ${FU_C_DIM}(${names:-$(fu_msg "all turned off")})${FU_C_RESET}"
fi fi
if [[ $PFU_ST_CAMERA_PRESENT == true ]]; then if [[ $FU_ST_CAMERA_PRESENT == true ]]; then
camera="${PFU_ST_CAMERA_NAME:-$PFU_ST_CAMERA}" camera="${FU_ST_CAMERA_NAME:-$FU_ST_CAMERA}"
else else
camera="${PFU_C_YELLOW}$(pfu_msg "none found")${PFU_C_RESET}" camera="${FU_C_YELLOW}$(fu_msg "none found")${FU_C_RESET}"
fi fi
_pfu_row "$(pfu_msg "Camera")" "$camera" _fu_row "$(fu_msg "Camera")" "$camera"
_pfu_row "$(pfu_msg "Lock screen")" "$(_pfu_small_onoff "$( [[ $CFG_ENABLED == yes && $CFG_LOCK == yes ]] && echo yes || echo no)")" _fu_row "$(fu_msg "Lock screen")" "$(_fu_small_onoff "$( [[ $CFG_ENABLED == yes && $CFG_LOCK == yes ]] && echo yes || echo no)")"
_pfu_row "$(pfu_msg "sudo")" "$(_pfu_small_onoff "$(pfu_pam_enabled sudo && echo yes || echo no)")" if [[ $CFG_ENABLED == yes && $CFG_LOCK == yes ]] && fu_agent_available && ! fu_agent_running; then
_pfu_row "$(pfu_msg "Admin prompts")" "$(_pfu_small_onoff "$(pfu_pam_enabled polkit-1 && echo yes || echo no)")" agent=no
_pfu_row "$(pfu_msg "Photo check")" "$(pfu_value_label Liveness "$CFG_LIVENESS")"
if (( PFU_ST_LOCKOUT > 0 )); then
_pfu_row "$(pfu_msg "Paused")" "${PFU_C_YELLOW}$(pfu_msg "for %d more minutes, or until the password is used" "$(( (PFU_ST_LOCKOUT + 59) / 60 ))")${PFU_C_RESET}"
fi fi
_pfu_row "$(pfu_msg "Last unlock")" "$(pfu_time_ago "$PFU_ST_LAST")" _fu_row "$(fu_msg "sudo")" "$(_fu_small_onoff "$(fu_pam_enabled sudo && echo yes || echo no)")"
_fu_row "$(fu_msg "Admin prompts")" "$(_fu_small_onoff "$(fu_pam_enabled polkit-1 && echo yes || echo no)")"
if [[ $PFU_ST_MODELS != true ]]; then if fu_pam_lockers_here; then
printf '\n %s%s%s\n' "$PFU_C_RED" "$(pfu_msg "The recognition models are missing. Reinstall the package.")" "$PFU_C_RESET" _fu_row "$(fu_msg "Lock screens")" "$(_fu_small_onoff "$(fu_pam_lockers_enabled && echo yes || echo no)") ${FU_C_DIM}($(fu_pam_lockers_list))${FU_C_RESET}"
fi fi
_fu_row "$(fu_msg "Photo check")" "$(fu_value_label Liveness "$CFG_LIVENESS")"
if (( FU_ST_LOCKOUT > 0 )); then
_fu_row "$(fu_msg "Paused")" "${FU_C_YELLOW}$(fu_msg "for %d more minutes, or until the password is used" "$(( (FU_ST_LOCKOUT + 59) / 60 ))")${FU_C_RESET}"
fi
_fu_row "$(fu_msg "Last unlock")" "$(fu_time_ago "$FU_ST_LAST")"
if [[ $FU_ST_MODELS != true ]]; then
printf '\n %s%s%s\n' "$FU_C_RED" "$(fu_msg "The recognition models are missing. Reinstall the package.")" "$FU_C_RESET"
fi
if [[ $agent == no ]]; then
printf '\n %s%s%s\n' "$FU_C_YELLOW" "$(fu_msg "The lock screen agent is not running.")" "$FU_C_RESET"
# Printed here already, so not again under the menu.
local -a notices=("${FU_UI_NOTICES[@]}")
fu_agent_autostarts || fu_agent_hint
FU_UI_NOTICES=("${notices[@]}")
fi
_fu_polkit_warning
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Settings # Settings
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Format: scope|Key|type|default|label-msgid|choices|needs # Format: scope|Key|type|default|label-msgid|choices|needs|only
# scope user (this user's file), sys (the system file, through sudo), # scope user (this user's file), sys (the system file, through sudo),
# pam (the service of that name, through sudo), or group for a # pam (the service of that name, through sudo), or group for a
# heading, with only the label after it # heading, with only the label after it
# type bool, choice (steps through the choices) or camera # type bool, choice (steps through the choices), camera, path (typed
# needs a bool setting this one does nothing without; it is dimmed while # in; empty for the desktop's picture), or lockstyle (the window
# that is off # with the two lock screens, see fu_lock_choose)
PFU_SETTINGS=( # needs a bool setting this one does nothing without, or Key=value; it is
# dimmed while that is not so
# only lockers: only where the lock screen is a program of its own with a
# PAM file (Hyprland, Niri), see fu_pam_lockers_here; ownlock: only
# where face-unlock's own lock screen can be used
FU_SETTINGS=(
"group|Lock screen" "group|Lock screen"
"user|LockScreen|bool|yes|Unlock with your face" "user|LockScreen|bool|yes|Unlock with your face"
"user|ScanOnWake|bool|yes|Scan when you come back||LockScreen" "user|ScanOnWake|bool|yes|Scan when you come back||LockScreen"
"user|ScanOnLock|bool|no|Scan right after locking||LockScreen" "user|ScanOnLock|bool|no|Scan right after locking||LockScreen"
"user|LockScreenStyle|lockstyle|yours|Which lock screen|||ownlock"
"user|LockWallpaper|path||Wallpaper||LockScreenStyle=own|ownlock"
"user|LockBlur|bool|no|Blur the wallpaper||LockScreenStyle=own|ownlock"
"group|Password prompts" "group|Password prompts"
"pam|sudo|bool|no|sudo in a terminal" "pam|sudo|bool|no|sudo in a terminal"
"pam|polkit-1|bool|no|Admin prompts" "pam|polkit-1|bool|no|Admin prompts"
"pam|lockscreens|bool|yes|Lock screens|||lockers"
"group|Recognition" "group|Recognition"
"sys|Liveness|choice|light|Photo check|off,light,heavy" "sys|Liveness|choice|light|Photo check|off,light,heavy"
"sys|Strictness|choice|normal|How closely the face has to match|relaxed,normal,strict" "sys|Strictness|choice|normal|How closely the face has to match|relaxed,normal,strict"
@@ -280,70 +324,107 @@ PFU_SETTINGS=(
"user|BubbleForPrompts|bool|yes|Also for sudo and admin prompts||Bubble" "user|BubbleForPrompts|bool|yes|Also for sudo and admin prompts||Bubble"
) )
# pfu_setting_help <Key> <value> # fu_setting_help <Key> <value>
# What a setting does, shown under the list for the selected one. # What a setting does, shown under the list for the selected one.
pfu_setting_help() { fu_setting_help() {
case "$FU_DESKTOP:$1" in
gnome:Bubble)
fu_msg "On GNOME a small GNOME extension shows the bubble. Turning face unlock on switches it on."
return
;;
hyprland:LockScreen|niri:LockScreen)
fu_msg "Scans when you come back to hyprlock, swaylock or face-unlock's own lock screen (\`face-unlock lock\`, with the bubble), and opens them. Other lock screens scan on Enter."
return
;;
esac
case "$1:$2" in case "$1:$2" in
LockScreen:*) pfu_msg "Unlocks the lock screen when it sees your face. Off: only your password works there." ;; LockScreen:*) fu_msg "Unlocks the lock screen when it sees your face. Off: only your password works there." ;;
ScanOnWake:*) pfu_msg "Scans when you press a key or move the mouse on the lock screen, and when the computer wakes up." ;; ScanOnWake:*) fu_msg "Scans when you press a key or move the mouse on the lock screen, and when the computer wakes up." ;;
ScanOnLock:*) pfu_msg "Scans as soon as the screen locks. Off by default: if you lock it yourself, it would unlock again right away." ;; ScanOnLock:*) fu_msg "Scans as soon as the screen locks. Off by default: if you lock it yourself, it would unlock again right away." ;;
sudo:*) pfu_msg "sudo takes your face instead of the password. No match: you type the password as usual." ;; sudo:*) fu_msg "sudo takes your face instead of the password. No match: you type the password as usual." ;;
polkit-1:*) pfu_msg "The password windows of Plasma and apps, for example when you install software. No match: you type the password." ;; LockScreenStyle:*) fu_msg "face-unlock's lock screen shows the bubble, and you lock with \`face-unlock lock\`. Or keep yours: hyprlock then shows a line of text at the top. Enter opens the choice." ;;
Liveness:heavy) pfu_msg "You have to blink or turn your head a little. This also stops printed photos." ;; LockWallpaper:*) fu_msg "The picture behind face-unlock's own lock screen (\`face-unlock lock\`). Empty: the one on your desktop. Or a file, a folder to take one from at random, or \`none\`. Enter to type it." ;;
Liveness:off) pfu_msg "No check at all. Only for trying out a camera." ;; LockBlur:*) fu_msg "Blurs the picture behind face-unlock's own lock screen." ;;
Liveness:*) pfu_msg "Stops photos on a phone, a tablet or glossy paper. You do not have to blink. A matte printed photo can get through." ;; lockscreens:*) fu_msg "The lock screen takes your face in its password check. Press Enter on the empty field to scan. Found here: %s." "$(fu_pam_lockers_list)" ;;
Strictness:strict) pfu_msg "Fewer wrong matches, but it may not know you with glasses or in bad light." ;; polkit-1:*) fu_msg "The password windows of your desktop and apps, for example when you install software. No match: you type the password." ;;
Strictness:relaxed) pfu_msg "Knows you more easily, but also somebody who looks a lot like you." ;; Liveness:heavy) fu_msg "You have to blink or turn your head a little. This also stops printed photos." ;;
Strictness:*) pfu_msg "The default. Right for most people." ;; Liveness:off) fu_msg "No check at all. Only for trying out a camera." ;;
Attention:*) pfu_msg "Your eyes have to be open and on the screen. So it does not unlock while you look away or sleep." ;; Liveness:*) fu_msg "Stops photos on a phone, a tablet or glossy paper. You do not have to blink. A matte printed photo can get through." ;;
Camera:*) pfu_msg "Automatic takes the first normal camera. After a change, set up your face again. An infrared camera needs its light on (linux-enable-ir-emitter)." ;; Strictness:strict) fu_msg "Fewer wrong matches, but it may not know you with glasses or in bad light." ;;
ScanSeconds:*) pfu_msg "How long the camera looks for your face before it gives up." ;; Strictness:relaxed) fu_msg "Knows you more easily, but also somebody who looks a lot like you." ;;
Adapt:*) pfu_msg "After a sure match it keeps how you look now. So a new haircut or glasses need no new setup." ;; Strictness:*) fu_msg "The default. Right for most people." ;;
SkipLidClosed:*) pfu_msg "No scan while the laptop is closed, for example at a desk with an external screen." ;; Attention:*) fu_msg "Your eyes have to be open and on the screen. So it does not unlock while you look away or sleep." ;;
Bubble:*) pfu_msg "The bubble at the top of the screen shows what the camera is doing." ;; Camera:*) fu_msg "Automatic takes the first normal camera. After a change, set up your face again. An infrared camera needs its light on (linux-enable-ir-emitter)." ;;
BubbleStyle:minimal) pfu_msg "A small pill with a lock that opens." ;; ScanSeconds:*) fu_msg "How long the camera looks for your face before it gives up." ;;
BubbleStyle:*) pfu_msg "An island with a face that looks around, then rings and a tick." ;; Adapt:*) fu_msg "After a sure match it keeps how you look now. So a new haircut or glasses need no new setup." ;;
AnimationSpeed:*) pfu_msg "How fast the bubble moves." ;; SkipLidClosed:*) fu_msg "No scan while the laptop is closed, for example at a desk with an external screen." ;;
BubbleForPrompts:*) pfu_msg "Shows the bubble when sudo or an admin prompt scans your face, too." ;; Bubble:*) fu_msg "The bubble at the top of the screen shows what the camera is doing." ;;
BubbleStyle:minimal) fu_msg "A small pill with a lock that opens." ;;
BubbleStyle:*) fu_msg "An island with a face that looks around, then rings and a tick." ;;
AnimationSpeed:*) fu_msg "How fast the bubble moves." ;;
BubbleForPrompts:*) fu_msg "Shows the bubble when sudo or an admin prompt scans your face, too." ;;
esac esac
} }
# _pfu_wrap <width> <text> # _fu_wrap <width> <text>
# Word wrap, into PFU_WRAP_LINES. # Word wrap, into FU_WRAP_LINES.
PFU_WRAP_LINES=() FU_WRAP_LINES=()
_pfu_wrap() { _fu_wrap() {
local width="$1" word line='' local width="$1" word line='' used=0 c i
local -a words local -a words
PFU_WRAP_LINES=() FU_WRAP_LINES=()
read -r -a words <<< "$2" read -r -a words <<< "$2"
for word in "${words[@]}"; do for word in "${words[@]}"; do
if [[ -n $line ]] && (( ${#line} + 1 + ${#word} > width )); then _fu_width "$word"
PFU_WRAP_LINES+=("$line") if (( used > 0 && used + 1 + FU_WIDTH > width )); then
line="$word" FU_WRAP_LINES+=("$line")
else line='' used=0
line="${line:+$line }$word"
fi fi
(( used > 0 )) && line+=' ' used=$(( used + 1 ))
if (( used + FU_WIDTH <= width )); then
line+="$word" used=$(( used + FU_WIDTH ))
continue
fi
# Longer than a line: Chinese and Japanese have no spaces, so break
# between any two characters.
for (( i = 0; i < ${#word}; i++ )); do
c="${word:i:1}"
_fu_width "$c"
if (( used + FU_WIDTH > width )); then
FU_WRAP_LINES+=("$line")
line='' used=0
fi
line+="$c" used=$(( used + FU_WIDTH ))
done
done done
[[ -n $line ]] && PFU_WRAP_LINES+=("$line") [[ -n $line ]] && FU_WRAP_LINES+=("$line")
return 0 return 0
} }
# _pfu_setting_value <scope> <Key> <default> # _fu_setting_value <scope> <Key> <default>
# The current value, in PFU_SETTING_VALUE. # The current value, in FU_SETTING_VALUE.
PFU_SETTING_VALUE='' FU_SETTING_VALUE=''
_pfu_setting_value() { _fu_setting_value() {
case "$1" in case "$1" in
user) _pfu_kv_lookup "$PFU_CONFIG" "$2" "$3"; PFU_SETTING_VALUE="$PFU_KV_VALUE" ;; user) _fu_kv_lookup "$FU_CONFIG" "$2" "$3"; FU_SETTING_VALUE="$FU_KV_VALUE" ;;
sys) _pfu_kv_lookup "$PFU_SYSCONFIG" "$2" "$3"; PFU_SETTING_VALUE="$PFU_KV_VALUE" ;; sys) _fu_kv_lookup "$FU_SYSCONFIG" "$2" "$3"; FU_SETTING_VALUE="$FU_KV_VALUE" ;;
pam) if pfu_pam_enabled "$2"; then PFU_SETTING_VALUE=yes; else PFU_SETTING_VALUE=no; fi ;; pam)
if [[ $2 == lockscreens ]]; then
if fu_pam_lockers_enabled; then FU_SETTING_VALUE=yes; else FU_SETTING_VALUE=no; fi
elif fu_pam_enabled "$2"; then
FU_SETTING_VALUE=yes
else
FU_SETTING_VALUE=no
fi
;;
esac esac
} }
# _pfu_step <current> <step> <choice>... # _fu_step <current> <step> <choice>...
# The choice <step> (1 or -1) away from the current one, round at the ends. # The choice <step> (1 or -1) away from the current one, round at the ends.
_pfu_step() { _fu_step() {
local current="$1" step="$2" i local current="$1" step="$2" i
shift 2 shift 2
local -a all=("$@") local -a all=("$@")
@@ -356,98 +437,143 @@ _pfu_step() {
printf '%s\n' "${all[0]}" printf '%s\n' "${all[0]}"
} }
# _pfu_next_choice <current> <a,b,c> <step> # _fu_next_choice <current> <a,b,c> <step>
_pfu_next_choice() { _fu_next_choice() {
local -a choices local -a choices
IFS=',' read -r -a choices <<< "$2" IFS=',' read -r -a choices <<< "$2"
_pfu_step "$1" "$3" "${choices[@]}" _fu_step "$1" "$3" "${choices[@]}"
} }
# _pfu_next_camera <current> <step> # _fu_next_camera <current> <step>
_pfu_next_camera() { _fu_next_camera() {
pfu_cameras_load fu_cameras_load
_pfu_step "$1" "$2" auto "${PFU_CAM_PATHS[@]}" _fu_step "$1" "$2" auto "${FU_CAM_PATHS[@]}"
} }
_pfu_camera_label() { _fu_camera_label() {
local value="$1" i local value="$1" i
if [[ $value == auto ]]; then if [[ $value == auto ]]; then
for i in "${!PFU_CAM_PATHS[@]}"; do for i in "${!FU_CAM_PATHS[@]}"; do
if [[ ${PFU_CAM_PATHS[i]} == "$PFU_CAM_AUTO" ]]; then if [[ ${FU_CAM_PATHS[i]} == "$FU_CAM_AUTO" ]]; then
pfu_msg "automatic (%s)" "${PFU_CAM_NAMES[i]}" fu_msg "automatic (%s)" "${FU_CAM_NAMES[i]}"
return return
fi fi
done done
pfu_msg "automatic" fu_msg "automatic"
return return
fi fi
for i in "${!PFU_CAM_PATHS[@]}"; do for i in "${!FU_CAM_PATHS[@]}"; do
if [[ ${PFU_CAM_PATHS[i]} == "$value" ]]; then if [[ ${FU_CAM_PATHS[i]} == "$value" ]]; then
printf '%s' "${PFU_CAM_NAMES[i]}" printf '%s' "${FU_CAM_NAMES[i]}"
[[ ${PFU_CAM_IR[i]} == true ]] && printf ' %s' "$(pfu_msg "(infrared)")" [[ ${FU_CAM_IR[i]} == true ]] && printf ' %s' "$(fu_msg "(infrared)")"
return return
fi fi
done done
printf '%s %s' "$value" "$(pfu_msg "(not connected)")" printf '%s %s' "$value" "$(fu_msg "(not connected)")"
} }
# _pfu_setting_change <scope> <Key> <type> <current> <choices> <step> # _fu_path_label <path>
_pfu_setting_change() { # A path as it fits in the list: with ~ for home, and cut from the left.
local scope="$1" key="$2" type="$3" current="$4" choices="$5" step="$6" next # Empty is the desktop's picture.
_fu_path_label() {
local path="$1"
if [[ -z $path ]]; then
fu_msg "same as desktop"
return
fi
if [[ ${path,,} == none ]]; then
fu_msg "none"
return
fi
[[ $path == "$HOME"/* ]] && path="~${path#"$HOME"}"
(( ${#path} > 36 )) && path="…${path: -35}"
printf '%s' "$path"
}
# _fu_setting_change <scope> <Key> <type> <current> <choices> <step>
_fu_setting_change() {
local scope="$1" key="$2" type="$3" current="$4" options="$5" step="$6" next
case "$type" in case "$type" in
bool) if pfu_is_true "$current"; then next=no; else next=yes; fi ;; bool) if fu_is_true "$current"; then next=no; else next=yes; fi ;;
choice) next="$(_pfu_next_choice "$current" "$choices" "$step")" ;; choice) next="$(_fu_next_choice "$current" "$options" "$step")" ;;
camera) next="$(_pfu_next_camera "$current" "$step")" ;; camera) next="$(_fu_next_camera "$current" "$step")" ;;
lockstyle)
# The window with the pictures where there is a screen for it,
# else the other one of the two.
if [[ -n ${WAYLAND_DISPLAY:-} ]]; then
fu_ui_cooked fu_lock_choose
elif [[ $current == own ]]; then
fu_lock_style_set yours
else
fu_lock_style_set own
fi
return 0
;;
path)
printf '\n %s ' "$(fu_msg "Picture or folder:")"
fu_ui_read_line "$current" || return 0
next="$FU_LINE_RESULT"
# shellcheck disable=SC2088 # typed by the user, expanded here
if [[ -n $next && ${next,,} != none && ! -e ${next/#\~/$HOME} ]]; then
fu_bad "$(fu_msg "Not found: %s" "$next")"
return 0
fi
;;
esac esac
case "$scope" in case "$scope" in
user) user)
pfu_config_set "$key" "$next" || pfu_bad "$(pfu_msg "Could not save the setting.")" fu_config_set "$key" "$next" || fu_bad "$(fu_msg "Could not save the setting.")"
;; ;;
sys) sys)
printf '\n' printf '\n'
pfu_ui_cooked pfu_root set "$key" "$next" || pfu_bad "$(pfu_msg "Could not save the setting.")" fu_ui_cooked fu_root set "$key" "$next" || fu_bad "$(fu_msg "Could not save the setting.")"
PFU_KV_CACHE=() FU_KV_CACHE=()
;; ;;
pam) pam)
printf '\n' printf '\n'
if [[ $next == yes ]]; then if [[ $next == yes ]]; then
pfu_ui_cooked pfu_root pam-enable "$key" || pfu_bad "$(pfu_msg "Could not save the setting.")" fu_ui_cooked fu_root pam-enable "$key" || fu_bad "$(fu_msg "Could not save the setting.")"
else else
pfu_ui_cooked pfu_root pam-disable "$key" || pfu_bad "$(pfu_msg "Could not save the setting.")" fu_ui_cooked fu_root pam-disable "$key" || fu_bad "$(fu_msg "Could not save the setting.")"
fi fi
# Remembered, so that turning face unlock off and on again brings # Remembered, so that turning face unlock off and on again brings
# it back. # it back.
case "$key" in case "$key" in
sudo) pfu_config_set Sudo "$next" ;; sudo) fu_config_set Sudo "$next" ;;
polkit-1) pfu_config_set Polkit "$next" ;; polkit-1) fu_config_set Polkit "$next" ;;
lockscreens) fu_config_set LockScreens "$next" ;;
esac esac
;; ;;
esac esac
} }
# pfu_ui_settings # fu_ui_settings
# A cursor list in groups, with what the selected setting does under it. The # A cursor list in groups, with what the selected setting does under it. The
# frame is assembled in memory and written once, and everything constant is # frame is assembled in memory and written once, and everything constant is
# resolved before the loop. # resolved before the loop.
pfu_ui_settings() { fu_ui_settings() {
local -a scopes=() keys=() types=() defaults=() labels=() choices=() needs=() values=() rows=() local -a scopes=() keys=() types=() defaults=() labels=() widths=() choices=() needs=() values=() rows=()
local spec scope key type default label choice need locale i j frame row pad dirty=1 cursor=0 shown local spec scope key type default label choice need only locale i j frame row pad dirty=1 cursor=0 shown
local width=0 wrap cols local width=0 wrap cols
locale="$(pfu_ui_locale)" locale="$(fu_ui_locale)"
for spec in "${PFU_SETTINGS[@]}"; do for spec in "${FU_SETTINGS[@]}"; do
IFS='|' read -r scope key type default label choice need <<< "$spec" IFS='|' read -r scope key type default label choice need only <<< "$spec"
[[ $only == lockers ]] && ! fu_pam_lockers_here && continue
[[ $only == ownlock ]] && ! fu_own_lock_here && continue
# A heading has its label where the key would be. # A heading has its label where the key would be.
[[ $scope == group ]] && label="$key" key='' [[ $scope == group ]] && label="$key" key=''
scopes+=("$scope"); keys+=("$key"); types+=("$type"); defaults+=("$default") scopes+=("$scope"); keys+=("$key"); types+=("$type"); defaults+=("$default")
choices+=("$choice"); needs+=("$need") choices+=("$choice"); needs+=("$need")
pfu_msg_into "$locale" "$label" fu_msg_into "$locale" "$label"
labels+=("$PFU_MSG_RESULT") labels+=("$FU_MSG_RESULT")
_fu_width "$FU_MSG_RESULT"
widths+=("$FU_WIDTH")
if [[ $scope != group ]]; then if [[ $scope != group ]]; then
rows+=($(( ${#keys[@]} - 1 ))) rows+=($(( ${#keys[@]} - 1 )))
(( ${#PFU_MSG_RESULT} > width )) && width=${#PFU_MSG_RESULT} (( FU_WIDTH > width )) && width=$FU_WIDTH
fi fi
done done
local count=${#rows[@]} local count=${#rows[@]}
@@ -461,77 +587,82 @@ pfu_ui_settings() {
rule="${rule// /─}" rule="${rule// /─}"
local title hint shared l_on l_off local title hint shared l_on l_off
pfu_msg_into "$locale" "Settings"; title="$PFU_MSG_RESULT" fu_msg_into "$locale" "Settings"; title="$FU_MSG_RESULT"
pfu_msg_into "$locale" "↑↓ select ←→ or Space: change q: back"; hint="$PFU_MSG_RESULT" fu_msg_into "$locale" "↑↓ select ←→ or Space: change q: back"; hint="$FU_MSG_RESULT"
pfu_msg_into "$locale" "for all users, asks for your password"; shared="$PFU_MSG_RESULT" fu_msg_into "$locale" "for all users, asks for your password"; shared="$FU_MSG_RESULT"
pfu_msg_into "$locale" "ON"; l_on="$PFU_MSG_RESULT" fu_msg_into "$locale" "ON"; l_on="$FU_MSG_RESULT"
pfu_msg_into "$locale" "OFF"; l_off="$PFU_MSG_RESULT" fu_msg_into "$locale" "OFF"; l_off="$FU_MSG_RESULT"
local clearseq local clearseq
clearseq="$(clear 2>/dev/null)" || clearseq=$'\033[H\033[2J' clearseq="$(clear 2>/dev/null)" || clearseq=$'\033[H\033[2J'
pfu_cameras_load fu_cameras_load
while true; do while true; do
if (( dirty )); then if (( dirty )); then
PFU_KV_CACHE=() FU_KV_CACHE=()
declare -A current=() declare -A current=()
for i in "${rows[@]}"; do for i in "${rows[@]}"; do
_pfu_setting_value "${scopes[i]}" "${keys[i]}" "${defaults[i]}" _fu_setting_value "${scopes[i]}" "${keys[i]}" "${defaults[i]}"
values[i]="$PFU_SETTING_VALUE" values[i]="$FU_SETTING_VALUE"
current[${keys[i]}]="$PFU_SETTING_VALUE" current[${keys[i]}]="$FU_SETTING_VALUE"
done done
dirty=0 dirty=0
fi fi
frame="$clearseq"$'\n'"${PFU_C_BOLD}${PFU_C_BLUE} ${title}${PFU_C_RESET}"$'\n' frame="$clearseq"$'\n'"${FU_C_BOLD}${FU_C_BLUE} ${title}${FU_C_RESET}"$'\n'
local selected=${rows[cursor]} marker before after dim local selected=${rows[cursor]} marker before after dim
for i in "${!keys[@]}"; do for i in "${!keys[@]}"; do
if [[ ${scopes[i]} == group ]]; then if [[ ${scopes[i]} == group ]]; then
frame+=$'\n'" ${PFU_C_BOLD}${labels[i]}${PFU_C_RESET}" frame+=$'\n'" ${FU_C_BOLD}${labels[i]}${FU_C_RESET}"
# The next row says whose settings these are. # The next row says whose settings these are.
[[ ${scopes[i + 1]} != user ]] && frame+=" ${PFU_C_DIM}${shared}${PFU_C_RESET}" [[ ${scopes[i + 1]} != user ]] && frame+=" ${FU_C_DIM}${shared}${FU_C_RESET}"
frame+=$'\n' frame+=$'\n'
continue continue
fi fi
case "${types[i]}" in case "${types[i]}" in
bool) bool)
if pfu_is_true "${values[i]}"; then if fu_is_true "${values[i]}"; then
shown="${PFU_C_GREEN}${l_on}${PFU_C_RESET}" shown="${FU_C_GREEN}${l_on}${FU_C_RESET}"
else else
shown="${PFU_C_DIM}${l_off}${PFU_C_RESET}" shown="${FU_C_DIM}${l_off}${FU_C_RESET}"
fi fi
;; ;;
camera) shown="$(_pfu_camera_label "${values[i]}")" ;; camera) shown="$(_fu_camera_label "${values[i]}")" ;;
*) shown="$(pfu_value_label "${keys[i]}" "${values[i]}")" ;; path) shown="$(_fu_path_label "${values[i]}")" ;;
*) shown="$(fu_value_label "${keys[i]}" "${values[i]}")" ;;
esac esac
# Arrows on the selected choice: left and right step through it. # Arrows on the selected choice: left and right step through it.
before=' ' after='' before=' ' after=''
if (( i == selected )) && [[ ${types[i]} != bool ]]; then if (( i == selected )) && [[ ${types[i]} != bool ]]; then
before="${PFU_C_BLUE}◂${PFU_C_RESET} " after=" ${PFU_C_BLUE}▸${PFU_C_RESET}" before="${FU_C_BLUE}◂${FU_C_RESET} " after=" ${FU_C_BLUE}▸${FU_C_RESET}"
fi fi
dim='' dim=''
[[ -n ${needs[i]} ]] && ! pfu_is_true "${current[${needs[i]}]}" && dim="$PFU_C_DIM" case "${needs[i]}" in
pad=$(( width + 2 - ${#labels[i]} )) '') ;;
if (( i == selected )); then marker="${PFU_C_BLUE}▸${PFU_C_RESET} "; else marker=' '; fi *=*) [[ ${current[${needs[i]%%=*}]} == "${needs[i]#*=}" ]] || dim="$FU_C_DIM" ;;
printf -v row ' %s%s%s%s%*s%s%s%s' "$marker" "$dim" "${labels[i]}" "$PFU_C_RESET" "$pad" '' "$before" "$dim$shown$PFU_C_RESET" "$after" *) fu_is_true "${current[${needs[i]}]}" || dim="$FU_C_DIM" ;;
esac
pad=$(( width + 2 - widths[i] ))
if (( i == selected )); then marker="${FU_C_BLUE}▸${FU_C_RESET} "; else marker=' '; fi
printf -v row ' %s%s%s%s%*s%s%s%s' "$marker" "$dim" "${labels[i]}" "$FU_C_RESET" "$pad" '' "$before" "$dim$shown$FU_C_RESET" "$after"
frame+="$row"$'\n' frame+="$row"$'\n'
done done
# What the selected setting does, in a box of fixed height so the # What the selected setting does, in a box of fixed height so the
# screen does not jump while moving through the list. # screen does not jump while moving through the list.
_pfu_wrap "$wrap" "$(pfu_setting_help "${keys[selected]}" "${values[selected]}")" _fu_wrap "$wrap" "$(fu_setting_help "${keys[selected]}" "${values[selected]}")"
frame+=$'\n'" ${PFU_C_DIM}${rule}${PFU_C_RESET}"$'\n' frame+=$'\n'" ${FU_C_DIM}${rule}${FU_C_RESET}"$'\n'
for j in 0 1 2; do for j in 0 1 2; do
frame+=" ${PFU_WRAP_LINES[j]:-}"$'\n' frame+=" ${FU_WRAP_LINES[j]:-}"$'\n'
done done
frame+=$'\n'" ${PFU_C_DIM}${hint}${PFU_C_RESET}"$'\n' frame+=$'\n'" ${FU_C_DIM}${hint}${FU_C_RESET}"$'\n'
_pfu_ui_take_notices _fu_ui_take_notices
frame+="$PFU_UI_NOTICE_TEXT" frame+="$FU_UI_NOTICE_TEXT"
printf '%s' "$frame" printf '%s' "$frame"
key="$(pfu_read_key)" || return 0 key="$(fu_read_key)" || return 0
case "$key" in case "$key" in
up|k) cursor=$(( (cursor - 1 + count) % count )) ;; up|k) cursor=$(( (cursor - 1 + count) % count )) ;;
@@ -539,7 +670,7 @@ pfu_ui_settings() {
space|enter|right|l|left|h) space|enter|right|l|left|h)
local step=1 local step=1
[[ $key == left || $key == h ]] && step=-1 [[ $key == left || $key == h ]] && step=-1
_pfu_setting_change "${scopes[selected]}" "${keys[selected]}" "${types[selected]}" "${values[selected]}" "${choices[selected]}" "$step" _fu_setting_change "${scopes[selected]}" "${keys[selected]}" "${types[selected]}" "${values[selected]}" "${choices[selected]}" "$step"
dirty=1 dirty=1
;; ;;
q|Q|escape) return 0 ;; q|Q|escape) return 0 ;;
@@ -552,70 +683,72 @@ pfu_ui_settings() {
# Faces # Faces
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
pfu_ui_faces() { fu_ui_faces() {
local key frame row pad i cursor=0 count locale shown local key frame row pad i cursor=0 count locale shown
locale="$(pfu_ui_locale)" locale="$(fu_ui_locale)"
local title hint empty l_on l_off local title hint empty l_on l_off
pfu_msg_into "$locale" "Faces"; title="$PFU_MSG_RESULT" fu_msg_into "$locale" "Faces"; title="$FU_MSG_RESULT"
pfu_msg_into "$locale" "Up/Down: select, Space: on/off, r: rename, d: delete, a: add, q: back"; hint="$PFU_MSG_RESULT" # TRANSLATORS: keep the letters, they are the keys.
pfu_msg_into "$locale" "No face is set up yet. Press a to add one."; empty="$PFU_MSG_RESULT" fu_msg_into "$locale" "Up/Down: select, Space: on/off, r: rename, d: delete, a: add, q: back"; hint="$FU_MSG_RESULT"
pfu_msg_into "$locale" "on"; l_on="$PFU_MSG_RESULT" fu_msg_into "$locale" "No face is set up yet. Press a to add one."; empty="$FU_MSG_RESULT"
pfu_msg_into "$locale" "off"; l_off="$PFU_MSG_RESULT" fu_msg_into "$locale" "on"; l_on="$FU_MSG_RESULT"
fu_msg_into "$locale" "off"; l_off="$FU_MSG_RESULT"
local clearseq local clearseq
clearseq="$(clear 2>/dev/null)" || clearseq=$'\033[H\033[2J' clearseq="$(clear 2>/dev/null)" || clearseq=$'\033[H\033[2J'
while true; do while true; do
pfu_faces_load fu_faces_load
count=${#PFU_FACE_IDS[@]} count=${#FU_FACE_IDS[@]}
(( cursor >= count )) && cursor=$(( count > 0 ? count - 1 : 0 )) (( cursor >= count )) && cursor=$(( count > 0 ? count - 1 : 0 ))
frame="$clearseq"$'\n'"${PFU_C_BOLD}${PFU_C_BLUE} ${title}${PFU_C_RESET}"$'\n\n' frame="$clearseq"$'\n'"${FU_C_BOLD}${FU_C_BLUE} ${title}${FU_C_RESET}"$'\n\n'
if (( count == 0 )); then if (( count == 0 )); then
frame+=" ${PFU_C_DIM}${empty}${PFU_C_RESET}"$'\n' frame+=" ${FU_C_DIM}${empty}${FU_C_RESET}"$'\n'
fi fi
local marker selected="${PFU_C_BLUE}▸${PFU_C_RESET} " samples local marker selected="${FU_C_BLUE}▸${FU_C_RESET} " samples
for i in "${!PFU_FACE_IDS[@]}"; do for i in "${!FU_FACE_IDS[@]}"; do
if [[ ${PFU_FACE_ON[i]} == true ]]; then shown="${PFU_C_GREEN}${l_on}${PFU_C_RESET}"; else shown="${PFU_C_DIM}${l_off}${PFU_C_RESET}"; fi if [[ ${FU_FACE_ON[i]} == true ]]; then shown="${FU_C_GREEN}${l_on}${FU_C_RESET}"; else shown="${FU_C_DIM}${l_off}${FU_C_RESET}"; fi
samples="$(pfu_msg "%s samples, %s learned" "${PFU_FACE_SAMPLES[i]}" "${PFU_FACE_LEARNED[i]}")" samples="$(fu_msg "%s samples, %s learned" "${FU_FACE_SAMPLES[i]}" "${FU_FACE_LEARNED[i]}")"
pad=$(( 30 - ${#PFU_FACE_NAMES[i]} )) _fu_width "${FU_FACE_NAMES[i]}"
pad=$(( 30 - FU_WIDTH ))
(( pad < 0 )) && pad=0 (( pad < 0 )) && pad=0
if (( i == cursor )); then marker="$selected"; else marker=' '; fi if (( i == cursor )); then marker="$selected"; else marker=' '; fi
printf -v row ' %s%s%*s %s %s%s%s' "$marker" "${PFU_FACE_NAMES[i]}" "$pad" '' "$shown" "$PFU_C_DIM" "$samples" "$PFU_C_RESET" printf -v row ' %s%s%*s %s %s%s%s' "$marker" "${FU_FACE_NAMES[i]}" "$pad" '' "$shown" "$FU_C_DIM" "$samples" "$FU_C_RESET"
frame+="$row"$'\n' frame+="$row"$'\n'
done done
frame+=$'\n'" ${PFU_C_DIM}${hint}${PFU_C_RESET}"$'\n' frame+=$'\n'" ${FU_C_DIM}${hint}${FU_C_RESET}"$'\n'
_pfu_ui_take_notices _fu_ui_take_notices
frame+="$PFU_UI_NOTICE_TEXT" frame+="$FU_UI_NOTICE_TEXT"
printf '%s' "$frame" printf '%s' "$frame"
key="$(pfu_read_key)" || return 0 key="$(fu_read_key)" || return 0
case "$key" in case "$key" in
up|k) (( count )) && cursor=$(( (cursor - 1 + count) % count )) ;; up|k) (( count )) && cursor=$(( (cursor - 1 + count) % count )) ;;
down|j) (( count )) && cursor=$(( (cursor + 1) % count )) ;; down|j) (( count )) && cursor=$(( (cursor + 1) % count )) ;;
space|enter) space|enter)
(( count )) || continue (( count )) || continue
if [[ ${PFU_FACE_ON[cursor]} == true ]]; then if [[ ${FU_FACE_ON[cursor]} == true ]]; then
pfu_ctl disable "${PFU_FACE_IDS[cursor]}" > /dev/null fu_ctl disable "${FU_FACE_IDS[cursor]}" > /dev/null
else else
pfu_ctl enable "${PFU_FACE_IDS[cursor]}" > /dev/null fu_ctl enable "${FU_FACE_IDS[cursor]}" > /dev/null
fi fi
;; ;;
r|R) r|R)
(( count )) || continue (( count )) || continue
printf '\n %s ' "$(pfu_msg "New name:")" printf '\n %s ' "$(fu_msg "New name:")"
if pfu_ui_read_line "${PFU_FACE_NAMES[cursor]}" && [[ -n $PFU_LINE_RESULT ]]; then if fu_ui_read_line "${FU_FACE_NAMES[cursor]}" && [[ -n $FU_LINE_RESULT ]]; then
pfu_ctl rename "${PFU_FACE_IDS[cursor]}" "$PFU_LINE_RESULT" > /dev/null fu_ctl rename "${FU_FACE_IDS[cursor]}" "$FU_LINE_RESULT" > /dev/null
fi fi
;; ;;
d|D) d|D)
(( count )) || continue (( count )) || continue
if pfu_ui_confirm "$(pfu_msg "Delete \"%s\"?" "${PFU_FACE_NAMES[cursor]}")"; then if fu_ui_confirm "$(fu_msg "Delete \"%s\"?" "${FU_FACE_NAMES[cursor]}")"; then
pfu_ctl remove "${PFU_FACE_IDS[cursor]}" > /dev/null fu_ctl remove "${FU_FACE_IDS[cursor]}" > /dev/null
fi fi
;; ;;
a|A) pfu_ui_cooked pfu_do_setup ;; a|A) fu_ui_cooked fu_do_setup ;;
q|Q|escape) return 0 ;; q|Q|escape) return 0 ;;
*) ;; *) ;;
esac esac
@@ -626,11 +759,11 @@ pfu_ui_faces() {
# The menu # The menu
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
pfu_ui_menu() { fu_ui_menu() {
local choice keep=0 local choice keep=0
pfu_ui_term_raw fu_ui_term_raw
trap 'pfu_ui_term_restore' EXIT INT TERM trap 'fu_ui_term_restore' EXIT INT TERM
while true; do while true; do
# After a test the menu goes under what the camera saw instead. # After a test the menu goes under what the camera saw instead.
@@ -639,21 +772,24 @@ pfu_ui_menu() {
else else
clear 2>/dev/null || true clear 2>/dev/null || true
fi fi
pfu_head " $PFU_PRETTY" fu_head " $FU_PRETTY"
pfu_ui_status fu_ui_status
printf '\n' printf '\n'
printf ' [1] %s\n' "$(pfu_msg "Turn face unlock on or off")" printf ' [1] %s\n' "$(fu_msg "Turn face unlock on or off")"
printf ' [2] %s\n' "$(pfu_msg "Add a face")" printf ' [2] %s\n' "$(fu_msg "Add a face")"
printf ' [3] %s\n' "$(pfu_msg "Faces")" printf ' [3] %s\n' "$(fu_msg "Faces")"
printf ' [4] %s\n' "$(pfu_msg "Settings")" printf ' [4] %s\n' "$(fu_msg "Settings")"
printf ' [5] %s\n' "$(pfu_msg "Try it")" printf ' [5] %s\n' "$(fu_msg "Try it")"
printf ' [q] %s\n' "$(pfu_msg "Quit")" if fu_polkit_agent_missing; then
_pfu_ui_take_notices printf ' %s[p] %s%s\n' "$FU_C_YELLOW" "$(fu_msg "Install a polkit agent")" "$FU_C_RESET"
printf '%s' "$PFU_UI_NOTICE_TEXT" fi
printf ' [q] %s\n' "$(fu_msg "Quit")"
_fu_ui_take_notices
printf '%s' "$FU_UI_NOTICE_TEXT"
printf '\n > ' printf '\n > '
choice="$(pfu_read_key)" || { choice="$(fu_read_key)" || {
printf '\n'; pfu_ui_term_restore; trap - EXIT INT TERM; return 0 printf '\n'; fu_ui_term_restore; trap - EXIT INT TERM; return 0
} }
case "$choice" in case "$choice" in
enter|space|up|down|left|right|escape) choice='' ;; enter|space|up|down|left|right|escape) choice='' ;;
@@ -662,24 +798,25 @@ pfu_ui_menu() {
case "$choice" in case "$choice" in
1) 1)
pfu_config_load fu_config_load
if [[ $CFG_ENABLED == yes ]]; then if [[ $CFG_ENABLED == yes ]]; then
pfu_ui_cooked pfu_do_disable fu_ui_cooked fu_do_disable
else else
pfu_ui_cooked pfu_do_enable fu_ui_cooked fu_do_enable
fi fi
;; ;;
2) pfu_ui_cooked pfu_do_setup ;; 2) fu_ui_cooked fu_do_setup ;;
3) pfu_ui_faces ;; 3) fu_ui_faces ;;
4) pfu_ui_settings ;; 4) fu_ui_settings ;;
5) 5)
printf '\n' printf '\n'
pfu_ui_cooked pfu_test fu_ui_cooked fu_test
# Already on screen, with the rest of the test. # Already on screen, with the rest of the test.
PFU_UI_NOTICES=() FU_UI_NOTICES=()
keep=1 keep=1
;; ;;
q|Q) pfu_ui_term_restore; trap - EXIT INT TERM; return 0 ;; p|P) fu_polkit_agent_missing && fu_ui_cooked fu_polkit_agent_fix ;;
q|Q) fu_ui_term_restore; trap - EXIT INT TERM; return 0 ;;
# Anything else (Enter, arrow keys, stray characters) just # Anything else (Enter, arrow keys, stray characters) just
# redraws. Escape is deliberately not a quit key, so a mistyped # redraws. Escape is deliberately not a quit key, so a mistyped
# arrow key cannot close the menu. # arrow key cannot close the menu.
+129
View File
@@ -0,0 +1,129 @@
# shellcheck shell=bash
#
# Moving over from plasma-face-unlock, the old name.
#
# The packages run `face-unlock --root migrate` when they are installed. The
# command runs it too (through sudo) when it finds something left over, for
# anybody who built it from source. This user's own settings need no root and
# are moved every time the command starts.
FU_OLD_NAME="plasma-face-unlock"
FU_OLD_PAM_MARK="# plasma-face-unlock: the face first, the password if that does not work"
FU_OLD_PAM_WRAPPER_MARK="# Written by plasma-face-unlock."
# Overridable for the tests only, like the PAM directories.
FU_OLD_SYSDIR="${FU_OLD_SYSDIR:-/etc/$FU_OLD_NAME}"
FU_OLD_STATEDIR="${FU_OLD_STATEDIR:-/var/lib/$FU_OLD_NAME}"
FU_STATEDIR="${FU_STATEDIR:-/var/lib/$FU_NAME}"
FU_SYSTEMD_ETC="${FU_SYSTEMD_ETC:-/etc/systemd/system}"
_fu_old_socket_link() {
printf '%s/sockets.target.wants/%sd.socket\n' "$FU_SYSTEMD_ETC" "$FU_OLD_NAME"
}
# _fu_pam_has_old <service>
_fu_pam_has_old() {
local file
file="$(fu_pam_etc "$1")"
[[ -r $file ]] && grep -q 'pam_plasma_face_unlock\.so' "$file"
}
# _fu_pam_drop_old <service> (root)
_fu_pam_drop_old() {
local file content
file="$(fu_pam_etc "$1")"
if head -n 3 "$file" | grep -qF "$FU_OLD_PAM_WRAPPER_MARK"; then
rm -f -- "$file"
return
fi
content="$(awk -v mark="$FU_OLD_PAM_MARK" '
$0 == mark { next }
/pam_plasma_face_unlock\.so/ { next }
{ print }
' "$file")" || return 1
_fu_pam_replace "$file" "$content"
}
# fu_migrate_system (root)
# The system settings, the faces, sudo and admin prompts and the daemon's
# socket, as plasma-face-unlock left them.
fu_migrate_system() {
local service link used=0
if [[ -d $FU_OLD_SYSDIR ]]; then
if [[ -f $FU_OLD_SYSDIR/config && ! -e $FU_SYSCONFIG ]]; then
install -D -m 0644 "$FU_OLD_SYSDIR/config" "$FU_SYSCONFIG" || return 1
fi
rm -rf -- "$FU_OLD_SYSDIR"
fi
if [[ -d $FU_OLD_STATEDIR ]]; then
# Faces already set up under the new name win.
if [[ -d $FU_OLD_STATEDIR/users ]] && ! compgen -G "$FU_STATEDIR/users/*" > /dev/null; then
install -d -m 0700 "$FU_STATEDIR" || return 1
rm -rf -- "$FU_STATEDIR/users"
mv -- "$FU_OLD_STATEDIR/users" "$FU_STATEDIR/users" || return 1
used=1
fi
rm -rf -- "$FU_OLD_STATEDIR"
fi
for service in "${FU_PAM_SERVICES[@]}"; do
_fu_pam_has_old "$service" || continue
_fu_pam_drop_old "$service" && fu_pam_enable "$service"
used=1
done
link="$(_fu_old_socket_link)"
if [[ -L $link ]]; then
systemctl disable --now "${FU_OLD_NAME}d.socket" > /dev/null 2>&1 || true
rm -f -- "$link"
used=1
fi
# The old package switches its socket off when it is removed, mostly before
# this runs. So its faces and PAM lines count as the sign that it was used.
if (( used )); then
systemctl enable --now "$FU_UNIT_SOCKET" > /dev/null 2>&1 || true
fi
return 0
}
# fu_migrate_pending
# Whether something needs fu_migrate_system.
fu_migrate_pending() {
local service
[[ -d $FU_OLD_SYSDIR || -d $FU_OLD_STATEDIR || -L $(_fu_old_socket_link) ]] && return 0
for service in "${FU_PAM_SERVICES[@]}"; do
_fu_pam_has_old "$service" && return 0
done
return 1
}
# fu_migrate_user
# This user's settings and the agent's service.
fu_migrate_user() {
local old="$FU_XDG_CONFIG/$FU_OLD_NAME" link
if [[ -d $old ]]; then
if [[ -e $FU_CONFDIR ]]; then
rm -rf -- "$old"
else
mv -- "$old" "$FU_CONFDIR"
fi
fi
link="$FU_XDG_CONFIG/systemd/user/graphical-session.target.wants/$FU_OLD_NAME-agent.service"
if [[ -L $link ]]; then
systemctl --user disable --now "$FU_OLD_NAME-agent.service" > /dev/null 2>&1 || true
rm -f -- "$link"
fu_agent_available && fu_agent_enable
fi
return 0
}
# fu_migrate
fu_migrate() {
fu_migrate_user
fu_migrate_pending || return 0
fu_say " $(fu_msg "Moving your faces and settings over from plasma-face-unlock. That needs your password.")"
fu_root migrate
}
+114 -55
View File
@@ -1,16 +1,20 @@
# shellcheck shell=bash # shellcheck shell=bash
# #
# Putting face unlock in front of sudo and polkit's admin prompts, and taking # Putting face unlock in front of sudo, polkit's admin prompts and the lock
# it out again. # screens of Hyprland and Niri, and taking it out again.
# #
# Two services and nothing else. The lock screen does not go through PAM at all # The lock screens of Plasma and GNOME do not go through this at all: the
# (see src/agent/lockcontroller.h), and the login screen stays with the # agent opens them (see src/agent/lockcontroller.h). hyprlock, swaylock and
# password: logging in is what unlocks the wallet, and a face has no password # the others are programs of their own that only open themselves, so the face
# to hand it. # goes into their password check, and pressing Enter on the empty field is
# how a scan starts. The login screen stays with the password: logging in is
# what unlocks the wallet, and a face has no password to hand it.
# #
# The line that goes in: # The line that goes in:
# #
# -auth sufficient /usr/lib/security/pam_plasma_face_unlock.so # -auth sufficient /usr/lib/security/pam_face_unlock.so
#
# with "lockscreen" after it for a lock screen (see the PAM module).
# #
# "sufficient": a match lets the person in, anything else falls through to the # "sufficient": a match lets the person in, anything else falls through to the
# lines below as if this one were not there. The dash makes PAM skip it # lines below as if this one were not there. The dash makes PAM skip it
@@ -26,24 +30,27 @@
# an update to that file still counts. # an update to that file still counts.
# Undoing takes out exactly those lines, or that file. # Undoing takes out exactly those lines, or that file.
PFU_PAM_MARK="# plasma-face-unlock: the face first, the password if that does not work" FU_PAM_MARK="# face-unlock: the face first, the password if that does not work"
PFU_PAM_WRAPPER_MARK="# Written by plasma-face-unlock." FU_PAM_WRAPPER_MARK="# Written by face-unlock."
PFU_PAM_SERVICES=(sudo polkit-1) FU_PAM_SERVICES=(sudo polkit-1)
# Lock screens with a PAM file of their own. One that uses "login" or
# "system-auth" directly is left alone: those also let people log in.
FU_PAM_LOCKERS=(hyprlock swaylock gtklock waylock)
# Overridable for the tests only; the root side never takes them from the # Overridable for the tests only; the root side never takes them from the
# environment (see the top of plasma-face-unlock). # environment (see the top of face-unlock).
PFU_PAM_ETC_DIR="${PFU_PAM_ETC_DIR:-/etc/pam.d}" FU_PAM_ETC_DIR="${FU_PAM_ETC_DIR:-/etc/pam.d}"
read -r -a PFU_PAM_VENDOR_DIRS <<< "${PFU_PAM_VENDOR_DIRS:-/usr/lib/pam.d /usr/share/pam.d /lib/pam.d}" read -r -a FU_PAM_VENDOR_DIRS <<< "${FU_PAM_VENDOR_DIRS:-/usr/lib/pam.d /usr/share/pam.d /lib/pam.d}"
pfu_pam_etc() { fu_pam_etc() {
printf '%s/%s\n' "$PFU_PAM_ETC_DIR" "$1" printf '%s/%s\n' "$FU_PAM_ETC_DIR" "$1"
} }
# pfu_pam_vendor <service> # fu_pam_vendor <service>
# The distribution's own copy, when it keeps one outside /etc. # The distribution's own copy, when it keeps one outside /etc.
pfu_pam_vendor() { fu_pam_vendor() {
local dir local dir
for dir in "${PFU_PAM_VENDOR_DIRS[@]}"; do for dir in "${FU_PAM_VENDOR_DIRS[@]}"; do
if [[ -f $dir/$1 ]]; then if [[ -f $dir/$1 ]]; then
printf '%s\n' "$dir/$1" printf '%s\n' "$dir/$1"
return 0 return 0
@@ -52,24 +59,75 @@ pfu_pam_vendor() {
return 1 return 1
} }
pfu_pam_line() { # fu_pam_is_locker <service>
printf -- '-auth sufficient %s\n' "$PFU_PAM_MODULE" fu_pam_is_locker() {
local s
for s in "${FU_PAM_LOCKERS[@]}"; do
[[ $s == "$1" ]] && return 0
done
return 1
} }
# pfu_pam_enabled <service> # fu_pam_line <service>
pfu_pam_enabled() { fu_pam_line() {
if fu_pam_is_locker "$1"; then
printf -- '-auth sufficient %s lockscreen\n' "$FU_PAM_MODULE"
else
printf -- '-auth sufficient %s\n' "$FU_PAM_MODULE"
fi
}
# fu_pam_lockers
# The lock screens installed here, one per line.
fu_pam_lockers() {
local s
for s in "${FU_PAM_LOCKERS[@]}"; do
if [[ -f $(fu_pam_etc "$s") ]] || fu_pam_vendor "$s" > /dev/null; then
printf '%s\n' "$s"
fi
done
}
# fu_pam_lockers_list
# The same, for people: "hyprlock, swaylock".
fu_pam_lockers_list() {
local list
list="$(fu_pam_lockers | paste -sd ',')"
printf '%s\n' "${list//,/, }"
}
# fu_pam_lockers_here
# Whether this desktop's lock screen is a program of its own (Hyprland,
# Niri and the like), and one with a PAM file is installed.
fu_pam_lockers_here() {
[[ $FU_DESKTOP != plasma && $FU_DESKTOP != gnome && -n $(fu_pam_lockers) ]]
}
# fu_pam_lockers_enabled
# Whether every lock screen installed here takes the face, and there is one.
fu_pam_lockers_enabled() {
local s found=0
while IFS= read -r s; do
found=1
fu_pam_enabled "$s" || return 1
done < <(fu_pam_lockers)
(( found ))
}
# fu_pam_enabled <service>
fu_pam_enabled() {
local file local file
file="$(pfu_pam_etc "$1")" file="$(fu_pam_etc "$1")"
[[ -r $file ]] && grep -q 'pam_plasma_face_unlock\.so' "$file" [[ -r $file ]] && grep -q 'pam_face_unlock\.so' "$file"
} }
# pfu_pam_insert <file> # fu_pam_insert <file> <service>
# Prints the file with the line added before its first auth line. Debian and # Prints the file with the line added before its first auth line. Debian and
# Ubuntu have none in sudo's file, only "@include common-auth", and that # Ubuntu have none in sudo's file, only "@include common-auth", and that
# counts as one: after it the password has already been asked for. A file # counts as one: after it the password has already been asked for. A file
# with neither (which would be odd for either service) gets it at the end. # with neither (which would be odd for either service) gets it at the end.
pfu_pam_insert() { fu_pam_insert() {
awk -v mark="$PFU_PAM_MARK" -v line="$(pfu_pam_line)" ' awk -v mark="$FU_PAM_MARK" -v line="$(fu_pam_line "$2")" '
!done && ($0 ~ /^[[:space:]]*-?auth[[:space:]]/ || $0 ~ /^[[:space:]]*@include[[:space:]]+common-auth([[:space:]]|$)/) { !done && ($0 ~ /^[[:space:]]*-?auth[[:space:]]/ || $0 ~ /^[[:space:]]*@include[[:space:]]+common-auth([[:space:]]|$)/) {
print mark print mark
print line print line
@@ -85,35 +143,36 @@ pfu_pam_insert() {
' "$1" ' "$1"
} }
# pfu_pam_remove_lines <file> # fu_pam_remove_lines <file>
# Prints the file without our comment and our line. # Prints the file without our comment and our line.
pfu_pam_remove_lines() { fu_pam_remove_lines() {
awk -v mark="$PFU_PAM_MARK" ' awk -v mark="$FU_PAM_MARK" '
$0 == mark { next } $0 == mark { next }
/pam_plasma_face_unlock\.so/ { next } /pam_face_unlock\.so/ { next }
{ print } { print }
' "$1" ' "$1"
} }
pfu_pam_wrapper() { # fu_pam_wrapper <vendor file> <service>
fu_pam_wrapper() {
local vendor="$1" local vendor="$1"
printf '#%%PAM-1.0\n' printf '#%%PAM-1.0\n'
printf '%s The face first, then everything\n' "$PFU_PAM_WRAPPER_MARK" printf '%s The face first, then everything\n' "$FU_PAM_WRAPPER_MARK"
printf '# %s does for this service. Removed again by\n' "$vendor" printf '# %s does for this service. Removed again by\n' "$vendor"
printf '# "plasma-face-unlock disable" or from its settings.\n\n' printf '# "face-unlock disable" or from its settings.\n\n'
pfu_pam_line fu_pam_line "$2"
printf 'auth include %s\n' "$vendor" printf 'auth include %s\n' "$vendor"
printf 'account include %s\n' "$vendor" printf 'account include %s\n' "$vendor"
printf 'password include %s\n' "$vendor" printf 'password include %s\n' "$vendor"
printf 'session include %s\n' "$vendor" printf 'session include %s\n' "$vendor"
} }
# _pfu_pam_replace <file> <content> # _fu_pam_replace <file> <content>
# Writes the new file next to the old one and swaps it in, with the old one's # Writes the new file next to the old one and swaps it in, with the old one's
# owner and mode. A half-written PAM file is a locked-out machine. # owner and mode. A half-written PAM file is a locked-out machine.
_pfu_pam_replace() { _fu_pam_replace() {
local file="$1" content="$2" tmp local file="$1" content="$2" tmp
tmp="$(mktemp "${file}.pfu.XXXXXX")" || return 1 tmp="$(mktemp "${file}.fu.XXXXXX")" || return 1
printf '%s\n' "$content" > "$tmp" || { rm -f "$tmp"; return 1; } printf '%s\n' "$content" > "$tmp" || { rm -f "$tmp"; return 1; }
if [[ -e $file ]]; then if [[ -e $file ]]; then
chown --reference="$file" "$tmp" 2>/dev/null chown --reference="$file" "$tmp" 2>/dev/null
@@ -124,40 +183,40 @@ _pfu_pam_replace() {
mv -f "$tmp" "$file" mv -f "$tmp" "$file"
} }
# pfu_pam_enable <service> (root) # fu_pam_enable <service> (root)
pfu_pam_enable() { fu_pam_enable() {
local service="$1" file vendor content local service="$1" file vendor content
file="$(pfu_pam_etc "$service")" file="$(fu_pam_etc "$service")"
[[ -e $PFU_PAM_MODULE ]] || { pfu_bad "$(pfu_msg "The PAM module is not installed at %s." "$PFU_PAM_MODULE")"; return 1; } [[ -e $FU_PAM_MODULE ]] || { fu_bad "$(fu_msg "The PAM module is not installed at %s." "$FU_PAM_MODULE")"; return 1; }
pfu_pam_enabled "$service" && return 0 fu_pam_enabled "$service" && return 0
if [[ -f $file ]]; then if [[ -f $file ]]; then
content="$(pfu_pam_insert "$file")" || return 1 content="$(fu_pam_insert "$file" "$service")" || return 1
elif vendor="$(pfu_pam_vendor "$service")"; then elif vendor="$(fu_pam_vendor "$service")"; then
content="$(pfu_pam_wrapper "$vendor")" content="$(fu_pam_wrapper "$vendor" "$service")"
else else
pfu_bad "$(pfu_msg "There is no PAM configuration for %s on this system." "$service")" fu_bad "$(fu_msg "There is no PAM configuration for %s on this system." "$service")"
return 1 return 1
fi fi
_pfu_pam_replace "$file" "$content" _fu_pam_replace "$file" "$content"
} }
# pfu_pam_disable <service> (root) # fu_pam_disable <service> (root)
pfu_pam_disable() { fu_pam_disable() {
local service="$1" file content local service="$1" file content
file="$(pfu_pam_etc "$service")" file="$(fu_pam_etc "$service")"
pfu_pam_enabled "$service" || return 0 fu_pam_enabled "$service" || return 0
if head -n 3 "$file" | grep -qF "$PFU_PAM_WRAPPER_MARK"; then if head -n 3 "$file" | grep -qF "$FU_PAM_WRAPPER_MARK"; then
# Ours from the first line to the last. Without it the distribution's # Ours from the first line to the last. Without it the distribution's
# own copy is in charge again. # own copy is in charge again.
rm -f -- "$file" rm -f -- "$file"
return return
fi fi
content="$(pfu_pam_remove_lines "$file")" || return 1 content="$(fu_pam_remove_lines "$file")" || return 1
_pfu_pam_replace "$file" "$content" _fu_pam_replace "$file" "$content"
} }
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more