47 Commits
Author SHA1 Message Date
Felitendo 5b46b8f70a chore: 2.1.0
release / Debian package (Ubuntu 26.04) (push) Failing after 2m7s
release / Debian package (Debian 13) (push) Canceled after 0s
release / RPM package (push) Failing after 1m28s
release / Arch Linux tarball (push) Failing after 36s
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
2026-09-28 14:40:44 +02:00
Felitendo da532e711f docs: update the settings screenshot 2026-09-28 14:39:31 +02:00
Felitendo c1cf6cd948 feat: turn on sudo and admin prompts by default 2026-09-28 14:39:31 +02:00
Felitendo d8bbefed8a feat: add a setting for sudo over ssh 2026-09-28 14:38:02 +02:00
Felitendo 658adb7da7 feat: add a setting to stay quiet while the camera is in use 2026-09-28 14:36:40 +02:00
Felitendo 5eb9ea7438 feat: show a crossed-out camera when another app uses it 2026-09-28 14:34:40 +02:00
Felitendo ac38638dd4 ci: build the arch tarball with arch's flags 2026-09-28 11:18:39 +02:00
Felitendo a6f6d1fab4 chore: revert 2.0.1 2026-09-28 10:33:32 +02:00
Felitendo fd8449ea31 ci: add an arch tarball with static opencv 2026-09-28 10:21:39 +02:00
Felitendo 0a48ba9338 chore: 2.0.1 2026-09-28 09:03:54 +02:00
Felitendo c94a94aa8b fix: enter no longer switches settings and faces 2026-09-28 09:03:45 +02:00
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
121 changed files with 25025 additions and 1749 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.1.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 ;;
docs:* | docs\(*) kind=docs ;;
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 ;;
Fix\ * | Repair\ * | Recover\ * | Stop\ *) kind=fix ;;
*\ *) 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).
<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:
name: build, tests and shellcheck
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.
container: archlinux:latest
steps:
+56 -18
View File
@@ -24,12 +24,12 @@ jobs:
strategy:
matrix:
include:
# Plasma 6 arrived in Debian with trixie.
# Qt 6 and KDE Frameworks 6 arrived in Debian with trixie.
- name: Debian 13
image: debian:trixie
codename: trixie
suffix: "~deb13"
- name: Kubuntu 26.04
- name: Ubuntu 26.04
image: ubuntu:26.04
codename: resolute
suffix: "~ubuntu26.04"
@@ -116,8 +116,8 @@ jobs:
- name: Look inside what was built
run: |
rpm -qip dist/plasma-face-unlock-[0-9]*.rpm
rpm -qlp dist/plasma-face-unlock-[0-9]*.rpm
rpm -qip dist/face-unlock-[0-9]*.rpm
rpm -qlp dist/face-unlock-[0-9]*.rpm
- uses: actions/upload-artifact@v7
with:
@@ -127,9 +127,47 @@ jobs:
dist/rpm-signer.asc
if-no-files-found: error
tarball:
name: Arch Linux tarball
runs-on: ubuntu-latest
# Built on Arch itself, because the agent only fits the Qt it was built
# against. OpenCV is linked in, so no opencv here.
container: archlinux:latest
steps:
- name: Install the build tools
run: |
pacman -Syu --noconfirm --needed git base-devel cmake pkgconf curl zstd \
qt6-base qt6-declarative qt6-wayland layer-shell-qt ki18n \
pam systemd-libs gettext scdoc
- uses: actions/checkout@v7
- name: Check the tag against the Makefile
run: packaging/check-version.sh "${{ github.ref_name }}"
- run: packaging/build-opencv.sh
- run: packaging/build-tarball.sh
- name: Install it and run it
run: |
tar -xf dist/*.tar.zst --strip-components=1 -C /
for f in /usr/lib/face-unlock/* /usr/lib/security/pam_face_unlock.so; do
if ldd "$f" | grep 'not found'; then echo "$f is missing a library"; exit 1; fi
done
/usr/lib/face-unlock/face-unlockd --version
useradd -m tester
runuser -u tester -- face-unlock --version
- uses: actions/upload-artifact@v7
with:
name: tarball
path: dist/*.tar.zst
if-no-files-found: error
publish:
name: Release and repositories
needs: [deb, rpm]
needs: [deb, rpm, tarball]
if: startsWith(github.ref, 'refs/tags/v') || inputs.dry_run
runs-on: ubuntu-latest
steps:
@@ -152,9 +190,9 @@ jobs:
gh release create "${{ github.ref_name }}" \
--title "$(.github/release-notes.sh --title "${{ github.ref_name }}")" \
--notes-file notes.md \
incoming/*.deb incoming/*.rpm \
incoming/*.deb incoming/*.rpm incoming/*.tar.zst \
|| gh release upload "${{ github.ref_name }}" \
incoming/*.deb incoming/*.rpm --clobber
incoming/*.deb incoming/*.rpm incoming/*.tar.zst --clobber
- name: Install the repository tools
run: |
@@ -239,7 +277,7 @@ jobs:
- name: Debian 13
image: debian:trixie
codename: trixie
- name: Kubuntu 26.04
- name: Ubuntu 26.04
image: ubuntu:26.04
codename: resolute
container: ${{ matrix.image }}
@@ -254,13 +292,13 @@ jobs:
run: |
apt-get update -qq && apt-get install -y --no-install-recommends gpg
install -d -m 0755 /etc/apt/keyrings
gpg --dearmor -o /etc/apt/keyrings/plasma-face-unlock.gpg < pages/KEY.gpg
echo "deb [signed-by=/etc/apt/keyrings/plasma-face-unlock.gpg] file://$PWD/pages/deb/${{ matrix.codename }} ./" \
> /etc/apt/sources.list.d/plasma-face-unlock.list
gpg --dearmor -o /etc/apt/keyrings/face-unlock.gpg < pages/KEY.gpg
echo "deb [signed-by=/etc/apt/keyrings/face-unlock.gpg] file://$PWD/pages/deb/${{ matrix.codename }} ./" \
> /etc/apt/sources.list.d/face-unlock.list
apt-get update
apt-get install -y plasma-face-unlock
apt-get install -y face-unlock
useradd -m tester
runuser -u tester -- plasma-face-unlock --version
runuser -u tester -- face-unlock --version
verify-dnf:
name: Install from the RPM repository
@@ -281,15 +319,15 @@ jobs:
run: |
rpm --import pages/KEY.gpg
rpm --import signer/rpm-signer.asc
cat > /etc/yum.repos.d/plasma-face-unlock.repo <<EOF
[plasma-face-unlock]
name=plasma-face-unlock
cat > /etc/yum.repos.d/face-unlock.repo <<EOF
[face-unlock]
name=face-unlock
baseurl=file://$PWD/pages/rpm
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=file://$PWD/pages/KEY.gpg
EOF
dnf install -y plasma-face-unlock util-linux
dnf install -y face-unlock util-linux
useradd -m tester
runuser -u tester -- plasma-face-unlock --version
runuser -u tester -- face-unlock --version
+39 -2
View File
@@ -1,6 +1,43 @@
# 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.1.0
_2026-09-28_
Welcome to face-unlock `v2.1.0`! It now gets out of the way during video calls: when another app uses the camera, you type your password right away. sudo and admin prompts take your face from the start.
### Highlights
- A crossed-out camera when another app uses it
- A setting to stay quiet while the camera is in use
- sudo and admin prompts are on by default
- A setting for sudo over SSH, off by default
## 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
@@ -17,7 +54,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.
<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>
### Highlights
+13 -6
View File
@@ -14,10 +14,14 @@
- Subject as short as possible. Imperative, lowercase, no trailing period.
- 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.
- 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
- `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/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.
@@ -45,10 +49,10 @@ A minor or major release (has `### Highlights`, gets a heading and the support s
_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">
<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>
### 🚨 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.
- Without a camera, point the daemon at pictures or a video: `Camera=images:<dir>` or
`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
the other parts at it with `PFU_SOCKET`.
- `make check` after every change. New strings: `po/update-pot.sh`, then translate them in `po/de.po`.
the other parts at it with `FU_SOCKET`.
- 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`.
+99 -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
# 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.
cmake_minimum_required(VERSION 3.22)
project(plasma-face-unlock VERSION 1.0.1 LANGUAGES C CXX)
project(face-unlock VERSION 2.1.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_REQUIRED ON)
@@ -21,19 +21,19 @@ endif()
include(GNUInstallDirs)
option(PFU_BUILD_AGENT "Build the Plasma agent (bubble, lock screen, set up window)" ON)
option(PFU_BUILD_PAM "Build the PAM module" ON)
option(PFU_BUILD_TESTS "Build the tests" ON)
option(FU_BUILD_AGENT "Build the agent (bubble, lock screen, set up window)" ON)
option(FU_BUILD_PAM "Build the PAM module" ON)
option(FU_BUILD_TESTS "Build the tests" ON)
# 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.
set(PFU_LIBEXECDIR "${CMAKE_INSTALL_PREFIX}/lib/plasma-face-unlock" CACHE PATH "Helper programs")
set(PFU_MODELDIR "${CMAKE_INSTALL_FULL_DATADIR}/plasma-face-unlock/models" CACHE PATH "Neural network models")
set(PFU_LOCALEDIR "${CMAKE_INSTALL_FULL_LOCALEDIR}" CACHE PATH "Translations")
set(PFU_SOCKET "/run/plasma-face-unlock/socket" CACHE STRING "The daemon's socket")
set(PFU_STATEDIR "/var/lib/plasma-face-unlock" CACHE PATH "Face data")
set(PFU_CONFIG "/etc/plasma-face-unlock/config" CACHE FILEPATH "System settings")
set(PFU_PAMDIR "${CMAKE_INSTALL_PREFIX}/lib/security" CACHE PATH "Where PAM modules live")
set(FU_LIBEXECDIR "${CMAKE_INSTALL_PREFIX}/lib/face-unlock" CACHE PATH "Helper programs")
set(FU_MODELDIR "${CMAKE_INSTALL_FULL_DATADIR}/face-unlock/models" CACHE PATH "Neural network models")
set(FU_LOCALEDIR "${CMAKE_INSTALL_FULL_LOCALEDIR}" CACHE PATH "Translations")
set(FU_SOCKET "/run/face-unlock/socket" CACHE STRING "The daemon's socket")
set(FU_STATEDIR "/var/lib/face-unlock" CACHE PATH "Face data")
set(FU_CONFIG "/etc/face-unlock/config" CACHE FILEPATH "System settings")
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)
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)
# OpenCV 5 split the contour and transform helpers into "geometry".
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)
list(APPEND PFU_OPENCV_MODULES geometry)
list(APPEND FU_OPENCV_MODULES geometry)
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
# OpenCV).
add_library(pfu_common STATIC src/core/keyvalue.cpp)
target_include_directories(pfu_common PUBLIC src/core)
target_link_libraries(pfu_common PUBLIC Qt6::Core)
set_target_properties(pfu_common PROPERTIES POSITION_INDEPENDENT_CODE ON)
add_library(fu_common STATIC src/core/keyvalue.cpp)
target_include_directories(fu_common PUBLIC src/core)
target_link_libraries(fu_common PUBLIC Qt6::Core)
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/camera.cpp
src/core/vision.cpp
src/core/liveness.cpp
src/core/store.cpp
)
target_include_directories(pfu_core PUBLIC src/core ${OpenCV_INCLUDE_DIRS})
target_link_libraries(pfu_core PUBLIC pfu_common Qt6::Core ${OpenCV_LIBS})
set_target_properties(pfu_core PROPERTIES POSITION_INDEPENDENT_CODE ON)
target_include_directories(fu_core PUBLIC src/core ${OpenCV_INCLUDE_DIRS})
target_link_libraries(fu_core PUBLIC fu_common Qt6::Core ${OpenCV_LIBS})
set_target_properties(fu_core PROPERTIES POSITION_INDEPENDENT_CODE ON)
# ---------------------------------------------------------------------------
# The daemon
# ---------------------------------------------------------------------------
add_executable(plasma-face-unlockd
add_executable(face-unlockd
src/daemon/job.h
src/daemon/agentlink.cpp
src/daemon/main.cpp
@@ -87,38 +87,40 @@ add_executable(plasma-face-unlockd
src/daemon/userstate.cpp
src/daemon/system.cpp
)
target_link_libraries(plasma-face-unlockd PRIVATE pfu_core Qt6::DBus Qt6::Network)
install(TARGETS plasma-face-unlockd DESTINATION ${PFU_LIBEXECDIR})
target_link_libraries(face-unlockd PRIVATE fu_core Qt6::DBus Qt6::Network)
install(TARGETS face-unlockd DESTINATION ${FU_LIBEXECDIR})
# ---------------------------------------------------------------------------
# The client the shell code uses
# ---------------------------------------------------------------------------
add_executable(plasma-face-unlock-ctl src/ctl/main.cpp)
target_link_libraries(plasma-face-unlock-ctl PRIVATE Qt6::Core Qt6::Network)
install(TARGETS plasma-face-unlock-ctl DESTINATION ${PFU_LIBEXECDIR})
add_executable(face-unlock-ctl src/ctl/main.cpp)
target_link_libraries(face-unlock-ctl PRIVATE Qt6::Core Qt6::Network)
install(TARGETS face-unlock-ctl DESTINATION ${FU_LIBEXECDIR})
# ---------------------------------------------------------------------------
# The PAM module
# ---------------------------------------------------------------------------
if(PFU_BUILD_PAM)
find_path(PAM_INCLUDE_DIR security/pam_modules.h REQUIRED)
find_library(PAM_LIBRARY pam REQUIRED)
# 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_library(PAM_LIBRARY pam REQUIRED)
if(FU_BUILD_PAM)
find_package(PkgConfig REQUIRED)
pkg_check_modules(SYSTEMD IMPORTED_TARGET libsystemd)
add_library(pam_plasma_face_unlock MODULE src/pam/pam_plasma_face_unlock.c)
set_target_properties(pam_plasma_face_unlock PROPERTIES PREFIX "" C_VISIBILITY_PRESET hidden)
target_include_directories(pam_plasma_face_unlock PRIVATE ${PAM_INCLUDE_DIR})
target_link_libraries(pam_plasma_face_unlock PRIVATE ${PAM_LIBRARY})
add_library(pam_face_unlock MODULE src/pam/pam_face_unlock.c)
set_target_properties(pam_face_unlock PROPERTIES PREFIX "" C_VISIBILITY_PRESET hidden)
target_include_directories(pam_face_unlock PRIVATE ${PAM_INCLUDE_DIR})
target_link_libraries(pam_face_unlock PRIVATE ${PAM_LIBRARY})
if(SYSTEMD_FOUND)
target_compile_definitions(pam_plasma_face_unlock PRIVATE HAVE_SYSTEMD=1)
target_link_libraries(pam_plasma_face_unlock PRIVATE PkgConfig::SYSTEMD)
target_compile_definitions(pam_face_unlock PRIVATE HAVE_SYSTEMD=1)
target_link_libraries(pam_face_unlock PRIVATE PkgConfig::SYSTEMD)
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)
target_include_directories(pam_harness PRIVATE ${PAM_INCLUDE_DIR})
target_link_libraries(pam_harness PRIVATE ${PAM_LIBRARY})
@@ -129,7 +131,7 @@ endif()
# The agent
# ---------------------------------------------------------------------------
if(PFU_BUILD_AGENT)
if(FU_BUILD_AGENT)
find_package(Qt6 6.5 REQUIRED COMPONENTS Gui Quick WaylandClient)
# 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.
@@ -142,38 +144,48 @@ if(PFU_BUILD_AGENT)
find_package(LayerShellQt REQUIRED)
find_package(KF6I18n REQUIRED)
qt_add_executable(plasma-face-unlock-agent
qt_add_executable(face-unlock-agent
src/agent/main.cpp
src/agent/daemonclient.cpp
src/agent/userconfig.cpp
src/agent/agentsocket.cpp
src/agent/bubblewindow.cpp
src/agent/bubblecontroller.cpp
src/agent/bubbleservice.cpp
src/agent/lockcontroller.cpp
src/agent/lockwatcher.cpp
src/agent/lockers.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
)
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)
target_compile_definitions(plasma-face-unlock-agent PRIVATE PFU_LAYERSHELL_HAS_SCREEN)
target_compile_definitions(face-unlock-agent PRIVATE FU_LAYERSHELL_HAS_SCREEN)
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
${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
# see each other (and the Theme singleton) without importing anything.
# Under /qt/qml, which the engine searches, or an installed agent finds no
# types (the build directory only works by its PlasmaFaceUnlock/qmldir).
set(PFU_QML_FILES Theme FaceGlyph LockGlyph Checkmark Bubble TickRing Enroll PillButton)
foreach(f ${PFU_QML_FILES})
# types (the build directory only works by its FaceUnlock/qmldir).
set(FU_QML_FILES Theme FaceGlyph LockGlyph CameraGlyph Checkmark Bubble TickRing Enroll PillButton LockScreen LockChoice LockPreview)
foreach(f ${FU_QML_FILES})
set_source_files_properties(src/agent/qml/${f}.qml PROPERTIES QT_RESOURCE_ALIAS ${f}.qml)
endforeach()
set_source_files_properties(src/agent/qml/Theme.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
qt_add_qml_module(plasma-face-unlock-agent
URI PlasmaFaceUnlock
qt_add_qml_module(face-unlock-agent
URI FaceUnlock
VERSION 1.0
RESOURCE_PREFIX /qt/qml
SOURCES
@@ -183,38 +195,66 @@ if(PFU_BUILD_AGENT)
src/agent/qml/Theme.qml
src/agent/qml/FaceGlyph.qml
src/agent/qml/LockGlyph.qml
src/agent/qml/CameraGlyph.qml
src/agent/qml/Checkmark.qml
src/agent/qml/Bubble.qml
src/agent/qml/TickRing.qml
src/agent/qml/Enroll.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::WaylandClient Qt6::WaylandClientPrivate
LayerShellQt::Interface
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()
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
if(PFU_BUILD_TESTS)
if(FU_BUILD_TESTS)
enable_testing()
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_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_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()
+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
# 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
# (`make VERSION=$pkgver`). The literal below is the fallback for builds
# straight from a checkout, and is what a release tag has to carry.
VERSION ?= 1.0.1
VERSION ?= 2.1.0
PREFIX ?= /usr
DESTDIR ?=
BINDIR ?= $(PREFIX)/bin
DATADIR ?= $(PREFIX)/share
LIBDIR ?= $(DATADIR)/plasma-face-unlock/lib
LIBEXECDIR ?= $(PREFIX)/lib/plasma-face-unlock
MODELDIR ?= $(DATADIR)/plasma-face-unlock/models
LIBDIR ?= $(DATADIR)/face-unlock/lib
LIBEXECDIR ?= $(PREFIX)/lib/face-unlock
MODELDIR ?= $(DATADIR)/face-unlock/models
LOCALEDIR ?= $(DATADIR)/locale
MANDIR ?= $(DATADIR)/man
APPDIR ?= $(DATADIR)/applications
POLKITDIR ?= $(DATADIR)/polkit-1/actions
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.
# A build with a prefix of its own keeps them under that prefix.
@@ -44,11 +46,14 @@ endif
BUILDDIR ?= build
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 ?=
LINGUAS := de
LINGUAS := de es fr it ja ko nl pl pt_BR ru tr uk zh_CN
MOFILES := $(patsubst %,po/%.mo,$(LINGUAS))
MANPAGE := doc/plasma-face-unlock.1
MANPAGE := doc/face-unlock.1
LIBS := $(wildcard src/lib/*.sh)
@@ -85,13 +90,13 @@ native:
$(CMAKE) -S . -B $(BUILDDIR) \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=$(PREFIX) \
-DPFU_VERSION=$(VERSION) \
-DPFU_LIBEXECDIR=$(LIBEXECDIR) \
-DPFU_MODELDIR=$(MODELDIR) \
-DPFU_LOCALEDIR=$(LOCALEDIR) \
-DPFU_PAMDIR=$(PAMDIR) \
-DFU_VERSION=$(VERSION) \
-DFU_LIBEXECDIR=$(LIBEXECDIR) \
-DFU_MODELDIR=$(MODELDIR) \
-DFU_LOCALEDIR=$(LOCALEDIR) \
-DFU_PAMDIR=$(PAMDIR) \
$(CMAKE_FLAGS)
$(CMAKE) --build $(BUILDDIR) --parallel
$(CMAKE) --build $(BUILDDIR) --parallel $(JOBS)
models: $(addprefix models/,$(MODELS))
@@ -108,7 +113,7 @@ else
@echo "msgfmt not found, skipping $@"
endif
$(MANPAGE): doc/plasma-face-unlock.1.scd
$(MANPAGE): doc/face-unlock.1.scd
ifdef SCDOC
$(SCDOC) < $< > $@
else
@@ -116,13 +121,15 @@ else
endif
# 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:
@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"; \
done
@if command -v shellcheck >/dev/null 2>&1; then \
shellcheck -x -e SC1090,SC1091 src/plasma-face-unlock $(LIBS) tests/*.sh; \
echo "ok shellcheck"; \
shellcheck -x -e SC1090,SC1091,SC2034 src/face-unlock $(LIBS) tests/*.sh \
&& echo "ok shellcheck"; \
else \
echo "shellcheck not found, skipped"; \
fi
@@ -144,7 +151,7 @@ install: build
DESTDIR="$(DESTDIR)" $(CMAKE) --install $(BUILDDIR)
# 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 -Dm644 -t "$(DESTDIR)$(LIBDIR)" $(LIBS)
sed -i -e 's|@VERSION@|$(VERSION)|g' \
@@ -152,7 +159,7 @@ install: build
-e 's|@LIBEXECDIR@|$(LIBEXECDIR)|g' \
-e 's|@LOCALEDIR@|$(LOCALEDIR)|g' \
-e 's|@PAMDIR@|$(PAMDIR)|g' \
"$(DESTDIR)$(BINDIR)/plasma-face-unlock" \
"$(DESTDIR)$(BINDIR)/face-unlock" \
"$(DESTDIR)$(LIBDIR)"/*.sh
# the models
@@ -161,50 +168,54 @@ install: build
done
# 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/plasma-face-unlockd.service "$(DESTDIR)$(SYSTEMUNITDIR)/plasma-face-unlockd.service"
install -Dm644 res/systemd/plasma-face-unlock-agent.service "$(DESTDIR)$(USERUNITDIR)/plasma-face-unlock-agent.service"
install -Dm644 res/systemd/face-unlockd.socket "$(DESTDIR)$(SYSTEMUNITDIR)/face-unlockd.socket"
install -Dm644 res/systemd/face-unlockd.service "$(DESTDIR)$(SYSTEMUNITDIR)/face-unlockd.service"
install -Dm644 res/systemd/face-unlock-agent.service "$(DESTDIR)$(USERUNITDIR)/face-unlock-agent.service"
sed -i -e 's|@LIBEXECDIR@|$(LIBEXECDIR)|g' \
"$(DESTDIR)$(SYSTEMUNITDIR)/plasma-face-unlockd.service" \
"$(DESTDIR)$(USERUNITDIR)/plasma-face-unlock-agent.service"
"$(DESTDIR)$(SYSTEMUNITDIR)/face-unlockd.service" \
"$(DESTDIR)$(USERUNITDIR)/face-unlock-agent.service"
# the desktop file KWin looks for before it lets the bubble above the
# lock screen, the polkit action, the icon
install -Dm644 res/applications/io.github.loonixtools.plasma-face-unlock-agent.desktop \
"$(DESTDIR)$(APPDIR)/io.github.loonixtools.plasma-face-unlock-agent.desktop"
sed -i -e 's|@LIBEXECDIR@|$(LIBEXECDIR)|g' "$(DESTDIR)$(APPDIR)/io.github.loonixtools.plasma-face-unlock-agent.desktop"
install -Dm644 res/polkit/io.github.loonixtools.plasma-face-unlock.policy \
"$(DESTDIR)$(POLKITDIR)/io.github.loonixtools.plasma-face-unlock.policy"
install -Dm644 res/plasma-face-unlock.svg "$(DESTDIR)$(ICONDIR)/plasma-face-unlock.svg"
install -Dm644 res/applications/io.github.loonixtools.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.face-unlock-agent.desktop"
install -Dm644 res/polkit/io.github.loonixtools.face-unlock.policy \
"$(DESTDIR)$(POLKITDIR)/io.github.loonixtools.face-unlock.policy"
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
@for l in $(LINGUAS); do \
if [ -f "po/$$l.mo" ]; then \
install -Dm644 "po/$$l.mo" \
"$(DESTDIR)$(LOCALEDIR)/$$l/LC_MESSAGES/plasma-face-unlock.mo"; \
"$(DESTDIR)$(LOCALEDIR)/$$l/LC_MESSAGES/face-unlock.mo"; \
fi; \
done
# documentation
@if [ -f $(MANPAGE) ]; then \
install -Dm644 $(MANPAGE) "$(DESTDIR)$(MANDIR)/man1/plasma-face-unlock.1"; \
install -Dm644 $(MANPAGE) "$(DESTDIR)$(MANDIR)/man1/face-unlock.1"; \
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:
rm -f "$(DESTDIR)$(BINDIR)/plasma-face-unlock"
rm -rf "$(DESTDIR)$(DATADIR)/plasma-face-unlock"
rm -f "$(DESTDIR)$(BINDIR)/face-unlock"
rm -rf "$(DESTDIR)$(DATADIR)/face-unlock"
rm -rf "$(DESTDIR)$(LIBEXECDIR)"
rm -f "$(DESTDIR)$(PAMDIR)/pam_plasma_face_unlock.so"
rm -f "$(DESTDIR)$(SYSTEMUNITDIR)/plasma-face-unlockd.socket"
rm -f "$(DESTDIR)$(SYSTEMUNITDIR)/plasma-face-unlockd.service"
rm -f "$(DESTDIR)$(USERUNITDIR)/plasma-face-unlock-agent.service"
rm -f "$(DESTDIR)$(APPDIR)/io.github.loonixtools.plasma-face-unlock-agent.desktop"
rm -f "$(DESTDIR)$(POLKITDIR)/io.github.loonixtools.plasma-face-unlock.policy"
rm -f "$(DESTDIR)$(ICONDIR)/plasma-face-unlock.svg"
rm -f "$(DESTDIR)$(MANDIR)/man1/plasma-face-unlock.1"
rm -rf "$(DESTDIR)$(DATADIR)/doc/plasma-face-unlock"
@for l in $(LINGUAS); do rm -f "$(DESTDIR)$(LOCALEDIR)/$$l/LC_MESSAGES/plasma-face-unlock.mo"; done
rm -f "$(DESTDIR)$(PAMDIR)/pam_face_unlock.so"
rm -f "$(DESTDIR)$(SYSTEMUNITDIR)/face-unlockd.socket"
rm -f "$(DESTDIR)$(SYSTEMUNITDIR)/face-unlockd.service"
rm -f "$(DESTDIR)$(USERUNITDIR)/face-unlock-agent.service"
rm -f "$(DESTDIR)$(APPDIR)/io.github.loonixtools.face-unlock-agent.desktop"
rm -f "$(DESTDIR)$(POLKITDIR)/io.github.loonixtools.face-unlock.policy"
rm -f "$(DESTDIR)$(ICONDIR)/face-unlock.svg"
rm -rf "$(DESTDIR)$(GNOMEEXTDIR)/$(GNOMEEXT)"
rm -f "$(DESTDIR)$(MANDIR)/man1/face-unlock.1"
rm -rf "$(DESTDIR)$(DATADIR)/doc/face-unlock"
@for l in $(LINGUAS); do rm -f "$(DESTDIR)$(LOCALEDIR)/$$l/LC_MESSAGES/face-unlock.mo"; done
clean:
rm -rf $(BUILDDIR) po/*.mo $(MANPAGE)
+90 -68
View File
@@ -1,24 +1,25 @@
<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>
<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">
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>
<h5 align="center">
<a href="#install">Install</a> |
<a href="#how-to-use">How to use</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>
<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 align="center">
@@ -26,122 +27,143 @@
</p>
<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>
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
**Arch, CachyOS, EndeavourOS, Manjaro** (AUR)
<details>
<summary><b>Arch</b>, CachyOS, EndeavourOS, Manjaro</summary>
```bash
yay -S plasma-face-unlock
yay -S face-unlock
```
**Fedora**
</details>
<details>
<summary><b>Fedora</b></summary>
```bash
sudo curl -fsSL -o /etc/yum.repos.d/plasma-face-unlock.repo \
https://loonixtools.github.io/plasma-face-unlock/plasma-face-unlock.repo
sudo dnf install plasma-face-unlock
sudo curl -fsSL -o /etc/yum.repos.d/face-unlock.repo \
https://loonixtools.github.io/face-unlock/face-unlock.repo
sudo dnf install face-unlock
```
**Debian, Kubuntu**
</details>
<details>
<summary><b>Debian</b>, Ubuntu</summary>
```bash
codename="$(sed -n 's/^VERSION_CODENAME=//p' /etc/os-release)"
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://loonixtools.github.io/plasma-face-unlock/KEY.gpg \
| sudo gpg --dearmor -o /etc/apt/keyrings/plasma-face-unlock.gpg
echo "deb [signed-by=/etc/apt/keyrings/plasma-face-unlock.gpg] https://loonixtools.github.io/plasma-face-unlock/deb/$codename ./" \
| sudo tee /etc/apt/sources.list.d/plasma-face-unlock.list
sudo apt update && sudo apt install plasma-face-unlock
curl -fsSL https://loonixtools.github.io/face-unlock/KEY.gpg \
| sudo gpg --dearmor -o /etc/apt/keyrings/face-unlock.gpg
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/face-unlock.list
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
updates.
</details>
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
```bash
plasma-face-unlock
face-unlock
```
This opens a menu:
<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>
Press **1** and follow the setup window: look at the camera, then turn your head slowly in a circle
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.
Press **1** and look at the camera. Then lock the screen and look at it.
**4** opens the settings. The text at the bottom says what the selected one does:
<details>
<summary>Settings</summary>
<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">
</p>
Without the menu: `plasma-face-unlock enable`, `disable`, `setup [NAME]`, `faces`, `remove ID`,
`test` and `status`.
</details>
## Is it safe?
It is a convenience, not a security upgrade. Face ID on a phone sees your face in 3D. A webcam only
sees a flat picture.
It is a convenience upgrade, not extra security. Your webcam only sees a flat picture, so
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:
it gives itself away by its reflection and its straight edges.
- 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.
- Photos and videos on a phone, tablet or glossy screen are caught.
- A matte printed photo is caught when the photo check is set to *strict*.
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 asks for the
password, unless you turn that on.
## 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. |
| `plasma-face-unlock-agent` | Runs in your session. It watches the lock screen and draws the bubble. |
| `pam_plasma_face_unlock.so` | Lets sudo and admin prompts ask the daemon. No match: you type your password as usual. |
| `plasma-face-unlock` | The menu. |
| `face-unlockd` | The root service. Owns the camera and the face data. |
| `face-unlock-agent` | Runs in your session. Watches the lock screen, draws the bubble. |
| `pam_face_unlock.so` | Lets sudo and admin prompts ask the service. |
| `face-unlock` | The menu. |
Two small networks from the OpenCV model zoo find the face (YuNet) and turn it into numbers (SFace).
They run on the CPU in a few milliseconds. The lock screen is unlocked through logind, the same way
`loginctl unlock-session` does it. `man plasma-face-unlock` has all the details.
Two small networks from the OpenCV model zoo run on the CPU: YuNet finds the face, SFace turns it
into numbers. All details: `man face-unlock`.
## Build from source
</details>
<details>
<summary>Build from source</summary>
```bash
make models # downloads the two networks and checks them
make models
make
make test
sudo make install
```
You need CMake, a C++20 compiler, Qt 6, LayerShellQt, KI18n, OpenCV 4.5.4 or newer (with DNN),
Linux-PAM and libsystemd. `scdoc` and `msgfmt` are optional (man page, translations).
[packaging/README.md](packaging/README.md) explains releases.
Needs CMake, a C++20 compiler, Qt 6, LayerShellQt, KI18n, OpenCV 4.5.4+ (with DNN), Linux-PAM and
libsystemd.
</details>
## Credits
- [Glance](https://github.com/jonnyoo/glance) by Jonathan Zhou (MIT): face unlock for the Mac. The
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) (MIT) and
[SFace](https://github.com/opencv/opencv_zoo/tree/main/models/face_recognition_sface) (Apache-2.0)
from the OpenCV model zoo.
## License
- [Glance](https://github.com/jonnyoo/glance) by Jonathan Zhou: the idea and the look of the bubble.
- [YuNet](https://github.com/opencv/opencv_zoo/tree/main/models/face_detection_yunet) and
[SFace](https://github.com/opencv/opencv_zoo/tree/main/models/face_recognition_sface) from the
OpenCV model zoo.
GPL-3.0-or-later.
@@ -1,19 +1,20 @@
plasma-face-unlock(1)
face-unlock(1)
# NAME
plasma-face-unlock - face unlock for KDE Plasma
face-unlock - face unlock for Plasma, GNOME, Hyprland and Niri
# SYNOPSIS
*plasma-face-unlock* [_command_]
*face-unlock* [_command_]
# DESCRIPTION
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
instead of a password. A photo of somebody on a phone or tablet does not get
in, and with the strict photo check a printed one does not either.
in a terminal and the admin password prompts can all take a face instead of a
password, on KDE Plasma, GNOME, Hyprland and Niri (see *DESKTOPS*). A photo of
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
the camera starts looking, the face in it looks around, and when it recognises
@@ -30,8 +31,11 @@ It is a convenience, not a security upgrade. See *HOW SAFE IS THIS*.
*enable*
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
they were on before.
starts the lock screen agent, and turns on sudo and admin prompts, unless
they were turned off under *Settings*. 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*
Turn it off: the agent stops and sudo and the admin prompts go back to the
@@ -56,6 +60,12 @@ It is a convenience, not a security upgrade. See *HOW SAFE IS THIS*.
*status*
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*
Show a summary of the commands.
@@ -64,23 +74,23 @@ It is a convenience, not a security upgrade. See *HOW SAFE IS THIS*.
# THE PIECES
*plasma-face-unlockd*
*face-unlockd*
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
with nothing to do.
*plasma-face-unlock-agent*
Runs in the Plasma session as a user service
(_plasma-face-unlock-agent.service_). It watches the lock screen, asks the
*face-unlock-agent*
Runs in the desktop session as a user service
(_face-unlock-agent.service_). It watches the lock screen, asks the
daemon to scan when somebody comes back, unlocks the session when the face
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 answer into a PAM result.
*plasma-face-unlock-ctl*
*face-unlock-ctl*
How this command talks to the daemon.
# RECOGNITION
@@ -156,8 +166,8 @@ The other guards:
face needs the password (polkit, *auth_self_keep*). A face cannot answer
that question even with admin prompts on: the daemon refuses to scan while
it is being asked;
- sudo and admin prompts are refused over SSH and for anybody without an
active session at the machine;
- sudo and admin prompts are refused over SSH, unless *In SSH sessions* is
on, and always for anybody without an active session at the machine;
- the lock screen is unlocked through logind, the same way
*loginctl unlock-session* does it. That does not lower the bar: any program
running as the user could already do that. For sudo and admin prompts the
@@ -171,14 +181,16 @@ prompts off, or face unlock altogether.
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
fingerprints. So face unlock does not go through the lock screen's PAM at all.
The agent watches for the screen to lock (org.freedesktop.ScreenSaver) and
scans when somebody comes back:
fingerprints. GNOME's, hyprlock and swaylock only ask their PAM stack once the
password is typed. So face unlock does not go through the lock screen's PAM at
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
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
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;
- 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
@@ -188,24 +200,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
does not start a scan with every key.
Unlocked through logind, the lock screen cuts off its own password prompt and
counts that as a wrong password. After a face unlock the daemon resets the
When the face matches, the agent unlocks the session the way its lock screen
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
never lock the account.
The bubble is a layer-shell surface that KWin keeps above the lock screen
(kde_lockscreen_overlay_v1). KWin only allows that for a program whose desktop
file asks for it, which is
_io.github.loonixtools.plasma-face-unlock-agent.desktop_.
On Plasma the bubble is a layer-shell surface that KWin keeps above the lock
screen (kde_lockscreen_overlay_v1). KWin only allows that for a program whose
desktop file asks for it, which is
_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:
```
-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
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
@@ -230,31 +319,49 @@ easier. A face set up with one camera should be set up again for another.
A laptop with its lid shut is not scanned (*Not when the lid is closed*).
A camera that another app uses, in a video call for example, is not scanned
either. The password is asked for at once, and the bubble shows a camera with
a line through it. *Quiet while the camera is in use* leaves out the bubble
too.
# FILES
_~/.config/plasma-face-unlock/config_
_~/.config/face-unlock/config_
This user's settings: the lock screen, the bubble and its animation
speed. Written by the menu.
_/etc/plasma-face-unlock/config_
_/etc/face-unlock/config_
The system settings: camera, photo check, strictness, attention, scan
length. Written by the menu through sudo.
length, SSH sessions. 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.
_/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.
_/usr/share/plasma-face-unlock/models/_
_/usr/share/face-unlock/models/_
The two networks.
_/run/plasma-face-unlock/socket_
_/run/face-unlock/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
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
@@ -263,17 +370,18 @@ _$XDG_RUNTIME_DIR/plasma-face-unlock/agent.socket_
# REQUIREMENTS
KDE Plasma 6 on Wayland, a camera, OpenCV 4.5.4 or newer with its DNN module,
Qt 6, LayerShellQt, KI18n, systemd, polkit and Linux-PAM.
KDE Plasma 6, GNOME, Hyprland or Niri on Wayland, a camera, OpenCV 4.5.4 or
newer with its DNN module, Qt 6, LayerShellQt, KI18n, systemd, polkit and
Linux-PAM.
# SEE ALSO
*loginctl*(1), *pam*(8), *polkit*(8), *systemctl*(1)
*loginctl*(1), *pam*(8), *polkit*(8), *systemctl*(1), *swaylock*(1)
# AUTHORS
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
(https://github.com/jonnyoo/glance, MIT). The models are YuNet and SFace from
+28 -17
View File
@@ -7,34 +7,43 @@ description of the layout, and two descriptions drift.
| | |
|---|---|
| `deb/control`, `deb/copyright` | metadata for the Debian binary package |
| `rpm/plasma-face-unlock.spec` | the RPM spec |
| `build-deb.sh`, `build-rpm.sh` | build one package into `dist/` |
| `rpm/face-unlock.spec` | the RPM spec |
| `build-deb.sh`, `build-rpm.sh`, `build-tarball.sh` | build one package into `dist/` |
| `build-opencv.sh` | builds the static OpenCV the Arch tarball links in |
| `check-version.sh` | refuses a tag that disagrees with the Makefile |
| `publish-repos.sh` | regenerates the APT and RPM repositories |
| `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).
Its CI notices a new GitHub release, updates the checksum and pushes to the AUR.
The AUR packages live in [Felitendo/PKGBUILDS](https://github.com/Felitendo/PKGBUILDS):
`face-unlock` builds from source, `face-unlock-bin` takes the Arch tarball.
Its CI notices a new GitHub release, updates the checksums and pushes to the AUR.
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
them. The Debian package's library dependencies are read off the binaries by
`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
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
built on the current Fedora.
The Arch tarball (`face-unlock-<version>-arch-x86_64.tar.zst`) is built on Arch,
for the same reason. It holds a folder with the `make install` tree. Arch
changes the OpenCV soname with every new OpenCV, so OpenCV is linked in
statically (`build-opencv.sh`: only the modules the daemon uses, nothing it
would load at run time). Qt stays shared, so a new Qt minor version on Arch
needs a new release.
The two networks (YuNet and SFace, from the OpenCV model zoo) are not in the
repository. `make models` downloads them and checks them against the
checksums in the Makefile. The RPM spec and the PKGBUILD list them as sources
of their own, with the same checksums.
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
touched when somebody asks for sudo or admin prompts. Removing a package
disables the socket.
@@ -44,22 +53,24 @@ disables the socket.
```bash
packaging/build-deb.sh # in a Debian 13 container, with the -dev packages from release.yml
packaging/build-rpm.sh # in a Fedora container, with the -devel packages from the spec
packaging/build-opencv.sh && packaging/build-tarball.sh # in an Arch container, with the packages from release.yml
```
Both take the version from `make version` unless one is passed as the first
All three take the version from `make version` unless one is passed as the first
argument.
## Making a release
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.
3. Commit, then `git tag vX.Y.Z && git push --tags`.
The `release` workflow builds both packages in a Debian and a Fedora container,
refuses the tag if it disagrees with the Makefile or has no changelog entry,
attaches the packages to a GitHub release with the entry as its notes, and adds
them to the APT and RPM repositories on the `gh-pages` branch.
The `release` workflow builds the packages in Debian, Ubuntu, Fedora and Arch
containers, refuses the tag if it disagrees with the Makefile or has no
changelog entry, attaches the packages to a GitHub release with the entry as
its notes, and adds the deb and rpm files to the APT and RPM repositories on
the `gh-pages` branch.
## Trying the release path first
@@ -67,7 +78,7 @@ them to the APT and RPM repositories on the `gh-pages` branch.
gh workflow run release.yml -f dry_run=true
```
Builds both packages, builds both repositories with a key generated on the
Builds the packages, builds both repositories with a key generated on the
spot, checks the signatures, and installs the packages back out of the
repositories. Nothing is pushed and no release is made.
@@ -75,13 +86,13 @@ repositories. Nothing is pushed and no release is made.
```bash
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
```
Without the secret the workflow still builds both packages and attaches them to
Without the secret the workflow still builds the packages and attaches them to
the release; it says so in the log and leaves the repositories alone.
## Pointing Pages at it, once, in this order
+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
# each one gets a build of its own, told apart by a suffix: ~deb13, ~ubuntu26.04.
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
# should be quietly willing to produce.
@@ -63,11 +63,21 @@ cat > "$root/DEBIAN/prerm" <<'SH'
#!/bin/sh
set -e
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
SH
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' \
| LC_ALL=C sort -z | xargs -0 md5sum > DEBIAN/md5sums )
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
#
# Builds the OpenCV the Arch tarball links in, as static libraries.
#
# Arch moves to a new OpenCV now and then, each with a new soname, and a
# daemon linked against the old one then no longer starts. Linked in
# statically, the daemon brings its own. Only the modules face-unlock uses are
# built, with OpenCV's own copies of zlib, libjpeg and libpng, so nothing is
# left to load at run time. The camera is read through V4L2, which OpenCV
# talks to directly.
#
# packaging/build-opencv.sh [PREFIX]
#
# Installs into PREFIX (build/opencv-static by default) and does nothing when
# PREFIX already holds what this script builds. Needs: cmake, a C++ compiler,
# curl.
set -euo pipefail
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
prefix="${1:-$here/build/opencv-static}"
# 4.x, like the deb and the rpm. The static dnn of 5.0.0 does not link
# (https://github.com/opencv/opencv/issues/29342).
version=4.14.0
sha256=ee8fb9b30eb60850431b4656447080e3737b56e45719c92b67f245950609f86e
# The flags Arch builds its own packages with: hardening, and full RELRO.
if [[ -f /etc/makepkg.conf ]]; then
# shellcheck source=/dev/null
source /etc/makepkg.conf
export CFLAGS CXXFLAGS LDFLAGS
fi
# A change to this script or to the flags is a different build.
stamp="$({ cat "${BASH_SOURCE[0]}"; echo "${CFLAGS:-} ${CXXFLAGS:-} ${LDFLAGS:-}"; } | sha256sum | cut -d' ' -f1)"
if [[ -f $prefix/.stamp && $(<"$prefix/.stamp") == "$stamp" ]]; then
echo "$prefix already has this OpenCV"
exit 0
fi
# It is emptied before the install, so it has to be one of ours.
if [[ -e $prefix && ! -f $prefix/.stamp ]]; then
echo "$0: $prefix exists and was not made by this script" >&2
exit 1
fi
work="$(mktemp -d)"
trap 'rm -rf -- "$work"' EXIT
curl -fL --retry 3 -o "$work/opencv.tar.gz" \
"https://github.com/opencv/opencv/archive/refs/tags/$version.tar.gz"
echo "$sha256 $work/opencv.tar.gz" | sha256sum -c --quiet -
tar -xzf "$work/opencv.tar.gz" -C "$work"
src="$work/opencv-$version"
# BUILD_LIST adds what the listed modules need (calib3d, features2d, flann).
# The rest is switched off because it would be picked up from the system or
# downloaded: codecs, video backends, IPP and the other accelerators.
cmake -S "$src" -B "$work/build" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="$prefix" \
-DBUILD_SHARED_LIBS=OFF \
-DBUILD_LIST=core,imgproc,imgcodecs,videoio,objdetect,dnn \
-DBUILD_TESTS=OFF -DBUILD_PERF_TESTS=OFF -DBUILD_EXAMPLES=OFF \
-DBUILD_DOCS=OFF -DBUILD_opencv_apps=OFF -DBUILD_JAVA=OFF \
-DBUILD_ZLIB=ON -DBUILD_JPEG=ON -DBUILD_PNG=ON -DBUILD_PROTOBUF=ON \
-DWITH_V4L=ON \
-DWITH_FFMPEG=OFF -DWITH_GSTREAMER=OFF -DWITH_OBSENSOR=OFF \
-DWITH_TIFF=OFF -DWITH_WEBP=OFF -DWITH_AVIF=OFF -DWITH_OPENEXR=OFF \
-DWITH_OPENJPEG=OFF -DWITH_JASPER=OFF \
-DWITH_IPP=OFF -DWITH_ITT=OFF -DWITH_OPENCL=OFF -DWITH_VA=OFF -DWITH_VA_INTEL=OFF \
-DWITH_LAPACK=OFF -DWITH_EIGEN=OFF -DWITH_FLATBUFFERS=OFF -DWITH_QUIRC=OFF -DWITH_ADE=OFF
cmake --build "$work/build" --parallel "$(nproc)"
rm -rf -- "$prefix"
cmake --install "$work/build"
# OpenCV installs the licences of the libraries in it, but not its own.
mv "$prefix"/share/licenses/opencv* "$prefix/share/licenses/opencv"
install -Dm644 "$src/LICENSE" "$prefix/share/licenses/opencv/LICENSE"
# A picture with a face, for the check that the models load and find one.
install -Dm644 "$src/samples/data/messi5.jpg" "$prefix/share/face-unlock-check/face.jpg"
echo "$stamp" > "$prefix/.stamp"
+1 -1
View File
@@ -14,7 +14,7 @@ set -euo pipefail
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
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; }
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
#
# Builds the tarball for Arch Linux into dist/. The AUR package
# face-unlock-bin is made from it.
#
# Like the deb and the rpm, it holds what `make install` produced, under a
# folder named like the tarball. One thing is different: OpenCV is linked in
# statically (packaging/build-opencv.sh), so a new OpenCV on Arch does not
# break the daemon. Qt stays shared. The agent uses Qt's private API, so the
# tarball fits the Qt that Arch had when it was built.
#
# Needs: make, cmake, a C++ compiler, the packages check.yml installs (without
# opencv), msgfmt (gettext), scdoc, curl, zstd, and the static OpenCV:
#
# packaging/build-opencv.sh && packaging/build-tarball.sh
set -euo pipefail
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
version="${1:-$(make -s -C "$here" version)}"
opencv="${OPENCV_PREFIX:-$here/build/opencv-static}"
name="face-unlock-$version-arch-$(uname -m)"
# The flags Arch builds its own packages with: hardening, and full RELRO.
if [[ -f /etc/makepkg.conf ]]; then
# shellcheck source=/dev/null
source /etc/makepkg.conf
export CFLAGS CXXFLAGS LDFLAGS
fi
for tool in msgfmt scdoc cmake readelf zstd; do
command -v "$tool" > /dev/null || { echo "$0: $tool is not installed" >&2; exit 1; }
done
opencv_dir="$(dirname "$(find "$opencv" -name OpenCVConfig.cmake -print -quit 2>/dev/null)")"
[[ $opencv_dir != . ]] || { echo "$0: no OpenCV in $opencv, run packaging/build-opencv.sh first" >&2; exit 1; }
root="$(mktemp -d)"
work="$(mktemp -d)"
trap 'rm -rf -- "$root" "$work"' EXIT
dest="$root/$name"
make -C "$here" models
make -C "$here" install \
DESTDIR="$dest" \
PREFIX=/usr \
VERSION="$version" \
BUILDDIR="$work/build" \
PAMDIR=/usr/lib/security \
SYSTEMUNITDIR=/usr/lib/systemd/system \
USERUNITDIR=/usr/lib/systemd/user \
CMAKE_FLAGS="-DOpenCV_DIR=$opencv_dir"
# The tests, and the models on a real face: the part a smaller OpenCV could
# break without anything else noticing.
ctest --test-dir "$work/build" --output-on-failure
face="$opencv/share/face-unlock-check/face.jpg"
"$work/build/test_images" "$dest/usr/share/face-unlock/models" "$face" "$face" | tee "$work/faces"
grep -q 'similarity' "$work/faces" || { echo "$0: the models found no face" >&2; exit 1; }
if readelf -d "$dest/usr/lib/face-unlock/face-unlockd" | grep -q 'libopencv'; then
echo "$0: the daemon still loads a shared OpenCV" >&2
exit 1
fi
# The program is GPL, OpenCV and the libraries in it come with their own
# licences, and the copyright file names the models' authors and licences.
lic="$dest/usr/share/licenses/face-unlock"
install -Dm644 "$here/LICENSE" "$lic/LICENSE"
install -Dm644 "$here/packaging/deb/copyright" "$lic/copyright"
cp -r "$opencv/share/licenses/opencv" "$lic/opencv"
mkdir -p "$here/dist"
out="$here/dist/$name.tar.zst"
tar -C "$root" --owner=0 --group=0 --numeric-owner --sort=name \
-I 'zstd -19 -T0' -cf "$out" "$name"
echo "$out"
+13 -8
View File
@@ -1,17 +1,22 @@
Package: plasma-face-unlock
Package: face-unlock
Version: @VERSION@
Section: admin
Priority: optional
Architecture: @ARCH@
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
Recommends: gettext-base, kscreenlocker
Homepage: https://github.com/LoonixTools/plasma-face-unlock
Description: face unlock for KDE Plasma
Recommends: gettext-base
Replaces: plasma-face-unlock
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,
sudo in a terminal and the admin password prompts of Plasma can take a face
instead of a password. A bubble at the top of the screen, above the lock
screen too, shows the face being looked for, recognised or refused.
sudo in a terminal and the admin password prompts can take a face instead
of a password, on KDE Plasma, GNOME, Hyprland and Niri. A bubble at the top
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
reflection and its straight edges. The strict photo check also wants a sign
@@ -21,5 +26,5 @@ Description: face unlock for KDE Plasma
through.
.
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.
+4 -4
View File
@@ -1,17 +1,17 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: plasma-face-unlock
Source: https://github.com/LoonixTools/plasma-face-unlock
Upstream-Name: face-unlock
Source: https://github.com/LoonixTools/face-unlock
Files: *
Copyright: 2026 Felitendo
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
License: MIT
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
License: Apache-2.0
Comment: https://github.com/opencv/opencv_zoo/tree/main/models/face_recognition_sface
@@ -1,5 +1,5 @@
[plasma-face-unlock]
name=plasma-face-unlock
[face-unlock]
name=face-unlock
baseurl=@BASEURL@/rpm
enabled=1
gpgcheck=1
+22 -22
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>plasma-face-unlock</title>
<title>face-unlock</title>
<style>
:root {
--bg: #ffffff;
@@ -62,52 +62,52 @@
</head>
<body>
<h1>plasma-face-unlock</h1>
<h1>face-unlock</h1>
<p class="lead">
Face ID for KDE Plasma: look at the screen and it unlocks. The lock screen,
sudo and the admin prompts can take a face instead of a password, and a photo
of somebody is not enough.
Face ID for Linux: look at the screen and it unlocks. The lock screen, sudo
and the admin prompts can take a face instead of a password, on KDE Plasma,
GNOME, Hyprland and Niri. A photo of somebody is not enough.
</p>
<p>
This page is the package repository. Set it up once and every new version
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.
</p>
<h2>Debian 13</h2>
<pre><code>sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL @BASEURL@/KEY.gpg \
| sudo gpg --dearmor -o /etc/apt/keyrings/plasma-face-unlock.gpg
echo "deb [signed-by=/etc/apt/keyrings/plasma-face-unlock.gpg] @BASEURL@/deb/trixie ./" \
| sudo tee /etc/apt/sources.list.d/plasma-face-unlock.list
| sudo gpg --dearmor -o /etc/apt/keyrings/face-unlock.gpg
echo "deb [signed-by=/etc/apt/keyrings/face-unlock.gpg] @BASEURL@/deb/trixie ./" \
| sudo tee /etc/apt/sources.list.d/face-unlock.list
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
curl -fsSL @BASEURL@/KEY.gpg \
| sudo gpg --dearmor -o /etc/apt/keyrings/plasma-face-unlock.gpg
echo "deb [signed-by=/etc/apt/keyrings/plasma-face-unlock.gpg] @BASEURL@/deb/resolute ./" \
| sudo tee /etc/apt/sources.list.d/plasma-face-unlock.list
| sudo gpg --dearmor -o /etc/apt/keyrings/face-unlock.gpg
echo "deb [signed-by=/etc/apt/keyrings/face-unlock.gpg] @BASEURL@/deb/resolute ./" \
| sudo tee /etc/apt/sources.list.d/face-unlock.list
sudo apt update
sudo apt install plasma-face-unlock</code></pre>
sudo apt install face-unlock</code></pre>
<h2>Fedora 44 (KDE Plasma)</h2>
<pre><code>sudo curl -fsSL -o /etc/yum.repos.d/plasma-face-unlock.repo \
@BASEURL@/plasma-face-unlock.repo
sudo dnf install plasma-face-unlock</code></pre>
<h2>Fedora 44</h2>
<pre><code>sudo curl -fsSL -o /etc/yum.repos.d/face-unlock.repo \
@BASEURL@/face-unlock.repo
sudo dnf install face-unlock</code></pre>
<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>
<pre><code>plasma-face-unlock</code></pre>
<pre><code>face-unlock</code></pre>
<p>
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
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
polkit.
</p>
+5 -5
View File
@@ -19,7 +19,7 @@ pages="$(cd -- "$1" && pwd)"
incoming="$(cd -- "$2" && 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 }')"
[[ -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
gzip -9kf Packages
apt-ftparchive \
-o APT::FTPArchive::Release::Origin=plasma-face-unlock \
-o APT::FTPArchive::Release::Label=plasma-face-unlock \
-o APT::FTPArchive::Release::Origin=face-unlock \
-o APT::FTPArchive::Release::Label=face-unlock \
-o APT::FTPArchive::Release::Suite="$codename" \
-o APT::FTPArchive::Release::Codename="$codename" \
-o APT::FTPArchive::Release::Architectures=amd64 \
@@ -65,8 +65,8 @@ done
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/plasma-face-unlock.repo" \
> "$pages/plasma-face-unlock.repo"
sed "s|@BASEURL@|$base_url|g" "$here/packaging/pages/face-unlock.repo" \
> "$pages/face-unlock.repo"
# Pages would otherwise hand the whole directory to Jekyll, which drops every
# 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
# 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.1.0}
Name: plasma-face-unlock
Name: face-unlock
Version: %{upstream_version}
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
# Apache-2.0 (SFace).
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
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
@@ -45,13 +45,18 @@ Requires: polkit
Requires: systemd
Requires: qt6-qtdeclarative
Recommends: /usr/bin/gettext
Recommends: kscreenlocker
# The old name.
Obsoletes: plasma-face-unlock < 2
Provides: plasma-face-unlock = %{version}-%{release}
%description
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
of a password. A bubble at the top of the screen, above the lock screen too,
shows the face being looked for, recognised or refused.
in a terminal and the admin password prompts can take a face instead of a
password, on KDE Plasma, GNOME, Hyprland and Niri. A bubble at the top 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 reflection and
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
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.
%prep
@@ -78,10 +83,14 @@ make install DESTDIR=%{buildroot} PREFIX=%{_prefix} VERSION=%{upstream_version}
%find_lang %{name}
%preun
%systemd_preun plasma-face-unlockd.socket plasma-face-unlockd.service
%systemd_preun face-unlockd.socket face-unlockd.service
%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
%license LICENSE
@@ -89,13 +98,14 @@ make install DESTDIR=%{buildroot} PREFIX=%{_prefix} VERSION=%{upstream_version}
%{_bindir}/%{name}
%{_prefix}/lib/%{name}/
%{_datadir}/%{name}/
%{_libdir}/security/pam_plasma_face_unlock.so
%{_unitdir}/plasma-face-unlockd.socket
%{_unitdir}/plasma-face-unlockd.service
%{_userunitdir}/plasma-face-unlock-agent.service
%{_datadir}/applications/io.github.loonixtools.plasma-face-unlock-agent.desktop
%{_datadir}/polkit-1/actions/io.github.loonixtools.plasma-face-unlock.policy
%{_datadir}/icons/hicolor/scalable/apps/plasma-face-unlock.svg
%{_libdir}/security/pam_face_unlock.so
%{_unitdir}/face-unlockd.socket
%{_unitdir}/face-unlockd.service
%{_userunitdir}/face-unlock-agent.service
%{_datadir}/applications/io.github.loonixtools.face-unlock-agent.desktop
%{_datadir}/polkit-1/actions/io.github.loonixtools.face-unlock.policy
%{_datadir}/icons/hicolor/scalable/apps/face-unlock.svg
%{_datadir}/gnome-shell/extensions/face-unlock@loonixtools.github.io/
%{_mandir}/man1/%{name}.1*
%changelog
+543 -184
View File
File diff suppressed because it is too large. Load diff
+1324
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1331
View File
File diff suppressed because it is too large. Load diff
+1315
View File
File diff suppressed because it is too large. Load diff
+1294
View File
File diff suppressed because it is too large. Load diff
+1287
View File
File diff suppressed because it is too large. Load diff
+1308
View File
File diff suppressed because it is too large. Load diff
+1296
View File
File diff suppressed because it is too large. Load diff
+1313
View File
File diff suppressed because it is too large. Load diff
+1307
View File
File diff suppressed because it is too large. Load diff
+1302
View File
File diff suppressed because it is too large. Load diff
+1305
View File
File diff suppressed because it is too large. Load diff
+14 -14
View File
@@ -1,8 +1,8 @@
#!/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 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.
#
# 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)"
# The labels, as calls xgettext understands, pointing back at menu.sh.
awk -F'|' '/^\t"(user|sys|pam)\|/ { sub(/"$/, "", $5); print "pfu_msg \"" $5 "\"" }
/^\t"group\|/ { sub(/"$/, "", $2); print "pfu_msg \"" $2 "\"" }' src/lib/menu.sh > "$tmp/settings.sh"
awk -F'|' '/^\t"(user|sys|pam)\|/ { sub(/"$/, "", $5); print "fu_msg \"" $5 "\"" }
/^\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"
--msgid-bugs-address=https://github.com/LoonixTools/plasma-face-unlock/issues)
common=(--from-code=UTF-8 --add-comments=TRANSLATORS --package-name=face-unlock --package-version="$version"
--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 \
-o "$tmp/shell.pot" src/plasma-face-unlock src/lib/*.sh "$tmp/settings.sh"
sed -i "s|#: $tmp/settings.sh:[0-9]*|#: src/lib/menu.sh|" "$tmp/shell.pot"
xgettext "${common[@]}" -L Shell -k --keyword=fu_msg --keyword=fu_msg_into:2 --keyword=fu_msg_in:2 \
-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|g" "$tmp/shell.pot"
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
msgcat --use-first -o po/plasma-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' \
po/plasma-face-unlock.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 face-unlock.\n# Copyright (C) 2026 Felitendo\n# This file is distributed under the same license as face-unlock.' -e '1,4d' \
po/face-unlock.pot
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
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"
+1233
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.
File diff suppressed because it is too large. Load diff
@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 190 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]
Description=Face unlock for KDE Plasma
Documentation=man:plasma-face-unlock(1)
Requires=plasma-face-unlockd.socket
After=plasma-face-unlockd.socket
Description=Face unlock
Documentation=man:face-unlock(1)
Requires=face-unlockd.socket
After=face-unlockd.socket
[Service]
Type=simple
# Started by the socket, and gone again after a minute with nothing to do.
ExecStart=@LIBEXECDIR@/plasma-face-unlockd
StateDirectory=plasma-face-unlock
ExecStart=@LIBEXECDIR@/face-unlockd
StateDirectory=face-unlock
StateDirectoryMode=0700
UMask=0077
@@ -1,11 +1,11 @@
[Unit]
Description=Face unlock for KDE Plasma (socket)
Documentation=man:plasma-face-unlock(1)
Description=Face unlock (socket)
Documentation=man:face-unlock(1)
[Socket]
# 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.
ListenStream=/run/plasma-face-unlock/socket
ListenStream=/run/face-unlock/socket
SocketMode=0666
DirectoryMode=0755
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;
}
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();
if (buffer->size() > 64 * 1024) {
socket->abort();
@@ -36,8 +36,12 @@ AgentSocket::AgentSocket(QObject *parent)
while ((nl = buffer->indexOf('\n')) >= 0) {
const QJsonObject o = QJsonDocument::fromJson(buffer->left(nl)).object();
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);
} 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()
{
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()
+16 -2
View File
@@ -2,18 +2,21 @@
//
// 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
// $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
// it is idle).
//
// 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
#include <QJsonObject>
#include <QList>
#include <QLocalServer>
#include <QLocalSocket>
#include <QObject>
#include <QPointer>
class AgentSocket : public QObject
{
@@ -22,11 +25,22 @@ public:
explicit AgentSocket(QObject *parent = nullptr);
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();
Q_SIGNALS:
void scanEvent(const QJsonObject &event);
void lockRequested();
private:
QLocalServer m_server;
QList<QPointer<QLocalSocket>> m_waiting;
};
+12
View File
@@ -117,6 +117,18 @@ void BubbleController::succeeded()
void BubbleController::failed(const QString &reason, qint64 lockout)
{
if (reason == u"camera-busy") {
// A video call, most likely. The daemon starts no scan then, so this
// may come with nothing on screen yet.
if (!enabled() || m_config->quietWhenCameraBusy()) {
dismiss();
return;
}
setMessage(i18n("Camera in use"));
setPhase(QStringLiteral("busy"));
m_hide.start(int(FailureHoldMs * pace()));
return;
}
if (m_phase == u"hidden") {
return;
}
+2 -1
View File
@@ -17,7 +17,8 @@ class UserConfig;
class BubbleController : public QObject
{
Q_OBJECT
// hidden, scanning, success, failure, lockout
// hidden, scanning, success, failure, lockout, busy (another program has
// the camera)
Q_PROPERTY(QString phase READ phase NOTIFY phaseChanged)
// A short line under the face in the full style: a hint while
// scanning, the reason after a failure.
+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
// display only fades, which a clear window does not show.
layer->setScope(QStringLiteral("on-screen-display"));
#ifdef PFU_LAYERSHELL_HAS_SCREEN
#ifdef FU_LAYERSHELL_HAS_SCREEN
layer->setScreen(QGuiApplication::primaryScreen());
#endif
}
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) {
for (const QQmlError &e : m_view->errors()) {
qWarning("%s", qPrintable(e.toString()));
@@ -101,7 +101,8 @@ void BubbleWindow::allowOverLockscreen()
return;
}
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;
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
// 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.
//
// 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
+2 -2
View File
@@ -38,8 +38,8 @@ DaemonRequest::~DaemonRequest()
QString DaemonRequest::socketPath()
{
const QByteArray env = qgetenv("PFU_SOCKET");
return env.isEmpty() ? QStringLiteral(PFU_SOCKET) : QString::fromLocal8Bit(env);
const QByteArray env = qgetenv("FU_SOCKET");
return env.isEmpty() ? QStringLiteral(FU_SOCKET) : QString::fromLocal8Bit(env);
}
void DaemonRequest::send(const QJsonObject &message)
+5
View File
@@ -191,6 +191,11 @@ void EnrollController::onFinished(const QJsonObject &result)
}
if (reason == u"denied") {
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") {
m_error = i18n("The camera could not be used: %1", result.value(u"message").toString());
} else if (reason == u"models") {
+144 -2
View File
@@ -2,10 +2,15 @@
#include "inputwatcher.h"
#include <QDBusConnection>
#include <QDBusConnectionInterface>
#include <QDBusMessage>
#include <QDBusPendingCallWatcher>
#include <QGuiApplication>
#include <QWaylandClientExtensionTemplate>
#include <QtGui/qguiapplication_platform.h>
#include <algorithm>
#include <functional>
#include "qwayland-ext-idle-notify-v1.h"
@@ -54,6 +59,111 @@ private:
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)
: QObject(parent)
, m_notifier(std::make_unique<IdleNotifier>())
@@ -64,12 +174,29 @@ InputWatcher::~InputWatcher() = default;
void InputWatcher::watch(int calmMs)
{
m_notification.reset();
stop();
m_watching = true;
m_calmMs = calmMs;
m_calm.start();
auto *wayland = qGuiApp->nativeInterface<QNativeInterface::QWaylandApplication>();
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;
}
// 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.
::ext_idle_notification_v1 *object = m_notifier->QWaylandClientExtension::version() >= 2
? 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()
{
m_watching = false;
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
// 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
// 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
#include <QElapsedTimer>
#include <QObject>
#include <memory>
class IdleNotifier;
class IdleNotification;
class MutterIdle;
class InputWatcher : public QObject
{
@@ -27,6 +30,9 @@ public:
// calmMs. 0: the next input. Replaces what was watched before.
void watch(int calmMs);
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:
void input();
@@ -34,4 +40,8 @@ Q_SIGNALS:
private:
std::unique_ptr<IdleNotifier> m_notifier;
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 "daemonclient.h"
#include "lockscreencontroller.h"
#include "userconfig.h"
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDBusPendingCallWatcher>
#include <QDBusPendingReply>
#include <QJsonObject>
#include <QProcess>
namespace
{
@@ -27,24 +24,32 @@ constexpr int CalmBeforeRetryMs = 2000;
constexpr int ScanAfterWakeMs = 1000;
} // namespace
LockController::LockController(BubbleController *bubble, UserConfig *config, QObject *parent)
LockController::LockController(BubbleController *bubble, UserConfig *config, SessionLock *lock, LockScreenController *screen, QObject *parent)
: QObject(parent)
, m_bubble(bubble)
, 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);
connect(&m_armTimer, &QTimer::timeout, this, [this] {
arm(0);
});
connect(&m_input, &InputWatcher::input, this, &LockController::onResume);
QDBusConnection session = QDBusConnection::sessionBus();
session.connect(QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("/ScreenSaver"),
QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("ActiveChanged"),
this,
SLOT(onActiveChanged(bool)));
connect(&m_lock, &LockWatcher::lockedChanged, this, &LockController::onLockedChanged);
// A lock screen that is only starting cannot be opened yet: the scan
// that was due when it locked follows once it can.
connect(&m_lock, &LockWatcher::unlockable, this, [this] {
if (m_lockScanDue) {
m_lockScanDue = false;
startScan(QStringLiteral("lock"));
}
});
QDBusConnection::systemBus().connect(QStringLiteral("org.freedesktop.login1"),
QStringLiteral("/org/freedesktop/login1"),
@@ -52,29 +57,16 @@ LockController::LockController(BubbleController *bubble, UserConfig *config, QOb
QStringLiteral("PrepareForSleep"),
this,
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;
}
if (!active) {
if (!locked) {
m_locked = false;
m_lockScanDue = false;
m_armTimer.stop();
m_input.stop();
if (m_scan) {
@@ -93,7 +85,11 @@ void LockController::onActiveChanged(bool active)
return;
}
if (!m_config->lockScreen()) {
const bool face = m_config->enabled() && m_config->lockScreen();
if (m_screen) {
m_screen->setFaceUnlock(face);
}
if (!face) {
return;
}
m_locked = true;
@@ -103,6 +99,7 @@ void LockController::onActiveChanged(bool active)
if (m_config->scanOnLock()) {
QTimer::singleShot(400, this, [this] {
m_lockScanDue = m_locked && !m_lock.canUnlock();
startScan(QStringLiteral("lock"));
});
} else {
@@ -155,7 +152,9 @@ void LockController::warmUp()
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;
}
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
// and the tick play on over it.
m_bubble->succeeded();
unlock();
m_lock.unlock();
return;
}
@@ -207,30 +206,3 @@ void LockController::onScanFinished(const QJsonObject &result)
}
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
// 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
// same request `loginctl unlock-session` makes and which Plasma's screen
// locker has always honoured.
// when the face matches it unlocks the session the way that desktop's lock
// screen wants it (see LockWatcher).
//
// 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
// 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
// sits back down, no wrong label, and a bubble that knows what is going on.
// It does not lower the bar either: any program running as this user can
// already unlock this user's session through logind. Face data and the
// decision stay with the daemon, which runs as root.
// fingerprint". GNOME's, hyprlock's and swaylock's only ask once the password
// is typed. Doing it from here means scanning again every time somebody sits
// back down, no wrong label, and a bubble that knows what is going on.
#pragma once
#include "inputwatcher.h"
#include "lockwatcher.h"
#include <QElapsedTimer>
#include <QObject>
@@ -28,13 +26,16 @@
class BubbleController;
class DaemonRequest;
class LockScreenController;
class SessionLock;
class UserConfig;
class LockController : public QObject
{
Q_OBJECT
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
{
@@ -42,7 +43,7 @@ public:
}
private Q_SLOTS:
void onActiveChanged(bool active);
void onLockedChanged(bool locked);
void onPrepareForSleep(bool sleeping);
private:
@@ -51,15 +52,18 @@ private:
void onResume();
void startScan(const QString &why);
void onScanFinished(const QJsonObject &result);
void unlock();
void warmUp();
BubbleController *m_bubble;
UserConfig *m_config;
LockScreenController *m_screen;
bool m_locked = false;
bool m_stopped = false;
// Scan right after locking, once the lock screen can be opened.
bool m_lockScanDue = false;
QElapsedTimer m_lockedFor;
QPointer<DaemonRequest> m_scan;
QTimer m_armTimer;
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, lockout and busy 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;
};
+260 -12
View File
@@ -1,19 +1,34 @@
// 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,
// and show the bubble for every scan
// --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
// 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 "bubblecontroller.h"
#include "bubbleservice.h"
#include "bubblewindow.h"
#include "enrollcontroller.h"
#include "lockcontroller.h"
#include "lockpreview.h"
#include "lockscreencontroller.h"
#include "locktext.h"
#include "sessionlock.h"
#include "userconfig.h"
#include "wallpaper.h"
#include "wayland.h"
#include "buildconfig.h"
@@ -21,11 +36,20 @@
#include <KLocalizedString>
#include <QCommandLineParser>
#include <QDBusConnection>
#include <QDBusMessage>
#include <QFile>
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlEngine>
#include <QScreen>
#include <QTimer>
#include <QWindow>
#include <cstdio>
#include <cstring>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
namespace
@@ -39,7 +63,7 @@ int runEnroll(QGuiApplication &app, const QString &name)
QQmlApplicationEngine engine;
KLocalization::setupLocalizedContext(&engine);
engine.setInitialProperties({{QStringLiteral("controller"), QVariant::fromValue(&controller)}});
engine.loadFromModule(QStringLiteral("PlasmaFaceUnlock"), QStringLiteral("Enroll"));
engine.loadFromModule(QStringLiteral("FaceUnlock"), QStringLiteral("Enroll"));
if (engine.rootObjects().isEmpty()) {
return 2;
}
@@ -53,8 +77,144 @@ int runEnroll(QGuiApplication &app, const QString &name)
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,
// then one that is not.
// then one that is not. Last, a camera another program has.
void scheduleDemo(BubbleController *bubble)
{
const QList<std::pair<int, std::function<void()>>> steps = {
@@ -65,7 +225,8 @@ void scheduleDemo(BubbleController *bubble)
{5600, [bubble] { bubble->scanStarted(); }},
{6200, [bubble] { bubble->faceFound(); }},
{7800, [bubble] { bubble->failed(QStringLiteral("mismatch")); }},
{11000, [] { QCoreApplication::quit(); }},
{11000, [bubble] { bubble->failed(QStringLiteral("camera-busy")); }},
{14000, [] { QCoreApplication::quit(); }},
};
for (const auto &[at, what] : steps) {
QTimer::singleShot(at, bubble, what);
@@ -75,24 +236,34 @@ void scheduleDemo(BubbleController *bubble)
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);
app.setApplicationName(QStringLiteral("plasma-face-unlock-agent"));
app.setApplicationVersion(QStringLiteral(PFU_VERSION));
app.setDesktopFileName(QStringLiteral("io.github.loonixtools.plasma-face-unlock-agent"));
KLocalizedString::setApplicationDomain(PFU_NAME);
app.setApplicationName(QStringLiteral("face-unlock-agent"));
app.setApplicationVersion(QStringLiteral(FU_VERSION));
app.setDesktopFileName(QStringLiteral("io.github.loonixtools.face-unlock-agent"));
KLocalizedString::setApplicationDomain(FU_NAME);
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.addVersionOption();
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 lockOpt(QStringLiteral("lock"), i18n("Lock the screen."));
const QCommandLineOption demoOpt(QStringLiteral("demo"), i18n("Play the bubble's animations once."));
// 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"));
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);
if (parser.isSet(chooseOpt)) {
return runChooseLockScreen(app);
}
if (parser.isSet(enrollOpt)) {
QString name = parser.value(nameOpt);
if (name.isEmpty()) {
@@ -101,6 +272,13 @@ int main(int argc, char **argv)
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);
QQmlEngine engine;
KLocalization::setupLocalizedContext(&engine);
@@ -110,17 +288,87 @@ int main(int argc, char **argv)
config.overrideStyle(parser.value(styleOpt));
}
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 (!window) {
qWarning("this desktop has no layer-shell; on GNOME the extension draws the bubble");
return 1;
}
scheduleDemo(&bubble);
return app.exec();
}
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();
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();
}
+1 -1
View File
@@ -212,7 +212,7 @@ Item {
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
text: root.bubble ? root.bubble.message : ""
color: root.shownPhase === "failure" || root.shownPhase === "lockout" ? Theme.textDetail : Theme.textSecondary
color: root.shownPhase === "failure" || root.shownPhase === "lockout" || root.shownPhase === "busy" ? Theme.textDetail : Theme.textSecondary
font.pixelSize: 13
font.weight: Font.Medium
opacity: full.hasMessage ? 1 : 0
+91
View File
@@ -0,0 +1,91 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// A video camera with a line through it: another program has the camera, a
// video call most likely. The line strikes through once the camera shows.
import QtQuick
import QtQuick.Shapes
Item {
id: root
property color color: Theme.textPrimary
// What the line cuts out of the camera: the island behind it.
property color background: Theme.panel
property bool shown: false
property real pace: 1
readonly property real u: width / 100
// How far the line has come, 0 to 1.
property real strike: 0
onShownChanged: {
if (shown) {
striking.restart()
} else {
striking.stop()
strike = 0
}
}
SequentialAnimation {
id: striking
PropertyAction { target: root; property: "strike"; value: 0 }
PauseAnimation { duration: 120 * root.pace }
NumberAnimation { target: root; property: "strike"; to: 1; duration: 320 * root.pace; easing.type: Easing.OutCubic }
}
readonly property point lineStart: Qt.point(12 * u, 10 * u)
readonly property point lineEnd: Qt.point((12 + 76 * strike) * u, (10 + 80 * strike) * u)
// Body
Rectangle {
x: 4 * root.u
y: 24 * root.u
width: 62 * root.u
height: 52 * root.u
radius: 12 * root.u
color: root.color
}
// Lens, its corners rounded by a stroke of the same colour
Shape {
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: root.color
strokeColor: root.color
strokeWidth: 6 * root.u
joinStyle: ShapePath.RoundJoin
startX: 72 * root.u; startY: 42 * root.u
PathLine { x: 94 * root.u; y: 29 * root.u }
PathLine { x: 94 * root.u; y: 71 * root.u }
PathLine { x: 72 * root.u; y: 58 * root.u }
PathLine { x: 72 * root.u; y: 42 * root.u }
}
}
// The line, with a gap round it so it reads on the filled camera
Shape {
anchors.fill: parent
visible: root.strike > 0.01
preferredRendererType: Shape.CurveRenderer
ShapePath {
strokeColor: root.background
strokeWidth: 24 * root.u
fillColor: "transparent"
capStyle: ShapePath.RoundCap
startX: root.lineStart.x; startY: root.lineStart.y
PathLine { x: root.lineEnd.x; y: root.lineEnd.y }
}
ShapePath {
strokeColor: root.color
strokeWidth: 9 * root.u
fillColor: "transparent"
capStyle: ShapePath.RoundCap
startX: root.lineStart.x; startY: root.lineStart.y
PathLine { x: root.lineEnd.x; y: root.lineEnd.y }
}
}
}
+8 -3
View File
@@ -6,17 +6,22 @@
import QtQuick
import QtQuick.Window
import PlasmaFaceUnlock
import FaceUnlock
Window {
id: window
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
height: 680
minimumWidth: 460
minimumHeight: 620
minimumWidth: width
maximumWidth: width
minimumHeight: height
maximumHeight: height
visible: true
color: Theme.panel
title: i18n("Set up Face Unlock")
+17 -2
View File
@@ -11,6 +11,8 @@
// success the rings (below), then a tick in the one that is left
// failure it turns red and shakes its head, as on iOS
// lockout a lock instead of a face
// busy a camera with a line through it: another program has the
// camera
//
// The rings are Face ID's in the Dynamic Island: the face gives way to two
// crossed rings that tumble in 3D with a soft glow, slow down, and land flat
@@ -352,8 +354,8 @@ Item {
id: face
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
// Gives way to the rings, and to the lock.
property real shown: root.mode === "lockout" ? 0 : 1
// Gives way to the rings, the lock and the camera.
property real shown: root.mode === "lockout" || root.mode === "busy" ? 0 : 1
Behavior on shown { NumberAnimation { duration: 180 * root.pace } }
opacity: shown * (1 - root.faceGone)
scale: 1 - 0.2 * root.faceGone
@@ -418,5 +420,18 @@ Item {
Behavior on opacity { NumberAnimation { duration: 200 * root.pace } }
Behavior on scale { NumberAnimation { duration: 260 * root.pace; easing.type: Easing.OutBack } }
}
CameraGlyph {
anchors.centerIn: parent
width: parent.width * 0.5
height: width
pace: root.pace
color: root.color
shown: root.mode === "busy"
opacity: shown ? 1 : 0
scale: shown ? 1 : 0.7
Behavior on opacity { NumberAnimation { duration: 200 * root.pace } }
Behavior on scale { NumberAnimation { duration: 260 * root.pace; easing.type: Easing.OutBack } }
}
}
}
+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;
};
+6 -1
View File
@@ -23,18 +23,23 @@ UserConfig::UserConfig(QObject *parent)
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()
{
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_scanOnWake = kv.boolean(QStringLiteral("ScanOnWake"), true);
m_scanOnLock = kv.boolean(QStringLiteral("ScanOnLock"), false);
m_bubble = kv.boolean(QStringLiteral("Bubble"), true);
m_bubbleStyle = kv.value(QStringLiteral("BubbleStyle"), QStringLiteral("full")) == u"minimal" ? QStringLiteral("minimal") : QStringLiteral("full");
m_bubbleForPrompts = kv.boolean(QStringLiteral("BubbleForPrompts"), true);
m_quietWhenCameraBusy = kv.boolean(QStringLiteral("QuietWhenCameraBusy"), false);
const QString speed = kv.value(QStringLiteral("AnimationSpeed"), QStringLiteral("normal"));
// The durations in the code are brisk, like on a phone. On a big screen
// they read better a little longer, so normal stretches them.
+36 -1
View File
@@ -1,6 +1,6 @@
// 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.
#pragma once
@@ -17,6 +17,30 @@ class UserConfig : public QObject
public:
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.
bool lockScreen() const
{
@@ -55,6 +79,12 @@ public:
{
return m_bubbleForPrompts;
}
// No bubble at all while another program has the camera (a video call).
// Off: the bubble shows a camera with a line through it.
bool quietWhenCameraBusy() const
{
return m_quietWhenCameraBusy;
}
// How long the bubble's animations take, as a multiple of the durations
// in the code: the animation speed setting (fast 1, normal 1.3,
// slow 2).
@@ -72,12 +102,17 @@ private:
void load();
QFileSystemWatcher m_watcher;
bool m_enabled = false;
QString m_lockWallpaper;
QString m_lockScreenStyle;
bool m_lockBlur = false;
bool m_lockScreen = true;
bool m_scanOnWake = true;
bool m_scanOnLock = false;
bool m_bubble = true;
QString m_bubbleStyle = QStringLiteral("full");
bool m_bubbleForPrompts = true;
bool m_quietWhenCameraBusy = false;
qreal m_pace = 1.3;
QString m_styleOverride;
};
+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
+32
View File
@@ -15,6 +15,7 @@
#include <unistd.h>
#include <algorithm>
#include <cerrno>
#include <thread>
using namespace std::chrono;
@@ -105,6 +106,24 @@ bool probe(const QString &path, CameraInfo *info)
}
return ok;
}
// A program that streams holds the camera's buffers. Asking for none is how
// V4L2 lets a program check without taking them: the driver says EBUSY when
// they belong to somebody else.
bool busy(const QString &path)
{
const int fd = ::open(QFile::encodeName(path).constData(), O_RDWR | O_NONBLOCK | O_CLOEXEC);
if (fd < 0) {
return errno == EBUSY;
}
v4l2_requestbuffers req{};
req.count = 0;
req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
req.memory = V4L2_MEMORY_MMAP;
const bool result = ::ioctl(fd, VIDIOC_REQBUFS, &req) != 0 && errno == EBUSY;
::close(fd);
return result;
}
} // namespace
Camera::Camera() = default;
@@ -142,6 +161,15 @@ QString Camera::autoPath()
return cameras.isEmpty() ? QString() : cameras.first().path;
}
bool Camera::inUse(const QString &spec)
{
if (spec.startsWith(u"file:") || spec.startsWith(u"images:")) {
return false;
}
const QString path = spec.isEmpty() || spec == u"auto" ? autoPath() : spec;
return !path.isEmpty() && busy(path);
}
bool Camera::open(const QString &spec, QString *error)
{
close();
@@ -178,6 +206,10 @@ bool Camera::open(const QString &spec, QString *error)
*error = QStringLiteral("%1 is not a camera").arg(path);
return false;
}
if (busy(path)) {
*error = QStringLiteral("%1 is in use by another program").arg(path);
return false;
}
if (!m_capture.open(QFile::encodeName(path).toStdString(), cv::CAP_V4L2) || !m_capture.isOpened()) {
*error = QStringLiteral("cannot open %1 (in use by another program?)").arg(path);
+4
View File
@@ -55,6 +55,10 @@ public:
// What "auto" means on this machine: the first colour camera, else the
// first camera at all.
static QString autoPath();
// Whether another program streams from the camera right now, a video
// call for example. Asked without starting it, so the light stays off.
// Never true for a video file or pictures.
static bool inUse(const QString &spec);
private:
bool openImages(const QString &dir, QString *error);
+1
View File
@@ -50,6 +50,7 @@ Settings Settings::load(const QString &path)
s.maxFailures = kv.integer(QStringLiteral("MaxFailures"), s.maxFailures, 1, 20);
s.lockoutMinutes = kv.integer(QStringLiteral("LockoutMinutes"), s.lockoutMinutes, 1, 24 * 60);
s.skipLidClosed = kv.boolean(QStringLiteral("SkipLidClosed"), s.skipLidClosed);
s.sshSessions = kv.boolean(QStringLiteral("SshSessions"), s.sshSessions);
s.adapt = kv.boolean(QStringLiteral("Adapt"), s.adapt);
return s;
}
+5 -1
View File
@@ -1,6 +1,6 @@
// 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
// strict the match is, whether a photo is checked for. They belong to root
@@ -44,6 +44,10 @@ struct Settings {
int lockoutMinutes = 15;
// A laptop with the lid shut has its camera looking at the keyboard.
bool skipLidClosed = true;
// sudo and admin prompts in an SSH session. Off by default: whoever sits
// in front of the camera need not be whoever types. On, that person still
// has to be at the machine (see the PAM module).
bool sshSessions = false;
// Take in a little of each confident unlock, so a new haircut or a pair
// of glasses does not need a new setup. Face ID does the same.
bool adapt = true;
+1 -1
View File
@@ -2,7 +2,7 @@
//
// 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
// 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
+4 -4
View File
@@ -1,6 +1,6 @@
// 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
// 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()
{
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"
" verify [USER] [PURPOSE] | test\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);
QStringList args = app.arguments().mid(1);
QString socketPath = QStringLiteral(PFU_SOCKET);
QString socketPath = QStringLiteral(FU_SOCKET);
if (args.size() >= 2 && args.first() == u"--socket") {
socketPath = args.at(1);
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);
}
if (args.isEmpty()) {
+1 -1
View File
@@ -25,7 +25,7 @@ AgentLink::AgentLink(uid_t uid, QObject *parent)
: QObject(parent)
, 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");
if (!ownedBy(dir, uid, false) || !ownedBy(path, uid, true)) {
// No agent in that session, or not one of this user's making.
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more