feat: add plasma-face-unlock

This commit is contained in:
Felitendo committed 2026-09-22 19:39:04 +02:00
commit f671acc93b
105 files changed
+13862

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: Felitendo
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: felitendo
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+32
View File
@@ -0,0 +1,32 @@
name: check
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
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
# newest OpenCV, which is the one that moved things around.
container: archlinux:latest
steps:
- name: Install the build tools
run: |
pacman -Syu --noconfirm --needed git base-devel cmake pkgconf \
qt6-base qt6-declarative layer-shell-qt kidletime ki18n \
opencv pam systemd-libs gettext scdoc shellcheck desktop-file-utils
- uses: actions/checkout@v7
- run: make check
# The liveness cues against synthetic heads and photos, the face store,
# and the PAM file editing (with real PAM for the last part).
- run: make test
- name: Build the catalogs and the man page
run: make build
+254
View File
@@ -0,0 +1,254 @@
name: release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
dry_run:
description: >-
Build the repositories with a throwaway key and install from them,
without publishing anything.
type: boolean
default: false
permissions:
contents: write
jobs:
deb:
name: Debian package
runs-on: ubuntu-latest
# Plasma 6 arrived in Debian with trixie.
container: debian:trixie
steps:
- name: Install the build tools
run: |
apt-get update -qq
apt-get install -y --no-install-recommends \
ca-certificates curl git make cmake g++ pkg-config gettext scdoc dpkg-dev \
qt6-base-dev qt6-base-private-dev qt6-declarative-dev \
qt6-wayland-dev qt6-wayland-dev-tools qt6-wayland-private-dev \
liblayershellqtinterface-dev libkf6idletime-dev libkf6i18n-dev \
libopencv-dev libpam0g-dev libsystemd-dev
- uses: actions/checkout@v7
- name: Check the tag against the Makefile
run: packaging/check-version.sh "${{ github.ref_name }}"
- run: packaging/build-deb.sh
- name: Look inside what was built
run: |
dpkg-deb --info dist/*.deb
dpkg-deb --contents dist/*.deb
- uses: actions/upload-artifact@v7
with:
name: deb
path: dist/*.deb
if-no-files-found: error
rpm:
name: RPM package
runs-on: ubuntu-latest
container: fedora:latest
steps:
- name: Install the build tools
run: |
dnf install -y --setopt=install_weak_deps=False \
git make cmake gcc-c++ gettext scdoc tar curl rpm-build rpm-sign systemd-rpm-macros \
'pkgconfig(systemd)' 'pkgconfig(libsystemd)' pam-devel opencv-devel \
qt6-qtbase-devel qt6-qtbase-private-devel qt6-qtdeclarative-devel qt6-qtwayland-devel \
layer-shell-qt-devel kf6-kidletime-devel kf6-ki18n-devel
- uses: actions/checkout@v7
- name: Check the tag against the Makefile
run: packaging/check-version.sh "${{ github.ref_name }}"
- run: packaging/build-rpm.sh
- name: Sign the package
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
if [ "$DRY_RUN" = "true" ]; then
gpg --batch --passphrase '' --quick-generate-key \
'dry run <dry-run@example.invalid>' rsa2048 sign never
elif [ -n "${GPG_PRIVATE_KEY:-}" ]; then
printf '%s' "$GPG_PRIVATE_KEY" | gpg --batch --import
else
echo "No GPG_PRIVATE_KEY secret; leaving the package unsigned."
exit 0
fi
keyid="$(gpg --list-secret-keys --with-colons | awk -F: '/^sec:/ { print $5; exit }')"
rpmsign --define "_gpg_name $keyid" --addsign dist/*.rpm
gpg --armor --export "$keyid" > dist/rpm-signer.asc
rpm --import dist/rpm-signer.asc
rpm --checksig dist/*.rpm
- 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
- uses: actions/upload-artifact@v7
with:
name: rpm
path: |
dist/*.rpm
dist/rpm-signer.asc
if-no-files-found: error
publish:
name: Release and repositories
needs: [deb, rpm]
if: startsWith(github.ref, 'refs/tags/v') || inputs.dry_run
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
path: incoming
merge-multiple: true
- name: Attach the packages to the release
if: ${{ !inputs.dry_run }}
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${{ github.ref_name }}" \
--title "${{ github.ref_name }}" \
--generate-notes \
incoming/*.deb incoming/*.rpm \
|| gh release upload "${{ github.ref_name }}" \
incoming/*.deb incoming/*.rpm --clobber
- name: Install the repository tools
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends \
dpkg-dev apt-utils createrepo-c
- name: Get a signing key
id: key
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
if [ "$DRY_RUN" = "true" ]; then
gpg --batch --passphrase '' --quick-generate-key \
'dry run <dry-run@example.invalid>' rsa2048 sign never
elif [ -n "${GPG_PRIVATE_KEY:-}" ]; then
printf '%s' "$GPG_PRIVATE_KEY" | gpg --batch --import
else
echo "present=no" >> "$GITHUB_OUTPUT"
echo "::warning::No GPG_PRIVATE_KEY secret, so the apt and dnf repositories were not updated. The packages are on the release."
exit 0
fi
echo "present=yes" >> "$GITHUB_OUTPUT"
- name: Check out the published repositories
if: steps.key.outputs.present == 'yes' && !inputs.dry_run
uses: actions/checkout@v7
with:
ref: gh-pages
path: pages
continue-on-error: true
- name: Update the repositories
if: steps.key.outputs.present == 'yes'
run: |
if [ ! -d pages/.git ]; then
rm -rf pages && mkdir pages
git -C pages init -q -b gh-pages
git -C pages remote add origin "https://github.com/${{ github.repository }}.git"
fi
rm -f incoming/rpm-signer.asc
packaging/publish-repos.sh pages incoming
- name: Check that what was written can be verified
if: steps.key.outputs.present == 'yes'
run: |
gpg --verify pages/deb/InRelease
gpg --verify pages/deb/Release.gpg pages/deb/Release
gpg --verify pages/rpm/repodata/repomd.xml.asc pages/rpm/repodata/repomd.xml
- uses: actions/upload-artifact@v7
if: inputs.dry_run
with:
name: pages
path: pages
include-hidden-files: true
- name: Push them
if: steps.key.outputs.present == 'yes' && !inputs.dry_run
env:
GH_TOKEN: ${{ github.token }}
run: |
cd pages
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
git diff --quiet --cached && { echo "nothing changed"; exit 0; }
git commit -q -m "Publish ${{ github.ref_name }}"
git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" HEAD:gh-pages
verify-apt:
name: Install from the APT repository
needs: publish
if: inputs.dry_run
runs-on: ubuntu-latest
container: debian:trixie
steps:
- uses: actions/download-artifact@v8
with:
name: pages
path: pages
- name: Install from the repository that was just built
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 ./" \
> /etc/apt/sources.list.d/plasma-face-unlock.list
apt-get update
apt-get install -y plasma-face-unlock
useradd -m tester
runuser -u tester -- plasma-face-unlock --version
verify-dnf:
name: Install from the RPM repository
needs: publish
if: inputs.dry_run
runs-on: ubuntu-latest
container: fedora:latest
steps:
- uses: actions/download-artifact@v8
with:
name: pages
path: pages
- uses: actions/download-artifact@v8
with:
name: rpm
path: signer
- name: Install from the repository that was just built
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
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
useradd -m tester
runuser -u tester -- plasma-face-unlock --version
+10
View File
@@ -0,0 +1,10 @@
# build artifacts
build/
po/*.mo
doc/*.1
# the networks, fetched by `make models`
models/
# packages
dist/
+38
View File
@@ -0,0 +1,38 @@
# CLAUDE.md
## Style
- Keep everything short: replies, explanations, comments, docs.
- Use simple English: short sentences, common words.
- Avoid em dashes (—). Do not just swap them for "-" either. Rewrite the sentence instead, for
example with a comma, a colon, brackets or two sentences.
## Commits
- English only.
- Conventional Commits prefix: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `ci:`, `build:`.
- 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.
## Layout
- `src/plasma-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.
- `src/pam`: the PAM module for sudo and admin prompts.
- `src/ctl`: the client the bash code talks to the daemon with.
## Testing
Never test against the real setup: enrolling and the PAM files belong to the user's machine.
- `make test` runs the liveness cues against synthetic heads and photos, the face store, and the
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
--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`.
+213
View File
@@ -0,0 +1,213 @@
# plasma-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,
# the PAM module and the tests. The shell code, the units and the models are
# installed by the Makefile, which is where the paths come from.
cmake_minimum_required(VERSION 3.22)
project(plasma-face-unlock VERSION 1.0.0 LANGUAGES C CXX)
set(PFU_VERSION "${PROJECT_VERSION}" CACHE STRING "Version reported by every binary")
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_C_STANDARD 11)
set(CMAKE_AUTOMOC ON)
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE RelWithDebInfo)
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)
# 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")
configure_file(src/shared/buildconfig.h.in ${CMAKE_CURRENT_BINARY_DIR}/buildconfig.h @ONLY)
include_directories(${CMAKE_CURRENT_BINARY_DIR} src/shared)
add_compile_options(-Wall -Wextra -Wno-unused-parameter)
# ---------------------------------------------------------------------------
# The core: camera, vision, liveness, store
# ---------------------------------------------------------------------------
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)
if(OpenCV_VERSION_MAJOR GREATER_EQUAL 5)
list(APPEND PFU_OPENCV_MODULES geometry)
endif()
find_package(OpenCV REQUIRED COMPONENTS ${PFU_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(pfu_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)
# ---------------------------------------------------------------------------
# The daemon
# ---------------------------------------------------------------------------
add_executable(plasma-face-unlockd
src/daemon/job.h
src/daemon/agentlink.cpp
src/daemon/main.cpp
src/daemon/server.cpp
src/daemon/client.cpp
src/daemon/scanjob.cpp
src/daemon/enrolljob.cpp
src/daemon/polkit.cpp
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})
# ---------------------------------------------------------------------------
# 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})
# ---------------------------------------------------------------------------
# The PAM module
# ---------------------------------------------------------------------------
if(PFU_BUILD_PAM)
find_path(PAM_INCLUDE_DIR security/pam_modules.h REQUIRED)
find_library(PAM_LIBRARY pam REQUIRED)
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})
if(SYSTEMD_FOUND)
target_compile_definitions(pam_plasma_face_unlock PRIVATE HAVE_SYSTEMD=1)
target_link_libraries(pam_plasma_face_unlock PRIVATE PkgConfig::SYSTEMD)
endif()
install(TARGETS pam_plasma_face_unlock DESTINATION ${PFU_PAMDIR})
if(PFU_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})
endif()
endif()
# ---------------------------------------------------------------------------
# The agent
# ---------------------------------------------------------------------------
if(PFU_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.
set(QT_NO_PRIVATE_MODULE_WARNING ON)
find_package(Qt6 REQUIRED COMPONENTS GuiPrivate WaylandClientPrivate)
find_package(LayerShellQt REQUIRED)
find_package(KF6IdleTime REQUIRED)
find_package(KF6I18n REQUIRED)
qt_add_executable(plasma-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/lockcontroller.cpp
src/agent/enrollcontroller.cpp
)
target_include_directories(plasma-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)
endif()
qt6_generate_wayland_protocol_client_sources(plasma-face-unlock-agent
FILES ${CMAKE_CURRENT_SOURCE_DIR}/protocols/kde-lockscreen-overlay-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.
set(PFU_QML_FILES Theme FaceGlyph LockGlyph Checkmark Bubble TickRing Enroll PillButton)
foreach(f ${PFU_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
VERSION 1.0
NO_RESOURCE_TARGET_PATH
SOURCES
src/agent/previewitem.cpp
src/agent/previewitem.h
QML_FILES
src/agent/qml/Theme.qml
src/agent/qml/FaceGlyph.qml
src/agent/qml/LockGlyph.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
)
target_link_libraries(plasma-face-unlock-agent PRIVATE
Qt6::Gui Qt6::GuiPrivate Qt6::Quick Qt6::DBus Qt6::Network
Qt6::WaylandClient Qt6::WaylandClientPrivate
LayerShellQt::Interface
KF6::IdleTime KF6::I18n KF6::I18nQml
pfu_common
)
install(TARGETS plasma-face-unlock-agent DESTINATION ${PFU_LIBEXECDIR})
endif()
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
if(PFU_BUILD_TESTS)
enable_testing()
add_executable(test_liveness tests/test_liveness.cpp)
target_link_libraries(test_liveness PRIVATE pfu_core)
add_test(NAME liveness COMMAND test_liveness)
add_executable(test_store tests/test_store.cpp)
target_link_libraries(test_store PRIVATE pfu_core)
add_test(NAME store COMMAND test_store)
add_executable(test_images tests/test_images.cpp)
target_link_libraries(test_images PRIVATE pfu_core)
endif()
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+210
View File
@@ -0,0 +1,210 @@
# plasma-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
# shell code uses), which this Makefile drives, handing it every path so the
# two halves agree on where things are. Translations and the man page are
# optional and skipped when msgfmt or scdoc are missing.
# 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.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
LOCALEDIR ?= $(DATADIR)/locale
MANDIR ?= $(DATADIR)/man
APPDIR ?= $(DATADIR)/applications
POLKITDIR ?= $(DATADIR)/polkit-1/actions
ICONDIR ?= $(DATADIR)/icons/hicolor/scalable/apps
# 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.
ifeq ($(PREFIX),/usr)
SYSTEMUNITDIR ?= $(shell pkg-config --variable=systemdsystemunitdir systemd 2>/dev/null || echo /usr/lib/systemd/system)
USERUNITDIR ?= $(shell pkg-config --variable=systemduserunitdir systemd 2>/dev/null || echo /usr/lib/systemd/user)
else
SYSTEMUNITDIR ?= $(PREFIX)/lib/systemd/system
USERUNITDIR ?= $(DATADIR)/systemd/user
endif
# Where PAM loads modules from. Not the same everywhere (/usr/lib/security on
# Arch, /usr/lib64/security on Fedora, a multiarch directory on Debian), and
# not something PAM itself answers, so it is found by looking for pam_unix.
PAMDIR ?= $(shell for d in /usr/lib/security /usr/lib64/security /usr/lib/$$(gcc -dumpmachine 2>/dev/null)/security /lib/$$(gcc -dumpmachine 2>/dev/null)/security /lib/security; do [ -e $$d/pam_unix.so ] && { echo $$d; break; }; done)
ifeq ($(PAMDIR),)
PAMDIR := /usr/lib/security
endif
BUILDDIR ?= build
CMAKE ?= cmake
CMAKE_FLAGS ?=
LINGUAS := de
MOFILES := $(patsubst %,po/%.mo,$(LINGUAS))
MANPAGE := doc/plasma-face-unlock.1
LIBS := $(wildcard src/lib/*.sh)
MSGFMT := $(shell command -v msgfmt 2>/dev/null)
SCDOC := $(shell command -v scdoc 2>/dev/null)
# The two networks, from the OpenCV model zoo. YuNet (MIT) finds faces,
# SFace (Apache-2.0) recognises them. Pinned by checksum: a model is code as
# far as trust goes.
MODEL_BASE := https://github.com/opencv/opencv_zoo/raw/main/models
MODELS := face_detection_yunet_2023mar.onnx face_recognition_sface_2021dec.onnx
MODEL_URL_face_detection_yunet_2023mar.onnx := $(MODEL_BASE)/face_detection_yunet/face_detection_yunet_2023mar.onnx
MODEL_SUM_face_detection_yunet_2023mar.onnx := 8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4
MODEL_URL_face_recognition_sface_2021dec.onnx := $(MODEL_BASE)/face_recognition_sface/face_recognition_sface_2021dec.onnx
MODEL_SUM_face_recognition_sface_2021dec.onnx := 0ba9fbfa01b5270c96627c4ef784da859931e02f04419c829e83484087c34e79
# A packager with the models already downloaded (an AUR source array, a
# build without network) points this at them.
MODELS_SRC ?= models
.PHONY: all build native models check test install uninstall clean version
all: build
build: native $(MOFILES) $(MANPAGE)
# The one place the version is written down, for everything that has to agree
# with it: the packaging scripts, and the release workflow checking that the
# tag it was handed says the same thing.
version:
@echo $(VERSION)
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) \
$(CMAKE_FLAGS)
$(CMAKE) --build $(BUILDDIR) --parallel
models: $(addprefix models/,$(MODELS))
models/%.onnx:
@mkdir -p models
curl -fL --retry 3 -o $@.part "$(MODEL_URL_$*.onnx)"
@echo "$(MODEL_SUM_$*.onnx) $@.part" | sha256sum -c --quiet - || { rm -f $@.part; echo "checksum mismatch for $@" >&2; exit 1; }
mv $@.part $@
po/%.mo: po/%.po
ifdef MSGFMT
$(MSGFMT) --check --output-file=$@ $<
else
@echo "msgfmt not found, skipping $@"
endif
$(MANPAGE): doc/plasma-face-unlock.1.scd
ifdef SCDOC
$(SCDOC) < $< > $@
else
@echo "scdoc not found, skipping $@"
endif
# Syntax-check every shell file, and run shellcheck when it is available.
check:
@set -e; for f in src/plasma-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"; \
else \
echo "shellcheck not found, skipped"; \
fi
@if command -v desktop-file-validate >/dev/null 2>&1; then \
desktop-file-validate res/applications/*.desktop && echo "ok desktop-file-validate"; \
fi
# The liveness cues against synthetic heads and photographs, the face store,
# and the PAM file editing against copies of real PAM files. None of it needs
# a camera, root or the models.
test: native
cd $(BUILDDIR) && ctest --output-on-failure
bash tests/test_pam.sh
install: build
@for m in $(MODELS); do \
[ -f "$(MODELS_SRC)/$$m" ] || { echo "$(MODELS_SRC)/$$m is missing: run 'make models' first" >&2; exit 1; }; \
done
DESTDIR="$(DESTDIR)" $(CMAKE) --install $(BUILDDIR)
# the command
install -Dm755 src/plasma-face-unlock "$(DESTDIR)$(BINDIR)/plasma-face-unlock"
install -d "$(DESTDIR)$(LIBDIR)"
install -Dm644 -t "$(DESTDIR)$(LIBDIR)" $(LIBS)
sed -i -e 's|@VERSION@|$(VERSION)|g' \
-e 's|@LIBDIR@|$(LIBDIR)|g' \
-e 's|@LIBEXECDIR@|$(LIBEXECDIR)|g' \
-e 's|@LOCALEDIR@|$(LOCALEDIR)|g' \
-e 's|@PAMDIR@|$(PAMDIR)|g' \
"$(DESTDIR)$(BINDIR)/plasma-face-unlock" \
"$(DESTDIR)$(LIBDIR)"/*.sh
# the models
@for m in $(MODELS); do \
install -Dm644 "$(MODELS_SRC)/$$m" "$(DESTDIR)$(MODELDIR)/$$m"; \
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"
sed -i -e 's|@LIBEXECDIR@|$(LIBEXECDIR)|g' \
"$(DESTDIR)$(SYSTEMUNITDIR)/plasma-face-unlockd.service" \
"$(DESTDIR)$(USERUNITDIR)/plasma-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"
# 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"; \
fi; \
done
# documentation
@if [ -f $(MANPAGE) ]; then \
install -Dm644 $(MANPAGE) "$(DESTDIR)$(MANDIR)/man1/plasma-face-unlock.1"; \
fi
install -Dm644 README.md "$(DESTDIR)$(DATADIR)/doc/plasma-face-unlock/README.md"
uninstall:
rm -f "$(DESTDIR)$(BINDIR)/plasma-face-unlock"
rm -rf "$(DESTDIR)$(DATADIR)/plasma-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
clean:
rm -rf $(BUILDDIR) po/*.mo $(MANPAGE)
+229
View File
@@ -0,0 +1,229 @@
<p align="center">
<img width="200" src="res/plasma-face-unlock.svg" alt="plasma-face-unlock">
</p>
<h1 align="center">plasma-face-unlock</h1>
<h3 align="center">Face ID for KDE Plasma.</h3>
<p align="center">
Look at the screen and it unlocks. Works for the lock screen, sudo and admin prompts, and a photo of you is not enough.
</p>
<h5 align="center">
<a href="#how-to-use">How to use</a> |
<a href="#how-to-install">Install</a> |
<a href="#is-it-safe">Is it safe?</a> |
<a href="https://github.com/LoonixTools/plasma-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>
</p>
<p align="center">
<img src="res/screenshots/bubble.png" alt="The bubble above the lock screen: asking for a blink, recognised, not recognised" width="720">
</p>
Come back to your locked screen, move the mouse, look at it: a little bubble drops down at the top,
the face in it looks around, turns into a tick, and you are in. The same for `sudo` in a terminal
and for the admin password prompts of Plasma, if you want. Everything runs on your machine, and
your face is stored as numbers, never as a picture.
The look of the bubble and the photo check are inspired by [Glance](https://github.com/jonnyoo/glance)
(face unlock for the Mac). The code is written from scratch for Plasma.
## How to use
Just run `plasma-face-unlock`. This will open the configuration TUI that looks like this:
```
Plasma Face Unlock
Face unlock ON
Faces 2 (Felix, Felix with glasses)
Camera Integrated Camera
Lock screen on
sudo on
Admin prompts off
Photo check strict (blink or turn your head)
Last unlock 2 minutes ago
[1] Turn face unlock on or off
[2] Add a face
[3] Faces
[4] Settings
[5] Try it
[q] Quit
>
```
Press `[1]`. The first time, it opens the setup window: look at the camera, then move your head
slowly in a circle until the ring around the picture is full (like setting up Face ID on a phone).
It asks for your password once before it adds the face. After that, lock the screen and look at it.
`[5]` does one scan and shows what the camera sees, which helps a lot when something does not work.
`[4]` has everything else:
```
Settings
▸ Unlock the lock screen ON
Look when somebody comes back to the screen ON
Look right after the screen locks OFF
Use for sudo in a terminal* ON
Use for admin prompts* OFF
Photo check* strict (blink or turn your head)
How closely a face has to match* normal
Only while looking at the screen* ON
Camera* automatic (Integrated Camera)
How long one look lasts* 5 seconds
Learn from every unlock* ON
Not while the lid is closed* ON
Show the bubble at the top ON
Bubble style island with the face
Bubble for sudo and admin prompts too ON
Settings marked * are for the whole computer and ask for your password.
Up/Down: select, Space or Right: change, q: back
```
## How to install
**Arch**
```bash
yay -S plasma-face-unlock
```
**Fedora**
```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
```
**Debian** (13 or newer) **and Kubuntu** (25.04 or newer)
```bash
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 ./" \
| sudo tee /etc/apt/sources.list.d/plasma-face-unlock.list
sudo apt update && sudo apt install plasma-face-unlock
```
You need Plasma 6 on Wayland and a camera. An infrared camera (the Windows Hello kind) works too.
## Is it safe?
**It is a convenience, not a security upgrade.** A phone builds a 3D map of your face with a dot
projector. A webcam only sees a flat picture, so this cannot be as safe as Face ID. What it does:
- A photo, printed or on a phone, held up and turned any way, **does not** get in. With the photo
check on *strict* (the default) the face has to blink or turn a little, and a photo can do neither.
- A screen or a glossy print held up to the camera throws back one big flat reflection, and a phone
has straight edges around the face. Both fail the scan straight away.
- A **video** of you blinking can still get through. So can, some of the time, a photo that is
curled a lot and turned a lot.
- Five failed tries in a row pause it for 15 minutes, or until you use your password.
- Your face data can only be read by root. Adding or deleting a face always needs your password,
never a face.
- sudo and admin prompts never take a face over SSH, or when you are not sitting at the machine.
If the machine guards something that matters, leave sudo and admin prompts off.
## How it works
Four parts:
| | |
|---|---|
| `plasma-face-unlockd` | Runs as root, started on demand. Owns the camera and the face data, and decides. |
| `plasma-face-unlock-agent` | Runs in your session. Watches the lock screen, draws the bubble, is the setup window. |
| `pam_plasma_face_unlock.so` | Lets sudo and polkit ask the daemon. |
| `plasma-face-unlock` | This menu. |
Faces are found with **YuNet** and turned into 128 numbers with **SFace**, two small networks from
the OpenCV model zoo that run on the CPU in a few milliseconds. Two pictures of the same person give
numbers that point the same way; how much they do is the match.
**The photo check** looks for a sign of life on top of the match:
- **Blink:** the dark of both eyes shrinks to a line and comes back within the fraction of a second a
blink takes, while the rest of the face holds still.
- **Head turn:** the eyes and the corners of the mouth lie close to one plane, so for a flat picture
they predict exactly where the nose has to go when it is turned. A real nose sticks out of that
plane and misses the prediction by about as much as the head turned.
The head turn check is tested against simulated heads and photos (`make test`): photos turned up to
60 degrees at heavy camera noise never pass, real heads of all shapes pass 98% of the time.
**The lock screen** is not unlocked through its password prompt (Plasma runs fingerprints there, but
only once per lock). Instead the agent notices the screen locking, scans when you come back (a key,
the mouse, the lid, waking from sleep), and unlocks the session through logind, just like
`loginctl unlock-session`. KWin lets the bubble show above the lock screen because the agent's desktop
file asks for it.
**sudo and admin prompts** get one line in front of their PAM stack:
```
-auth sufficient /usr/lib/security/pam_plasma_face_unlock.so
```
A match lets you in, anything else falls through to the password. The dash makes PAM skip it quietly
if the module is ever missing, so sudo keeps working even after uninstalling. Turning it off takes out
exactly that line.
The man page (`man plasma-face-unlock`) has all the details.
## Commands
| Command | |
|---|---|
| `plasma-face-unlock` | Interactive menu |
| `… enable` | Turn on (sets up a face first if needed) |
| `… disable` | Turn off, keep the faces |
| `… setup [NAME]` | Add a face |
| `… faces` | List the faces |
| `… remove ID` | Delete a face |
| `… test` | One scan, with what the camera sees |
| `… status` | What is on |
## Building from source
```bash
make models # downloads the two networks, checked against pinned checksums
make
make test
sudo make install
```
Needs CMake, a C++20 compiler, Qt 6 (Core, DBus, Network, Gui, Quick, WaylandClient), LayerShellQt,
KIdleTime, KI18n, OpenCV 4.5.4 or newer with the DNN module, Linux-PAM and libsystemd. Optionally
`msgfmt` (gettext) for translations and `scdoc` for the man page. Supports `PREFIX` and `DESTDIR`.
`make check` runs syntax checks and shellcheck.
To try it without a camera, point the camera setting at a folder of pictures (`images:/path`) or a
video (`file:/path.mp4`) in `/etc/plasma-face-unlock/config`.
See [packaging/README.md](packaging/README.md) for release builds and repo signing.
## Credits
- [Glance](https://github.com/jonnyoo/glance) by Jonathan Zhou (MIT): the idea, the look of the bubble
and the model of deny and confirm cues.
- [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
GPL-3.0-or-later.
+267
View File
@@ -0,0 +1,267 @@
plasma-face-unlock(1)
# NAME
plasma-face-unlock - face unlock for KDE Plasma
# SYNOPSIS
*plasma-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. Before a face counts, it has to show a sign of life, so
holding up a photo of somebody is not enough.
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 it turns into a
tick when it recognises somebody. It shows above the lock screen too.
Run without a command it shows an interactive menu. Everything it can be told
is reachable from there; the settings files behind it do not need to be edited
by hand.
It is a convenience, not a security upgrade. See *HOW SAFE IS THIS*.
# COMMANDS
*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.
*disable*
Turn it off: the agent stops and sudo and the admin prompts go back to the
password alone. The faces are kept.
*setup* [_name_]
Add a face. Opens the setup window: look at the camera, then move your head
slowly in a circle until the ring is full. Adding a face asks for the
password first.
*faces*
List the faces, with the id *remove* takes.
*remove* _id_
Delete a face.
*test*
One scan, with what the checks see printed as it happens: how well the face
matches, which way the head points, and how close each photo check is to
firing.
*status*
What is on, and when the last unlock was.
*-h*, *--help*
Show a summary of the commands.
*-V*, *--version*
Show the version.
# THE PIECES
*plasma-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
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
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*
The PAM module for sudo and admin prompts. It asks the daemon and turns
the answer into a PAM result.
*plasma-face-unlock-ctl*
How this command talks to the daemon.
# RECOGNITION
Two small networks from the OpenCV model zoo do the work, on the CPU through
OpenCV's DNN module. YuNet finds the face and five points on it (the eyes, the
tip of the nose, the corners of the mouth). SFace turns an aligned crop of the
face into 128 numbers; two crops of the same person give numbers that point
the same way, and the match is how closely they do (cosine similarity).
Setting up a face stores a few of those numbers per head direction: straight
ahead, and eight directions around it. No picture is ever written anywhere.
With *Learn from every unlock* on, a confident unlock adds one more sample (at
most twelve per face, the oldest go first), so a new haircut or glasses do
not need a new setup. Face ID does the same. Only unlocks well above the
threshold count, so the samples cannot drift towards somebody else one
borderline unlock at a time.
A match has to hold for two frames. The face has to look at the screen with
its eyes open (*Only while looking at the screen*), and it has to be the same
face that shows the sign of life: a face that jumps in the picture, disappears
or stops matching starts the whole check again.
# HOW IT TELLS A FACE FROM A PHOTO
The *Photo check* follows the model Glance (the macOS face unlock) arrived at:
a few cues, each decisive on its own, in two kinds.
Deny cues are evidence of a fake and fail the scan straight away:
- *glare*: one large, flat, colourless highlight on the face, the way a
phone screen or a glossy print throws back light. Skin shines in small
scattered spots. The eyes are left out, because glasses reflect the screen.
- *edge*: the straight edges of a phone or tablet framing the face.
Confirm cues are evidence of a real head. *strict* needs one of them:
- *blink*: the dark of both eyes shrinks to a line and comes back within the
fraction of a second a blink takes, while the rest of the face holds still
and the light does not change.
- *head turn*: when the head turns, the eyes and the corners of the mouth
(which lie close to one plane) predict exactly where a flat picture's nose
would go. A real nose sits in front of that plane and misses the prediction
by about as much as the head turned. The miss has to be consistent with a
real turn, measured without the nose's own jitter, and the face has to
narrow no more than a head that turned that far would.
*basic* uses the deny cues only. *off* checks nothing and is only for trying
out a camera.
# HOW SAFE IS THIS
A webcam sees a flat picture. A phone's face unlock builds a depth map with a
projector and an infrared camera; this cannot. What the photo check does:
- a photo, printed or on a screen, held up and turned any way, is refused by
*strict*;
- a photo curled strongly and turned a lot can pass the head turn cue some of
the time. Five points on a face cannot tell that from a very flat real face.
Blink, glare and edge are what is left against it;
- a *video* of the person blinking or turning their head can pass the confirm
cues. The deny cues catch a screen held up to the camera often, not always.
The other guards:
- five failed scans in a row with a face in view pause face unlock for
fifteen minutes, or until the session is unlocked with the password;
- the face data is readable by root only, and adding, changing or deleting a
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;
- 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
decision is the daemon's, which runs as root, and the PAM module only talks
to a daemon that runs as root.
If that is not enough for what the machine guards, leave sudo and admin
prompts off, or face unlock altogether.
# THE LOCK SCREEN
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:
- on any key or mouse movement after the screen locked, once 1.5 seconds have
passed (the key that locked it does not count);
- when the machine wakes from sleep;
- right after locking, if *Look right after the screen locks* is on. Off by
default: whoever locks their screen on purpose is usually still in front of
it.
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.
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_.
# SUDO AND ADMIN PROMPTS
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
```
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
turning this off first.
Where _/etc/pam.d/<service>_ exists the line goes in before its first auth
line (on Debian and Ubuntu, before *@include common-auth*). Where only the
distribution's copy in _/usr/lib/pam.d_ exists, a small _/etc/pam.d/<service>_
is written that puts the line first and includes the distribution's file for
everything else. Turning it off takes out exactly that line, or that file.
The login screen is left alone on purpose: logging in is also what unlocks the
wallet, and a face has no password to hand it.
# CAMERAS
Any V4L2 camera. *automatic* picks the first colour camera. An infrared
camera (the kind Windows Hello uses) can be picked under *Settings*; its
emitter has to be switched on with a tool such as linux-enable-ir-emitter.
In infrared a phone screen shows up black, which makes the photo check's job
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 while the lid is closed*).
# FILES
_~/.config/plasma-face-unlock/config_
This user's settings: the lock screen, the bubble. Written by the menu.
_/etc/plasma-face-unlock/config_
The system settings: camera, photo check, strictness, attention, scan
length. Written by the menu through sudo.
_/var/lib/plasma-face-unlock/users/<uid>.json_
The face data: numbers, no pictures. Root only.
_/var/lib/plasma-face-unlock/users/<uid>.state_
Failed scans in a row, the pause they lead to, the last unlock.
_/usr/share/plasma-face-unlock/models/_
The two networks.
_/run/plasma-face-unlock/socket_
The daemon's socket.
_$XDG_RUNTIME_DIR/plasma-face-unlock/agent.socket_
Where the daemon tells the agent about scans it did not start, for the
bubble.
# ENVIRONMENT
*NO_COLOR*
Disables colour.
# REQUIREMENTS
KDE Plasma 6 on Wayland, a camera, OpenCV 4.5.4 or newer with its DNN module,
Qt 6, LayerShellQt, KIdleTime, KI18n, systemd, polkit and Linux-PAM.
# SEE ALSO
*loginctl*(1), *pam*(8), *polkit*(8), *systemctl*(1)
# AUTHORS
Felitendo. Source and issue tracker at
https://github.com/LoonixTools/plasma-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
the OpenCV model zoo (MIT and Apache-2.0).
+83
View File
@@ -0,0 +1,83 @@
# Packaging and releases
The Makefile installs everything; these only wrap what it produced. That is
on purpose: a packaging script that lists the files again is a second
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 |
| `aur/PKGBUILD`, `aur/plasma-face-unlock.install` | the AUR package |
| `build-deb.sh`, `build-rpm.sh` | build one package into `dist/` |
| `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 |
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
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 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,
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.
## Building one by hand
```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
```
Both 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`).
2. 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, attaches the packages to a
GitHub release, and adds them to the APT and RPM repositories on the `gh-pages`
branch.
## Trying the release path first
```bash
gh workflow run release.yml -f dry_run=true
```
Builds both 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.
## Setting up the signing, once
```bash
gpg --batch --passphrase '' --quick-generate-key \
'plasma-face-unlock repository <felitendoyt@gmail.com>' rsa4096 sign never
gpg --armor --export-secret-keys 'plasma-face-unlock repository' \
| gh secret set GPG_PRIVATE_KEY
```
Without the secret the workflow still builds both 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
1. Set the secret, above.
2. Tag a release. The workflow creates the `gh-pages` branch and fills it.
3. *Then* set **Pages** to deploy from a branch and pick `gh-pages` at the
root.
+35
View File
@@ -0,0 +1,35 @@
# Maintainer: Felitendo
#
# The PKGBUILD for the AUR, kept here next to the code it builds. The copy in
# github.com/Felitendo/PKGBUILDS is the one CI bumps and pushes.
pkgname=plasma-face-unlock
pkgver=1.0.0
pkgrel=1
pkgdesc="Face ID for KDE Plasma: the lock screen, sudo and admin prompts by face, with a photo check"
arch=('x86_64' 'aarch64')
url="https://github.com/LoonixTools/plasma-face-unlock"
license=('GPL-3.0-or-later' 'MIT' 'Apache-2.0')
depends=('bash' 'coreutils' 'gawk' 'grep' 'sed' 'gettext' 'systemd' 'systemd-libs' 'pam' 'polkit'
'opencv' 'qt6-base' 'qt6-declarative' 'layer-shell-qt' 'kidletime' 'ki18n' 'kscreenlocker')
makedepends=('cmake' 'scdoc')
install="${pkgname}.install"
source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz"
"face_detection_yunet_2023mar.onnx::https://github.com/opencv/opencv_zoo/raw/main/models/face_detection_yunet/face_detection_yunet_2023mar.onnx"
"face_recognition_sface_2021dec.onnx::https://github.com/opencv/opencv_zoo/raw/main/models/face_recognition_sface/face_recognition_sface_2021dec.onnx")
noextract=('face_detection_yunet_2023mar.onnx' 'face_recognition_sface_2021dec.onnx')
sha256sums=('SKIP'
'8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4'
'0ba9fbfa01b5270c96627c4ef784da859931e02f04419c829e83484087c34e79')
build() {
make -C "${pkgname}-${pkgver}" VERSION="$pkgver"
}
check() {
make -C "${pkgname}-${pkgver}" VERSION="$pkgver" test
}
package() {
make -C "${pkgname}-${pkgver}" VERSION="$pkgver" MODELS_SRC="$srcdir" DESTDIR="$pkgdir" install
}
+29
View File
@@ -0,0 +1,29 @@
post_install() {
cat <<'MSG'
plasma-face-unlock is installed but not active yet.
plasma-face-unlock interactive menu
plasma-face-unlock enable set up your face and turn it on
Run it as your own user, not with sudo. It asks for your password when it
needs to, and sudo and admin prompts stay with the password until you switch
them on under Settings.
MSG
}
pre_remove() {
cat <<'MSG'
Before removing this package, run
plasma-face-unlock disable
as each user that turned it on. That takes face unlock back out of sudo and
polkit. (Removing it without that is safe too: the PAM line is written so
that a missing module is skipped, and sudo keeps asking for the password.)
MSG
systemctl disable --now plasma-face-unlockd.socket plasma-face-unlockd.service >/dev/null 2>&1 || true
}
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
#
# Builds the binary package for Debian, Ubuntu and their derivatives into
# dist/.
#
# Everything the package contains comes out of `make install`. This only wraps
# what that produced, so there is exactly one description of where a file goes
# and it is the Makefile.
#
# Unlike the shell-only tools, this one is compiled, so the package is for one
# architecture and its library dependencies are read off the binaries by
# dpkg-shlibdeps rather than written down by hand.
#
# Needs: make, cmake, a C++ compiler, the -dev packages the README lists,
# dpkg-dev, msgfmt (gettext), scdoc, curl.
set -euo pipefail
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
version="${1:-$(make -s -C "$here" version)}"
name=plasma-face-unlock
# A package without its man page or its translations is not a package this
# should be quietly willing to produce.
for tool in msgfmt scdoc dpkg-deb dpkg-shlibdeps cmake; do
command -v "$tool" > /dev/null || { echo "$0: $tool is not installed" >&2; exit 1; }
done
root="$(mktemp -d)"
work="$(mktemp -d)"
trap 'rm -rf -- "$root" "$work"' EXIT
make -C "$here" models
make -C "$here" install \
DESTDIR="$root" \
PREFIX=/usr \
VERSION="$version" \
BUILDDIR="$work/build" \
SYSTEMUNITDIR=/usr/lib/systemd/system \
USERUNITDIR=/usr/lib/systemd/user
arch="$(dpkg --print-architecture)"
# The shared libraries the binaries need, as package names.
mkdir -p "$work/debian"
printf 'Source: %s\n\nPackage: %s\nArchitecture: any\n' "$name" "$name" > "$work/debian/control"
mapfile -t elves < <(find "$root" -type f \( -name '*.so' -o -perm -u+x \) -exec sh -c 'head -c4 "$1" | grep -q ELF' _ {} \; -print)
depends="$(cd "$work" && dpkg-shlibdeps -O "${elves[@]/#/-e}" 2>/dev/null | sed -n 's/^shlibs:Depends=//p')"
[[ -n $depends ]] || { echo "$0: dpkg-shlibdeps found no dependencies" >&2; exit 1; }
install -d "$root/DEBIAN"
sed -e "s|@VERSION@|$version|g" -e "s|@ARCH@|$arch|g" -e "s|@DEPENDS@|$depends|g" \
"$here/packaging/deb/control" > "$root/DEBIAN/control"
install -Dm644 "$here/packaging/deb/copyright" "$root/usr/share/doc/$name/copyright"
# The daemon's socket is switched on by the program itself, the first time it
# is turned on, so there is nothing to do as root at install time. Removing
# the package stops it.
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
fi
SH
chmod 755 "$root/DEBIAN/prerm"
( cd "$root" && find . -type f ! -path './DEBIAN/*' -printf '%P\0' \
| LC_ALL=C sort -z | xargs -0 md5sum > DEBIAN/md5sums )
mkdir -p "$here/dist"
out="$here/dist/${name}_${version}_${arch}.deb"
dpkg-deb --root-owner-group --build "$root" "$out" > /dev/null
echo "$out"
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
#
# Builds the binary package for Fedora into dist/.
#
# The spec takes the version as a macro rather than carrying one of its own,
# for the same reason the Debian control file has a placeholder: the Makefile
# is where the version is written down. The two networks are sources of their
# own, downloaded (and checked) by `make models` before rpmbuild sees them.
#
# Needs: rpmbuild, make, cmake, a C++ compiler, the -devel packages the spec
# lists, msgfmt (gettext), scdoc, curl, systemd-rpm-macros.
set -euo pipefail
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
version="${1:-$(make -s -C "$here" version)}"
name=plasma-face-unlock
command -v rpmbuild > /dev/null || { echo "$0: rpmbuild is not installed" >&2; exit 1; }
make -C "$here" models
top="$(mktemp -d)"
trap 'rm -rf -- "$top"' EXIT
mkdir -p "$top"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS}
# The working tree as it is, not as it was committed: a package built from a
# checkout has to contain what is in that checkout.
tar czf "$top/SOURCES/$name-$version.tar.gz" \
--transform "s,^\\.,$name-$version," \
--exclude=./.git --exclude=./dist --exclude=./build --exclude=./models --exclude=./po/'*.mo' \
-C "$here" .
cp "$here"/models/*.onnx "$top/SOURCES/"
rpmbuild \
--define "_topdir $top" \
--define "_version $version" \
-bb "$here/packaging/rpm/$name.spec" > /dev/null
mkdir -p "$here/dist"
find "$top/RPMS" -name '*.rpm' -exec cp {} "$here/dist/" \;
ls "$here/dist/$name-$version"*.rpm
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
#
# Refuses a release whose tag and Makefile disagree.
#
# The version is baked into the program at install time from the Makefile, and
# the packages take theirs from the same place. But the tag is what people see
# and what the release is named after. A tag that says something else produces
# a package called 1.0.4 containing a program that reports 1.0.3, and nothing
# would have complained.
#
# Anything that is not a v-tag (a run started by hand from a branch) is not a
# release and has nothing to check.
set -euo pipefail
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
ref="${1:-}"
case "$ref" in
v[0-9]*) ;;
*)
echo "not a release tag (${ref:-none}), nothing to check against"
exit 0
;;
esac
tag_version="${ref#v}"
make_version="$(make -s -C "$here" version)"
if [[ $tag_version != "$make_version" ]]; then
echo "tag $ref says $tag_version, the Makefile says $make_version" >&2
echo "Bump VERSION in the Makefile to match the tag, or retag." >&2
exit 1
fi
echo "$ref matches the Makefile"
+23
View File
@@ -0,0 +1,23 @@
Package: plasma-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
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.
.
Before a face counts it has to show a sign of life (a blink, or the nose
moving the way a real nose does when the head turns), so a photo held up to
the camera is not enough. It is a convenience, not a security upgrade: a
webcam sees a flat picture, and a video of the person can get 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
this package, so that sudo and polkit go back to the password alone.
+49
View File
@@ -0,0 +1,49 @@
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
Files: *
Copyright: 2026 Felitendo
License: GPL-3+
Files: usr/share/plasma-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
Copyright: 2021 Yaoyao Zhong and Weihong Deng
License: Apache-2.0
Comment: https://github.com/opencv/opencv_zoo/tree/main/models/face_recognition_sface
License: GPL-3+
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later
version.
.
This program is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE. See the GNU General Public License for more details.
.
On Debian systems the full text of the GNU General Public License version 3 can
be found in /usr/share/common-licenses/GPL-3.
License: MIT
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.
License: Apache-2.0
On Debian systems the full text of the Apache License 2.0 can be found in
/usr/share/common-licenses/Apache-2.0.
+112
View File
@@ -0,0 +1,112 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>plasma-face-unlock</title>
<style>
:root {
--bg: #ffffff;
--fg: #1b1f23;
--muted: #57606a;
--rule: #d8dee4;
--code-bg: #f6f8fa;
--accent: #1793d1;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0d1117;
--fg: #e6edf3;
--muted: #9198a1;
--rule: #30363d;
--code-bg: #161b22;
--accent: #58a6ff;
}
}
* { box-sizing: border-box; }
body {
margin: 0 auto;
padding: 3rem 1.25rem 5rem;
max-width: 46rem;
background: var(--bg);
color: var(--fg);
font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
}
h1 { font-size: 1.6rem; margin: 0 0 .4rem; }
h2 {
font-size: 1.05rem; margin: 2.5rem 0 .6rem;
padding-bottom: .3rem; border-bottom: 1px solid var(--rule);
}
p.lead { color: var(--muted); margin: 0 0 2rem; }
p { margin: 0 0 1rem; }
a { color: var(--accent); }
code {
background: var(--code-bg); padding: .12em .35em;
border-radius: 4px; font-size: .9em;
}
pre {
background: var(--code-bg);
border: 1px solid var(--rule);
border-radius: 6px;
padding: .9rem 1rem;
overflow-x: auto;
}
pre code { background: none; padding: 0; font-size: .85rem; }
footer {
margin-top: 3.5rem; padding-top: 1rem;
border-top: 1px solid var(--rule);
color: var(--muted); font-size: .9rem;
}
</style>
</head>
<body>
<h1>plasma-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.
</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
documentation</a> are on GitHub.
</p>
<h2>Debian 13 or newer, Kubuntu 25.04 or newer</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 ./" \
| sudo tee /etc/apt/sources.list.d/plasma-face-unlock.list
sudo apt update
sudo apt install plasma-face-unlock</code></pre>
<h2>Fedora (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>Arch, CachyOS, EndeavourOS, Manjaro</h2>
<pre><code>paru -S plasma-face-unlock</code></pre>
<h2>Then, once</h2>
<pre><code>plasma-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>
before removing the package: it takes face unlock back out of sudo and
polkit.
</p>
<footer>
GPL-3.0-or-later. Packages are signed; the public key is
<a href="@BASEURL@/KEY.gpg">KEY.gpg</a>.
</footer>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
[plasma-face-unlock]
name=plasma-face-unlock
baseurl=@BASEURL@/rpm
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=@BASEURL@/KEY.gpg
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
#
# Puts the packages that were just built into the APT and RPM repositories on
# the gh-pages branch, and regenerates the indexes over everything that is
# there.
#
# Old versions are kept rather than replaced. An index built over all of them
# is what lets somebody pin a version or go back to one, and it costs a few
# hundred kilobytes.
#
# Usage: publish-repos.sh <gh-pages checkout> <directory of new packages>
#
# Needs: dpkg-dev, apt-utils, createrepo-c, gpg, and a secret key already
# imported. Its id is taken from the keyring.
set -euo pipefail
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}"
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; }
mkdir -p "$pages/deb" "$pages/rpm"
cp -- "$incoming"/*.deb "$pages/deb/"
cp -- "$incoming"/*.rpm "$pages/rpm/"
# ---------------------------------------------------------------------------
# APT
# ---------------------------------------------------------------------------
# A flat repository: the packages and their index sit in one directory and the
# sources line ends in "./". There is one distribution here and it is the same
# package for all of them, so the suite and component machinery of a pool
# layout would describe nothing.
(
cd "$pages/deb"
# The old index must be gone before the new one is written: apt-ftparchive
# hashes every file in the directory, and a Release that hashes the
# previous Release is a Release that cannot be verified.
rm -f Packages Packages.gz Release Release.gpg InRelease
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::Suite=stable \
-o APT::FTPArchive::Release::Codename=stable \
-o APT::FTPArchive::Release::Architectures=amd64 \
-o APT::FTPArchive::Release::Components=main \
release . > Release
# Both signatures: InRelease is what current apt fetches, Release.gpg is
# what an older one falls back to.
gpg --batch --yes --local-user "$keyid" --clearsign --output InRelease Release
gpg --batch --yes --local-user "$keyid" --detach-sign --armor --output Release.gpg Release
)
# ---------------------------------------------------------------------------
# RPM
# ---------------------------------------------------------------------------
(
cd "$pages/rpm"
createrepo_c --quiet --update .
rm -f repodata/repomd.xml.asc
gpg --batch --yes --local-user "$keyid" --detach-sign --armor repodata/repomd.xml
)
# ---------------------------------------------------------------------------
# The key and the landing page
# ---------------------------------------------------------------------------
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"
# Pages would otherwise hand the whole directory to Jekyll, which drops every
# file whose name starts with an underscore and can rewrite the rest.
touch "$pages/.nojekyll"
echo "signed with $keyid"
ls -1 "$pages/deb" "$pages/rpm"
+101
View File
@@ -0,0 +1,101 @@
# 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.0}
Name: plasma-face-unlock
Version: %{upstream_version}
Release: 1%{?dist}
Summary: Face unlock for KDE Plasma
# 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
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
BuildRequires: cmake
BuildRequires: gcc-c++
BuildRequires: make
BuildRequires: gettext
BuildRequires: scdoc
BuildRequires: systemd-rpm-macros
BuildRequires: pkgconfig(systemd)
BuildRequires: pkgconfig(libsystemd)
BuildRequires: pam-devel
BuildRequires: opencv-devel
BuildRequires: cmake(Qt6Core)
BuildRequires: cmake(Qt6DBus)
BuildRequires: cmake(Qt6Network)
BuildRequires: cmake(Qt6Gui)
BuildRequires: cmake(Qt6Quick)
BuildRequires: cmake(Qt6WaylandClient)
BuildRequires: qt6-qtbase-private-devel
BuildRequires: qt6-qtwayland-devel
BuildRequires: cmake(LayerShellQt)
BuildRequires: cmake(KF6IdleTime)
BuildRequires: cmake(KF6I18n)
Requires: bash >= 4.2
Requires: coreutils
Requires: gawk
Requires: grep
Requires: sed
Requires: polkit
Requires: systemd
Requires: qt6-qtdeclarative
Recommends: /usr/bin/gettext
Recommends: kscreenlocker
%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.
Before a face counts it has to show a sign of life (a blink, or the nose moving
the way a real nose does when the head turns), so a photo held up to the camera
is not enough. 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
and polkit go back to the password alone.
%prep
%autosetup -n %{name}-%{version}
mkdir -p models
cp %{SOURCE1} %{SOURCE2} models/
%build
%set_build_flags
make VERSION=%{upstream_version} PREFIX=%{_prefix} PAMDIR=%{_libdir}/security
%install
make install DESTDIR=%{buildroot} PREFIX=%{_prefix} VERSION=%{upstream_version} \
PAMDIR=%{_libdir}/security SYSTEMUNITDIR=%{_unitdir} USERUNITDIR=%{_userunitdir}
%find_lang %{name}
%preun
%systemd_preun plasma-face-unlockd.socket plasma-face-unlockd.service
%postun
%systemd_postun plasma-face-unlockd.socket plasma-face-unlockd.service
%files -f %{name}.lang
%license LICENSE
%doc %{_datadir}/doc/%{name}/README.md
%{_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
%{_mandir}/man1/%{name}.1*
%changelog
+768
View File
@@ -0,0 +1,768 @@
# German translation for plasma-face-unlock.
# Copyright (C) 2026 Felitendo
# This file is distributed under the same license as plasma-face-unlock.
#
msgid ""
msgstr ""
"Project-Id-Version: plasma-face-unlock 1.0.0\n"
"Report-Msgid-Bugs-To: https://github.com/LoonixTools/plasma-face-unlock/"
"issues\n"
"POT-Creation-Date: 2026-09-22 18:45+0200\n"
"PO-Revision-Date: 2026-09-22 18:45+0200\n"
"Last-Translator: Felitendo\n"
"Language-Team: German\n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: src/plasma-face-unlock:50
msgid ""
"Face unlock's service has to be switched on once for this computer. That "
"needs your password."
msgstr "Der Dienst für die Gesichtsentsperrung muss auf diesem Computer einmal eingeschaltet werden. Dafür wird dein Passwort gebraucht."
#: src/plasma-face-unlock:57
#, sh-printf-format
msgid "Check it with: systemctl status %s"
msgstr "Prüfen mit: systemctl status %s"
#: src/plasma-face-unlock:67
msgid ""
"Setting up a face needs the camera picture on screen. Run this inside your "
"Plasma session."
msgstr "Zum Einrichten eines Gesichts muss das Kamerabild auf dem Bildschirm zu sehen sein. Starte das in deiner Plasma-Sitzung."
#: src/plasma-face-unlock:71
msgid "The setup window is open. Follow it there."
msgstr "Das Einrichtungsfenster ist offen. Folge den Schritten dort."
#: src/plasma-face-unlock:80
msgid "The face is set up."
msgstr "Das Gesicht ist eingerichtet."
#: src/plasma-face-unlock:83
#, sh-printf-format
msgid "Face unlock is still off. Turn it on with [1] or `%s enable`."
msgstr "Die Gesichtsentsperrung ist noch aus. Schalte sie mit [1] oder `%s enable` ein."
#: src/plasma-face-unlock:87
msgid "No face was added."
msgstr "Es wurde kein Gesicht hinzugefügt."
#: src/plasma-face-unlock:96
msgid "First, set up your face."
msgstr "Richte zuerst dein Gesicht ein."
#: src/plasma-face-unlock:100 src/plasma-face-unlock:120 src/lib/menu.sh:338
msgid "Could not save the setting."
msgstr "Einstellung konnte nicht gespeichert werden."
#: src/plasma-face-unlock:104
msgid "Could not start the lock screen agent."
msgstr "Der Sperrbildschirm-Dienst konnte nicht gestartet werden."
#: src/plasma-face-unlock:106
msgid "No systemd user session, so the lock screen agent was not started."
msgstr "Keine systemd-Benutzersitzung, daher wurde der Sperrbildschirm-Dienst nicht gestartet."
#: src/plasma-face-unlock:113
msgid "Face unlock is on. Lock the screen and look at it to try."
msgstr "Die Gesichtsentsperrung ist an. Sperre den Bildschirm und schau ihn an, um es auszuprobieren."
#: src/plasma-face-unlock:115
msgid "It can do sudo and admin prompts too. See Settings."
msgstr "Sie kann auch sudo und Admin-Abfragen übernehmen. Siehe Einstellungen."
#: src/plasma-face-unlock:130
msgid "Face unlock is off. Your faces are kept; delete them under Faces."
msgstr "Die Gesichtsentsperrung ist aus. Deine Gesichter bleiben erhalten; löschen kannst du sie unter Gesichter."
#: src/plasma-face-unlock:144 src/lib/daemon.sh:109
msgid "No face is set up yet."
msgstr "Es ist noch kein Gesicht eingerichtet."
#: src/plasma-face-unlock:148 src/lib/menu.sh:157 src/lib/menu.sh:460
msgid "on"
msgstr "an"
#: src/plasma-face-unlock:148 src/lib/menu.sh:159 src/lib/menu.sh:169
#: src/lib/menu.sh:461
msgid "off"
msgstr "aus"
#: src/plasma-face-unlock:150 src/lib/menu.sh:478
#, sh-printf-format
msgid "%s samples, %s learned"
msgstr "%s Aufnahmen, %s dazugelernt"
#: src/plasma-face-unlock:156
#, sh-printf-format
msgid "Which face? See `%s faces` for the ids."
msgstr "Welches Gesicht? Die IDs zeigt `%s faces`."
#: src/plasma-face-unlock:160
msgid "Deleted."
msgstr "Gelöscht."
#: src/plasma-face-unlock:171
msgid "Usage: plasma-face-unlock [command]"
msgstr "Aufruf: plasma-face-unlock [Befehl]"
#: src/plasma-face-unlock:173
msgid "Commands:"
msgstr "Befehle:"
#: src/plasma-face-unlock:174
msgid "Turn face unlock on"
msgstr "Gesichtsentsperrung einschalten"
#: src/plasma-face-unlock:175
msgid "Turn it off (faces are kept)"
msgstr "Ausschalten (Gesichter bleiben erhalten)"
#: src/plasma-face-unlock:176 src/lib/menu.sh:540
msgid "Add a face"
msgstr "Gesicht hinzufügen"
#: src/plasma-face-unlock:177
msgid "List the faces"
msgstr "Gesichter auflisten"
#: src/plasma-face-unlock:178
msgid "Delete a face"
msgstr "Ein Gesicht löschen"
#: src/plasma-face-unlock:179
msgid "Look at the camera and see what it sees"
msgstr "In die Kamera schauen und sehen, was sie sieht"
#: src/plasma-face-unlock:180
msgid "Show what is on"
msgstr "Anzeigen, was eingeschaltet ist"
#: src/plasma-face-unlock:181
msgid "Show this help"
msgstr "Diese Hilfe anzeigen"
#: src/plasma-face-unlock:182
msgid "Show the version"
msgstr "Die Version anzeigen"
#: src/plasma-face-unlock:184
msgid "Without a command an interactive menu is shown."
msgstr "Ohne Befehl wird ein interaktives Menü angezeigt."
#: src/plasma-face-unlock:203
msgid ""
"Run this as your own user, not as root. It asks for your password when it "
"needs to."
msgstr "Starte das als dein eigener Benutzer, nicht als root. Wenn nötig, wird nach deinem Passwort gefragt."
#: src/plasma-face-unlock:221 src/lib/system.sh:89
#, sh-printf-format
msgid "Unknown command: %s"
msgstr "Unbekannter Befehl: %s"
#: src/lib/common.sh:160
msgid "never"
msgstr "nie"
#: src/lib/common.sh:170
msgid "just now"
msgstr "gerade eben"
#: src/lib/common.sh:173
#, sh-printf-format
msgid "%d minutes ago"
msgstr "vor %d Minuten"
#: src/lib/common.sh:176
#, sh-printf-format
msgid "%d hours ago"
msgstr "vor %d Stunden"
#: src/lib/common.sh:179
#, sh-printf-format
msgid "%d days ago"
msgstr "vor %d Tagen"
#: src/lib/daemon.sh:102
msgid "The face did not match."
msgstr "Das Gesicht passte nicht."
#: src/lib/daemon.sh:103
msgid "That looked like a photo or a screen."
msgstr "Das sah aus wie ein Foto oder ein Bildschirm."
#: src/lib/daemon.sh:104
msgid "The face matched, but did not blink or move."
msgstr "Das Gesicht passte, hat aber nicht geblinzelt und sich nicht bewegt."
#: src/lib/daemon.sh:105
msgid "The face was not looking at the screen."
msgstr "Das Gesicht hat nicht auf den Bildschirm geschaut."
#: src/lib/daemon.sh:106
msgid "The picture was too dark, too blurry or too far away."
msgstr "Das Bild war zu dunkel, zu unscharf oder zu weit weg."
#: src/lib/daemon.sh:107
msgid "No face in view."
msgstr "Kein Gesicht zu sehen."
#: src/lib/daemon.sh:108
msgid ""
"Face unlock is paused after too many tries. Unlock once with your password."
msgstr "Die Gesichtsentsperrung ist nach zu vielen Versuchen pausiert. Entsperre einmal mit deinem Passwort."
#: src/lib/daemon.sh:110
msgid "The camera could not be used."
msgstr "Die Kamera konnte nicht benutzt werden."
#: src/lib/daemon.sh:111 src/lib/menu.sh:233
msgid "The recognition models are missing. Reinstall the package."
msgstr "Die Erkennungsmodelle fehlen. Installiere das Paket neu."
#: src/lib/daemon.sh:112
msgid "The lid is closed."
msgstr "Der Deckel ist zu."
#: src/lib/daemon.sh:113
msgid "The camera is busy with another scan."
msgstr "Die Kamera ist gerade mit einem anderen Scan beschäftigt."
#: src/lib/daemon.sh:114
msgid "The face unlock service is not running."
msgstr "Der Dienst für die Gesichtsentsperrung läuft nicht."
#: src/lib/daemon.sh:115
msgid "Not allowed."
msgstr "Nicht erlaubt."
#: src/lib/daemon.sh:116
#, sh-printf-format
msgid "Something went wrong (%s)."
msgstr "Etwas ist schiefgelaufen (%s)."
#: src/lib/daemon.sh:127
msgid "Look at the camera. Blink once, or turn your head a little."
msgstr "Schau in die Kamera. Blinzle einmal oder dreh den Kopf ein wenig."
#: src/lib/daemon.sh:137
msgid "Face found."
msgstr "Gesicht gefunden."
#: src/lib/daemon.sh:141
msgid "Recognised. Now blink once, or turn your head a little."
msgstr "Erkannt. Jetzt einmal blinzeln oder den Kopf ein wenig drehen."
#: src/lib/daemon.sh:142
msgid "Look at the screen."
msgstr "Schau auf den Bildschirm."
#: src/lib/daemon.sh:143
msgid "Move closer to the camera."
msgstr "Geh näher an die Kamera."
#: src/lib/daemon.sh:144
msgid "It is too dark to see your face."
msgstr "Es ist zu dunkel, um dein Gesicht zu sehen."
#: src/lib/daemon.sh:153
msgid "match"
msgstr "Treffer"
#: src/lib/daemon.sh:153
msgid "turn"
msgstr "Drehung"
#: src/lib/daemon.sh:154
msgid "eyes"
msgstr "Augen"
#: src/lib/daemon.sh:155
msgid "depth"
msgstr "Tiefe"
#: src/lib/daemon.sh:155 src/lib/daemon.sh:164
msgid "blink"
msgstr "Blinzeln"
#: src/lib/daemon.sh:156
msgid "glare"
msgstr "Spiegelung"
#: src/lib/daemon.sh:156
msgid "edge"
msgstr "Rand"
#: src/lib/daemon.sh:165
msgid "head turn"
msgstr "Kopfdrehung"
#: src/lib/daemon.sh:167
#, sh-printf-format
msgid "Recognised as %s (match %s) in %s ms."
msgstr "Erkannt als %s (Treffer %s) in %s ms."
#: src/lib/daemon.sh:168
#, sh-printf-format
msgid "Sign of life: %s"
msgstr "Lebenszeichen: %s"
#: src/lib/daemon.sh:173
msgid ""
"That was too many tries. Face unlock is paused until you unlock with your "
"password."
msgstr "Das waren zu viele Versuche. Die Gesichtsentsperrung ist pausiert, bis du mit deinem Passwort entsperrst."
#: src/lib/menu.sh:122
msgid "Press any key to continue..."
msgstr "Beliebige Taste drücken …"
#: src/lib/menu.sh:130
msgid "[y/N]"
msgstr "[j/N]"
#: src/lib/menu.sh:149 src/lib/menu.sh:382
msgid "ON"
msgstr "AN"
#: src/lib/menu.sh:151 src/lib/menu.sh:383
msgid "OFF"
msgstr "AUS"
#: src/lib/menu.sh:167
msgid "strict (blink or turn your head)"
msgstr "streng (blinzeln oder Kopf drehen)"
#: src/lib/menu.sh:168
msgid "basic (screens and phone edges)"
msgstr "einfach (Bildschirme und Handyränder)"
#: src/lib/menu.sh:170
msgid "normal"
msgstr "normal"
#: src/lib/menu.sh:171
msgid "strict"
msgstr "streng"
#: src/lib/menu.sh:172
msgid "relaxed"
msgstr "locker"
#: src/lib/menu.sh:173
msgid "island with the face"
msgstr "Insel mit Gesicht"
#: src/lib/menu.sh:174
msgid "small pill with a lock"
msgstr "kleine Pille mit Schloss"
#: src/lib/menu.sh:175
#, sh-printf-format
msgid "%s seconds"
msgstr "%s Sekunden"
#: src/lib/menu.sh:176 src/lib/menu.sh:313
msgid "automatic"
msgstr "automatisch"
#: src/lib/menu.sh:193
msgid "Face unlock"
msgstr "Gesichtsentsperrung"
#: src/lib/menu.sh:197
msgid "Service"
msgstr "Dienst"
#: src/lib/menu.sh:197
msgid "not running"
msgstr "läuft nicht"
#: src/lib/menu.sh:199
msgid "Turning face unlock on again starts it."
msgstr "Die Gesichtsentsperrung erneut einzuschalten startet ihn."
#: src/lib/menu.sh:210 src/lib/menu.sh:212 src/lib/menu.sh:457
#: src/lib/menu.sh:541
msgid "Faces"
msgstr "Gesichter"
#: src/lib/menu.sh:210
msgid "none set up yet"
msgstr "noch keins eingerichtet"
#: src/lib/menu.sh:212
msgid "all turned off"
msgstr "alle ausgeschaltet"
#: src/lib/menu.sh:218
msgid "none found"
msgstr "keine gefunden"
#: src/lib/menu.sh:220 /tmp/tmp.q9gMIu3Tjj/settings.sh:9
msgid "Camera"
msgstr "Kamera"
#: src/lib/menu.sh:222
msgid "Lock screen"
msgstr "Sperrbildschirm"
#: src/lib/menu.sh:223
msgid "sudo"
msgstr "sudo"
#: src/lib/menu.sh:224
msgid "Admin prompts"
msgstr "Admin-Abfragen"
#: src/lib/menu.sh:225 /tmp/tmp.q9gMIu3Tjj/settings.sh:6
msgid "Photo check"
msgstr "Foto-Prüfung"
#: src/lib/menu.sh:228
msgid "Paused"
msgstr "Pausiert"
#: src/lib/menu.sh:228
#, sh-printf-format
msgid "for %d more minutes, or until the password is used"
msgstr "noch %d Minuten, oder bis das Passwort benutzt wird"
#: src/lib/menu.sh:230
msgid "Last unlock"
msgstr "Letzte Entsperrung"
#: src/lib/menu.sh:309
#, sh-printf-format
msgid "automatic (%s)"
msgstr "automatisch (%s)"
#: src/lib/menu.sh:319
msgid "(infrared)"
msgstr "(Infrarot)"
#: src/lib/menu.sh:323
msgid "(not connected)"
msgstr "(nicht angeschlossen)"
#: src/lib/menu.sh:379 src/lib/menu.sh:542
msgid "Settings"
msgstr "Einstellungen"
#: src/lib/menu.sh:380
msgid "Up/Down: select, Space or Right: change, q: back"
msgstr "Hoch/Runter: wählen, Leertaste oder Rechts: ändern, q: zurück"
#: src/lib/menu.sh:381
msgid "Settings marked * are for the whole computer and ask for your password."
msgstr "Mit * markierte Einstellungen gelten für den ganzen Computer und fragen nach deinem Passwort."
#: src/lib/menu.sh:458
msgid "Up/Down: select, Space: on/off, r: rename, d: delete, a: add, q: back"
msgstr "Hoch/Runter: wählen, Leertaste: an/aus, r: umbenennen, d: löschen, a: hinzufügen, q: zurück"
#: src/lib/menu.sh:459
msgid "No face is set up yet. Press a to add one."
msgstr "Es ist noch kein Gesicht eingerichtet. Drück a, um eins hinzuzufügen."
#: src/lib/menu.sh:502
msgid "New name:"
msgstr "Neuer Name:"
#: src/lib/menu.sh:509
#, sh-printf-format
msgid "Delete \"%s\"?"
msgstr "„%s“ löschen?"
#: src/lib/menu.sh:539
msgid "Turn face unlock on or off"
msgstr "Gesichtsentsperrung ein- oder ausschalten"
#: src/lib/menu.sh:543
msgid "Try it"
msgstr "Ausprobieren"
#: src/lib/menu.sh:544
msgid "Quit"
msgstr "Beenden"
#: src/lib/pam.sh:132
#, sh-printf-format
msgid "The PAM module is not installed at %s."
msgstr "Das PAM-Modul ist nicht unter %s installiert."
#: src/lib/pam.sh:140
#, sh-printf-format
msgid "There is no PAM configuration for %s on this system."
msgstr "Auf diesem System gibt es keine PAM-Konfiguration für %s."
#: src/lib/system.sh:47
msgid "This needs root, and neither sudo, run0 nor doas is installed."
msgstr "Dafür braucht es root, aber weder sudo noch run0 noch doas ist installiert."
#: src/lib/system.sh:56
msgid "This command has to run as root."
msgstr "Dieser Befehl muss als root laufen."
#: src/lib/system.sh:64
#, sh-printf-format
msgid "Not a valid setting: %s=%s"
msgstr "Keine gültige Einstellung: %s=%s"
#: src/lib/system.sh:75
#, sh-printf-format
msgid "Not a service this can be used for: %s"
msgstr "Dafür kann es nicht verwendet werden: %s"
#: src/lib/menu.sh
msgid "Unlock the lock screen"
msgstr "Sperrbildschirm entsperren"
#: src/lib/menu.sh
msgid "Look when somebody comes back to the screen"
msgstr "Schauen, wenn jemand zum Bildschirm zurückkommt"
#: src/lib/menu.sh
msgid "Look right after the screen locks"
msgstr "Gleich nach dem Sperren schauen"
#: src/lib/menu.sh
msgid "Use for sudo in a terminal"
msgstr "Für sudo im Terminal verwenden"
#: src/lib/menu.sh
msgid "Use for admin prompts"
msgstr "Für Admin-Abfragen verwenden"
#: src/lib/menu.sh
msgid "How closely a face has to match"
msgstr "Wie genau ein Gesicht passen muss"
#: src/lib/menu.sh
msgid "Only while looking at the screen"
msgstr "Nur beim Blick auf den Bildschirm"
#: src/lib/menu.sh
msgid "How long one look lasts"
msgstr "Wie lange ein Blick dauert"
#: src/lib/menu.sh
msgid "Learn from every unlock"
msgstr "Bei jeder Entsperrung dazulernen"
#: src/lib/menu.sh
msgid "Not while the lid is closed"
msgstr "Nicht bei geschlossenem Deckel"
#: src/lib/menu.sh
msgid "Show the bubble at the top"
msgstr "Die Bubble oben anzeigen"
#: src/lib/menu.sh
msgid "Bubble style"
msgstr "Bubble-Stil"
#: src/lib/menu.sh
msgid "Bubble for sudo and admin prompts too"
msgstr "Bubble auch für sudo und Admin-Abfragen"
#: src/agent/bubblecontroller.cpp:91
msgid "Blink once"
msgstr "Einmal blinzeln"
#: src/agent/bubblecontroller.cpp:93
msgid "Look at the screen"
msgstr "Auf den Bildschirm schauen"
#: src/agent/bubblecontroller.cpp:95
msgid "Move closer"
msgstr "Näher kommen"
#: src/agent/bubblecontroller.cpp:97
msgid "Too dark"
msgstr "Zu dunkel"
#: src/agent/bubblecontroller.cpp:117
msgid "Use your password"
msgstr "Nutze dein Passwort"
#: src/agent/bubblecontroller.cpp:123
msgid "Not recognized"
msgstr "Nicht erkannt"
#: src/agent/bubblecontroller.cpp:125
msgid "Try again and blink"
msgstr "Nochmal, und blinzeln"
#: src/agent/bubblecontroller.cpp:127
msgid "Camera unavailable"
msgstr "Kamera nicht verfügbar"
#: src/agent/enrollcontroller.cpp:68
msgid "Bring your face into the circle."
msgstr "Bring dein Gesicht in den Kreis."
#: src/agent/enrollcontroller.cpp:70
msgid "Only one face, please."
msgstr "Bitte nur ein Gesicht."
#: src/agent/enrollcontroller.cpp:72
msgid "Move a little closer."
msgstr "Komm etwas näher."
#: src/agent/enrollcontroller.cpp:74
msgid "It is too dark. Turn on a light."
msgstr "Es ist zu dunkel. Mach ein Licht an."
#: src/agent/enrollcontroller.cpp:76
msgid "Too bright. Turn away from the light."
msgstr "Zu hell. Dreh dich vom Licht weg."
#: src/agent/enrollcontroller.cpp:78
msgid "Hold still for a moment."
msgstr "Halte kurz still."
#: src/agent/enrollcontroller.cpp:80
msgid "Look straight at the camera."
msgstr "Schau direkt in die Kamera."
#: src/agent/enrollcontroller.cpp:82
msgid "Move your head slowly to complete the circle."
msgstr "Beweg den Kopf langsam, bis der Kreis voll ist."
#: src/agent/enrollcontroller.cpp:99 src/agent/enrollcontroller.cpp:132
msgid "Starting the camera…"
msgstr "Kamera wird gestartet …"
#: src/agent/enrollcontroller.cpp:129
msgid "Confirm with your password to add a face."
msgstr "Bestätige mit deinem Passwort, um ein Gesicht hinzuzufügen."
#: src/agent/enrollcontroller.cpp:184
msgid "Face Unlock is set up."
msgstr "Die Gesichtsentsperrung ist eingerichtet."
#: src/agent/enrollcontroller.cpp:193
msgid "A face can only be added with your password."
msgstr "Ein Gesicht kann nur mit deinem Passwort hinzugefügt werden."
#: src/agent/enrollcontroller.cpp:195
msgid "The camera could not be used: %1"
msgstr "Die Kamera konnte nicht benutzt werden: %1"
#: src/agent/enrollcontroller.cpp:197
msgid "The face recognition models are missing. Reinstall the package."
msgstr "Die Modelle für die Gesichtserkennung fehlen. Installiere das Paket neu."
#: src/agent/enrollcontroller.cpp:199
msgid "That took too long. Try again, in good light."
msgstr "Das hat zu lange gedauert. Versuch es nochmal, bei gutem Licht."
#: src/agent/enrollcontroller.cpp:201
msgid "The face unlock service is not running. Turn face unlock on first."
msgstr "Der Dienst für die Gesichtsentsperrung läuft nicht. Schalte die Gesichtsentsperrung zuerst ein."
#: src/agent/enrollcontroller.cpp:203
msgid "The camera is busy with another scan. Try again in a moment."
msgstr "Die Kamera ist gerade mit einem anderen Scan beschäftigt. Versuch es gleich nochmal."
#: src/agent/enrollcontroller.cpp:205
msgid "The face could not be added (%1)."
msgstr "Das Gesicht konnte nicht hinzugefügt werden (%1)."
#: src/agent/main.cpp:85
msgid "Face unlock for KDE Plasma"
msgstr "Gesichtsentsperrung für KDE Plasma"
#: src/agent/main.cpp:88
msgid "Set up a face."
msgstr "Ein Gesicht einrichten."
#: src/agent/main.cpp:89
msgid "What to call the new face."
msgstr "Wie das neue Gesicht heißen soll."
#: src/agent/main.cpp:90
msgid "Play the bubble's animations once."
msgstr "Die Animationen der Bubble einmal abspielen."
#: src/agent/main.cpp:92
msgid "Bubble style for the demo: full or minimal."
msgstr "Bubble-Stil für die Demo: full oder minimal."
#: src/pam/pam_plasma_face_unlock.c:293
msgid "Look at the camera to unlock."
msgstr "Schau zum Entsperren in die Kamera."
#: src/pam/pam_plasma_face_unlock.c:296
msgid "Blink, or turn your head a little."
msgstr "Blinzle oder dreh den Kopf ein wenig."
#: src/pam/pam_plasma_face_unlock.c:312
msgid "Face unlock is paused after too many tries. Use your password."
msgstr "Die Gesichtsentsperrung ist nach zu vielen Versuchen pausiert. Nutze dein Passwort."
#: src/pam/pam_plasma_face_unlock.c:316
msgid "Face not recognised."
msgstr "Gesicht nicht erkannt."
#: src/agent/qml/Enroll.qml:22
msgid "Set up Face Unlock"
msgstr "Gesichtsentsperrung einrichten"
#: src/agent/qml/Enroll.qml:105
msgid "Face Unlock"
msgstr "Gesichtsentsperrung"
#: src/agent/qml/Enroll.qml:106
msgid "Confirm it is you"
msgstr "Bestätige, dass du es bist"
#: src/agent/qml/Enroll.qml:107
msgid "You are all set"
msgstr "Alles bereit"
#: src/agent/qml/Enroll.qml:108
msgid "Setup did not finish"
msgstr "Die Einrichtung wurde nicht abgeschlossen"
#: src/agent/qml/Enroll.qml:121
msgid ""
"Look at the camera, then move your head slowly in a circle. Your face is "
"turned into numbers on this computer and never stored as a picture."
msgstr "Schau in die Kamera und beweg dann den Kopf langsam im Kreis. Dein Gesicht wird auf diesem Computer in Zahlen umgewandelt und nie als Bild gespeichert."
#: src/agent/qml/Enroll.qml:123
msgid ""
"Lock the screen and look at it to try it out. You can add a second look, "
"with glasses for example, from the menu."
msgstr "Sperre den Bildschirm und schau ihn an, um es auszuprobieren. Einen zweiten Look, zum Beispiel mit Brille, kannst du im Menü hinzufügen."
#: src/agent/qml/Enroll.qml:156
msgid "Name (optional)"
msgstr "Name (optional)"
#: src/agent/qml/Enroll.qml:171
msgid "Cancel"
msgstr "Abbrechen"
#: src/agent/qml/Enroll.qml:177
msgid "Finish now"
msgstr "Jetzt abschließen"
#: src/agent/qml/Enroll.qml:182
msgid "Try again"
msgstr "Nochmal versuchen"
#: src/agent/qml/Enroll.qml:182
msgid "Get started"
msgstr "Los geht's"
#: src/agent/qml/Enroll.qml:187
msgid "Done"
msgstr "Fertig"
+768
View File
@@ -0,0 +1,768 @@
# Translation template for plasma-face-unlock.
# Copyright (C) 2026 Felitendo
# This file is distributed under the same license as plasma-face-unlock.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: plasma-face-unlock 1.0.0\n"
"Report-Msgid-Bugs-To: https://github.com/LoonixTools/plasma-face-unlock/"
"issues\n"
"POT-Creation-Date: 2026-09-22 18:45+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: src/plasma-face-unlock:50
msgid ""
"Face unlock's service has to be switched on once for this computer. That "
"needs your password."
msgstr ""
#: src/plasma-face-unlock:57
#, sh-printf-format
msgid "Check it with: systemctl status %s"
msgstr ""
#: src/plasma-face-unlock:67
msgid ""
"Setting up a face needs the camera picture on screen. Run this inside your "
"Plasma session."
msgstr ""
#: src/plasma-face-unlock:71
msgid "The setup window is open. Follow it there."
msgstr ""
#: src/plasma-face-unlock:80
msgid "The face is set up."
msgstr ""
#: src/plasma-face-unlock:83
#, sh-printf-format
msgid "Face unlock is still off. Turn it on with [1] or `%s enable`."
msgstr ""
#: src/plasma-face-unlock:87
msgid "No face was added."
msgstr ""
#: src/plasma-face-unlock:96
msgid "First, set up your face."
msgstr ""
#: src/plasma-face-unlock:100 src/plasma-face-unlock:120 src/lib/menu.sh:338
msgid "Could not save the setting."
msgstr ""
#: src/plasma-face-unlock:104
msgid "Could not start the lock screen agent."
msgstr ""
#: src/plasma-face-unlock:106
msgid "No systemd user session, so the lock screen agent was not started."
msgstr ""
#: src/plasma-face-unlock:113
msgid "Face unlock is on. Lock the screen and look at it to try."
msgstr ""
#: src/plasma-face-unlock:115
msgid "It can do sudo and admin prompts too. See Settings."
msgstr ""
#: src/plasma-face-unlock:130
msgid "Face unlock is off. Your faces are kept; delete them under Faces."
msgstr ""
#: src/plasma-face-unlock:144 src/lib/daemon.sh:109
msgid "No face is set up yet."
msgstr ""
#: src/plasma-face-unlock:148 src/lib/menu.sh:157 src/lib/menu.sh:460
msgid "on"
msgstr ""
#: src/plasma-face-unlock:148 src/lib/menu.sh:159 src/lib/menu.sh:169
#: src/lib/menu.sh:461
msgid "off"
msgstr ""
#: src/plasma-face-unlock:150 src/lib/menu.sh:478
#, sh-printf-format
msgid "%s samples, %s learned"
msgstr ""
#: src/plasma-face-unlock:156
#, sh-printf-format
msgid "Which face? See `%s faces` for the ids."
msgstr ""
#: src/plasma-face-unlock:160
msgid "Deleted."
msgstr ""
#: src/plasma-face-unlock:171
msgid "Usage: plasma-face-unlock [command]"
msgstr ""
#: src/plasma-face-unlock:173
msgid "Commands:"
msgstr ""
#: src/plasma-face-unlock:174
msgid "Turn face unlock on"
msgstr ""
#: src/plasma-face-unlock:175
msgid "Turn it off (faces are kept)"
msgstr ""
#: src/plasma-face-unlock:176 src/lib/menu.sh:540
msgid "Add a face"
msgstr ""
#: src/plasma-face-unlock:177
msgid "List the faces"
msgstr ""
#: src/plasma-face-unlock:178
msgid "Delete a face"
msgstr ""
#: src/plasma-face-unlock:179
msgid "Look at the camera and see what it sees"
msgstr ""
#: src/plasma-face-unlock:180
msgid "Show what is on"
msgstr ""
#: src/plasma-face-unlock:181
msgid "Show this help"
msgstr ""
#: src/plasma-face-unlock:182
msgid "Show the version"
msgstr ""
#: src/plasma-face-unlock:184
msgid "Without a command an interactive menu is shown."
msgstr ""
#: src/plasma-face-unlock:203
msgid ""
"Run this as your own user, not as root. It asks for your password when it "
"needs to."
msgstr ""
#: src/plasma-face-unlock:221 src/lib/system.sh:89
#, sh-printf-format
msgid "Unknown command: %s"
msgstr ""
#: src/lib/common.sh:160
msgid "never"
msgstr ""
#: src/lib/common.sh:170
msgid "just now"
msgstr ""
#: src/lib/common.sh:173
#, sh-printf-format
msgid "%d minutes ago"
msgstr ""
#: src/lib/common.sh:176
#, sh-printf-format
msgid "%d hours ago"
msgstr ""
#: src/lib/common.sh:179
#, sh-printf-format
msgid "%d days ago"
msgstr ""
#: src/lib/daemon.sh:102
msgid "The face did not match."
msgstr ""
#: src/lib/daemon.sh:103
msgid "That looked like a photo or a screen."
msgstr ""
#: src/lib/daemon.sh:104
msgid "The face matched, but did not blink or move."
msgstr ""
#: src/lib/daemon.sh:105
msgid "The face was not looking at the screen."
msgstr ""
#: src/lib/daemon.sh:106
msgid "The picture was too dark, too blurry or too far away."
msgstr ""
#: src/lib/daemon.sh:107
msgid "No face in view."
msgstr ""
#: src/lib/daemon.sh:108
msgid ""
"Face unlock is paused after too many tries. Unlock once with your password."
msgstr ""
#: src/lib/daemon.sh:110
msgid "The camera could not be used."
msgstr ""
#: src/lib/daemon.sh:111 src/lib/menu.sh:233
msgid "The recognition models are missing. Reinstall the package."
msgstr ""
#: src/lib/daemon.sh:112
msgid "The lid is closed."
msgstr ""
#: src/lib/daemon.sh:113
msgid "The camera is busy with another scan."
msgstr ""
#: src/lib/daemon.sh:114
msgid "The face unlock service is not running."
msgstr ""
#: src/lib/daemon.sh:115
msgid "Not allowed."
msgstr ""
#: src/lib/daemon.sh:116
#, sh-printf-format
msgid "Something went wrong (%s)."
msgstr ""
#: src/lib/daemon.sh:127
msgid "Look at the camera. Blink once, or turn your head a little."
msgstr ""
#: src/lib/daemon.sh:137
msgid "Face found."
msgstr ""
#: src/lib/daemon.sh:141
msgid "Recognised. Now blink once, or turn your head a little."
msgstr ""
#: src/lib/daemon.sh:142
msgid "Look at the screen."
msgstr ""
#: src/lib/daemon.sh:143
msgid "Move closer to the camera."
msgstr ""
#: src/lib/daemon.sh:144
msgid "It is too dark to see your face."
msgstr ""
#: src/lib/daemon.sh:153
msgid "match"
msgstr ""
#: src/lib/daemon.sh:153
msgid "turn"
msgstr ""
#: src/lib/daemon.sh:154
msgid "eyes"
msgstr ""
#: src/lib/daemon.sh:155
msgid "depth"
msgstr ""
#: src/lib/daemon.sh:155 src/lib/daemon.sh:164
msgid "blink"
msgstr ""
#: src/lib/daemon.sh:156
msgid "glare"
msgstr ""
#: src/lib/daemon.sh:156
msgid "edge"
msgstr ""
#: src/lib/daemon.sh:165
msgid "head turn"
msgstr ""
#: src/lib/daemon.sh:167
#, sh-printf-format
msgid "Recognised as %s (match %s) in %s ms."
msgstr ""
#: src/lib/daemon.sh:168
#, sh-printf-format
msgid "Sign of life: %s"
msgstr ""
#: src/lib/daemon.sh:173
msgid ""
"That was too many tries. Face unlock is paused until you unlock with your "
"password."
msgstr ""
#: src/lib/menu.sh:122
msgid "Press any key to continue..."
msgstr ""
#: src/lib/menu.sh:130
msgid "[y/N]"
msgstr ""
#: src/lib/menu.sh:149 src/lib/menu.sh:382
msgid "ON"
msgstr ""
#: src/lib/menu.sh:151 src/lib/menu.sh:383
msgid "OFF"
msgstr ""
#: src/lib/menu.sh:167
msgid "strict (blink or turn your head)"
msgstr ""
#: src/lib/menu.sh:168
msgid "basic (screens and phone edges)"
msgstr ""
#: src/lib/menu.sh:170
msgid "normal"
msgstr ""
#: src/lib/menu.sh:171
msgid "strict"
msgstr ""
#: src/lib/menu.sh:172
msgid "relaxed"
msgstr ""
#: src/lib/menu.sh:173
msgid "island with the face"
msgstr ""
#: src/lib/menu.sh:174
msgid "small pill with a lock"
msgstr ""
#: src/lib/menu.sh:175
#, sh-printf-format
msgid "%s seconds"
msgstr ""
#: src/lib/menu.sh:176 src/lib/menu.sh:313
msgid "automatic"
msgstr ""
#: src/lib/menu.sh:193
msgid "Face unlock"
msgstr ""
#: src/lib/menu.sh:197
msgid "Service"
msgstr ""
#: src/lib/menu.sh:197
msgid "not running"
msgstr ""
#: src/lib/menu.sh:199
msgid "Turning face unlock on again starts it."
msgstr ""
#: src/lib/menu.sh:210 src/lib/menu.sh:212 src/lib/menu.sh:457
#: src/lib/menu.sh:541
msgid "Faces"
msgstr ""
#: src/lib/menu.sh:210
msgid "none set up yet"
msgstr ""
#: src/lib/menu.sh:212
msgid "all turned off"
msgstr ""
#: src/lib/menu.sh:218
msgid "none found"
msgstr ""
#: src/lib/menu.sh:220 /tmp/tmp.q9gMIu3Tjj/settings.sh:9
msgid "Camera"
msgstr ""
#: src/lib/menu.sh:222
msgid "Lock screen"
msgstr ""
#: src/lib/menu.sh:223
msgid "sudo"
msgstr ""
#: src/lib/menu.sh:224
msgid "Admin prompts"
msgstr ""
#: src/lib/menu.sh:225 /tmp/tmp.q9gMIu3Tjj/settings.sh:6
msgid "Photo check"
msgstr ""
#: src/lib/menu.sh:228
msgid "Paused"
msgstr ""
#: src/lib/menu.sh:228
#, sh-printf-format
msgid "for %d more minutes, or until the password is used"
msgstr ""
#: src/lib/menu.sh:230
msgid "Last unlock"
msgstr ""
#: src/lib/menu.sh:309
#, sh-printf-format
msgid "automatic (%s)"
msgstr ""
#: src/lib/menu.sh:319
msgid "(infrared)"
msgstr ""
#: src/lib/menu.sh:323
msgid "(not connected)"
msgstr ""
#: src/lib/menu.sh:379 src/lib/menu.sh:542
msgid "Settings"
msgstr ""
#: src/lib/menu.sh:380
msgid "Up/Down: select, Space or Right: change, q: back"
msgstr ""
#: src/lib/menu.sh:381
msgid "Settings marked * are for the whole computer and ask for your password."
msgstr ""
#: src/lib/menu.sh:458
msgid "Up/Down: select, Space: on/off, r: rename, d: delete, a: add, q: back"
msgstr ""
#: src/lib/menu.sh:459
msgid "No face is set up yet. Press a to add one."
msgstr ""
#: src/lib/menu.sh:502
msgid "New name:"
msgstr ""
#: src/lib/menu.sh:509
#, sh-printf-format
msgid "Delete \"%s\"?"
msgstr ""
#: src/lib/menu.sh:539
msgid "Turn face unlock on or off"
msgstr ""
#: src/lib/menu.sh:543
msgid "Try it"
msgstr ""
#: src/lib/menu.sh:544
msgid "Quit"
msgstr ""
#: src/lib/pam.sh:132
#, sh-printf-format
msgid "The PAM module is not installed at %s."
msgstr ""
#: src/lib/pam.sh:140
#, sh-printf-format
msgid "There is no PAM configuration for %s on this system."
msgstr ""
#: src/lib/system.sh:47
msgid "This needs root, and neither sudo, run0 nor doas is installed."
msgstr ""
#: src/lib/system.sh:56
msgid "This command has to run as root."
msgstr ""
#: src/lib/system.sh:64
#, sh-printf-format
msgid "Not a valid setting: %s=%s"
msgstr ""
#: src/lib/system.sh:75
#, sh-printf-format
msgid "Not a service this can be used for: %s"
msgstr ""
#: src/lib/menu.sh
msgid "Unlock the lock screen"
msgstr ""
#: src/lib/menu.sh
msgid "Look when somebody comes back to the screen"
msgstr ""
#: src/lib/menu.sh
msgid "Look right after the screen locks"
msgstr ""
#: src/lib/menu.sh
msgid "Use for sudo in a terminal"
msgstr ""
#: src/lib/menu.sh
msgid "Use for admin prompts"
msgstr ""
#: src/lib/menu.sh
msgid "How closely a face has to match"
msgstr ""
#: src/lib/menu.sh
msgid "Only while looking at the screen"
msgstr ""
#: src/lib/menu.sh
msgid "How long one look lasts"
msgstr ""
#: src/lib/menu.sh
msgid "Learn from every unlock"
msgstr ""
#: src/lib/menu.sh
msgid "Not while the lid is closed"
msgstr ""
#: src/lib/menu.sh
msgid "Show the bubble at the top"
msgstr ""
#: src/lib/menu.sh
msgid "Bubble style"
msgstr ""
#: src/lib/menu.sh
msgid "Bubble for sudo and admin prompts too"
msgstr ""
#: src/agent/bubblecontroller.cpp:91
msgid "Blink once"
msgstr ""
#: src/agent/bubblecontroller.cpp:93
msgid "Look at the screen"
msgstr ""
#: src/agent/bubblecontroller.cpp:95
msgid "Move closer"
msgstr ""
#: src/agent/bubblecontroller.cpp:97
msgid "Too dark"
msgstr ""
#: src/agent/bubblecontroller.cpp:117
msgid "Use your password"
msgstr ""
#: src/agent/bubblecontroller.cpp:123
msgid "Not recognized"
msgstr ""
#: src/agent/bubblecontroller.cpp:125
msgid "Try again and blink"
msgstr ""
#: src/agent/bubblecontroller.cpp:127
msgid "Camera unavailable"
msgstr ""
#: src/agent/enrollcontroller.cpp:68
msgid "Bring your face into the circle."
msgstr ""
#: src/agent/enrollcontroller.cpp:70
msgid "Only one face, please."
msgstr ""
#: src/agent/enrollcontroller.cpp:72
msgid "Move a little closer."
msgstr ""
#: src/agent/enrollcontroller.cpp:74
msgid "It is too dark. Turn on a light."
msgstr ""
#: src/agent/enrollcontroller.cpp:76
msgid "Too bright. Turn away from the light."
msgstr ""
#: src/agent/enrollcontroller.cpp:78
msgid "Hold still for a moment."
msgstr ""
#: src/agent/enrollcontroller.cpp:80
msgid "Look straight at the camera."
msgstr ""
#: src/agent/enrollcontroller.cpp:82
msgid "Move your head slowly to complete the circle."
msgstr ""
#: src/agent/enrollcontroller.cpp:99 src/agent/enrollcontroller.cpp:132
msgid "Starting the camera…"
msgstr ""
#: src/agent/enrollcontroller.cpp:129
msgid "Confirm with your password to add a face."
msgstr ""
#: src/agent/enrollcontroller.cpp:184
msgid "Face Unlock is set up."
msgstr ""
#: src/agent/enrollcontroller.cpp:193
msgid "A face can only be added with your password."
msgstr ""
#: src/agent/enrollcontroller.cpp:195
msgid "The camera could not be used: %1"
msgstr ""
#: src/agent/enrollcontroller.cpp:197
msgid "The face recognition models are missing. Reinstall the package."
msgstr ""
#: src/agent/enrollcontroller.cpp:199
msgid "That took too long. Try again, in good light."
msgstr ""
#: src/agent/enrollcontroller.cpp:201
msgid "The face unlock service is not running. Turn face unlock on first."
msgstr ""
#: src/agent/enrollcontroller.cpp:203
msgid "The camera is busy with another scan. Try again in a moment."
msgstr ""
#: src/agent/enrollcontroller.cpp:205
msgid "The face could not be added (%1)."
msgstr ""
#: src/agent/main.cpp:85
msgid "Face unlock for KDE Plasma"
msgstr ""
#: src/agent/main.cpp:88
msgid "Set up a face."
msgstr ""
#: src/agent/main.cpp:89
msgid "What to call the new face."
msgstr ""
#: src/agent/main.cpp:90
msgid "Play the bubble's animations once."
msgstr ""
#: src/agent/main.cpp:92
msgid "Bubble style for the demo: full or minimal."
msgstr ""
#: src/pam/pam_plasma_face_unlock.c:293
msgid "Look at the camera to unlock."
msgstr ""
#: src/pam/pam_plasma_face_unlock.c:296
msgid "Blink, or turn your head a little."
msgstr ""
#: src/pam/pam_plasma_face_unlock.c:312
msgid "Face unlock is paused after too many tries. Use your password."
msgstr ""
#: src/pam/pam_plasma_face_unlock.c:316
msgid "Face not recognised."
msgstr ""
#: src/agent/qml/Enroll.qml:22
msgid "Set up Face Unlock"
msgstr ""
#: src/agent/qml/Enroll.qml:105
msgid "Face Unlock"
msgstr ""
#: src/agent/qml/Enroll.qml:106
msgid "Confirm it is you"
msgstr ""
#: src/agent/qml/Enroll.qml:107
msgid "You are all set"
msgstr ""
#: src/agent/qml/Enroll.qml:108
msgid "Setup did not finish"
msgstr ""
#: src/agent/qml/Enroll.qml:121
msgid ""
"Look at the camera, then move your head slowly in a circle. Your face is "
"turned into numbers on this computer and never stored as a picture."
msgstr ""
#: src/agent/qml/Enroll.qml:123
msgid ""
"Lock the screen and look at it to try it out. You can add a second look, "
"with glasses for example, from the menu."
msgstr ""
#: src/agent/qml/Enroll.qml:156
msgid "Name (optional)"
msgstr ""
#: src/agent/qml/Enroll.qml:171
msgid "Cancel"
msgstr ""
#: src/agent/qml/Enroll.qml:177
msgid "Finish now"
msgstr ""
#: src/agent/qml/Enroll.qml:182
msgid "Try again"
msgstr ""
#: src/agent/qml/Enroll.qml:182
msgid "Get started"
msgstr ""
#: src/agent/qml/Enroll.qml:187
msgid "Done"
msgstr ""
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
#
# Collects every translatable string into po/plasma-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
# (dgettext), so a word is translated once wherever it shows up.
#
# The settings labels live in an array in menu.sh and reach gettext at run
# time, so xgettext cannot see them; they are pulled out here first.
set -euo pipefail
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.."
tmp="$(mktemp -d)"
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 "\"" }' 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)
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 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
for po in po/*.po; do
msgmerge --quiet --update --backup=none --no-fuzzy-matching "$po" po/plasma-face-unlock.pot
done
echo "po/plasma-face-unlock.pot: $(grep -c '^msgid' po/plasma-face-unlock.pot) strings"
+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="kde_lockscreen_overlay_v1">
<copyright><![CDATA[
SPDX-FileCopyrightText: 2022 Aleix Pol Gonzalez <aleixpol@kde.org>
SPDX-License-Identifier: LGPL-2.1-or-later
]]></copyright>
<interface name="kde_lockscreen_overlay_v1" version="1">
<description summary="Allow surfaces over the lockscreen">
Allows a client to request a surface to be visible when the system is locked.
This is meant to be used for specific high urgency cases like phone calls or alarms.
Warning! The protocol described in this file is a desktop environment
implementation detail. Regular clients must not use this protocol.
Backward incompatible changes may be added without bumping the major
version of the extension.
</description>
<enum name="error">
<entry name="invalid_surface_state" value="0" summary="the client provided an invalid surface state"/>
</enum>
<request name="allow">
<description summary="Tell about which surface could be raised above the lockscreen">
Informs the compositor that the surface could be shown when the screen is locked. This request should be called while the surface is unmapped.
</description>
<arg name="surface" type="object" interface="wl_surface"/>
</request>
<request name="destroy" type="destructor">
<description summary="Destroy the kde_lockscreen_overlay_v1">
This won't affect the surface previously marked with the allow request.
</description>
</request>
</interface>
</protocol>
@@ -0,0 +1,13 @@
[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
+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="disc" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#5fd0ff"/><stop offset="1" stop-color="#0c62a0"/></linearGradient>
<radialGradient id="glow" cx=".35" cy=".25" r=".7"><stop offset="0" stop-color="#ffffff" stop-opacity=".28"/><stop offset="1" stop-color="#ffffff" stop-opacity="0"/></radialGradient>
<filter id="soft" x="-20%" y="-20%" width="140%" height="150%"><feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="#04213a" flood-opacity=".35"/></filter>
</defs>
<g filter="url(#soft)">
<circle cx="256" cy="246" r="200" fill="url(#disc)"/>
<circle cx="256" cy="246" r="200" fill="url(#glow)"/>
</g>
<!-- the face from the bubble: four brackets, two eyes, a nose, a smile -->
<g transform="translate(136 126) scale(2.4)" fill="none" stroke="#ffffff" stroke-width="5.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 30 V19 Q6 6 19 6 H30"/>
<path d="M70 6 H81 Q94 6 94 19 V30"/>
<path d="M94 70 V81 Q94 94 81 94 H70"/>
<path d="M30 94 H19 Q6 94 6 81 V70"/>
<path d="M34 36 V45"/>
<path d="M66 36 V45"/>
<path d="M50 37 V56 Q50 61 45 61"/>
<path d="M35 70 Q50 81 65 70"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,24 @@
<?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: 79 KiB

@@ -0,0 +1,17 @@
[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
+40
View File
@@ -0,0 +1,40 @@
[Unit]
Description=Face unlock for KDE Plasma
Documentation=man:plasma-face-unlock(1)
Requires=plasma-face-unlockd.socket
After=plasma-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
StateDirectoryMode=0700
UMask=0077
# It reads a camera, runs two small networks and writes one directory. That is
# all it is allowed to do.
CapabilityBoundingSet=CAP_DAC_READ_SEARCH
# The one capability: to reach a user's agent socket through their 0700
# runtime directory, to tell the bubble about a sudo scan.
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=yes
PrivateNetwork=yes
RestrictAddressFamilies=AF_UNIX
DevicePolicy=closed
DeviceAllow=char-video4linux rw
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=Face unlock for KDE Plasma (socket)
Documentation=man:plasma-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
SocketMode=0666
DirectoryMode=0755
RemoveOnStop=yes
[Install]
WantedBy=sockets.target
+71
View File
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "agentsocket.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QLocalSocket>
#include <QStandardPaths>
#include <sys/socket.h>
#include <unistd.h>
AgentSocket::AgentSocket(QObject *parent)
: QObject(parent)
{
connect(&m_server, &QLocalServer::newConnection, this, [this] {
while (QLocalSocket *socket = m_server.nextPendingConnection()) {
ucred cred{};
socklen_t len = sizeof(cred);
if (::getsockopt(int(socket->socketDescriptor()), SOL_SOCKET, SO_PEERCRED, &cred, &len) != 0
|| (cred.uid != 0 && cred.uid != ::getuid())) {
socket->abort();
socket->deleteLater();
continue;
}
auto buffer = std::make_shared<QByteArray>();
connect(socket, &QLocalSocket::readyRead, this, [this, socket, buffer] {
*buffer += socket->readAll();
if (buffer->size() > 64 * 1024) {
socket->abort();
return;
}
qsizetype nl;
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") {
Q_EMIT scanEvent(o);
}
}
});
connect(socket, &QLocalSocket::disconnected, socket, &QObject::deleteLater);
}
});
}
QString AgentSocket::path()
{
return QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation) + QStringLiteral("/plasma-face-unlock/agent.socket");
}
bool AgentSocket::listen()
{
const QString p = path();
QDir().mkpath(QFileInfo(p).absolutePath());
QFile::setPermissions(QFileInfo(p).absolutePath(), QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner);
QLocalServer::removeServer(p);
// Anybody may write to the socket itself, because the directory around it
// lets nobody but this user in. The daemon gets through that directory with
// CAP_DAC_READ_SEARCH, which allows passing through but not writing to
// something that is not open to others. Who is on the other end is checked
// on every connection anyway.
m_server.setSocketOptions(QLocalServer::WorldAccessOption);
if (!m_server.listen(p)) {
qWarning("cannot listen on %s: %s", qPrintable(p), qPrintable(m_server.errorString()));
return false;
}
return true;
}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// 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,
// 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.
#pragma once
#include <QJsonObject>
#include <QLocalServer>
#include <QObject>
class AgentSocket : public QObject
{
Q_OBJECT
public:
explicit AgentSocket(QObject *parent = nullptr);
bool listen();
static QString path();
Q_SIGNALS:
void scanEvent(const QJsonObject &event);
private:
QLocalServer m_server;
};
+171
View File
@@ -0,0 +1,171 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "bubblecontroller.h"
#include "userconfig.h"
#include <KLocalizedString>
#include <QJsonObject>
namespace
{
// How long a result stays up before the bubble closes. Long enough to read,
// short enough not to be in the way.
constexpr int SuccessHoldMs = 900;
constexpr int FailureHoldMs = 1700;
constexpr int LockoutHoldMs = 3000;
// A scan that never reports back (the daemon went away half way) must not
// leave the bubble up for good.
constexpr int ScanWatchdogMs = 30000;
} // namespace
BubbleController::BubbleController(UserConfig *config, QObject *parent)
: QObject(parent)
, m_config(config)
{
m_hide.setSingleShot(true);
connect(&m_hide, &QTimer::timeout, this, &BubbleController::dismiss);
connect(m_config, &UserConfig::changed, this, &BubbleController::styleChanged);
}
QString BubbleController::style() const
{
return m_config->bubbleStyle();
}
bool BubbleController::enabled() const
{
return m_config->bubble();
}
void BubbleController::setPhase(const QString &phase)
{
if (m_phase == phase) {
return;
}
m_phase = phase;
if (phase != u"hidden" && !m_shown) {
m_shown = true;
Q_EMIT shownChanged();
}
Q_EMIT phaseChanged();
}
void BubbleController::setMessage(const QString &message)
{
if (m_message != message) {
m_message = message;
Q_EMIT messageChanged();
}
}
void BubbleController::scanStarted()
{
if (!enabled()) {
return;
}
m_hide.start(ScanWatchdogMs);
setMessage({});
if (m_faceSeen) {
m_faceSeen = false;
Q_EMIT faceSeenChanged();
}
setPhase(QStringLiteral("scanning"));
}
void BubbleController::faceFound()
{
if (m_phase == u"scanning" && !m_faceSeen) {
m_faceSeen = true;
Q_EMIT faceSeenChanged();
}
}
void BubbleController::hint(const QString &hint)
{
if (m_phase != u"scanning") {
return;
}
if (hint == u"blink") {
setMessage(i18n("Blink once"));
} else if (hint == u"look") {
setMessage(i18n("Look at the screen"));
} else if (hint == u"closer") {
setMessage(i18n("Move closer"));
} else if (hint == u"light") {
setMessage(i18n("Too dark"));
}
}
void BubbleController::succeeded()
{
if (m_phase == u"hidden") {
return;
}
setMessage({});
setPhase(QStringLiteral("success"));
m_hide.start(SuccessHoldMs);
}
void BubbleController::failed(const QString &reason, qint64 lockout)
{
if (m_phase == u"hidden") {
return;
}
if (lockout > 0 || reason == u"lockout") {
setMessage(i18n("Use your password"));
setPhase(QStringLiteral("lockout"));
m_hide.start(LockoutHoldMs);
return;
}
if (reason == u"mismatch" || reason == u"spoof") {
setMessage(i18n("Not recognized"));
} else if (reason == u"liveness") {
setMessage(i18n("Try again and blink"));
} else if (reason == u"camera") {
setMessage(i18n("Camera unavailable"));
} else {
// Nobody there, a head turned away, the scan cancelled because the
// password was typed: nothing worth saying. The bubble just goes.
setMessage({});
setPhase(QStringLiteral("hidden"));
m_hide.stop();
return;
}
setPhase(QStringLiteral("failure"));
m_hide.start(FailureHoldMs);
}
void BubbleController::dismiss()
{
m_hide.stop();
setPhase(QStringLiteral("hidden"));
}
void BubbleController::closed()
{
if (m_phase == u"hidden" && m_shown) {
m_shown = false;
Q_EMIT shownChanged();
}
}
void BubbleController::daemonEvent(const QJsonObject &event)
{
if (!m_config->bubbleForPrompts()) {
return;
}
const QString state = event.value(u"state").toString();
if (state == u"start") {
scanStarted();
} else if (state == u"face") {
faceFound();
} else if (state == u"hint") {
hint(event.value(u"hint").toString());
} else if (state == u"success") {
succeeded();
} else if (state == u"failure") {
failed(event.value(u"reason").toString(), qint64(event.value(u"lockout").toDouble()));
}
}
+83
View File
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// What the bubble shows, and for how long.
//
// Two things drive it: the lock screen (LockController, which runs its own
// scans) and the daemon, which tells the agent about every other scan (sudo,
// an admin prompt, a test from the menu). Both speak through the same few
// calls, so the bubble looks the same whatever asked for the scan.
#pragma once
#include <QObject>
#include <QTimer>
class UserConfig;
class BubbleController : public QObject
{
Q_OBJECT
// hidden, scanning, success, failure, lockout
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.
Q_PROPERTY(QString message READ message NOTIFY messageChanged)
Q_PROPERTY(bool faceSeen READ faceSeen NOTIFY faceSeenChanged)
Q_PROPERTY(QString style READ style NOTIFY styleChanged)
// Whether the window should be on screen. Stays true after the phase
// goes back to hidden until the bubble has finished closing.
Q_PROPERTY(bool shown READ shown NOTIFY shownChanged)
public:
explicit BubbleController(UserConfig *config, QObject *parent = nullptr);
QString phase() const
{
return m_phase;
}
QString message() const
{
return m_message;
}
bool faceSeen() const
{
return m_faceSeen;
}
QString style() const;
bool shown() const
{
return m_shown;
}
void scanStarted();
void faceFound();
void hint(const QString &hint);
void succeeded();
// reason is the daemon's; lockout is seconds left when there is one.
void failed(const QString &reason, qint64 lockout = 0);
void dismiss();
// An event from the daemon about a scan somebody else asked for.
void daemonEvent(const QJsonObject &event);
// The QML side calls this when the closing animation is over.
Q_INVOKABLE void closed();
Q_SIGNALS:
void phaseChanged();
void messageChanged();
void faceSeenChanged();
void styleChanged();
void shownChanged();
private:
void setPhase(const QString &phase);
void setMessage(const QString &message);
bool enabled() const;
UserConfig *m_config;
QString m_phase = QStringLiteral("hidden");
QString m_message;
bool m_faceSeen = false;
bool m_shown = false;
QTimer m_hide;
};
+119
View File
@@ -0,0 +1,119 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "bubblewindow.h"
#include "bubblecontroller.h"
#include <LayerShellQt/Window>
#include <QGuiApplication>
#include <QQuickView>
#include <QWaylandClientExtensionTemplate>
#include <qpa/qplatformwindow_p.h>
#include "qwayland-kde-lockscreen-overlay-v1.h"
namespace
{
// Big enough for the open island plus its shadow. Only the content moves;
// the window itself never changes size, which keeps the compositor from
// having to reconfigure the surface in the middle of an animation.
constexpr int WindowWidth = 420;
constexpr int WindowHeight = 300;
} // namespace
class LockscreenOverlay : public QWaylandClientExtensionTemplate<LockscreenOverlay>, public QtWayland::kde_lockscreen_overlay_v1
{
public:
LockscreenOverlay()
: QWaylandClientExtensionTemplate<LockscreenOverlay>(1)
{
initialize();
}
~LockscreenOverlay() override
{
if (isActive()) {
destroy();
}
}
};
BubbleWindow::BubbleWindow(QQmlEngine *engine, BubbleController *controller, QObject *parent)
: QObject(parent)
, m_engine(engine)
, m_controller(controller)
, m_overlay(std::make_unique<LockscreenOverlay>())
{
connect(m_controller, &BubbleController::shownChanged, this, &BubbleWindow::update);
create();
}
BubbleWindow::~BubbleWindow()
{
delete m_view;
}
void BubbleWindow::create()
{
m_view = new QQuickView(m_engine, nullptr);
m_view->setColor(Qt::transparent);
m_view->setFlags(Qt::FramelessWindowHint | Qt::WindowDoesNotAcceptFocus | Qt::WindowTransparentForInput);
m_view->resize(WindowWidth, WindowHeight);
m_view->setScreen(QGuiApplication::primaryScreen());
if (auto *layer = LayerShellQt::Window::get(m_view)) {
layer->setLayer(LayerShellQt::Window::LayerOverlay);
layer->setAnchors(LayerShellQt::Window::AnchorTop);
// -1: sit at the very top edge, over a top panel if there is one,
// rather than being pushed down below it.
layer->setExclusiveZone(-1);
layer->setKeyboardInteractivity(LayerShellQt::Window::KeyboardInteractivityNone);
layer->setActivateOnShow(false);
layer->setScope(QStringLiteral("plasma-face-unlock-bubble"));
#ifdef PFU_LAYERSHELL_HAS_SCREEN
layer->setScreen(QGuiApplication::primaryScreen());
#endif
}
m_view->setInitialProperties({{QStringLiteral("bubble"), QVariant::fromValue(m_controller)}});
m_view->loadFromModule(QStringLiteral("PlasmaFaceUnlock"), QStringLiteral("Bubble"));
if (m_view->status() == QQuickView::Error) {
for (const QQmlError &e : m_view->errors()) {
qWarning("%s", qPrintable(e.toString()));
}
}
m_view->create();
if (auto *wayland = m_view->nativeInterface<QNativeInterface::Private::QWaylandWindow>()) {
connect(wayland, &QNativeInterface::Private::QWaylandWindow::surfaceRoleCreated, this, &BubbleWindow::allowOverLockscreen);
}
}
void BubbleWindow::allowOverLockscreen()
{
static bool warned = false;
auto *wayland = m_view ? m_view->nativeInterface<QNativeInterface::Private::QWaylandWindow>() : nullptr;
if (!wayland || !wayland->surface()) {
return;
}
if (!m_overlay->isActive()) {
if (!warned) {
warned = true;
qWarning("KWin does not let this program show above the lock screen; is its desktop file installed?");
}
return;
}
m_overlay->allow(wayland->surface());
}
void BubbleWindow::update()
{
if (!m_view) {
return;
}
if (m_controller->shown()) {
m_view->show();
} else {
m_view->hide();
}
}
+43
View File
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The window the bubble lives in.
//
// A layer-shell surface on the overlay layer, anchored to the top edge and
// transparent to input, so it floats above everything the way the island on a
// phone does and never takes a click or a key from what is under it.
//
// On top of that it asks KWin to keep showing it while the screen is locked
// (kde_lockscreen_overlay_v1). KWin only grants that to programs whose desktop
// 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.
#pragma once
#include <QObject>
#include <QPointer>
#include <memory>
class QQuickView;
class QQmlEngine;
class BubbleController;
class LockscreenOverlay;
class BubbleWindow : public QObject
{
Q_OBJECT
public:
BubbleWindow(QQmlEngine *engine, BubbleController *controller, QObject *parent = nullptr);
~BubbleWindow() override;
private:
void create();
void update();
void allowOverLockscreen();
QQmlEngine *m_engine;
BubbleController *m_controller;
QPointer<QQuickView> m_view;
std::unique_ptr<LockscreenOverlay> m_overlay;
};
+83
View File
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "daemonclient.h"
#include "buildconfig.h"
#include <QJsonDocument>
DaemonRequest::DaemonRequest(const QJsonObject &request, QObject *parent)
: QObject(parent)
, m_request(request)
{
connect(&m_socket, &QLocalSocket::connected, this, [this] {
m_socket.write(QJsonDocument(m_request).toJson(QJsonDocument::Compact) + '\n');
});
connect(&m_socket, &QLocalSocket::readyRead, this, &DaemonRequest::readLines);
connect(&m_socket, &QLocalSocket::errorOccurred, this, [this](QLocalSocket::LocalSocketError error) {
if (error == QLocalSocket::PeerClosedError) {
return;
}
end({{QStringLiteral("event"), QStringLiteral("result")},
{QStringLiteral("ok"), false},
{QStringLiteral("reason"), QStringLiteral("unreachable")},
{QStringLiteral("message"), m_socket.errorString()}});
});
connect(&m_socket, &QLocalSocket::disconnected, this, [this] {
readLines();
end({{QStringLiteral("event"), QStringLiteral("result")}, {QStringLiteral("ok"), false}, {QStringLiteral("reason"), QStringLiteral("disconnected")}});
});
m_socket.connectToServer(socketPath());
}
DaemonRequest::~DaemonRequest()
{
m_done = true;
m_socket.abort();
}
QString DaemonRequest::socketPath()
{
const QByteArray env = qgetenv("PFU_SOCKET");
return env.isEmpty() ? QStringLiteral(PFU_SOCKET) : QString::fromLocal8Bit(env);
}
void DaemonRequest::send(const QJsonObject &message)
{
if (m_socket.state() == QLocalSocket::ConnectedState) {
m_socket.write(QJsonDocument(message).toJson(QJsonDocument::Compact) + '\n');
m_socket.flush();
}
}
void DaemonRequest::abort()
{
m_done = true;
m_socket.abort();
}
void DaemonRequest::readLines()
{
if (m_socket.isOpen() && m_socket.bytesAvailable() > 0) {
m_buffer += m_socket.readAll();
}
qsizetype nl;
while (!m_done && (nl = m_buffer.indexOf('\n')) >= 0) {
const QJsonObject o = QJsonDocument::fromJson(m_buffer.left(nl)).object();
m_buffer.remove(0, nl + 1);
if (o.value(u"event").toString() == u"result") {
end(o);
return;
}
Q_EMIT event(o);
}
}
void DaemonRequest::end(const QJsonObject &result)
{
if (m_done) {
return;
}
m_done = true;
Q_EMIT finished(result);
}
+43
View File
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Talking to the daemon: one connection per request, the way the daemon
// wants it. The connection lives as long as the request does, so closing it
// is also how a scan gets cancelled.
#pragma once
#include <QJsonObject>
#include <QLocalSocket>
#include <QObject>
class DaemonRequest : public QObject
{
Q_OBJECT
public:
DaemonRequest(const QJsonObject &request, QObject *parent);
~DaemonRequest() override;
// Something for the request that is already running ("cancel",
// "finish").
void send(const QJsonObject &message);
// Stop without waiting for an answer. finished() is not emitted.
void abort();
static QString socketPath();
Q_SIGNALS:
void event(const QJsonObject &event);
// The last message: the daemon's result, or one made up here when the
// daemon could not be reached or went away ("unreachable",
// "disconnected").
void finished(const QJsonObject &result);
private:
void readLines();
void end(const QJsonObject &result);
QLocalSocket m_socket;
QByteArray m_buffer;
QJsonObject m_request;
bool m_done = false;
};
+209
View File
@@ -0,0 +1,209 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "enrollcontroller.h"
#include "daemonclient.h"
#include <KLocalizedString>
#include <QJsonObject>
EnrollController::EnrollController(QObject *parent)
: QObject(parent)
{
}
void EnrollController::setName(const QString &name)
{
if (m_name != name) {
m_name = name;
Q_EMIT nameChanged();
}
}
QVariantList EnrollController::sectors() const
{
QVariantList list;
for (bool s : m_sectors) {
list.append(s);
}
return list;
}
int EnrollController::sectorsDone() const
{
int n = 0;
for (bool s : m_sectors) {
n += s;
}
return n;
}
bool EnrollController::canFinish() const
{
// The daemon's own minimum (EnrollJob::SectorsForEarlyFinish).
return m_state == u"circle" && sectorsDone() >= 4 && sectorsDone() < 8;
}
void EnrollController::setState(const QString &state)
{
if (m_state != state) {
m_state = state;
Q_EMIT stateChanged();
Q_EMIT sectorsChanged();
}
}
void EnrollController::setInstruction(const QString &text)
{
if (m_instruction != text) {
m_instruction = text;
Q_EMIT instructionChanged();
}
}
QString EnrollController::hintText(const QString &hint) const
{
if (hint == u"no-face") {
return i18n("Bring your face into the circle.");
} else if (hint == u"one-face") {
return i18n("Only one face, please.");
} else if (hint == u"closer") {
return i18n("Move a little closer.");
} else if (hint == u"light") {
return i18n("It is too dark. Turn on a light.");
} else if (hint == u"bright") {
return i18n("Too bright. Turn away from the light.");
} else if (hint == u"still") {
return i18n("Hold still for a moment.");
} else if (hint == u"straight" || hint == u"hold") {
return i18n("Look straight at the camera.");
} else if (hint == u"circle") {
return i18n("Move your head slowly to complete the circle.");
}
return {};
}
void EnrollController::start()
{
if (m_request) {
return;
}
for (bool &s : m_sectors) {
s = false;
}
m_error.clear();
m_frame = QImage();
Q_EMIT frameChanged();
setState(QStringLiteral("starting"));
setInstruction(i18n("Starting the camera…"));
m_request = new DaemonRequest({{QStringLiteral("cmd"), QStringLiteral("enroll")}, {QStringLiteral("name"), m_name}}, this);
connect(m_request, &DaemonRequest::event, this, &EnrollController::onEvent);
connect(m_request, &DaemonRequest::finished, this, &EnrollController::onFinished);
}
void EnrollController::finish()
{
if (m_request) {
m_request->send({{QStringLiteral("cmd"), QStringLiteral("finish")}});
}
}
void EnrollController::cancel()
{
if (m_request) {
m_request->abort();
m_request->deleteLater();
m_request = nullptr;
}
Q_EMIT completed(m_state == u"done");
}
void EnrollController::onEvent(const QJsonObject &e)
{
const QString what = e.value(u"event").toString();
if (what == u"authorizing") {
setState(QStringLiteral("authorizing"));
setInstruction(i18n("Confirm with your password to add a face."));
} else if (what == u"authorized") {
setState(QStringLiteral("starting"));
setInstruction(i18n("Starting the camera…"));
} else if (what == u"started") {
setState(QStringLiteral("center"));
setInstruction(hintText(QStringLiteral("straight")));
} else if (what == u"frame") {
const QByteArray jpeg = QByteArray::fromBase64(e.value(u"jpeg").toString().toLatin1());
QImage img;
if (img.loadFromData(jpeg, "JPG")) {
m_frame = img;
}
const QJsonObject f = e.value(u"face").toObject();
if (!f.isEmpty()) {
m_faceRect = QRectF(f.value(u"x").toDouble(), f.value(u"y").toDouble(), f.value(u"w").toDouble(), f.value(u"h").toDouble());
}
Q_EMIT frameChanged();
} else if (what == u"pose") {
m_faceVisible = e.value(u"face").toBool();
if (m_faceVisible) {
m_pose = QPointF(e.value(u"x").toDouble(), e.value(u"y").toDouble());
}
Q_EMIT poseChanged();
} else if (what == u"hint") {
const QString text = hintText(e.value(u"hint").toString());
if (!text.isEmpty()) {
setInstruction(text);
}
} else if (what == u"captured") {
const QString pose = e.value(u"pose").toString();
if (pose == u"center") {
setState(QStringLiteral("circle"));
setInstruction(hintText(QStringLiteral("circle")));
} else {
const int sector = e.value(u"sector").toInt(-1);
if (sector >= 0 && sector < 8) {
m_sectors[sector] = true;
Q_EMIT sectorsChanged();
}
}
}
}
void EnrollController::onFinished(const QJsonObject &result)
{
if (m_request) {
m_request->deleteLater();
m_request = nullptr;
}
if (result.value(u"ok").toBool()) {
for (bool &s : m_sectors) {
s = true;
}
setState(QStringLiteral("done"));
setInstruction(i18n("Face Unlock is set up."));
return;
}
const QString reason = result.value(u"reason").toString();
if (reason == u"cancelled") {
return;
}
if (reason == u"denied") {
m_error = i18n("A face can only be added with your password.");
} 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") {
m_error = i18n("The face recognition models are missing. Reinstall the package.");
} else if (reason == u"timeout") {
m_error = i18n("That took too long. Try again, in good light.");
} else if (reason == u"unreachable") {
m_error = i18n("The face unlock service is not running. Turn face unlock on first.");
} else if (reason == u"busy") {
m_error = i18n("The camera is busy with another scan. Try again in a moment.");
} else {
m_error = i18n("The face could not be added (%1).", reason);
}
setState(QStringLiteral("failed"));
setInstruction(m_error);
}
+112
View File
@@ -0,0 +1,112 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The setup window's side of an enrollment: it starts one with the daemon,
// hands the camera pictures to the preview and turns the daemon's progress
// into what the ring and the text show.
#pragma once
#include <QImage>
#include <QObject>
#include <QPointF>
#include <QPointer>
#include <QRectF>
#include <QVariantList>
class DaemonRequest;
class EnrollController : public QObject
{
Q_OBJECT
// intro, authorizing, starting, center, circle, done, failed
Q_PROPERTY(QString state READ state NOTIFY stateChanged)
Q_PROPERTY(QString instruction READ instruction NOTIFY instructionChanged)
Q_PROPERTY(QString error READ error NOTIFY stateChanged)
Q_PROPERTY(QString name READ name WRITE setName NOTIFY nameChanged)
// Eight booleans, clockwise from the top, as seen in the preview.
Q_PROPERTY(QVariantList sectors READ sectors NOTIFY sectorsChanged)
Q_PROPERTY(int sectorsDone READ sectorsDone NOTIFY sectorsChanged)
Q_PROPERTY(bool canFinish READ canFinish NOTIFY sectorsChanged)
// Where the head points, in the daemon's turn units (1.0 is a comfortable
// turn), screen directions: x to the right, y up.
Q_PROPERTY(QPointF pose READ pose NOTIFY poseChanged)
Q_PROPERTY(bool faceVisible READ faceVisible NOTIFY poseChanged)
// The face in the preview, as a share of the picture.
Q_PROPERTY(QRectF faceRect READ faceRect NOTIFY frameChanged)
Q_PROPERTY(bool hasFrame READ hasFrame NOTIFY frameChanged)
public:
explicit EnrollController(QObject *parent = nullptr);
QString state() const
{
return m_state;
}
QString instruction() const
{
return m_instruction;
}
QString error() const
{
return m_error;
}
QString name() const
{
return m_name;
}
void setName(const QString &name);
QVariantList sectors() const;
int sectorsDone() const;
bool canFinish() const;
QPointF pose() const
{
return m_pose;
}
bool faceVisible() const
{
return m_faceVisible;
}
QRectF faceRect() const
{
return m_faceRect;
}
bool hasFrame() const
{
return !m_frame.isNull();
}
QImage frame() const
{
return m_frame;
}
Q_INVOKABLE void start();
Q_INVOKABLE void finish();
Q_INVOKABLE void cancel();
Q_SIGNALS:
void stateChanged();
void instructionChanged();
void nameChanged();
void sectorsChanged();
void poseChanged();
void frameChanged();
// Emitted once, when the window can go: done or given up.
void completed(bool ok);
private:
void setState(const QString &state);
void setInstruction(const QString &text);
void onEvent(const QJsonObject &event);
void onFinished(const QJsonObject &result);
QString hintText(const QString &hint) const;
QPointer<DaemonRequest> m_request;
QString m_state = QStringLiteral("intro");
QString m_instruction;
QString m_error;
QString m_name;
bool m_sectors[8] = {};
QPointF m_pose;
bool m_faceVisible = false;
QRectF m_faceRect;
QImage m_frame;
};
+254
View File
@@ -0,0 +1,254 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "lockcontroller.h"
#include "bubblecontroller.h"
#include "daemonclient.h"
#include "userconfig.h"
#include <KIdleTime>
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDBusPendingCallWatcher>
#include <QDBusPendingReply>
#include <QJsonObject>
#include <QProcess>
namespace
{
// Locking with the keyboard is itself a key press, and the keys come back up
// a moment later. Input in this window after locking is not somebody coming
// back.
constexpr int GraceAfterLockMs = 1500;
// After a scan that did not get anybody in, the next one waits until the
// person has kept still for this long and then touched something again. That
// way typing the password does not start a scan with every key.
constexpr int CalmBeforeRetryMs = 2000;
// Show the tick before the screen goes: long enough to see, short enough
// not to feel like waiting.
constexpr int UnlockAfterSuccessMs = 450;
// A camera needs a moment after the machine wakes before it delivers frames.
constexpr int ScanAfterWakeMs = 1000;
} // namespace
LockController::LockController(BubbleController *bubble, UserConfig *config, QObject *parent)
: QObject(parent)
, m_bubble(bubble)
, m_config(config)
{
m_armTimer.setSingleShot(true);
connect(&m_armTimer, &QTimer::timeout, this, &LockController::arm);
QDBusConnection session = QDBusConnection::sessionBus();
session.connect(QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("/ScreenSaver"),
QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("ActiveChanged"),
this,
SLOT(onActiveChanged(bool)));
QDBusConnection::systemBus().connect(QStringLiteral("org.freedesktop.login1"),
QStringLiteral("/org/freedesktop/login1"),
QStringLiteral("org.freedesktop.login1.Manager"),
QStringLiteral("PrepareForSleep"),
this,
SLOT(onPrepareForSleep(bool)));
KIdleTime *idle = KIdleTime::instance();
connect(idle, &KIdleTime::resumingFromIdle, this, &LockController::onResume);
connect(idle, qOverload<int, int>(&KIdleTime::timeoutReached), this, [this, idle](int id, int) {
if (id != m_idleId) {
return;
}
idle->removeIdleTimeout(id);
m_idleId = -1;
arm();
});
// 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)
{
if (active == m_locked) {
return;
}
KIdleTime *idle = KIdleTime::instance();
if (!active) {
m_locked = false;
m_armTimer.stop();
if (m_idleId >= 0) {
idle->removeIdleTimeout(m_idleId);
m_idleId = -1;
}
idle->stopCatchingResumeEvent();
if (m_scan) {
m_scan->abort();
m_scan->deleteLater();
m_scan = nullptr;
}
// The tick of our own unlock is still playing; anything else goes.
if (m_bubble->phase() == u"scanning") {
m_bubble->dismiss();
}
// However it was unlocked, it was the right person: a lockout after
// failed scans ends here, the way a phone takes its code.
auto *done = new DaemonRequest({{QStringLiteral("cmd"), QStringLiteral("unlocked")}}, this);
connect(done, &DaemonRequest::finished, done, &QObject::deleteLater);
return;
}
if (!m_config->lockScreen()) {
return;
}
m_locked = true;
m_stopped = false;
m_lockedFor.start();
warmUp();
if (m_config->scanOnLock()) {
QTimer::singleShot(400, this, [this] {
startScan(QStringLiteral("lock"));
});
} else {
m_armTimer.start(GraceAfterLockMs);
}
}
void LockController::onPrepareForSleep(bool sleeping)
{
if (sleeping) {
if (m_scan) {
m_scan->abort();
m_scan->deleteLater();
m_scan = nullptr;
m_bubble->dismiss();
}
return;
}
// Waking up is somebody coming back, lid or no lid.
if (m_locked && !m_stopped && m_config->scanOnWake()) {
QTimer::singleShot(ScanAfterWakeMs, this, [this] {
startScan(QStringLiteral("resume"));
});
}
}
void LockController::arm()
{
if (!m_locked || m_stopped || !m_config->scanOnWake()) {
return;
}
KIdleTime::instance()->catchNextResumeEvent();
}
void LockController::onResume()
{
if (m_locked && !m_scan) {
startScan(QStringLiteral("wake"));
}
}
void LockController::warmUp()
{
// The daemon is started by its socket and loads the networks when it
// starts. Doing that now, while nobody is waiting, takes it off the first
// scan.
auto *hello = new DaemonRequest({{QStringLiteral("cmd"), QStringLiteral("hello")}}, this);
connect(hello, &DaemonRequest::finished, hello, &QObject::deleteLater);
}
void LockController::startScan(const QString &why)
{
if (!m_locked || m_scan || m_stopped) {
return;
}
qInfo("scanning (%s)", qPrintable(why));
m_bubble->scanStarted();
m_scan = new DaemonRequest({{QStringLiteral("cmd"), QStringLiteral("verify")}, {QStringLiteral("purpose"), QStringLiteral("unlock")}}, this);
connect(m_scan, &DaemonRequest::event, this, [this](const QJsonObject &e) {
const QString what = e.value(u"event").toString();
if (what == u"face") {
m_bubble->faceFound();
} else if (what == u"hint") {
m_bubble->hint(e.value(u"hint").toString());
}
});
connect(m_scan, &DaemonRequest::finished, this, &LockController::onScanFinished);
}
void LockController::onScanFinished(const QJsonObject &result)
{
if (m_scan) {
m_scan->deleteLater();
m_scan = nullptr;
}
if (!m_locked) {
return;
}
const QString reason = result.value(u"reason").toString();
if (result.value(u"ok").toBool()) {
m_bubble->succeeded();
QTimer::singleShot(UnlockAfterSuccessMs, this, &LockController::unlock);
return;
}
m_bubble->failed(reason, qint64(result.value(u"lockout").toDouble()));
if (reason == u"lockout" || result.contains(u"lockout") || reason == u"not-enrolled" || reason == u"models" || reason == u"denied"
|| reason == u"unreachable") {
// Nothing another scan could change before the next lock.
m_stopped = true;
return;
}
if (reason == u"busy") {
m_armTimer.start(GraceAfterLockMs);
return;
}
if (m_idleId < 0) {
m_idleId = KIdleTime::instance()->addIdleTimeout(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);
}
});
}
+62
View File
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The lock screen.
//
// 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.
//
// 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.
#pragma once
#include <QElapsedTimer>
#include <QObject>
#include <QPointer>
#include <QTimer>
class BubbleController;
class DaemonRequest;
class UserConfig;
class LockController : public QObject
{
Q_OBJECT
public:
LockController(BubbleController *bubble, UserConfig *config, QObject *parent = nullptr);
bool locked() const
{
return m_locked;
}
private Q_SLOTS:
void onActiveChanged(bool active);
void onPrepareForSleep(bool sleeping);
private:
void arm();
void onResume();
void startScan(const QString &why);
void onScanFinished(const QJsonObject &result);
void unlock();
void warmUp();
BubbleController *m_bubble;
UserConfig *m_config;
bool m_locked = false;
bool m_stopped = false;
QElapsedTimer m_lockedFor;
QPointer<DaemonRequest> m_scan;
QTimer m_armTimer;
int m_idleId = -1;
};
+126
View File
@@ -0,0 +1,126 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// plasma-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
// --demo play the bubble's animations once, for trying out a
// style (--bubble-style minimal) and for screenshots
#include "agentsocket.h"
#include "bubblecontroller.h"
#include "bubblewindow.h"
#include "enrollcontroller.h"
#include "lockcontroller.h"
#include "userconfig.h"
#include "buildconfig.h"
#include <KLocalizedQmlContext>
#include <KLocalizedString>
#include <QCommandLineParser>
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlEngine>
#include <QTimer>
#include <unistd.h>
namespace
{
int runEnroll(QGuiApplication &app, const QString &name)
{
app.setQuitOnLastWindowClosed(true);
EnrollController controller;
controller.setName(name);
QQmlApplicationEngine engine;
KLocalization::setupLocalizedContext(&engine);
engine.setInitialProperties({{QStringLiteral("controller"), QVariant::fromValue(&controller)}});
engine.loadFromModule(QStringLiteral("PlasmaFaceUnlock"), QStringLiteral("Enroll"));
if (engine.rootObjects().isEmpty()) {
return 2;
}
int code = 1;
QObject::connect(&controller, &EnrollController::completed, &app, [&code](bool ok) {
code = ok ? 0 : 1;
QCoreApplication::quit();
});
app.exec();
return controller.state() == u"done" ? 0 : code;
}
// The whole life of a bubble, twice: a face that is recognised after a blink,
// then one that is not.
void scheduleDemo(BubbleController *bubble)
{
const QList<std::pair<int, std::function<void()>>> steps = {
{300, [bubble] { bubble->scanStarted(); }},
{900, [bubble] { bubble->faceFound(); }},
{1900, [bubble] { bubble->hint(QStringLiteral("blink")); }},
{3000, [bubble] { bubble->succeeded(); }},
{5600, [bubble] { bubble->scanStarted(); }},
{6200, [bubble] { bubble->faceFound(); }},
{7800, [bubble] { bubble->failed(QStringLiteral("mismatch")); }},
{11000, [] { QCoreApplication::quit(); }},
};
for (const auto &[at, what] : steps) {
QTimer::singleShot(at, bubble, what);
}
}
} // namespace
int main(int argc, char **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);
QCommandLineParser parser;
parser.setApplicationDescription(i18n("Face unlock for KDE Plasma"));
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 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});
parser.process(app);
if (parser.isSet(enrollOpt)) {
QString name = parser.value(nameOpt);
if (name.isEmpty()) {
name = qEnvironmentVariable("USER");
}
return runEnroll(app, name);
}
app.setQuitOnLastWindowClosed(false);
QQmlEngine engine;
KLocalization::setupLocalizedContext(&engine);
UserConfig config;
if (parser.isSet(styleOpt)) {
config.overrideStyle(parser.value(styleOpt));
}
BubbleController bubble(&config);
BubbleWindow window(&engine, &bubble);
if (parser.isSet(demoOpt)) {
scheduleDemo(&bubble);
return app.exec();
}
AgentSocket socket;
socket.listen();
QObject::connect(&socket, &AgentSocket::scanEvent, &bubble, &BubbleController::daemonEvent);
LockController lock(&bubble, &config);
return app.exec();
}
+90
View File
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "previewitem.h"
#include "enrollcontroller.h"
#include <QPainter>
#include <QPainterPath>
PreviewItem::PreviewItem(QQuickItem *parent)
: QQuickPaintedItem(parent)
{
setAntialiasing(true);
}
QObject *PreviewItem::controller() const
{
return m_controller;
}
void PreviewItem::setController(QObject *controller)
{
auto *c = qobject_cast<EnrollController *>(controller);
if (c == m_controller) {
return;
}
if (m_controller) {
disconnect(m_controller, nullptr, this, nullptr);
}
m_controller = c;
if (m_controller) {
connect(m_controller, &EnrollController::frameChanged, this, &PreviewItem::onFrame);
}
Q_EMIT controllerChanged();
}
void PreviewItem::onFrame()
{
if (!m_controller) {
return;
}
m_frame = m_controller->frame();
const QRectF face = m_controller->faceRect();
if (face.isValid() && !m_frame.isNull()) {
// The face fills a little over half of the circle, whatever the
// distance, so a small face does not end up as a dot in the middle.
const qreal aspect = qreal(m_frame.width()) / m_frame.height();
const qreal faceSide = std::max(face.width() * aspect, face.height());
const qreal targetScale = std::clamp(0.55 / std::max(0.05, faceSide), 1.0, 2.2);
const QPointF target = face.center();
m_centre += (target - m_centre) * 0.25;
m_scale += (targetScale - m_scale) * 0.2;
}
update();
}
void PreviewItem::paint(QPainter *painter)
{
const QRectF bounds = boundingRect();
QPainterPath circle;
circle.addEllipse(bounds);
painter->setRenderHint(QPainter::Antialiasing);
painter->setRenderHint(QPainter::SmoothPixmapTransform);
painter->setClipPath(circle);
painter->fillRect(bounds, QColor(0x1e, 0x1e, 0x1e));
if (m_frame.isNull()) {
return;
}
// Cover the circle with the picture: its shorter side spans the circle,
// zoomed by m_scale and centred on the face.
const qreal fw = m_frame.width();
const qreal fh = m_frame.height();
const qreal side = std::min(fw, fh) / m_scale;
QRectF source(m_centre.x() * fw - side / 2, m_centre.y() * fh - side / 2, side, side);
if (source.left() < 0) {
source.moveLeft(0);
}
if (source.top() < 0) {
source.moveTop(0);
}
if (source.right() > fw) {
source.moveRight(fw);
}
if (source.bottom() > fh) {
source.moveBottom(fh);
}
painter->drawImage(bounds, m_frame, source);
}
+40
View File
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The camera picture in the setup window, cut to a circle around the face.
// The daemon already mirrors it, the way a mirror would show it.
#pragma once
#include <QImage>
#include <QPointer>
#include <QQuickPaintedItem>
#include <QtQml/qqmlregistration.h>
class EnrollController;
class PreviewItem : public QQuickPaintedItem
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(QObject *controller READ controller WRITE setController NOTIFY controllerChanged)
public:
explicit PreviewItem(QQuickItem *parent = nullptr);
QObject *controller() const;
void setController(QObject *controller);
void paint(QPainter *painter) override;
Q_SIGNALS:
void controllerChanged();
private:
void onFrame();
QPointer<EnrollController> m_controller;
QImage m_frame;
// Where the circle is centred in the picture, eased towards the face so
// the crop does not jump with every detection.
QPointF m_centre{0.5, 0.5};
qreal m_scale = 1.0;
};
+210
View File
@@ -0,0 +1,210 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The bubble: a black island at the top of the screen that slides down,
// opens up, shows the face while it looks, and closes again.
//
// The choreography is Glance's: entering, the island slides in first and grows
// a moment later; leaving, it shrinks first and slides away after. Growing is
// a spring that overshoots a little, shrinking does not. While it scans the
// content breathes, so it reads as looking rather than stuck.
//
// "full" is the open island with the face in it. "minimal" is a small pill
// with a lock on one side and the face on the other.
import QtQuick
import QtQuick.Effects
Item {
id: root
required property var bubble
width: 420
height: 300
readonly property string phase: bubble ? bubble.phase : "hidden"
readonly property bool minimal: bubble && bubble.style === "minimal"
readonly property bool wantOpen: phase !== "hidden"
property bool positioned: false
property bool expanded: false
function choreograph() {
if (wantOpen) {
slideOut.stop()
closeDone.stop()
positioned = true
if (!expanded) {
expandLater.restart()
}
} else {
expandLater.stop()
expanded = false
slideOut.restart()
}
}
onWantOpenChanged: choreograph()
Component.onCompleted: choreograph()
Timer {
id: expandLater
interval: Theme.expandDelay
onTriggered: root.expanded = true
}
Timer {
id: slideOut
interval: Theme.slideOutDelay
onTriggered: {
root.positioned = false
closeDone.restart()
}
}
Timer {
id: closeDone
interval: Theme.slideDuration + 60
onTriggered: if (root.bubble) root.bubble.closed()
}
// What the face shows, from the phase.
readonly property string glyphMode: phase === "success" ? "success"
: phase === "failure" ? "failure"
: phase === "lockout" ? "lockout"
: phase === "scanning" ? (bubble.faceSeen ? "tracking" : "scanning")
: "idle"
// -- the breathing while it scans
property real pulse: 0
SequentialAnimation on pulse {
id: pulseAnimation
running: root.phase === "scanning" && root.expanded
loops: Animation.Infinite
PauseAnimation { duration: 600 }
NumberAnimation { from: 0; to: 1; duration: 400; easing.type: Easing.InOutQuad }
PauseAnimation { duration: 50 }
NumberAnimation { from: 1; to: 0; duration: 400; easing.type: Easing.InOutQuad }
PauseAnimation { duration: 50 }
onRunningChanged: if (!running) settle.restart()
}
NumberAnimation {
id: settle
target: root
property: "pulse"
to: 0
duration: 200
easing.type: Easing.OutQuad
}
// -- the minimal pill shakes as a whole; the full island shakes its face
property real shake: 0
onPhaseChanged: if (phase === "failure" && minimal) pillShake.restart()
SequentialAnimation {
id: pillShake
NumberAnimation { target: root; property: "shake"; to: -10; duration: 55; easing.type: Easing.OutQuad }
NumberAnimation { target: root; property: "shake"; to: 9; duration: 70 }
NumberAnimation { target: root; property: "shake"; to: -6; duration: 65 }
NumberAnimation { target: root; property: "shake"; to: 4; duration: 60 }
NumberAnimation { target: root; property: "shake"; to: 0; duration: 55; easing.type: Easing.OutQuad }
}
Item {
id: island
readonly property real targetWidth: root.expanded ? (root.minimal ? Theme.minimalWidth : Theme.openWidth) : Theme.closedWidth
readonly property real targetHeight: root.expanded ? (root.minimal ? Theme.minimalHeight : Theme.openHeight) : Theme.closedHeight
width: targetWidth
height: targetHeight
// Growing overshoots a little; shrinking settles without bouncing.
Behavior on width { SpringAnimation { spring: root.expanded ? 3.4 : 6; damping: root.expanded ? 0.28 : 0.9; epsilon: 0.25 } }
Behavior on height { SpringAnimation { spring: root.expanded ? 3.4 : 6; damping: root.expanded ? 0.28 : 0.9; epsilon: 0.25 } }
x: (root.width - width) / 2 + root.shake
y: root.positioned ? Theme.topGap : -height - 30
Behavior on y { NumberAnimation { duration: Theme.slideDuration; easing.type: Easing.OutCubic } }
Rectangle {
id: shape
anchors.fill: parent
color: Theme.panel
radius: root.expanded && !root.minimal ? Math.min(Theme.openRadius, height / 2) : height / 2
layer.enabled: true
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: "#000000"
shadowOpacity: root.expanded ? 0.35 : 0
shadowBlur: 0.7
shadowVerticalOffset: 3
Behavior on shadowOpacity { NumberAnimation { duration: 200 } }
}
}
// -- full: the face, and a line of text under it
Item {
id: full
anchors.fill: parent
visible: !root.minimal
opacity: root.expanded ? 1 - 0.35 * root.pulse : 0
scale: root.expanded ? 1 - 0.03 * root.pulse : 0.3
Behavior on opacity { enabled: !pulseAnimation.running; NumberAnimation { duration: 220 } }
Behavior on scale { enabled: !pulseAnimation.running; NumberAnimation { duration: 260; easing.type: Easing.OutCubic } }
readonly property bool hasMessage: root.bubble && root.bubble.message.length > 0
FaceGlyph {
id: glyph
width: 100
height: 100
anchors.horizontalCenter: parent.horizontalCenter
y: full.hasMessage ? 28 : 40
Behavior on y { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
mode: root.glyphMode
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 20
width: parent.width - 32
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
text: root.bubble ? root.bubble.message : ""
color: root.phase === "failure" || root.phase === "lockout" ? Theme.textDetail : Theme.textSecondary
font.pixelSize: 13
font.weight: Font.Medium
opacity: full.hasMessage ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 200 } }
}
}
// -- minimal: [ lock ] ... [ face ]
Item {
id: small
anchors.fill: parent
visible: root.minimal
opacity: root.expanded ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 200 } }
LockGlyph {
width: 18
height: 18
anchors.verticalCenter: parent.verticalCenter
x: 16
open: root.phase === "success"
color: root.phase === "failure" || root.phase === "lockout" ? Theme.failure : Theme.textPrimary
}
FaceGlyph {
width: 24
height: 24
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
anchors.rightMargin: 14
lineWidth: 2.4
mode: root.glyphMode === "lockout" ? "failure" : root.glyphMode
opacity: 1 - 0.35 * root.pulse
scale: 1 - 0.03 * root.pulse
}
}
}
}
+46
View File
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// A tick that draws itself: the short stroke first, then the long one.
import QtQuick
import QtQuick.Shapes
Item {
id: root
property color color: Theme.success
property real lineWidth: width * 0.07
// 0: nothing, 1: the whole tick.
property real progress: 0
readonly property real u: width / 100
readonly property point a: Qt.point(28 * u, 52 * u)
readonly property point b: Qt.point(43 * u, 67 * u)
readonly property point c: Qt.point(73 * u, 35 * u)
// The short stroke is about a third of the length.
readonly property real split: 0.33
function lerp(p, q, t) {
return Qt.point(p.x + (q.x - p.x) * t, p.y + (q.y - p.y) * t)
}
Shape {
anchors.fill: parent
visible: root.progress > 0.001
preferredRendererType: Shape.CurveRenderer
ShapePath {
strokeColor: root.color
strokeWidth: root.lineWidth
fillColor: "transparent"
capStyle: ShapePath.RoundCap
joinStyle: ShapePath.RoundJoin
PathPolyline {
path: root.progress <= root.split
? [root.a, root.lerp(root.a, root.b, root.progress / root.split)]
: [root.a, root.b, root.lerp(root.b, root.c, (root.progress - root.split) / (1 - root.split))]
}
}
}
}
+192
View File
@@ -0,0 +1,192 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Setting up a face: a short introduction, the password (polkit's own
// dialog), then the camera in a circle with the ring of ticks around it, and
// a tick at the end. Laid out like the phone's setup and Glance's onboarding.
import QtQuick
import QtQuick.Window
import PlasmaFaceUnlock
Window {
id: window
required property var controller
width: 520
height: 680
minimumWidth: 460
minimumHeight: 620
visible: true
color: Theme.panel
title: i18n("Set up Face Unlock")
readonly property string step: controller.state
readonly property bool scanning: step === "center" || step === "circle"
readonly property bool done: step === "done"
onClosing: controller.cancel()
Item {
id: stage
anchors.horizontalCenter: parent.horizontalCenter
y: 40
width: 360
height: 360
// Before the camera, and when something went wrong: the face, alive.
FaceGlyph {
anchors.centerIn: parent
width: 150
height: 150
mode: window.step === "failed" ? "failure" : "scanning"
opacity: window.scanning || window.done ? 0 : 1
scale: window.scanning || window.done ? 0.8 : 1
Behavior on opacity { NumberAnimation { duration: 250 } }
Behavior on scale { NumberAnimation { duration: 300; easing.type: Easing.OutCubic } }
}
// The camera, the ring, and the tick at the end.
Item {
anchors.fill: parent
opacity: window.scanning || window.done ? 1 : 0
scale: window.scanning || window.done ? 1 : 0.9
Behavior on opacity { NumberAnimation { duration: 300 } }
Behavior on scale { NumberAnimation { duration: 350; easing.type: Easing.OutCubic } }
PreviewItem {
id: preview
anchors.centerIn: parent
width: 264
height: 264
controller: window.controller
opacity: window.done ? 0.25 : 1
Behavior on opacity { NumberAnimation { duration: 400 } }
}
TickRing {
anchors.fill: parent
sectors: window.controller.sectors
pose: window.controller.pose
tracking: window.step === "circle" && window.controller.faceVisible
complete: window.done
}
Checkmark {
anchors.centerIn: parent
width: 150
height: 150
color: Theme.accent
progress: window.done ? 1 : 0
Behavior on progress { SequentialAnimation {
PauseAnimation { duration: 350 }
NumberAnimation { duration: 450; easing.type: Easing.OutCubic }
} }
}
}
}
Column {
id: texts
anchors.top: stage.bottom
anchors.topMargin: 26
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - 80
spacing: 12
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
color: Theme.textPrimary
font.pixelSize: 26
font.weight: Font.Bold
visible: text.length > 0
text: window.step === "intro" ? i18n("Face Unlock")
: window.step === "authorizing" ? i18n("Confirm it is you")
: window.done ? i18n("You are all set")
: window.step === "failed" ? i18n("Setup did not finish")
: ""
}
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
color: window.scanning ? Theme.textPrimary : Theme.textSecondary
font.pixelSize: window.scanning ? 15 : 13
font.weight: Font.Medium
lineHeight: 1.15
text: window.step === "intro"
? i18n("Look at the camera, then move your head slowly in a circle. Your face is turned into numbers on this computer and never stored as a picture.")
: window.done
? i18n("Lock the screen and look at it to try it out. You can add a second look, with glasses for example, from the menu.")
: window.controller.instruction
}
// A name, so more than one face can be told apart in the menu.
Rectangle {
visible: window.step === "intro"
anchors.horizontalCenter: parent.horizontalCenter
width: 260
height: 38
radius: 10
color: Theme.surfaceRaised
TextInput {
id: nameInput
anchors.fill: parent
anchors.leftMargin: 14
anchors.rightMargin: 14
verticalAlignment: TextInput.AlignVCenter
color: Theme.textPrimary
selectionColor: Theme.accent
font.pixelSize: 13
clip: true
maximumLength: 64
focus: true
text: window.controller.name
onTextEdited: window.controller.name = text
onAccepted: window.controller.start()
}
Text {
anchors.fill: nameInput
verticalAlignment: Text.AlignVCenter
color: Theme.textSecondary
font.pixelSize: 13
text: i18n("Name (optional)")
visible: nameInput.text.length === 0
}
}
}
Row {
anchors.bottom: parent.bottom
anchors.bottomMargin: 32
anchors.horizontalCenter: parent.horizontalCenter
spacing: 12
PillButton {
visible: window.step !== "done"
primary: false
text: i18n("Cancel")
onClicked: window.controller.cancel()
}
PillButton {
visible: window.controller.canFinish
primary: false
text: i18n("Finish now")
onClicked: window.controller.finish()
}
PillButton {
visible: window.step === "intro" || window.step === "failed"
text: window.step === "failed" ? i18n("Try again") : i18n("Get started")
onClicked: window.controller.start()
}
PillButton {
visible: window.done
text: i18n("Done")
onClicked: window.controller.cancel()
}
}
}
+183
View File
@@ -0,0 +1,183 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The face: four corner brackets around two eyes, a nose and a smile, the
// shape everybody knows from a phone. Drawn with shapes rather than played
// from a video, so it is sharp at any size and every part can move on its own.
//
// idle still
// scanning the face looks around inside the brackets, the brackets breathe
// tracking a face is in view: it looks straight ahead, brackets close in
// success the face gives way to a tick, the brackets turn green
// failure it shakes its head and the smile goes flat
// lockout a lock instead of a face
import QtQuick
import QtQuick.Shapes
Item {
id: root
property string mode: "idle"
property color color: Theme.textPrimary
property real lineWidth: width * 0.055
readonly property real u: width / 100
readonly property bool looking: mode === "scanning"
readonly property color bracketColor: mode === "success" ? Theme.success
: mode === "failure" ? Theme.failure
: root.color
// -- where the face looks, while it looks around
property real lookAngle: 0
NumberAnimation on lookAngle {
running: root.looking
from: 0
to: 2 * Math.PI
duration: 2400
loops: Animation.Infinite
}
property real lookAmount: root.looking ? 1 : 0
Behavior on lookAmount { NumberAnimation { duration: 300; easing.type: Easing.InOutQuad } }
readonly property real lookX: 3.5 * u * Math.cos(lookAngle) * lookAmount
readonly property real lookY: 2.2 * u * Math.sin(lookAngle) * lookAmount
// -- the smile, flat on failure
property real smile: mode === "failure" ? 0 : 1
Behavior on smile { NumberAnimation { duration: 220; easing.type: Easing.OutQuad } }
// -- the brackets breathe while scanning and close in on a face
property real breathe: 0
SequentialAnimation on breathe {
running: root.looking
loops: Animation.Infinite
NumberAnimation { from: 0; to: 1; duration: 700; easing.type: Easing.InOutSine }
NumberAnimation { from: 1; to: 0; duration: 700; easing.type: Easing.InOutSine }
onRunningChanged: if (!running) root.breathe = 0
}
readonly property real bracketScale: mode === "tracking" ? 0.9
: mode === "success" ? 1.04
: 1 - 0.04 * breathe
// -- a shake of the head
property real shake: 0
onModeChanged: if (mode === "failure") shakeAnimation.restart()
SequentialAnimation {
id: shakeAnimation
NumberAnimation { target: root; property: "shake"; to: -9; duration: 55; easing.type: Easing.OutQuad }
NumberAnimation { target: root; property: "shake"; to: 8; duration: 70; easing.type: Easing.InOutQuad }
NumberAnimation { target: root; property: "shake"; to: -6; duration: 65; easing.type: Easing.InOutQuad }
NumberAnimation { target: root; property: "shake"; to: 4; duration: 60; easing.type: Easing.InOutQuad }
NumberAnimation { target: root; property: "shake"; to: -2; duration: 55; easing.type: Easing.InOutQuad }
NumberAnimation { target: root; property: "shake"; to: 0; duration: 50; easing.type: Easing.OutQuad }
}
Item {
id: glyph
anchors.fill: parent
transform: Translate { x: root.shake * root.u }
// Brackets
Shape {
id: brackets
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
scale: root.bracketScale
Behavior on scale { NumberAnimation { duration: 260; easing.type: Easing.OutBack } }
component Bracket: ShapePath {
strokeColor: root.bracketColor
strokeWidth: root.lineWidth
fillColor: "transparent"
capStyle: ShapePath.RoundCap
joinStyle: ShapePath.RoundJoin
Behavior on strokeColor { ColorAnimation { duration: 220 } }
}
Bracket {
startX: 6 * root.u; startY: 30 * root.u
PathLine { x: 6 * root.u; y: 19 * root.u }
PathQuad { x: 19 * root.u; y: 6 * root.u; controlX: 6 * root.u; controlY: 6 * root.u }
PathLine { x: 30 * root.u; y: 6 * root.u }
}
Bracket {
startX: 70 * root.u; startY: 6 * root.u
PathLine { x: 81 * root.u; y: 6 * root.u }
PathQuad { x: 94 * root.u; y: 19 * root.u; controlX: 94 * root.u; controlY: 6 * root.u }
PathLine { x: 94 * root.u; y: 30 * root.u }
}
Bracket {
startX: 94 * root.u; startY: 70 * root.u
PathLine { x: 94 * root.u; y: 81 * root.u }
PathQuad { x: 81 * root.u; y: 94 * root.u; controlX: 94 * root.u; controlY: 94 * root.u }
PathLine { x: 70 * root.u; y: 94 * root.u }
}
Bracket {
startX: 30 * root.u; startY: 94 * root.u
PathLine { x: 19 * root.u; y: 94 * root.u }
PathQuad { x: 6 * root.u; y: 81 * root.u; controlX: 6 * root.u; controlY: 94 * root.u }
PathLine { x: 6 * root.u; y: 70 * root.u }
}
}
// The face itself
Shape {
id: face
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
opacity: root.mode === "success" || root.mode === "lockout" ? 0 : 1
scale: root.mode === "success" ? 0.6 : 1
Behavior on opacity { NumberAnimation { duration: 180 } }
Behavior on scale { NumberAnimation { duration: 220; easing.type: Easing.InQuad } }
transform: Translate { x: root.lookX; y: root.lookY }
component Feature: ShapePath {
strokeColor: root.mode === "failure" ? Theme.failure : root.color
strokeWidth: root.lineWidth
fillColor: "transparent"
capStyle: ShapePath.RoundCap
joinStyle: ShapePath.RoundJoin
Behavior on strokeColor { ColorAnimation { duration: 220 } }
}
// Eyes
Feature {
startX: 34 * root.u; startY: 36 * root.u
PathLine { x: 34 * root.u; y: 45 * root.u }
}
Feature {
startX: 66 * root.u; startY: 36 * root.u
PathLine { x: 66 * root.u; y: 45 * root.u }
}
// Nose, with its little hook
Feature {
startX: 50 * root.u; startY: 37 * root.u
PathLine { x: 50 * root.u; y: 56 * root.u }
PathQuad { x: 45 * root.u; y: 61 * root.u; controlX: 50 * root.u; controlY: 61 * root.u }
}
// Mouth
Feature {
startX: 35 * root.u; startY: 70 * root.u
PathQuad { x: 65 * root.u; y: 70 * root.u; controlX: 50 * root.u; controlY: (70 + 11 * root.smile) * root.u }
}
}
Checkmark {
anchors.fill: parent
color: Theme.success
lineWidth: root.lineWidth * 1.15
progress: root.mode === "success" ? 1 : 0
Behavior on progress { NumberAnimation { duration: 380; easing.type: Easing.OutCubic } }
}
LockGlyph {
anchors.centerIn: parent
width: parent.width * 0.42
height: width
color: root.color
opacity: root.mode === "lockout" ? 1 : 0
scale: root.mode === "lockout" ? 1 : 0.7
Behavior on opacity { NumberAnimation { duration: 200 } }
Behavior on scale { NumberAnimation { duration: 260; easing.type: Easing.OutBack } }
}
}
}
+63
View File
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// A padlock whose shackle lifts and swings open.
import QtQuick
import QtQuick.Shapes
Item {
id: root
property color color: Theme.textPrimary
property bool open: false
readonly property real u: width / 100
// Body
Rectangle {
x: 14 * root.u
y: 46 * root.u
width: 72 * root.u
height: 50 * root.u
radius: 12 * root.u
color: root.color
}
// Shackle: a U standing on the body. Opening lifts it and swings it about
// its right leg.
Item {
id: shackle
x: 26 * root.u
y: 4 * root.u
width: 48 * root.u
height: 52 * root.u
transformOrigin: Item.BottomRight
property real lift: root.open ? 7 * root.u : 0
Behavior on lift { NumberAnimation { duration: 260; easing.type: Easing.OutBack } }
rotation: root.open ? -28 : 0
Behavior on rotation { NumberAnimation { duration: 320; easing.type: Easing.OutBack } }
transform: Translate { y: -shackle.lift }
Shape {
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath {
strokeColor: root.color
strokeWidth: 11 * root.u
fillColor: "transparent"
capStyle: ShapePath.FlatCap
startX: 5.5 * root.u
startY: shackle.height
PathLine { x: 5.5 * root.u; y: 24 * root.u }
PathArc {
x: shackle.width - 5.5 * root.u
y: 24 * root.u
radiusX: (shackle.width - 11 * root.u) / 2
radiusY: radiusX
}
PathLine { x: shackle.width - 5.5 * root.u; y: shackle.height }
}
}
}
}
+40
View File
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick
Rectangle {
id: root
property string text
property bool primary: true
signal clicked
implicitWidth: Math.max(140, label.implicitWidth + 44)
implicitHeight: 38
radius: height / 2
color: primary ? (area.pressed ? Qt.darker(Theme.accent, 1.2) : area.containsMouse ? Qt.lighter(Theme.accent, 1.1) : Theme.accent)
: (area.pressed ? Theme.surface : area.containsMouse ? Qt.lighter(Theme.surfaceRaised, 1.2) : Theme.surfaceRaised)
opacity: enabled ? 1 : 0.4
Behavior on color { ColorAnimation { duration: 120 } }
activeFocusOnTab: true
Keys.onReturnPressed: root.clicked()
Keys.onSpacePressed: root.clicked()
Text {
id: label
anchors.centerIn: parent
text: root.text
color: Theme.textPrimary
font.pixelSize: 13
font.weight: Font.Medium
}
MouseArea {
id: area
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
}
+41
View File
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Colours and sizes, in one place. The palette and the island's proportions
// follow Glance's (github.com/jonnyoo/glance): black panel, white glyph, one
// blue for anything that is done.
pragma Singleton
import QtQuick
QtObject {
readonly property color accent: "#3499FF"
readonly property color accentPale: "#CFE7FF"
readonly property color accentBright: "#7FC2FF"
readonly property color success: "#30D158"
readonly property color failure: "#FF453A"
readonly property color panel: "#000000"
readonly property color surface: "#1E1E1E"
readonly property color surfaceRaised: "#323232"
readonly property color placeholder: "#2F2F2F"
readonly property color textPrimary: "#FFFFFF"
readonly property color textSecondary: "#949494"
readonly property color textDetail: "#BDBDBD"
// The island, closed and open. It hangs this far below the top edge.
readonly property int closedWidth: 80
readonly property int closedHeight: 24
readonly property int openWidth: 180
readonly property int openHeight: 180
readonly property int openRadius: 48
readonly property int minimalWidth: 150
readonly property int minimalHeight: 40
readonly property int topGap: 6
// Enter: slide down, then grow. Leave: shrink, then slide up.
readonly property int slideDuration: 250
readonly property int expandDelay: 160
readonly property int slideOutDelay: 180
}
+101
View File
@@ -0,0 +1,101 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The ring of ticks around the camera picture during setup, as on the phone.
// 80 ticks in eight sectors; a sector lights up once the head has pointed
// that way long enough. A few ticks also follow where the head points right
// now, so it is clear which way is still missing. When everything is done the
// ticks give way to one closed ring.
import QtQuick
Item {
id: root
// Eight booleans, clockwise from the top.
property var sectors: [false, false, false, false, false, false, false, false]
// Where the head points, x to the right and y up, 1.0 a comfortable turn.
property point pose: Qt.point(0, 0)
property bool tracking: false
property bool complete: false
// Grows the centre ticks for a moment when the straight-ahead samples
// have been taken.
property bool centreDone: false
readonly property int tickCount: 80
readonly property real innerRadius: width / 2 - 22
readonly property real headAngle: {
const a = Math.atan2(pose.x, pose.y) * 180 / Math.PI
return a < 0 ? a + 360 : a
}
readonly property real headReach: Math.min(1, Math.sqrt(pose.x * pose.x + pose.y * pose.y))
function lit(index) {
if (complete) {
return true
}
const sector = Math.round(index * 360 / tickCount / 45) % 8
return sectors[sector] === true
}
// How much a tick lights up for the head pointing its way.
function intensity(index) {
if (!tracking || complete || lit(index)) {
return 0
}
let delta = Math.abs(index * 360 / tickCount - headAngle)
if (delta > 180) {
delta = 360 - delta
}
const halfSpan = 6 * 360 / tickCount
return headReach * Math.max(0, 1 - delta / halfSpan)
}
Repeater {
model: root.tickCount
Item {
id: tick
required property int index
readonly property bool isLit: root.lit(index)
readonly property real glow: root.intensity(index)
width: root.width
height: root.height
rotation: index * 360 / root.tickCount
Rectangle {
width: 3
// Grows outwards: the inner tip stays on the ring.
height: tick.isLit ? 18 : 10 + 8 * tick.glow
radius: 1.5
x: (parent.width - width) / 2
y: parent.height / 2 - root.innerRadius - height
antialiasing: true
color: tick.isLit ? Theme.accent : Qt.rgba(1 - 0.8 * tick.glow * (1 - Theme.accent.r) , 1 - 0.8 * tick.glow * (1 - Theme.accent.g), 1 - 0.8 * tick.glow * (1 - Theme.accent.b), 0.28 + 0.72 * tick.glow)
opacity: root.complete ? 0 : 1
Behavior on height { NumberAnimation { duration: 180; easing.type: Easing.OutCubic } }
Behavior on color { ColorAnimation { duration: 250 } }
Behavior on opacity { SequentialAnimation {
PauseAnimation { duration: tick.index * 4 }
NumberAnimation { duration: 400; easing.type: Easing.InOutQuad }
} }
}
}
}
// The closed ring that replaces the ticks at the end.
Rectangle {
anchors.centerIn: parent
width: 2 * root.innerRadius + 26
height: width
radius: width / 2
color: "transparent"
border.color: Theme.accent
border.width: 5
opacity: root.complete ? 1 : 0
scale: root.complete ? 1 : 0.92
Behavior on opacity { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
Behavior on scale { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
}
}
+43
View File
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "userconfig.h"
#include "keyvalue.h"
#include <QDir>
#include <QFileInfo>
#include <QStandardPaths>
UserConfig::UserConfig(QObject *parent)
: QObject(parent)
{
// The menu replaces the file instead of editing it, which a watch on the
// file alone would lose track of. The directory sees the new one arrive.
const QString dir = QFileInfo(path()).absolutePath();
QDir().mkpath(dir);
m_watcher.addPath(dir);
connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &UserConfig::load);
connect(&m_watcher, &QFileSystemWatcher::fileChanged, this, &UserConfig::load);
load();
}
QString UserConfig::path()
{
return QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation) + QStringLiteral("/plasma-face-unlock/config");
}
void UserConfig::load()
{
const KeyValueFile kv = KeyValueFile::load(path());
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);
if (QFileInfo::exists(path()) && !m_watcher.files().contains(path())) {
m_watcher.addPath(path());
}
Q_EMIT changed();
}
+74
View File
@@ -0,0 +1,74 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// ~/.config/plasma-face-unlock/config: what this user wants from the agent.
// Written by the menu, read here, and read again whenever it changes.
#pragma once
#include <QFileSystemWatcher>
#include <QObject>
class UserConfig : public QObject
{
Q_OBJECT
Q_PROPERTY(bool bubble READ bubble NOTIFY changed)
Q_PROPERTY(QString bubbleStyle READ bubbleStyle NOTIFY changed)
public:
explicit UserConfig(QObject *parent = nullptr);
// Unlock the lock screen by face.
bool lockScreen() const
{
return m_lockScreen;
}
// Scan when somebody comes back to a locked screen (a key, the mouse,
// opening the lid).
bool scanOnWake() const
{
return m_scanOnWake;
}
// Scan right as the screen locks. Off by default: somebody who locks
// their screen on purpose is usually still sitting in front of it.
bool scanOnLock() const
{
return m_scanOnLock;
}
bool bubble() const
{
return m_bubble;
}
// "full" (the island with the face) or "minimal" (a small pill with a
// lock).
QString bubbleStyle() const
{
return m_styleOverride.isEmpty() ? m_bubbleStyle : m_styleOverride;
}
// For --demo --style: try a style without writing it down.
void overrideStyle(const QString &style)
{
m_styleOverride = style == u"minimal" ? QStringLiteral("minimal") : QStringLiteral("full");
Q_EMIT changed();
}
// Show the bubble for sudo and admin prompts, not only the lock screen.
bool bubbleForPrompts() const
{
return m_bubbleForPrompts;
}
static QString path();
Q_SIGNALS:
void changed();
private:
void load();
QFileSystemWatcher m_watcher;
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;
QString m_styleOverride;
};
+285
View File
@@ -0,0 +1,285 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "camera.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <opencv2/imgcodecs.hpp>
#include <opencv2/imgproc.hpp>
#include <fcntl.h>
#include <linux/videodev2.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <algorithm>
#include <thread>
using namespace std::chrono;
namespace
{
constexpr int Width = 640;
constexpr int Height = 480;
// Pictures stand in for a camera at this rate, each held for a while so the
// checks see a still face the way they would see a person holding still.
constexpr double ImageFrameMs = 1000.0 / 15.0;
constexpr int ImageRepeat = 12;
QString sysName(const QString &device)
{
QFile file(QStringLiteral("/sys/class/video4linux/%1/name").arg(QFileInfo(device).fileName()));
if (!file.open(QIODevice::ReadOnly)) {
return {};
}
return QString::fromUtf8(file.readAll()).trimmed();
}
// Opens the node just long enough to ask what it is. Neither this nor the
// format list below starts streaming, so it does not switch the light on.
bool probe(const QString &path, CameraInfo *info)
{
const int fd = ::open(QFile::encodeName(path).constData(), O_RDONLY | O_NONBLOCK | O_CLOEXEC);
if (fd < 0) {
return false;
}
v4l2_capability cap{};
bool ok = ::ioctl(fd, VIDIOC_QUERYCAP, &cap) == 0;
if (ok) {
const quint32 caps = (cap.capabilities & V4L2_CAP_DEVICE_CAPS) ? cap.device_caps : cap.capabilities;
ok = (caps & V4L2_CAP_VIDEO_CAPTURE) && !(caps & V4L2_CAP_META_CAPTURE);
}
bool colour = false;
bool grey = false;
if (ok) {
for (quint32 i = 0;; ++i) {
v4l2_fmtdesc fmt{};
fmt.index = i;
fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
if (::ioctl(fd, VIDIOC_ENUM_FMT, &fmt) != 0) {
break;
}
switch (fmt.pixelformat) {
case V4L2_PIX_FMT_GREY:
case V4L2_PIX_FMT_Y10:
case V4L2_PIX_FMT_Y12:
case V4L2_PIX_FMT_Y16:
grey = true;
break;
default:
colour = true;
break;
}
}
ok = colour || grey;
}
::close(fd);
if (ok && info) {
info->path = path;
info->name = sysName(path);
if (info->name.isEmpty()) {
info->name = QString::fromUtf8(reinterpret_cast<const char *>(cap.card));
}
info->infrared = grey && !colour;
}
return ok;
}
} // namespace
Camera::Camera() = default;
Camera::~Camera()
{
close();
}
QList<CameraInfo> Camera::list()
{
QList<CameraInfo> result;
const QDir dev(QStringLiteral("/dev"));
QStringList nodes = dev.entryList({QStringLiteral("video*")}, QDir::System);
std::sort(nodes.begin(), nodes.end(), [](const QString &a, const QString &b) {
return a.mid(5).toInt() < b.mid(5).toInt();
});
for (const QString &node : std::as_const(nodes)) {
CameraInfo info;
if (probe(dev.filePath(node), &info)) {
result.append(info);
}
}
return result;
}
QString Camera::autoPath()
{
const QList<CameraInfo> cameras = list();
for (const CameraInfo &c : cameras) {
if (!c.infrared) {
return c.path;
}
}
return cameras.isEmpty() ? QString() : cameras.first().path;
}
bool Camera::open(const QString &spec, QString *error)
{
close();
m_start = steady_clock::now();
m_next = m_start;
if (spec.startsWith(u"images:")) {
return openImages(spec.mid(7), error);
}
if (spec.startsWith(u"file:")) {
const QString path = spec.mid(5);
if (!m_capture.open(QFile::encodeName(path).toStdString(), cv::CAP_ANY) || !m_capture.isOpened()) {
*error = QStringLiteral("cannot open video file %1").arg(path);
return false;
}
m_paced = true;
m_open = true;
m_description = QFileInfo(path).fileName();
return true;
}
QString path = spec;
if (path.isEmpty() || path == u"auto") {
path = autoPath();
if (path.isEmpty()) {
*error = QStringLiteral("no camera found");
return false;
}
}
CameraInfo info;
if (!probe(path, &info)) {
*error = QStringLiteral("%1 is not a camera").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);
return false;
}
// MJPEG gets a webcam its full frame rate over USB 2; raw YUYV at 640x480
// often tops out at 15 fps. A camera that has no MJPEG ignores the request.
if (!info.infrared) {
m_capture.set(cv::CAP_PROP_FOURCC, cv::VideoWriter::fourcc('M', 'J', 'P', 'G'));
}
m_capture.set(cv::CAP_PROP_FRAME_WIDTH, Width);
m_capture.set(cv::CAP_PROP_FRAME_HEIGHT, Height);
m_capture.set(cv::CAP_PROP_FPS, 30);
// A stale frame in the driver's queue is a picture of whoever sat there a
// moment ago. Keep the queue as short as the driver allows.
m_capture.set(cv::CAP_PROP_BUFFERSIZE, 1);
m_infrared = info.infrared;
m_paced = false;
m_open = true;
m_description = QStringLiteral("%1 (%2)").arg(info.name, path);
return true;
}
bool Camera::openImages(const QString &dir, QString *error)
{
const QDir d(dir);
const QStringList files = d.entryList({QStringLiteral("*.jpg"), QStringLiteral("*.jpeg"), QStringLiteral("*.png")},
QDir::Files, QDir::Name);
for (const QString &f : files) {
cv::Mat img = cv::imread(QFile::encodeName(d.filePath(f)).toStdString(), cv::IMREAD_COLOR);
if (img.empty()) {
continue;
}
const double scale = double(Width) / std::max(img.cols, img.rows);
if (scale < 1.0) {
cv::resize(img, img, {}, scale, scale, cv::INTER_AREA);
}
m_images.append(img);
}
if (m_images.isEmpty()) {
*error = QStringLiteral("no pictures in %1").arg(dir);
return false;
}
m_imageIndex = 0;
m_imageRepeat = 0;
m_paced = true;
m_open = true;
m_description = QStringLiteral("pictures in %1").arg(dir);
return true;
}
void Camera::close()
{
if (m_capture.isOpened()) {
m_capture.release();
}
m_images.clear();
m_open = false;
m_infrared = false;
}
bool Camera::isOpen() const
{
return m_open;
}
void Camera::pace(double frameMs)
{
m_next += duration_cast<steady_clock::duration>(duration<double, std::milli>(frameMs));
const auto now = steady_clock::now();
if (m_next > now) {
std::this_thread::sleep_until(m_next);
} else {
m_next = now;
}
}
bool Camera::readImages(cv::Mat &bgr)
{
pace(ImageFrameMs);
bgr = m_images.at(m_imageIndex).clone();
if (++m_imageRepeat >= ImageRepeat) {
m_imageRepeat = 0;
m_imageIndex = (m_imageIndex + 1) % m_images.size();
}
return true;
}
bool Camera::read(cv::Mat &bgr, double *timestampMs)
{
if (!m_open) {
return false;
}
bool ok;
if (!m_images.isEmpty()) {
ok = readImages(bgr);
} else {
if (m_paced) {
double fps = m_capture.get(cv::CAP_PROP_FPS);
if (!(fps > 1 && fps < 240)) {
fps = 30;
}
pace(1000.0 / fps);
}
ok = m_capture.read(bgr) && !bgr.empty();
}
if (!ok) {
return false;
}
if (bgr.channels() == 1) {
cv::cvtColor(bgr, bgr, cv::COLOR_GRAY2BGR);
}
if (timestampMs) {
*timestampMs = duration<double, std::milli>(steady_clock::now() - m_start).count();
}
return true;
}
+76
View File
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Where frames come from.
//
// Normally a V4L2 device. For testing without a camera (a virtual machine, a
// build server) a video file or a folder of pictures stands in for one and is
// played back at the pace a camera would deliver it, so that anything timed in
// the liveness checks behaves the way it would with the real thing.
#pragma once
#include <QList>
#include <QString>
#include <opencv2/core.hpp>
#include <opencv2/videoio.hpp>
#include <chrono>
#include <memory>
struct CameraInfo {
QString path;
QString name;
// Infrared cameras (the kind Windows Hello uses) only offer grey formats.
bool infrared = false;
};
class Camera
{
public:
Camera();
~Camera();
// spec is a /dev/video path, "auto", "file:<video>" or "images:<dir>".
bool open(const QString &spec, QString *error);
void close();
bool isOpen() const;
// Blocks until the next frame. The timestamp is in milliseconds on a
// monotonic clock and only means something relative to other frames.
bool read(cv::Mat &bgr, double *timestampMs);
bool isInfrared() const
{
return m_infrared;
}
QString description() const
{
return m_description;
}
// Every V4L2 device that can capture video. Metadata nodes, which every
// UVC camera also exposes, are left out.
static QList<CameraInfo> list();
// What "auto" means on this machine: the first colour camera, else the
// first camera at all.
static QString autoPath();
private:
bool openImages(const QString &dir, QString *error);
bool readImages(cv::Mat &bgr);
void pace(double frameMs);
cv::VideoCapture m_capture;
bool m_open = false;
bool m_infrared = false;
bool m_paced = false;
QString m_description;
QList<cv::Mat> m_images;
qsizetype m_imageIndex = 0;
int m_imageRepeat = 0;
std::chrono::steady_clock::time_point m_start;
std::chrono::steady_clock::time_point m_next;
};
+14
View File
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// OpenCV 5 moved the contour and transform helpers (getPerspectiveTransform,
// approxPolyDP and friends) out of imgproc into a module of their own. The
// distributions this builds on ship 4.x and 5.x, so both are included here.
#pragma once
#include <opencv2/core/version.hpp>
#include <opencv2/imgproc.hpp>
#if CV_VERSION_MAJOR >= 5
#include <opencv2/geometry.hpp>
#endif
+82
View File
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "keyvalue.h"
#include <QFile>
KeyValueFile KeyValueFile::load(const QString &path)
{
KeyValueFile kv;
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
return kv;
}
while (!file.atEnd()) {
QString line = QString::fromUtf8(file.readLine()).trimmed();
if (line.isEmpty() || line.startsWith(QLatin1Char('#'))) {
continue;
}
const qsizetype eq = line.indexOf(QLatin1Char('='));
if (eq <= 0) {
continue;
}
const QString key = line.left(eq).trimmed();
QString value = line.mid(eq + 1);
// A comment can follow a value, the same as in the shell reader.
const qsizetype hash = value.indexOf(QLatin1Char('#'));
if (hash >= 0) {
value.truncate(hash);
}
value = value.trimmed();
if (value.size() >= 2 && value.startsWith(QLatin1Char('"')) && value.endsWith(QLatin1Char('"'))) {
value = value.mid(1, value.size() - 2);
}
// The last occurrence wins, which is also what the shell reader does.
kv.m_values.insert(key, value);
}
return kv;
}
QString KeyValueFile::value(const QString &key, const QString &fallback) const
{
const auto it = m_values.constFind(key);
if (it == m_values.cend() || it->isEmpty()) {
return fallback;
}
return *it;
}
bool KeyValueFile::contains(const QString &key) const
{
return m_values.contains(key);
}
bool KeyValueFile::parseBool(const QString &value, bool fallback)
{
const QString v = value.trimmed().toLower();
if (v == u"yes" || v == u"y" || v == u"true" || v == u"1" || v == u"on" || v == u"enabled") {
return true;
}
if (v == u"no" || v == u"n" || v == u"false" || v == u"0" || v == u"off" || v == u"disabled") {
return false;
}
return fallback;
}
bool KeyValueFile::boolean(const QString &key, bool fallback) const
{
return parseBool(value(key), fallback);
}
int KeyValueFile::integer(const QString &key, int fallback, int min, int max) const
{
bool ok = false;
const int v = value(key).toInt(&ok);
if (!ok) {
return fallback;
}
return std::clamp(v, min, max);
}
+27
View File
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The settings files, read the same way the shell code reads them: one
// Key=Value per line, '#' starts a comment, quotes around a value are dropped.
// Both halves of the program write and read these files, so they have to
// agree on every detail of the format.
#pragma once
#include <QHash>
#include <QString>
class KeyValueFile
{
public:
static KeyValueFile load(const QString &path);
QString value(const QString &key, const QString &fallback = {}) const;
bool boolean(const QString &key, bool fallback) const;
int integer(const QString &key, int fallback, int min, int max) const;
bool contains(const QString &key) const;
static bool parseBool(const QString &value, bool fallback);
private:
QHash<QString, QString> m_values;
};
+535
View File
@@ -0,0 +1,535 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "liveness.h"
#include "cvcompat.h"
#include <algorithm>
#include <cmath>
namespace
{
// The eyes are levelled and scaled onto a fixed canvas before anything is
// measured, so the numbers below are in canvas pixels and mean the same at
// any distance and any head tilt. 64 pixels between the eyes.
constexpr int Canvas = 128;
constexpr float CanvasIod = 64.f;
constexpr int EyeY = Canvas / 2;
constexpr int RightEyeX = Canvas / 2 - int(CanvasIod / 2);
constexpr int LeftEyeX = Canvas / 2 + int(CanvasIod / 2);
// A slice through the middle of the eye, narrow enough to stay on the iris
// and tall enough to hold a fully open one (about 0.3 eye distances).
constexpr int BandHalfWidth = 7;
constexpr int BandHalfHeight = 10;
// Skin under the eye, clear of lashes and of the shadow below the lid.
constexpr int SkinTop = EyeY + 22;
constexpr int SkinBottom = EyeY + 32;
constexpr int SkinHalfWidth = 12;
// A row of the slice counts as dark below this share of the skin's
// brightness. Iris and pupil are well below it on every skin tone that
// was checked; a closed lid is skin and sits well above it.
constexpr float DarkShare = 0.7f;
// Glare: the Y floor and the chroma tolerance for "colourless and nearly
// white", in YCrCb.
constexpr int SpecularLuma = 235;
constexpr int SpecularChroma = 10;
constexpr int GlareGrid = 8;
cv::Mat levelledFace(const cv::Mat &bgr, const Face &face)
{
const cv::Point2f mid = face.eyeMid();
const cv::Point2f d = face.points[LeftEye] - face.points[RightEye];
const double roll = std::atan2(d.y, d.x) * 180.0 / M_PI;
const double scale = CanvasIod / std::max(1.f, face.interocular());
cv::Mat m = cv::getRotationMatrix2D(mid, roll, scale);
m.at<double>(0, 2) += Canvas / 2.0 - mid.x;
m.at<double>(1, 2) += Canvas / 2.0 - mid.y;
cv::Mat grey, out;
cv::cvtColor(bgr, grey, cv::COLOR_BGR2GRAY);
cv::warpAffine(grey, out, m, cv::Size(Canvas, Canvas), cv::INTER_LINEAR, cv::BORDER_REPLICATE);
return out;
}
float eyeOpenness(const cv::Mat &canvas, int eyeX, float *skinOut)
{
const cv::Rect skinRect(eyeX - SkinHalfWidth, SkinTop, 2 * SkinHalfWidth, SkinBottom - SkinTop);
const float skin = float(cv::mean(canvas(skinRect))[0]);
*skinOut = skin;
if (skin < 20) {
return -1;
}
const cv::Rect band(eyeX - BandHalfWidth, EyeY - BandHalfHeight, 2 * BandHalfWidth + 1, 2 * BandHalfHeight + 1);
cv::Mat rows;
cv::reduce(canvas(band), rows, 1, cv::REDUCE_AVG, CV_32F);
int dark = 0;
for (int y = 0; y < rows.rows; ++y) {
if (rows.at<float>(y, 0) < DarkShare * skin) {
++dark;
}
}
return float(dark) / float(rows.rows);
}
bool insidePolygon(const std::vector<cv::Point> &poly, cv::Point2f p)
{
return cv::pointPolygonTest(poly, p, false) >= 0;
}
} // namespace
GlareSample measureGlare(const cv::Mat &bgr, const Face &face)
{
GlareSample g;
const float iod = face.interocular();
const cv::Point2f centre = (face.eyeMid() + face.mouthMid()) * 0.5f;
cv::Rect roi(int(centre.x - 1.1f * iod), int(centre.y - 1.3f * iod), int(2.2f * iod), int(2.4f * iod));
roi &= cv::Rect(0, 0, bgr.cols, bgr.rows);
if (roi.width < 16 || roi.height < 16) {
return g;
}
cv::Mat ycc;
cv::cvtColor(bgr(roi), ycc, cv::COLOR_BGR2YCrCb);
// Glasses throw the screen back from right in front of the eyes, and
// that is a big flat highlight too. It is not what is being looked for,
// so the eyes are left out.
cv::Mat mask(roi.size(), CV_8U, cv::Scalar(255));
for (int e : {RightEye, LeftEye}) {
const cv::Point2f p = face.points[e] - cv::Point2f(float(roi.x), float(roi.y));
cv::circle(mask, p, int(0.38f * iod), cv::Scalar(0), cv::FILLED);
}
std::array<int, GlareGrid * GlareGrid> cells{};
int total = 0;
int considered = 0;
for (int y = 0; y < ycc.rows; ++y) {
const cv::Vec3b *row = ycc.ptr<cv::Vec3b>(y);
const uchar *m = mask.ptr<uchar>(y);
const int gy = std::min(GlareGrid - 1, y * GlareGrid / ycc.rows);
for (int x = 0; x < ycc.cols; ++x) {
if (!m[x]) {
continue;
}
++considered;
const cv::Vec3b &p = row[x];
if (p[0] < SpecularLuma || std::abs(p[1] - 128) > SpecularChroma || std::abs(p[2] - 128) > SpecularChroma) {
continue;
}
++total;
const int gx = std::min(GlareGrid - 1, x * GlareGrid / ycc.cols);
++cells[gy * GlareGrid + gx];
}
}
g.valid = considered > 0;
g.fraction = considered ? float(total) / float(considered) : 0.f;
// Too few pixels to say anything about their shape.
g.cluster = total >= 24 ? float(*std::max_element(cells.begin(), cells.end())) / float(total) : 0.f;
return g;
}
EyeSample measureEyes(const cv::Mat &bgr, const Face &face)
{
EyeSample s;
if (face.interocular() < 20.f) {
return s;
}
const cv::Mat canvas = levelledFace(bgr, face);
// "Right" is the person's right eye, which is on the canvas' left.
float skinR = 0, skinL = 0;
s.right = eyeOpenness(canvas, RightEyeX, &skinR);
s.left = eyeOpenness(canvas, LeftEyeX, &skinL);
if (s.right < 0 || s.left < 0) {
return s;
}
s.openness = (s.left + s.right) / 2;
s.skin = (skinL + skinR) / 2;
s.valid = true;
return s;
}
bool detectDevice(const cv::Mat &bgr, const Face &face)
{
// Edges are looked for at half size. The bezel of a phone held up to the
// camera is big, and small detail would only add rectangles that are not
// one.
const double scale = 320.0 / std::max(1, bgr.cols);
cv::Mat small, grey, edges;
cv::resize(bgr, small, cv::Size(), scale, scale, cv::INTER_AREA);
cv::cvtColor(small, grey, cv::COLOR_BGR2GRAY);
cv::GaussianBlur(grey, grey, cv::Size(5, 5), 0);
cv::Canny(grey, edges, 40, 120);
cv::dilate(edges, edges, cv::Mat(), cv::Point(-1, -1), 1);
std::vector<std::vector<cv::Point>> contours;
cv::findContours(edges, contours, cv::RETR_LIST, cv::CHAIN_APPROX_SIMPLE);
const double frameArea = double(small.cols) * small.rows;
const double faceArea = double(face.box.area()) * scale * scale;
std::array<cv::Point2f, 5> points;
for (int i = 0; i < 5; ++i) {
points[i] = face.points[i] * float(scale);
}
for (const auto &c : contours) {
const double area = std::abs(cv::contourArea(c));
// A device held up to take somebody's place fills a good part of the
// picture, and the face fills a good part of the device. A door or a
// monitor far behind somebody's head does neither.
if (area < 0.10 * frameArea || area > 0.95 * frameArea || area < 1.6 * faceArea || faceArea / area < 0.08) {
continue;
}
std::vector<cv::Point> quad;
cv::approxPolyDP(c, quad, 0.03 * cv::arcLength(c, true), true);
if (quad.size() != 4 || !cv::isContourConvex(quad)) {
continue;
}
const cv::RotatedRect r = cv::minAreaRect(quad);
const float shortSide = std::min(r.size.width, r.size.height);
const float longSide = std::max(r.size.width, r.size.height);
if (longSide <= 0 || shortSide / longSide < 0.3f) {
continue;
}
// It has to frame the face: every one of the five points inside.
bool framed = true;
for (const cv::Point2f &p : points) {
framed = framed && insidePolygon(quad, p);
}
if (framed) {
return true;
}
}
return false;
}
LivenessFrame measureFrame(const cv::Mat &bgr, const Face &face, double timestampMs)
{
LivenessFrame f;
f.t = timestampMs;
f.points = face.points;
f.interocular = face.interocular();
f.pose = estimatePose(face);
f.glare = measureGlare(bgr, face);
f.device = detectDevice(bgr, face);
f.eyes = measureEyes(bgr, face);
return f;
}
// ---------------------------------------------------------------------------
// The analyzer
// ---------------------------------------------------------------------------
void LivenessAnalyzer::reset()
{
m_frames.clear();
m_total = 0;
m_glareHits = 0;
m_deviceHits = 0;
m_depthFired = false;
m_blinkFired = false;
m_depth.clear();
m_depthNum = 0;
m_depthDen = 0;
m_depthPairs = 0;
}
void LivenessAnalyzer::add(const LivenessFrame &frame)
{
m_frames.push_back(frame);
while (!m_frames.empty() && frame.t - m_frames.front().t > WindowMs) {
m_frames.pop_front();
}
// Deny cues count over the whole scan, not just the window: a phone that
// was seen once does not stop having been a phone.
++m_total;
if (frame.glare.valid && frame.glare.fraction >= GlareFraction && frame.glare.cluster >= GlareCluster) {
++m_glareHits;
}
if (frame.device) {
++m_deviceHits;
}
addDepth(frame);
// Confirm cues latch once seen, for the rest of this scan.
if (!m_depthFired) {
m_depthFired = depthRange() >= DepthMinRange && m_depthPairs >= 6 && depthRatio() >= DepthMinRatio && depthConsistent();
}
if (!m_blinkFired) {
float strength = 0;
m_blinkFired = blinkSeen(&strength);
}
}
// How far the nose misses the spot a flat face would put it, measured against
// how far the head turned.
//
// For a pair of frames far enough apart in yaw, the four points that lie close
// to one plane (eyes, mouth corners) give the homography between the two
// frames. A point on a flat picture lands exactly where it predicts. The real
// nose tip is about a third of an eye distance in front of that plane, so it
// lands off the prediction, sideways, by about as much as the yaw estimate
// changed: both are the same parallax, seen two ways. The slope of miss
// against yaw change is therefore near 1 for anything with a nose sticking
// out of it, and near 0 for paper or a screen.
//
// Two details decide whether that holds up against a real camera.
//
// The yaw a frame is compared at comes from its neighbours, never from the
// frame itself. Yaw and miss are both read off the same nose, so the nose's
// own jitter would push both the same way in every frame, and the slope of
// noise against itself is 1. That alone made a photo shaken at two pixels of
// jitter pass as a head. The neighbours are 50 ms away, so they see the same
// head position with jitter of their own.
//
// And the slope alone says nothing about depth, only that nose and plane
// disagree consistently. A card curled around a vertical axis has a little
// depth too, and its slope is near 1 as well. What it cannot do is move the
// nose off the midline by much, so the cue also needs the (smoothed) yaw to
// have covered DepthMinRange: about 12 degrees of a real head turning.
//
// A card that is curled hard and turned far enough still gets there. So the
// last check asks whether the face turned as far as its nose says it did.
// Turning narrows the eyes against the eye to mouth distance by 1 - cos(turn),
// whatever the face is made of. A nose as flat as a real one can be (0.3 eye
// distances) that moved DepthMinRange can only have turned so far, and a face
// that narrowed a lot more than that was turned a lot more than that: it has
// less nose than any head. See depthConsistent().
void LivenessAnalyzer::addDepth(const LivenessFrame &frame)
{
constexpr size_t MaxFrames = 400;
if (m_depth.size() >= MaxFrames) {
return;
}
Face face;
face.points = frame.points;
const float emd = float(cv::norm(face.mouthMid() - face.eyeMid()));
const float shape = emd > 1.f ? face.interocular() / emd : 0.f;
m_depth.push_back({frame.points, frame.pose.yaw, 0.f, frame.pose.noseT, shape, 0.f});
// The frame two back now has two neighbours on each side.
const size_t n = m_depth.size();
if (n < 5) {
return;
}
const size_t j = n - 3;
DepthPoint &b = m_depth[j];
b.smoothYaw = (m_depth[j - 2].yaw + m_depth[j - 1].yaw + m_depth[j + 1].yaw + m_depth[j + 2].yaw) / 4.f;
b.smoothShape = (m_depth[j - 2].shape + m_depth[j - 1].shape + b.shape + m_depth[j + 1].shape + m_depth[j + 2].shape) / 5.f;
const cv::Point2f axis = b.points[LeftEye] - b.points[RightEye];
const float axisLen = std::max(1e-3f, float(cv::norm(axis)));
const cv::Point2f unit = axis * (1.f / axisLen);
for (size_t i = 2; i < j; ++i) {
const DepthPoint &a = m_depth[i];
const float dy = b.smoothYaw - a.smoothYaw;
if (std::abs(dy) < DepthMinTurn) {
continue;
}
const cv::Point2f src[4] = {a.points[RightEye], a.points[LeftEye], a.points[LeftMouth], a.points[RightMouth]};
const cv::Point2f dst[4] = {b.points[RightEye], b.points[LeftEye], b.points[LeftMouth], b.points[RightMouth]};
const cv::Mat h = cv::getPerspectiveTransform(src, dst);
if (h.empty()) {
continue;
}
std::vector<cv::Point2f> in{a.points[NoseTip]}, out;
cv::perspectiveTransform(in, out, h);
const cv::Point2f miss = b.points[NoseTip] - out[0];
const float rx = (miss.x * unit.x + miss.y * unit.y) / axisLen;
m_depthNum += double(rx) * dy;
m_depthDen += double(dy) * dy;
++m_depthPairs;
}
}
float LivenessAnalyzer::depthRatio() const
{
return m_depthDen > 0 ? float(m_depthNum / m_depthDen) : 0.f;
}
// The spread of the smoothed yaw, from the 10th to the 90th percentile, so a
// single bad frame cannot stretch it.
float LivenessAnalyzer::depthRange() const
{
if (m_depth.size() < 5) {
return 0;
}
std::vector<float> ys;
for (size_t k = 2; k + 2 < m_depth.size(); ++k) {
ys.push_back(m_depth[k].smoothYaw);
}
if (ys.size() < 3) {
return 0;
}
std::sort(ys.begin(), ys.end());
const auto at = [&](double q) {
return ys[size_t(q * double(ys.size() - 1))];
};
return at(0.9) - at(0.1);
}
bool LivenessAnalyzer::depthConsistent() const
{
// Nodding also changes the eye to mouth distance, so only frames at the
// head's usual pitch are compared. A card has no pitch of its own to read
// (its nose is printed on), so none of its frames are left out.
std::vector<float> noseTs;
for (size_t k = 2; k + 2 < m_depth.size(); ++k) {
noseTs.push_back(m_depth[k].noseT);
}
if (noseTs.size() < 3) {
return false;
}
std::vector<float> sortedT = noseTs;
std::sort(sortedT.begin(), sortedT.end());
const float medianT = sortedT[sortedT.size() / 2];
std::vector<float> shapes;
for (size_t k = 2; k + 2 < m_depth.size(); ++k) {
if (std::abs(m_depth[k].noseT - medianT) < 0.05f) {
shapes.push_back(m_depth[k].smoothShape);
}
}
if (shapes.size() < 3) {
return false;
}
std::sort(shapes.begin(), shapes.end());
const float widest = shapes[size_t(0.95 * double(shapes.size() - 1))];
const float narrowest = shapes[size_t(0.10 * double(shapes.size() - 1))];
if (widest <= 0) {
return false;
}
const float narrowed = 1.f - narrowest / widest;
const float sinTurn = std::min(1.f, depthRange() / DepthFlattestNose);
const float allowed = 1.f - std::sqrt(1.f - sinTurn * sinTurn);
return narrowed <= allowed + DepthShapeSlack;
}
bool LivenessAnalyzer::blinkSeen(float *strength) const
{
*strength = 0;
std::vector<const LivenessFrame *> s;
for (const LivenessFrame &f : m_frames) {
if (f.eyes.valid) {
s.push_back(&f);
}
}
if (s.size() < 6) {
return false;
}
std::vector<float> values;
for (const LivenessFrame *f : s) {
values.push_back(f->eyes.openness);
}
std::vector<float> sorted = values;
std::sort(sorted.begin(), sorted.end());
const float baseline = sorted[size_t(0.7 * double(sorted.size() - 1))];
// An eye this narrow is not one the measure works on (heavy lids, very
// dark eyes on dark skin, a camera that is too soft). Better to abstain
// than to guess.
if (baseline < 0.15f) {
return false;
}
*strength = std::clamp(1.f - sorted.front() / baseline, 0.f, 1.f) / (1.f - BlinkDip);
const size_t n = s.size();
for (size_t i = 1; i + 1 < n; ++i) {
if (values[i] >= BlinkDip * baseline) {
continue;
}
// The run of closed frames around this one.
size_t a = i, b = i;
while (a > 0 && values[a - 1] < BlinkOpen * baseline) {
--a;
}
while (b + 1 < n && values[b + 1] < BlinkOpen * baseline) {
++b;
}
if (a == 0 || b + 1 >= n) {
continue;
}
// Open right before and right after, within the time a blink takes
// (a real one is 100 to 400 ms; slower is somebody closing their
// eyes, or a picture being tilted away and back).
const LivenessFrame *before = s[a - 1];
const LivenessFrame *after = s[b + 1];
if (after->t - before->t > 600) {
continue;
}
// The rest of the face held still. A picture being moved blurs and
// shifts everything at once; a blink only moves the lids.
const cv::Point2f moved = (after->points[NoseTip] - before->points[NoseTip]);
const float iod = std::max(1.f, before->interocular);
if (float(cv::norm(moved)) > 0.15f * iod) {
continue;
}
if (std::abs(after->interocular - before->interocular) > 0.08f * iod) {
continue;
}
bool steadyLight = true;
for (size_t k = a; k <= b; ++k) {
const float ratio = s[k]->eyes.skin / std::max(1.f, before->eyes.skin);
steadyLight = steadyLight && ratio > 0.9f && ratio < 1.1f;
}
if (!steadyLight) {
continue;
}
// Both eyes together. One eye going dark on its own is a shadow or a
// hand, not a blink.
bool both = false;
for (size_t k = a; k <= b && !both; ++k) {
both = s[k]->eyes.left < 0.7f * baseline && s[k]->eyes.right < 0.7f * baseline;
}
if (both) {
return true;
}
}
return false;
}
LivenessReading LivenessAnalyzer::reading() const
{
LivenessReading r;
r.frames = int(m_frames.size());
const int seen = std::max(1, m_total);
const float glareShare = float(m_glareHits) / float(seen);
const float deviceShare = float(m_deviceHits) / float(seen);
r.glare = std::min(glareShare / 0.3f, float(m_glareHits) / 3.f);
r.device = std::min(deviceShare / 0.3f, float(m_deviceHits) / 3.f);
if (m_glareHits >= 3 && glareShare >= 0.3f) {
r.denied = true;
r.deniedBy = QStringLiteral("glare");
} else if (m_deviceHits >= 3 && deviceShare >= 0.3f) {
r.denied = true;
r.deniedBy = QStringLiteral("device");
}
r.depth = m_depthFired ? 1.f
: std::clamp(depthRatio() / DepthMinRatio, 0.f, 1.f) * std::clamp(depthRange() / DepthMinRange, 0.f, 1.f);
float strength = 0;
blinkSeen(&strength);
r.blink = m_blinkFired ? 1.f : std::min(strength, 0.99f);
if (m_depthFired) {
r.confirmed = true;
r.confirmedBy = QStringLiteral("depth");
} else if (m_blinkFired) {
r.confirmed = true;
r.confirmedBy = QStringLiteral("blink");
}
return r;
}
+154
View File
@@ -0,0 +1,154 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Telling a face from a picture of one.
//
// A webcam sees a flat image either way, so there is no single test that
// settles it. What is here follows the model Glance (the macOS face unlock)
// arrived at after trying and dropping a dozen weaker signals: a few cues,
// each one decisive on its own, split into two kinds.
//
// Deny cues are evidence of a fake. Either one fails the scan outright, and
// a match that already happened does not outvote it.
//
// glare a phone screen or a glossy print throws back one large, flat,
// colourless highlight. Skin shines in small scattered spots.
// device the straight edges of a phone or a tablet around the face.
//
// Confirm cues are evidence of a real head. Any one of them is enough, and
// their absence is never held against anybody on its own: a person can sit
// still and not blink for a few seconds.
//
// depth when the head turns, the tip of the nose moves further than a
// flat face would let it. The eyes and the corners of the mouth lie
// close to one plane, so four of them predict exactly where the
// nose of a flat picture has to go. A real nose, about a third of
// an eye distance in front of that plane, misses the prediction by
// about as much as the head turned.
// blink the dark of the eye shrinks to a line and comes back, within
// the fraction of a second a blink takes, while the rest of the face
// holds still.
//
// "Light" uses the deny cues. "Heavy" also needs one confirm cue before it
// lets anybody in.
#pragma once
#include "settings.h"
#include "vision.h"
#include <QString>
#include <deque>
struct GlareSample {
bool valid = false;
// Share of the face that is a colourless highlight.
float fraction = 0;
// Share of all highlight pixels that fall in the fullest of 8x8 cells.
// One slab of glass reflects into one place; skin sparkles everywhere.
float cluster = 0;
};
struct EyeSample {
bool valid = false;
// How much of the eye's height is dark (iris, pupil), per eye and on
// average. Falls towards zero when the lid comes down.
float left = 0;
float right = 0;
float openness = 0;
// Brightness of the skin under the eyes, to tell a blink from a change
// in the light.
float skin = 0;
};
struct LivenessFrame {
double t = 0;
std::array<cv::Point2f, 5> points{};
float interocular = 0;
HeadPose pose;
GlareSample glare;
bool device = false;
EyeSample eyes;
};
GlareSample measureGlare(const cv::Mat &bgr, const Face &face);
EyeSample measureEyes(const cv::Mat &bgr, const Face &face);
bool detectDevice(const cv::Mat &bgr, const Face &face);
LivenessFrame measureFrame(const cv::Mat &bgr, const Face &face, double timestampMs);
struct LivenessReading {
int frames = 0;
bool denied = false;
QString deniedBy;
bool confirmed = false;
QString confirmedBy;
// For the test screen: how close each cue is to firing, 0 to 1 and more.
float glare = 0;
float device = 0;
float depth = 0;
float blink = 0;
};
class LivenessAnalyzer
{
public:
void reset();
void add(const LivenessFrame &frame);
LivenessReading reading() const;
int frameCount() const
{
return int(m_frames.size());
}
// Tuning, in one place. See liveness.cpp for where each number comes
// from.
static constexpr double WindowMs = 4000;
static constexpr float GlareFraction = 0.012f;
static constexpr float GlareCluster = 0.55f;
static constexpr float DepthMinTurn = 0.05f;
static constexpr float DepthMinRange = 0.10f;
static constexpr float DepthMinRatio = 0.65f;
static constexpr float DepthFlattestNose = 0.30f;
static constexpr float DepthShapeSlack = 0.02f;
static constexpr float BlinkDip = 0.55f;
static constexpr float BlinkOpen = 0.8f;
// The depth cue's workings, for the test screen and the tests.
float depthRatio() const;
float depthRange() const;
bool depthConsistent() const;
private:
void addDepth(const LivenessFrame &frame);
bool blinkSeen(float *strength) const;
std::deque<LivenessFrame> m_frames;
int m_total = 0;
// The depth cue looks at the whole scan rather than the window, and is
// worked out a frame at a time so it stays cheap.
struct DepthPoint {
std::array<cv::Point2f, 5> points;
float yaw;
float smoothYaw;
float noseT;
// Eye distance over eye to mouth distance: shrinks as the face turns
// away from the camera, whatever the face is made of.
float shape;
float smoothShape;
};
std::vector<DepthPoint> m_depth;
double m_depthNum = 0;
double m_depthDen = 0;
int m_depthPairs = 0;
int m_glareHits = 0;
int m_deviceHits = 0;
bool m_depthFired = false;
bool m_blinkFired = false;
};
+81
View File
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "settings.h"
#include "keyvalue.h"
double Settings::threshold() const
{
// Cosine similarity between two SFace embeddings. Photos of the same
// person taken years apart land around 0.75, different people below 0.3.
// OpenCV's own recommendation (0.363) is tuned for telling apart photos
// in a data set; this guards a login, so the bar is higher.
switch (strictness) {
case Strictness::Relaxed:
return 0.42;
case Strictness::Normal:
return 0.50;
case Strictness::Strict:
return 0.58;
}
return 0.50;
}
Settings Settings::load(const QString &path)
{
Settings s;
const KeyValueFile kv = KeyValueFile::load(path);
s.camera = kv.value(QStringLiteral("Camera"), s.camera);
const QString liveness = kv.value(QStringLiteral("Liveness")).toLower();
if (liveness == u"off") {
s.liveness = LivenessMode::Off;
} else if (liveness == u"light") {
s.liveness = LivenessMode::Light;
} else if (liveness == u"heavy") {
s.liveness = LivenessMode::Heavy;
}
const QString strictness = kv.value(QStringLiteral("Strictness")).toLower();
if (strictness == u"relaxed") {
s.strictness = Strictness::Relaxed;
} else if (strictness == u"normal") {
s.strictness = Strictness::Normal;
} else if (strictness == u"strict") {
s.strictness = Strictness::Strict;
}
s.attention = kv.boolean(QStringLiteral("Attention"), s.attention);
s.scanSeconds = kv.integer(QStringLiteral("ScanSeconds"), s.scanSeconds, 2, 15);
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.adapt = kv.boolean(QStringLiteral("Adapt"), s.adapt);
return s;
}
QString Settings::livenessName(LivenessMode mode)
{
switch (mode) {
case LivenessMode::Off:
return QStringLiteral("off");
case LivenessMode::Light:
return QStringLiteral("light");
case LivenessMode::Heavy:
return QStringLiteral("heavy");
}
return {};
}
QString Settings::strictnessName(Strictness strictness)
{
switch (strictness) {
case Strictness::Relaxed:
return QStringLiteral("relaxed");
case Strictness::Normal:
return QStringLiteral("normal");
case Strictness::Strict:
return QStringLiteral("strict");
}
return {};
}
+55
View File
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The system settings, /etc/plasma-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
// for that reason. A setting a user process could change would be a setting
// any program running as that user could lower before asking sudo for help.
#pragma once
#include <QString>
enum class LivenessMode {
Off,
// Deny cues only: glare off a screen, the edge of a phone around the face.
Light,
// Deny cues, and a sign of life on top: a blink, or the nose moving like
// a nose does when the head turns.
Heavy,
};
enum class Strictness {
Relaxed,
Normal,
Strict,
};
struct Settings {
// A /dev/video path, "auto", or for testing "file:<video>" and
// "images:<directory>".
QString camera = QStringLiteral("auto");
LivenessMode liveness = LivenessMode::Heavy;
Strictness strictness = Strictness::Normal;
// Only a face that looks at the screen with its eyes open counts.
bool attention = true;
// How long one scan looks before it gives up.
int scanSeconds = 5;
// Failed scans in a row with a face in view before face unlock stops
// until the password has been used, and how long that lasts at most.
int maxFailures = 5;
int lockoutMinutes = 15;
// A laptop with the lid shut has its camera looking at the keyboard.
bool skipLidClosed = true;
// 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;
double threshold() const;
static Settings load(const QString &path);
static QString livenessName(LivenessMode mode);
static QString strictnessName(Strictness strictness);
};
+206
View File
@@ -0,0 +1,206 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "store.h"
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QRandomGenerator>
#include <QSaveFile>
namespace
{
constexpr int FormatVersion = 1;
QByteArray packEmbedding(const Embedding &e)
{
QByteArray raw(reinterpret_cast<const char *>(e.data()), qsizetype(e.size() * sizeof(float)));
return raw.toBase64();
}
Embedding unpackEmbedding(const QString &b64)
{
const QByteArray raw = QByteArray::fromBase64(b64.toLatin1());
Embedding e;
if (raw.size() != qsizetype(Vision::EmbeddingSize * sizeof(float))) {
return e;
}
e.resize(Vision::EmbeddingSize);
memcpy(e.data(), raw.constData(), size_t(raw.size()));
return e;
}
} // namespace
int Identity::adaptiveCount() const
{
int n = 0;
for (const FaceSample &s : samples) {
n += s.pose == u"adaptive";
}
return n;
}
FaceStore::FaceStore(const QString &stateDir)
: m_dir(QDir(stateDir).filePath(QStringLiteral("users")))
{
}
QString FaceStore::fileFor(uint uid) const
{
return QDir(m_dir).filePath(QStringLiteral("%1.json").arg(uid));
}
QList<Identity> FaceStore::load(uint uid, QString *error) const
{
QList<Identity> faces;
QFile file(fileFor(uid));
if (!file.exists()) {
return faces;
}
if (!file.open(QIODevice::ReadOnly)) {
if (error) {
*error = file.errorString();
}
return faces;
}
QJsonParseError parseError;
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &parseError);
if (!doc.isObject()) {
if (error) {
*error = parseError.errorString();
}
return faces;
}
const QJsonArray list = doc.object().value(u"faces").toArray();
for (const QJsonValue &v : list) {
const QJsonObject o = v.toObject();
Identity id;
id.id = o.value(u"id").toString();
id.name = o.value(u"name").toString();
id.created = qint64(o.value(u"created").toDouble());
id.enabled = o.value(u"enabled").toBool(true);
id.noseT = float(o.value(u"noseT").toDouble(0.55));
id.eyes = float(o.value(u"eyes").toDouble(0));
for (const QJsonValue &sv : o.value(u"samples").toArray()) {
const QJsonObject so = sv.toObject();
FaceSample s;
s.embedding = unpackEmbedding(so.value(u"e").toString());
s.pose = so.value(u"pose").toString();
s.time = qint64(so.value(u"t").toDouble());
if (!s.embedding.empty()) {
id.samples.append(s);
}
}
if (!id.id.isEmpty() && !id.samples.isEmpty()) {
faces.append(id);
}
}
return faces;
}
bool FaceStore::save(uint uid, const QList<Identity> &faces, QString *error) const
{
if (!QDir().mkpath(m_dir)) {
*error = QStringLiteral("cannot create %1").arg(m_dir);
return false;
}
QFile::setPermissions(m_dir, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner);
if (faces.isEmpty()) {
remove(uid);
return true;
}
QJsonArray list;
for (const Identity &id : faces) {
QJsonArray samples;
for (const FaceSample &s : id.samples) {
samples.append(QJsonObject{
{QStringLiteral("e"), QString::fromLatin1(packEmbedding(s.embedding))},
{QStringLiteral("pose"), s.pose},
{QStringLiteral("t"), double(s.time)},
});
}
list.append(QJsonObject{
{QStringLiteral("id"), id.id},
{QStringLiteral("name"), id.name},
{QStringLiteral("created"), double(id.created)},
{QStringLiteral("enabled"), id.enabled},
{QStringLiteral("noseT"), double(id.noseT)},
{QStringLiteral("eyes"), double(id.eyes)},
{QStringLiteral("samples"), samples},
});
}
const QJsonObject root{
{QStringLiteral("version"), FormatVersion},
{QStringLiteral("faces"), list},
};
QSaveFile file(fileFor(uid));
if (!file.open(QIODevice::WriteOnly)) {
*error = file.errorString();
return false;
}
file.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner);
file.write(QJsonDocument(root).toJson(QJsonDocument::Compact));
if (!file.commit()) {
*error = file.errorString();
return false;
}
return true;
}
bool FaceStore::remove(uint uid) const
{
return QFile::remove(fileFor(uid));
}
QString FaceStore::newId()
{
return QString::number(QRandomGenerator::system()->generate64() & 0xffffffffffffULL, 16);
}
FaceMatch FaceStore::bestMatch(const QList<Identity> &faces, const Embedding &e)
{
FaceMatch best;
for (int i = 0; i < faces.size(); ++i) {
const Identity &id = faces.at(i);
if (!id.enabled) {
continue;
}
for (int k = 0; k < id.samples.size(); ++k) {
const float s = Vision::similarity(id.samples.at(k).embedding, e);
if (s > best.score) {
best = {s, i, k};
}
}
}
return best;
}
bool FaceStore::adapt(Identity &identity, const Embedding &e, qint64 now)
{
// Something the samples already cover adds nothing but weight.
float closest = -1;
for (const FaceSample &s : std::as_const(identity.samples)) {
closest = std::max(closest, Vision::similarity(s.embedding, e));
}
if (closest >= 0.9f) {
return false;
}
if (identity.adaptiveCount() >= MaxAdaptive) {
for (qsizetype i = 0; i < identity.samples.size(); ++i) {
if (identity.samples.at(i).pose == u"adaptive") {
identity.samples.removeAt(i);
break;
}
}
}
identity.samples.append(FaceSample{e, QStringLiteral("adaptive"), now});
return true;
}
+72
View File
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The face data.
//
// One file per user under /var/lib/plasma-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
// itself was never written anywhere.
#pragma once
#include "vision.h"
#include <QList>
#include <QString>
struct FaceSample {
Embedding embedding;
// Which way the head pointed when it was taken: "center", one of the
// eight directions ("up", "up-right", ...), or "adaptive" for one taken
// in from a successful unlock.
QString pose;
qint64 time = 0;
};
struct Identity {
QString id;
QString name;
qint64 created = 0;
bool enabled = true;
// Where this person's nose sits for a level head (see HeadPose), and how
// open their eyes measure when they look at the camera. Both are what
// the attention check compares against.
float noseT = 0.55f;
float eyes = 0;
QList<FaceSample> samples;
int adaptiveCount() const;
};
struct FaceMatch {
float score = -1;
int identity = -1;
int sample = -1;
};
class FaceStore
{
public:
explicit FaceStore(const QString &stateDir);
QList<Identity> load(uint uid, QString *error = nullptr) const;
bool save(uint uid, const QList<Identity> &faces, QString *error) const;
bool remove(uint uid) const;
static QString newId();
// The best sample over every enabled identity.
static FaceMatch bestMatch(const QList<Identity> &faces, const Embedding &e);
// Adds what a confident unlock saw, if it adds anything, and keeps at most
// MaxAdaptive of those per identity (the oldest go first). The samples
// from the setup are never touched.
static bool adapt(Identity &identity, const Embedding &e, qint64 now);
static constexpr int MaxAdaptive = 12;
private:
QString fileFor(uint uid) const;
QString m_dir;
};
+219
View File
@@ -0,0 +1,219 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "vision.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <opencv2/core/utils/logger.hpp>
#include <opencv2/imgproc.hpp>
#include <algorithm>
#include <cmath>
#include <numeric>
namespace
{
const char DetectorFile[] = "face_detection_yunet_2023mar.onnx";
const char RecognizerFile[] = "face_recognition_sface_2021dec.onnx";
// A face whose eyes are closer together than this is too far away for the
// recognizer to see detail in, and the liveness checks drown in noise.
constexpr float MinInterocular = 28.f;
cv::Point2f rotateAround(cv::Point2f p, cv::Point2f centre, float angle)
{
const float c = std::cos(angle);
const float s = std::sin(angle);
const cv::Point2f d = p - centre;
return {centre.x + d.x * c - d.y * s, centre.y + d.x * s + d.y * c};
}
} // namespace
float Face::interocular() const
{
return float(cv::norm(points[LeftEye] - points[RightEye]));
}
cv::Point2f Face::eyeMid() const
{
return (points[RightEye] + points[LeftEye]) * 0.5f;
}
cv::Point2f Face::mouthMid() const
{
return (points[RightMouth] + points[LeftMouth]) * 0.5f;
}
HeadPose estimatePose(const Face &face)
{
HeadPose pose;
const cv::Point2f eyes = face.points[LeftEye] - face.points[RightEye];
pose.roll = std::atan2(eyes.y, eyes.x);
// Level the face first, so a tilted head does not read as a turned one.
const cv::Point2f centre = face.eyeMid();
std::array<cv::Point2f, 5> p;
for (int i = 0; i < 5; ++i) {
p[i] = rotateAround(face.points[i], centre, -pose.roll);
}
const cv::Point2f eyeMid = (p[RightEye] + p[LeftEye]) * 0.5f;
const cv::Point2f mouthMid = (p[RightMouth] + p[LeftMouth]) * 0.5f;
const float iod = std::max(1.f, float(cv::norm(p[LeftEye] - p[RightEye])));
const float span = mouthMid.y - eyeMid.y;
if (span < 1.f) {
return pose;
}
const cv::Point2f nose = p[NoseTip];
const float t = (nose.y - eyeMid.y) / span;
const float midlineX = eyeMid.x + (mouthMid.x - eyeMid.x) * t;
// The eyes are reported person-right first, which is image-left on an
// unmirrored frame. A nose moving image-right is a head turning to the
// person's left.
pose.yaw = (nose.x - midlineX) / iod;
pose.noseT = t;
return pose;
}
float yawDegrees(float yaw)
{
return float(std::asin(std::clamp(yaw / 0.5f, -1.f, 1.f)) * 180.0 / M_PI);
}
FaceQuality assessQuality(const cv::Mat &bgr, const Face &face)
{
FaceQuality q;
q.tooSmall = face.interocular() < MinInterocular;
// The middle of the face, without hair and background at the edges.
const float iod = face.interocular();
const cv::Point2f c = (face.eyeMid() + face.mouthMid()) * 0.5f;
cv::Rect roi(cv::Point(int(c.x - iod), int(c.y - iod)), cv::Size(int(2 * iod), int(2 * iod)));
roi &= cv::Rect(0, 0, bgr.cols, bgr.rows);
if (roi.width < 8 || roi.height < 8) {
q.tooSmall = true;
return q;
}
cv::Mat grey;
cv::cvtColor(bgr(roi), grey, cv::COLOR_BGR2GRAY);
q.brightness = float(cv::mean(grey)[0]);
// Sharpness at a fixed scale, so a face far away and one up close are
// judged the same.
cv::Mat small, lap;
cv::resize(grey, small, cv::Size(96, 96), 0, 0, cv::INTER_AREA);
cv::Laplacian(small, lap, CV_32F);
cv::Scalar mean, stddev;
cv::meanStdDev(lap, mean, stddev);
q.sharpness = float(stddev[0] * stddev[0]);
q.tooDark = q.brightness < 40;
q.tooBright = q.brightness > 230;
q.blurry = q.sharpness < 12;
return q;
}
bool Vision::load(const QString &modelDir, QString *error)
{
// The new DNN engine in OpenCV 5 prints a warning for every network it
// loads about targets it does not support yet. Nothing here asks for one.
cv::utils::logging::setLogLevel(cv::utils::logging::LOG_LEVEL_ERROR);
const QDir dir(modelDir);
const QString detector = dir.filePath(QLatin1String(DetectorFile));
const QString recognizer = dir.filePath(QLatin1String(RecognizerFile));
for (const QString &f : {detector, recognizer}) {
if (!QFileInfo::exists(f)) {
*error = QStringLiteral("model missing: %1").arg(f);
return false;
}
}
try {
m_inputSize = cv::Size(640, 480);
m_detector = cv::FaceDetectorYN::create(QFile::encodeName(detector).toStdString(), "", m_inputSize, 0.75f, 0.3f, 20);
m_recognizer = cv::FaceRecognizerSF::create(QFile::encodeName(recognizer).toStdString(), "");
} catch (const cv::Exception &e) {
*error = QString::fromStdString(e.what());
m_detector.reset();
m_recognizer.reset();
return false;
}
return true;
}
std::vector<Face> Vision::detect(const cv::Mat &bgr)
{
std::vector<Face> result;
if (!m_detector || bgr.empty()) {
return result;
}
if (bgr.size() != m_inputSize) {
m_inputSize = bgr.size();
m_detector->setInputSize(m_inputSize);
}
cv::Mat rows;
try {
m_detector->detect(bgr, rows);
} catch (const cv::Exception &) {
return result;
}
for (int i = 0; i < rows.rows; ++i) {
const float *r = rows.ptr<float>(i);
Face f;
f.box = cv::Rect2f(r[0], r[1], r[2], r[3]);
for (int k = 0; k < 5; ++k) {
f.points[k] = cv::Point2f(r[4 + 2 * k], r[5 + 2 * k]);
}
f.score = r[14];
f.row = rows.row(i).clone();
result.push_back(std::move(f));
}
std::sort(result.begin(), result.end(), [](const Face &a, const Face &b) {
return a.box.area() > b.box.area();
});
return result;
}
Embedding Vision::embed(const cv::Mat &bgr, const Face &face)
{
Embedding out;
if (!m_recognizer) {
return out;
}
try {
cv::Mat aligned, feature;
m_recognizer->alignCrop(bgr, face.row, aligned);
m_recognizer->feature(aligned, feature);
feature = feature.reshape(1, 1);
if (feature.cols != EmbeddingSize) {
return out;
}
const double norm = cv::norm(feature);
if (norm <= 0) {
return out;
}
out.resize(EmbeddingSize);
for (int i = 0; i < EmbeddingSize; ++i) {
out[i] = float(feature.at<float>(0, i) / norm);
}
} catch (const cv::Exception &) {
out.clear();
}
return out;
}
float Vision::similarity(const Embedding &a, const Embedding &b)
{
if (a.size() != b.size() || a.empty()) {
return -1.f;
}
return std::inner_product(a.begin(), a.end(), b.begin(), 0.f);
}
+108
View File
@@ -0,0 +1,108 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Finding a face and turning it into numbers.
//
// Two small networks from the OpenCV model zoo do the work. YuNet finds faces
// and five points on each (the eyes, the tip of the nose, the corners of the
// mouth). SFace turns an aligned crop of one face into 128 numbers, and two
// crops of the same person give numbers that point the same way. Both run on
// the CPU through OpenCV's own DNN module, in a few milliseconds each.
//
// Everything else here is plain geometry on those five points.
#pragma once
#include <QString>
#include <opencv2/core.hpp>
#include <opencv2/objdetect/face.hpp>
#include <array>
#include <vector>
// The five points, in the order YuNet reports them. "Right" and "left" are
// the person's own, so on an unmirrored picture the right eye is on the left.
enum Landmark {
RightEye = 0,
LeftEye = 1,
NoseTip = 2,
RightMouth = 3,
LeftMouth = 4,
};
struct Face {
cv::Rect2f box;
std::array<cv::Point2f, 5> points;
float score = 0;
// YuNet's own row, which the recognizer wants back for its alignment.
cv::Mat row;
float interocular() const;
cv::Point2f eyeMid() const;
cv::Point2f mouthMid() const;
};
// Where the head points, read off the five points alone.
//
// yaw is the nose's sideways offset from the line through the middle of the
// eyes and the middle of the mouth, in interocular distances. A nose sits
// about half an interocular distance in front of the face, so this is close to
// 0.5 * sin(head yaw). Positive when the head turns to the person's left.
//
// noseT is how far down the nose tip sits between the eye line (0) and the
// mouth line (1). It changes with pitch, but where it sits for a level head is
// different for every face, so it only means something next to the same
// person's own resting value.
struct HeadPose {
float roll = 0;
float yaw = 0;
float noseT = 0;
};
HeadPose estimatePose(const Face &face);
// Degrees, for people. The estimate is rough by nature.
float yawDegrees(float yaw);
struct FaceQuality {
float brightness = 0;
float sharpness = 0;
bool tooSmall = false;
bool tooDark = false;
bool tooBright = false;
bool blurry = false;
bool ok() const
{
return !tooSmall && !tooDark && !tooBright && !blurry;
}
};
FaceQuality assessQuality(const cv::Mat &bgr, const Face &face);
using Embedding = std::vector<float>;
class Vision
{
public:
bool load(const QString &modelDir, QString *error);
bool isLoaded() const
{
return m_detector && m_recognizer;
}
// Faces sorted by size, largest first.
std::vector<Face> detect(const cv::Mat &bgr);
// Unit length, so the similarity of two is their dot product.
Embedding embed(const cv::Mat &bgr, const Face &face);
static float similarity(const Embedding &a, const Embedding &b);
static constexpr int EmbeddingSize = 128;
private:
cv::Ptr<cv::FaceDetectorYN> m_detector;
cv::Ptr<cv::FaceRecognizerSF> m_recognizer;
cv::Size m_inputSize;
};
+160
View File
@@ -0,0 +1,160 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// plasma-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:
//
// event=frame score=0.71 yaw=3.2 ...
// event=result ok=true name=Felix score=0.74
//
// Lists (faces, cameras) come one line per entry, with event=item. Nothing
// in here is meant for people to read; the menu turns it into sentences.
//
// Exit status: 0 when the final result was ok, 1 when it was not, 2 when the
// daemon could not be reached.
#include "buildconfig.h"
#include <QCoreApplication>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLocalSocket>
#include <QStringList>
#include <cstdio>
namespace
{
QString flat(const QJsonValue &v)
{
QString s;
switch (v.type()) {
case QJsonValue::Bool:
s = v.toBool() ? QStringLiteral("true") : QStringLiteral("false");
break;
case QJsonValue::Double: {
const double d = v.toDouble();
s = (d == double(qint64(d)) && std::abs(d) < 1e15) ? QString::number(qint64(d)) : QString::number(d, 'f', 3);
break;
}
case QJsonValue::String:
s = v.toString();
break;
default:
s = QString::fromUtf8(QJsonDocument(v.toObject()).toJson(QJsonDocument::Compact));
break;
}
// Nothing a line or a field could be split on.
s.replace(QLatin1Char('\t'), QLatin1Char(' '));
s.replace(QLatin1Char('\n'), QLatin1Char(' '));
return s;
}
void printObject(const QJsonObject &o, const QString &event)
{
QStringList fields{QStringLiteral("event=") + event};
for (auto it = o.constBegin(); it != o.constEnd(); ++it) {
if (it.key() == u"event" || it.value().isArray() || it.key() == u"jpeg") {
continue;
}
fields << it.key() + QLatin1Char('=') + flat(it.value());
}
std::printf("%s\n", fields.join(QLatin1Char('\t')).toUtf8().constData());
std::fflush(stdout);
}
int usage()
{
std::fprintf(stderr,
"usage: plasma-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");
return 2;
}
} // namespace
int main(int argc, char **argv)
{
QCoreApplication app(argc, argv);
QStringList args = app.arguments().mid(1);
QString socketPath = QStringLiteral(PFU_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()) {
socketPath = QString::fromLocal8Bit(env);
}
if (args.isEmpty()) {
return usage();
}
const QString cmd = args.takeFirst();
QJsonObject request{{QStringLiteral("cmd"), cmd}};
if (cmd == u"verify") {
if (!args.isEmpty()) {
request.insert(QStringLiteral("user"), args.takeFirst());
}
request.insert(QStringLiteral("purpose"), args.isEmpty() ? QStringLiteral("other") : args.takeFirst());
} else if (cmd == u"test") {
request = {{QStringLiteral("cmd"), QStringLiteral("verify")}, {QStringLiteral("purpose"), QStringLiteral("test")}, {QStringLiteral("verbose"), true}};
} else if (cmd == u"remove" || cmd == u"enable" || cmd == u"disable") {
if (args.isEmpty()) {
return usage();
}
request.insert(QStringLiteral("id"), args.takeFirst());
} else if (cmd == u"rename") {
if (args.size() < 2) {
return usage();
}
request.insert(QStringLiteral("id"), args.takeFirst());
request.insert(QStringLiteral("name"), args.join(QLatin1Char(' ')));
} else if (!QStringList{QStringLiteral("hello"), QStringLiteral("status"), QStringLiteral("cameras"), QStringLiteral("list"), QStringLiteral("watch"),
QStringLiteral("unlocked"), QStringLiteral("clear")}
.contains(cmd)) {
return usage();
}
QLocalSocket socket;
socket.connectToServer(socketPath);
if (!socket.waitForConnected(5000)) {
std::printf("event=result\tok=false\treason=unreachable\tmessage=%s\n", qPrintable(socket.errorString()));
return 2;
}
socket.write(QJsonDocument(request).toJson(QJsonDocument::Compact) + '\n');
socket.flush();
QByteArray buffer;
// Changing faces can wait on somebody typing their password into the
// polkit dialog, and "watch" waits forever.
while (socket.state() == QLocalSocket::ConnectedState || socket.bytesAvailable() > 0) {
if (socket.bytesAvailable() == 0 && !socket.waitForReadyRead(-1)) {
break;
}
buffer += socket.readAll();
qsizetype nl;
while ((nl = buffer.indexOf('\n')) >= 0) {
const QJsonObject o = QJsonDocument::fromJson(buffer.left(nl)).object();
buffer.remove(0, nl + 1);
const QString event = o.value(u"event").toString();
if (event == u"result") {
for (const QJsonValue &f : o.value(u"faces").toArray()) {
printObject(f.toObject(), QStringLiteral("item"));
}
for (const QJsonValue &c : o.value(u"cameras").toArray()) {
printObject(c.toObject(), QStringLiteral("item"));
}
printObject(o, event);
return o.value(u"ok").toBool() ? 0 : 1;
}
printObject(o, event);
}
}
std::printf("event=result\tok=false\treason=disconnected\n");
return 1;
}
+82
View File
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "agentlink.h"
#include <QFile>
#include <QJsonDocument>
#include <QTimer>
#include <sys/socket.h>
#include <sys/stat.h>
namespace
{
bool ownedBy(const QString &path, uid_t uid, bool socket)
{
struct stat st;
if (::lstat(QFile::encodeName(path).constData(), &st) != 0 || st.st_uid != uid) {
return false;
}
return socket ? S_ISSOCK(st.st_mode) : S_ISDIR(st.st_mode);
}
} // namespace
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 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.
QTimer::singleShot(0, this, &QObject::deleteLater);
return;
}
connect(&m_socket, &QLocalSocket::connected, this, [this] {
ucred cred{};
socklen_t len = sizeof(cred);
if (::getsockopt(int(m_socket.socketDescriptor()), SOL_SOCKET, SO_PEERCRED, &cred, &len) != 0 || cred.uid != m_uid) {
m_socket.abort();
deleteLater();
return;
}
m_trusted = true;
flush();
});
connect(&m_socket, &QLocalSocket::errorOccurred, this, [this] {
deleteLater();
});
m_socket.connectToServer(path);
// However the scan ends, this does not outlive it by much.
QTimer::singleShot(60 * 1000, this, &QObject::deleteLater);
}
void AgentLink::send(const QJsonObject &event)
{
m_queue.append(QJsonDocument(event).toJson(QJsonDocument::Compact) + '\n');
flush();
}
void AgentLink::finish()
{
m_finishing = true;
flush();
}
void AgentLink::flush()
{
if (!m_trusted) {
return;
}
for (const QByteArray &line : std::as_const(m_queue)) {
m_socket.write(line);
}
m_queue.clear();
m_socket.flush();
if (m_finishing) {
m_socket.disconnectFromServer();
deleteLater();
}
}
+39
View File
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Telling a user's session about a scan it did not start itself (sudo, an
// admin prompt, a test from the menu), so the bubble can show it.
//
// The agent listens on /run/user/UID/plasma-face-unlock/agent.socket. That is
// a place the user controls, so nothing is taken for granted: the socket has
// to belong to the user, and so does the process that answers on it, checked
// by the kernel before a single byte is written. What is written is only
// where a scan is ("started", "success", ...), never anything about the face.
#pragma once
#include <QJsonObject>
#include <QList>
#include <QLocalSocket>
#include <QObject>
#include <sys/types.h>
class AgentLink : public QObject
{
Q_OBJECT
public:
AgentLink(uid_t uid, QObject *parent);
void send(const QJsonObject &event);
// Sends what is still queued, then goes away.
void finish();
private:
void flush();
uid_t m_uid;
QLocalSocket m_socket;
QList<QByteArray> m_queue;
bool m_trusted = false;
bool m_finishing = false;
};
+82
View File
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "client.h"
#include <QJsonDocument>
#include <sys/socket.h>
namespace
{
// A request is a line of JSON a few hundred bytes long. Anything that keeps
// talking without a newline for this long is not a client.
constexpr qsizetype MaxLine = 64 * 1024;
} // namespace
Client::Client(QLocalSocket *socket, QObject *parent)
: QObject(parent)
, m_socket(socket)
{
m_socket->setParent(this);
ucred cred{};
socklen_t len = sizeof(cred);
if (::getsockopt(int(m_socket->socketDescriptor()), SOL_SOCKET, SO_PEERCRED, &cred, &len) == 0) {
m_uid = cred.uid;
m_pid = cred.pid;
m_known = true;
}
connect(m_socket, &QLocalSocket::readyRead, this, &Client::readLines);
connect(m_socket, &QLocalSocket::disconnected, this, [this] {
if (!m_gone) {
m_gone = true;
Q_EMIT disconnected(this);
}
});
}
Client::~Client() = default;
void Client::readLines()
{
m_buffer += m_socket->readAll();
if (m_buffer.size() > MaxLine && !m_buffer.contains('\n')) {
close();
return;
}
qsizetype nl;
while ((nl = m_buffer.indexOf('\n')) >= 0) {
const QByteArray line = m_buffer.left(nl).trimmed();
m_buffer.remove(0, nl + 1);
if (line.isEmpty()) {
continue;
}
const QJsonDocument doc = QJsonDocument::fromJson(line);
if (!doc.isObject()) {
send({{QStringLiteral("event"), QStringLiteral("result")},
{QStringLiteral("ok"), false},
{QStringLiteral("reason"), QStringLiteral("bad-request")}});
continue;
}
Q_EMIT request(this, doc.object());
}
}
void Client::send(const QJsonObject &message)
{
if (m_gone || m_socket->state() != QLocalSocket::ConnectedState) {
return;
}
m_socket->write(QJsonDocument(message).toJson(QJsonDocument::Compact) + '\n');
m_socket->flush();
}
void Client::close()
{
if (m_socket->state() == QLocalSocket::ConnectedState) {
m_socket->flush();
m_socket->disconnectFromServer();
}
}
+57
View File
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// One connection to the daemon.
//
// The kernel says who is on the other end (SO_PEERCRED), and that is the only
// thing any decision here is based on. Nothing a client writes about itself is
// taken on trust.
#pragma once
#include <QJsonObject>
#include <QLocalSocket>
#include <QObject>
#include <sys/types.h>
class Client : public QObject
{
Q_OBJECT
public:
Client(QLocalSocket *socket, QObject *parent);
~Client() override;
uid_t uid() const
{
return m_uid;
}
pid_t pid() const
{
return m_pid;
}
bool credentialsKnown() const
{
return m_known;
}
void send(const QJsonObject &message);
void close();
// What this connection is for, once it has said so. A connection makes
// one request; "watch", "verify" and "enroll" keep it open.
QString role;
Q_SIGNALS:
void request(Client *client, const QJsonObject &message);
void disconnected(Client *client);
private:
void readLines();
QLocalSocket *m_socket;
QByteArray m_buffer;
uid_t m_uid = uid_t(-1);
pid_t m_pid = 0;
bool m_known = false;
bool m_gone = false;
};
+270
View File
@@ -0,0 +1,270 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "enrolljob.h"
#include "camera.h"
#include "liveness.h"
#include "vision.h"
#include <QDateTime>
#include <QElapsedTimer>
#include <opencv2/imgcodecs.hpp>
#include <opencv2/imgproc.hpp>
#include <array>
#include <cmath>
namespace
{
// A head turned about 15 degrees moves the nose this far (in eye
// distances), sideways for a turn and up or down for a nod. The ring is drawn
// in these units, so 1.0 is a comfortable turn.
constexpr float TurnUnit = 0.13f;
// Far enough out to count for a direction.
constexpr float CaptureAt = 0.8f;
// Straight enough to count as looking straight ahead.
constexpr float StraightYaw = 0.06f;
constexpr qint64 SampleSpacingMs = 150;
constexpr qint64 CentreSpacingMs = 250;
constexpr qint64 PreviewEveryMs = 66;
constexpr qint64 GiveUpAfterMs = 120 * 1000;
constexpr int PreviewWidth = 480;
float median(QList<float> v)
{
if (v.isEmpty()) {
return 0;
}
std::sort(v.begin(), v.end());
return v.at(v.size() / 2);
}
} // namespace
EnrollJob::EnrollJob(Vision *vision, uid_t uid, const Settings &settings, const QString &name)
: Job(vision, uid)
, m_settings(settings)
, m_name(name)
{
}
QString EnrollJob::sectorName(int sector)
{
static const char *names[] = {"up", "up-right", "right", "down-right", "down", "down-left", "left", "up-left"};
return QString::fromLatin1(names[((sector % 8) + 8) % 8]);
}
void EnrollJob::sendPreview(const cv::Mat &frame, const Face *face)
{
cv::Mat mirrored, small;
cv::flip(frame, mirrored, 1);
const double scale = double(PreviewWidth) / mirrored.cols;
cv::resize(mirrored, small, cv::Size(), scale, scale, cv::INTER_AREA);
std::vector<uchar> jpeg;
cv::imencode(".jpg", small, jpeg, {cv::IMWRITE_JPEG_QUALITY, 72});
QJsonObject msg{
{QStringLiteral("event"), QStringLiteral("frame")},
{QStringLiteral("w"), small.cols},
{QStringLiteral("h"), small.rows},
{QStringLiteral("jpeg"), QString::fromLatin1(QByteArray(reinterpret_cast<const char *>(jpeg.data()), qsizetype(jpeg.size())).toBase64())},
};
if (face) {
// In the mirrored picture, as a share of its size.
const float w = float(frame.cols);
const float h = float(frame.rows);
msg.insert(QStringLiteral("face"),
QJsonObject{
{QStringLiteral("x"), double((w - face->box.x - face->box.width) / w)},
{QStringLiteral("y"), double(face->box.y / h)},
{QStringLiteral("w"), double(face->box.width / w)},
{QStringLiteral("h"), double(face->box.height / h)},
});
}
Q_EMIT event(msg);
}
void EnrollJob::run()
{
Camera camera;
QString error;
if (!camera.open(m_settings.camera, &error)) {
result = {{QStringLiteral("event"), QStringLiteral("result")},
{QStringLiteral("ok"), false},
{QStringLiteral("reason"), QStringLiteral("camera")},
{QStringLiteral("message"), error}};
return;
}
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("started")}, {QStringLiteral("camera"), camera.description()}});
QElapsedTimer clock;
clock.start();
QList<float> centreNoseT;
QList<float> centreEyes;
QList<FaceSample> samples;
int centreCount = 0;
qint64 lastCentreSample = -CentreSpacingMs;
bool centreDone = false;
float restingNoseT = 0.55f;
std::array<int, 8> counts{};
std::array<qint64, 8> lastAt;
lastAt.fill(-SampleSpacingMs);
QString lastHint;
const auto hint = [&](const QString &what) {
if (what != lastHint) {
lastHint = what;
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("hint")}, {QStringLiteral("hint"), what}});
}
};
const auto sectorsDone = [&] {
return int(std::count(counts.begin(), counts.end(), SamplesPerSector));
};
cv::Mat frame;
int readFailures = 0;
while (!m_cancel && clock.elapsed() < GiveUpAfterMs) {
double t = 0;
if (!camera.read(frame, &t)) {
if (++readFailures > 10) {
result = {{QStringLiteral("event"), QStringLiteral("result")},
{QStringLiteral("ok"), false},
{QStringLiteral("reason"), QStringLiteral("camera")},
{QStringLiteral("message"), QStringLiteral("the camera stopped delivering frames")}};
return;
}
continue;
}
readFailures = 0;
const qint64 now = clock.elapsed();
const std::vector<Face> faces = m_vision->detect(frame);
const Face *face = faces.empty() ? nullptr : &faces.front();
if (now - m_lastPreview >= PreviewEveryMs) {
m_lastPreview = now;
sendPreview(frame, face);
}
if (!face) {
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("pose")}, {QStringLiteral("face"), false}});
hint(QStringLiteral("no-face"));
continue;
}
// Somebody else in the picture, close enough to be taken for the
// person setting up.
if (faces.size() > 1 && faces[1].box.area() > 0.5f * face->box.area()) {
hint(QStringLiteral("one-face"));
continue;
}
const FaceQuality quality = assessQuality(frame, *face);
if (!quality.ok()) {
hint(quality.tooSmall ? QStringLiteral("closer")
: quality.tooDark ? QStringLiteral("light")
: quality.tooBright ? QStringLiteral("bright")
: QStringLiteral("still"));
continue;
}
const HeadPose pose = estimatePose(*face);
if (!centreDone) {
const bool straight = std::abs(pose.yaw) <= StraightYaw;
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("pose")},
{QStringLiteral("face"), true},
{QStringLiteral("phase"), QStringLiteral("center")},
{QStringLiteral("x"), double(-pose.yaw / TurnUnit)},
{QStringLiteral("y"), 0.0}});
if (!straight) {
hint(QStringLiteral("straight"));
continue;
}
hint(QStringLiteral("hold"));
centreNoseT.append(pose.noseT);
const EyeSample eyes = measureEyes(frame, *face);
if (eyes.valid) {
centreEyes.append(eyes.openness);
}
if (centreCount < CentreSamples && now - lastCentreSample >= CentreSpacingMs) {
const Embedding e = m_vision->embed(frame, *face);
if (!e.empty()) {
samples.append({e, QStringLiteral("center"), QDateTime::currentSecsSinceEpoch()});
++centreCount;
lastCentreSample = now;
}
}
if (centreCount >= CentreSamples && centreNoseT.size() >= 6) {
restingNoseT = median(centreNoseT);
centreDone = true;
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("captured")}, {QStringLiteral("pose"), QStringLiteral("center")}});
hint(QStringLiteral("circle"));
}
continue;
}
// Screen directions in the mirrored preview: turning to one's own left
// moves the face to the left of the screen, looking up moves it up.
const float x = -pose.yaw / TurnUnit;
const float y = -(pose.noseT - restingNoseT) / TurnUnit;
const float reach = std::hypot(x, y);
double angle = std::atan2(x, y) * 180.0 / M_PI;
if (angle < 0) {
angle += 360;
}
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("pose")},
{QStringLiteral("face"), true},
{QStringLiteral("phase"), QStringLiteral("circle")},
{QStringLiteral("x"), double(x)},
{QStringLiteral("y"), double(y)},
{QStringLiteral("angle"), angle},
{QStringLiteral("reach"), double(reach)}});
if (reach >= CaptureAt) {
const int sector = int(std::lround(angle / 45.0)) % 8;
if (counts[sector] < SamplesPerSector && now - lastAt[sector] >= SampleSpacingMs) {
const Embedding e = m_vision->embed(frame, *face);
if (!e.empty()) {
samples.append({e, sectorName(sector), QDateTime::currentSecsSinceEpoch()});
lastAt[sector] = now;
if (++counts[sector] == SamplesPerSector) {
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("captured")},
{QStringLiteral("pose"), sectorName(sector)},
{QStringLiteral("sector"), sector},
{QStringLiteral("done"), sectorsDone()}});
}
}
}
}
if (sectorsDone() == 8 || (m_finishEarly && sectorsDone() >= SectorsForEarlyFinish)) {
break;
}
}
if (m_cancel) {
result = {{QStringLiteral("event"), QStringLiteral("result")}, {QStringLiteral("ok"), false}, {QStringLiteral("reason"), QStringLiteral("cancelled")}};
return;
}
if (!centreDone || sectorsDone() < SectorsForEarlyFinish) {
result = {{QStringLiteral("event"), QStringLiteral("result")}, {QStringLiteral("ok"), false}, {QStringLiteral("reason"), QStringLiteral("timeout")}};
return;
}
identity.id = FaceStore::newId();
identity.name = m_name;
identity.created = QDateTime::currentSecsSinceEpoch();
identity.noseT = restingNoseT;
identity.eyes = median(centreEyes);
identity.samples = samples;
result = {{QStringLiteral("event"), QStringLiteral("result")},
{QStringLiteral("ok"), true},
{QStringLiteral("reason"), QStringLiteral("ok")},
{QStringLiteral("id"), identity.id},
{QStringLiteral("name"), identity.name},
{QStringLiteral("samples"), int(samples.size())}};
}
+53
View File
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Setting up a face.
//
// Like the phone: look straight at the camera first, then move the head
// around in a circle while the ring fills up. The centre gives the samples
// most unlocks will match against and the level-head measurements the
// attention check needs; the eight directions around it are what still
// matches when somebody glances at the screen from the side.
#pragma once
#include "job.h"
#include "settings.h"
#include "store.h"
class EnrollJob : public Job
{
Q_OBJECT
public:
EnrollJob(Vision *vision, uid_t uid, const Settings &settings, const QString &name);
void run() override;
QString kind() const override
{
return QStringLiteral("enroll");
}
// Stop as soon as enough of the circle is done.
void finishEarly()
{
m_finishEarly = true;
}
// Filled when the setup completed.
Identity identity;
// The eight directions, clockwise from straight up, as seen in the
// mirrored preview.
static QString sectorName(int sector);
static constexpr int SamplesPerSector = 2;
static constexpr int CentreSamples = 3;
static constexpr int SectorsForEarlyFinish = 4;
private:
void sendPreview(const cv::Mat &frame, const struct Face *face);
Settings m_settings;
QString m_name;
std::atomic_bool m_finishEarly = false;
qint64 m_lastPreview = -1000;
};
+53
View File
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Work that holds the camera: a scan or a setup. Only one runs at a time,
// on a thread of its own so the socket stays responsive (a cancel has to get
// through while a frame is being processed).
#pragma once
#include <QJsonObject>
#include <QObject>
#include <atomic>
#include <sys/types.h>
class Vision;
class Job : public QObject
{
Q_OBJECT
public:
Job(Vision *vision, uid_t uid)
: m_vision(vision)
, m_uid(uid)
{
}
virtual void run() = 0;
virtual QString kind() const = 0;
void cancel()
{
m_cancel = true;
}
bool cancelled() const
{
return m_cancel;
}
uid_t uid() const
{
return m_uid;
}
QJsonObject result;
Q_SIGNALS:
// Progress for whoever asked, and for the bubble.
void event(const QJsonObject &event);
protected:
Vision *m_vision;
uid_t m_uid;
std::atomic_bool m_cancel = false;
};
+111
View File
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// plasma-face-unlockd: the part that runs as root.
//
// It owns the camera and the face data, and it is the only thing that does.
// Everything else (the lock screen agent, sudo, the setup window, the menu)
// asks it over one socket. systemd starts it on the first connection and it
// exits again after a minute with nothing to do, so most of the time it is not
// running at all.
#include "server.h"
#include "buildconfig.h"
#include <QCommandLineParser>
#include <QCoreApplication>
#include <QSocketNotifier>
#include <csignal>
#include <sys/signalfd.h>
#include <sys/stat.h>
#include <unistd.h>
namespace
{
// sd_listen_fds(), without linking libsystemd for one function.
int systemdSocket()
{
const QByteArray pid = qgetenv("LISTEN_PID");
const QByteArray fds = qgetenv("LISTEN_FDS");
if (pid.isEmpty() || fds.isEmpty() || pid.toLongLong() != getpid() || fds.toInt() < 1) {
return -1;
}
qunsetenv("LISTEN_PID");
qunsetenv("LISTEN_FDS");
qunsetenv("LISTEN_FDNAMES");
return 3;
}
// SIGTERM from systemd is the normal way this ends. The camera thread is
// cancelled and joined on the way out rather than being cut off mid-frame.
void quitOnSignals(QCoreApplication &app)
{
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGINT);
sigprocmask(SIG_BLOCK, &mask, nullptr);
const int fd = signalfd(-1, &mask, SFD_CLOEXEC | SFD_NONBLOCK);
if (fd < 0) {
return;
}
auto *notifier = new QSocketNotifier(fd, QSocketNotifier::Read, &app);
QObject::connect(notifier, &QSocketNotifier::activated, &app, [fd] {
signalfd_siginfo info;
while (read(fd, &info, sizeof(info)) > 0) {
}
QCoreApplication::quit();
});
}
} // namespace
int main(int argc, char **argv)
{
// Face data and state are for root's eyes only, whatever creates them.
umask(077);
QCoreApplication app(argc, argv);
app.setApplicationName(QStringLiteral("plasma-face-unlockd"));
app.setApplicationVersion(QStringLiteral(PFU_VERSION));
qSetMessagePattern(QStringLiteral("%{if-warning}warning: %{endif}%{if-critical}error: %{endif}%{message}"));
QCommandLineParser parser;
parser.setApplicationDescription(QStringLiteral("Face unlock daemon for KDE Plasma"));
parser.addHelpOption();
parser.addVersionOption();
const QCommandLineOption socketOpt(QStringLiteral("socket"), QStringLiteral("Listen here instead of the system socket."), QStringLiteral("path"),
QStringLiteral(PFU_SOCKET));
const QCommandLineOption stateOpt(QStringLiteral("state-dir"), QStringLiteral("Where face data is kept."), QStringLiteral("dir"),
QStringLiteral(PFU_STATEDIR));
const QCommandLineOption configOpt(QStringLiteral("config"), QStringLiteral("The settings file."), QStringLiteral("file"), QStringLiteral(PFU_CONFIG));
const QCommandLineOption modelOpt(QStringLiteral("models"), QStringLiteral("Where the networks are."), QStringLiteral("dir"),
QStringLiteral(PFU_MODELDIR));
const QCommandLineOption idleOpt(QStringLiteral("idle-exit"), QStringLiteral("Exit after this many idle seconds (0: never)."), QStringLiteral("seconds"));
parser.addOptions({socketOpt, stateOpt, configOpt, modelOpt, idleOpt});
parser.process(app);
ServerOptions options;
options.socketPath = parser.value(socketOpt);
options.stateDir = parser.value(stateOpt);
options.configPath = parser.value(configOpt);
options.modelDir = parser.value(modelOpt);
options.systemdFd = systemdSocket();
// Started by the socket: go away again when there is nothing to do.
// Started by hand (development): stay.
options.idleSeconds = parser.isSet(idleOpt) ? parser.value(idleOpt).toInt() : (options.systemdFd >= 0 ? 60 : 0);
options.askPolkit = geteuid() == 0;
if (!options.askPolkit) {
qInfo("not running as root: face changes are not checked with polkit (development only)");
}
quitOnSignals(app);
Server server(options);
QString error;
if (!server.start(&error)) {
qCritical("cannot listen: %s", qPrintable(error));
return 1;
}
return app.exec();
}
+115
View File
@@ -0,0 +1,115 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "polkit.h"
#include "system.h"
#include <QDBusArgument>
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDBusMetaType>
#include <QDBusPendingCallWatcher>
#include <QDBusPendingReply>
#include <QLoggingCategory>
namespace
{
// (sa{sv}): the subject, "unix-process" with its pid, start time and uid.
struct Subject {
QString kind;
QVariantMap details;
};
// (bba{ss}): authorized, challenge, details.
struct Result {
bool authorized = false;
bool challenge = false;
QMap<QString, QString> details;
};
} // namespace
Q_DECLARE_METATYPE(Subject)
Q_DECLARE_METATYPE(Result)
namespace
{
QDBusArgument &operator<<(QDBusArgument &arg, const Subject &s)
{
arg.beginStructure();
arg << s.kind << s.details;
arg.endStructure();
return arg;
}
const QDBusArgument &operator>>(const QDBusArgument &arg, Subject &s)
{
arg.beginStructure();
arg >> s.kind >> s.details;
arg.endStructure();
return arg;
}
QDBusArgument &operator<<(QDBusArgument &arg, const Result &r)
{
arg.beginStructure();
arg << r.authorized << r.challenge << r.details;
arg.endStructure();
return arg;
}
const QDBusArgument &operator>>(const QDBusArgument &arg, Result &r)
{
arg.beginStructure();
arg >> r.authorized >> r.challenge >> r.details;
arg.endStructure();
return arg;
}
void registerTypes()
{
static bool done = false;
if (!done) {
qDBusRegisterMetaType<Subject>();
qDBusRegisterMetaType<Result>();
qDBusRegisterMetaType<QMap<QString, QString>>();
done = true;
}
}
constexpr quint32 AllowUserInteraction = 1;
// Long enough to find the dialog and type a password.
constexpr int TimeoutMs = 5 * 60 * 1000;
} // namespace
namespace Polkit
{
void checkAuthorization(pid_t pid, uid_t uid, const char *action, QObject *context, std::function<void(bool)> done)
{
registerTypes();
Subject subject;
subject.kind = QStringLiteral("unix-process");
subject.details.insert(QStringLiteral("pid"), QVariant::fromValue(quint32(pid)));
subject.details.insert(QStringLiteral("start-time"), QVariant::fromValue(quint64(System::processStartTime(pid))));
subject.details.insert(QStringLiteral("uid"), QVariant::fromValue(qint32(uid)));
QDBusMessage msg = QDBusMessage::createMethodCall(QStringLiteral("org.freedesktop.PolicyKit1"),
QStringLiteral("/org/freedesktop/PolicyKit1/Authority"),
QStringLiteral("org.freedesktop.PolicyKit1.Authority"),
QStringLiteral("CheckAuthorization"));
msg << QVariant::fromValue(subject) << QString::fromLatin1(action) << QVariant::fromValue(QMap<QString, QString>())
<< AllowUserInteraction << QString();
const QDBusPendingCall call = QDBusConnection::systemBus().asyncCall(msg, TimeoutMs);
auto *watcher = new QDBusPendingCallWatcher(call, context);
QObject::connect(watcher, &QDBusPendingCallWatcher::finished, context, [watcher, done] {
watcher->deleteLater();
const QDBusPendingReply<Result> reply = *watcher;
if (reply.isError()) {
qWarning("polkit: %s", qPrintable(reply.error().message()));
done(false);
return;
}
done(reply.value().authorized);
});
}
} // namespace Polkit
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Asking polkit whether a process may change face data.
//
// Adding a face is adding a way in, so it asks for the password first, the
// same way a phone asks for its code before it sets up a face. The question
// goes to the polkit agent of the person's own session (on Plasma, the
// familiar password dialog), which is why the daemon never sees the
// password.
#pragma once
#include <QObject>
#include <functional>
#include <sys/types.h>
namespace Polkit
{
inline constexpr char ManageAction[] = "io.github.loonixtools.plasma-face-unlock.manage";
// Calls done(true) when the process may go ahead. Interactive: this can take
// as long as it takes somebody to type a password.
void checkAuthorization(pid_t pid, uid_t uid, const char *action, QObject *context, std::function<void(bool)> done);
} // namespace Polkit
+316
View File
@@ -0,0 +1,316 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "scanjob.h"
#include "camera.h"
#include "liveness.h"
#include "vision.h"
#include <QElapsedTimer>
#include <QJsonArray>
#include <cmath>
namespace
{
// Two frames have to match. One could be a fluke of the light.
constexpr int MatchesNeeded = 2;
// Once somebody has matched, every frame spent on the recognizer is a frame
// the blink check does not see, and a blink is only a few frames long. So
// after a match the recognizer only runs on every fourth frame, which is
// still often enough to notice a different face.
constexpr int RecheckEvery = 4;
// A face that jumps further than this between two frames is treated as a
// different face, and everything learned about the previous one is dropped.
constexpr float JumpLimit = 0.6f;
// How long the deny cues watch before "light" lets anybody in.
constexpr int LightFramesNeeded = 5;
// Heavy: after a match, how long to wait for a sign of life before asking
// for one, and how much longer to wait once asked.
constexpr qint64 AskForLifeAfterMs = 1200;
constexpr qint64 ExtraTimeMs = 2500;
// Attention: a head turned further than this is not looking at the screen.
constexpr float MaxYawDegrees = 25;
constexpr float MaxPitchT = 0.16f;
double median(QList<float> v)
{
if (v.isEmpty()) {
return 0;
}
std::sort(v.begin(), v.end());
return v.at(v.size() / 2);
}
} // namespace
ScanJob::ScanJob(Vision *vision, uid_t uid, const Settings &settings, const QList<Identity> &faces, const QString &purpose, bool verbose)
: Job(vision, uid)
, m_settings(settings)
, m_faces(faces)
, m_purpose(purpose)
, m_verbose(verbose)
{
}
void ScanJob::finish(bool ok, const QString &reason, const QJsonObject &extra)
{
result = extra;
result.insert(QStringLiteral("event"), QStringLiteral("result"));
result.insert(QStringLiteral("ok"), ok);
result.insert(QStringLiteral("reason"), reason);
}
void ScanJob::hint(const QString &what)
{
if (m_hinted.contains(what)) {
return;
}
m_hinted.insert(what);
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("hint")}, {QStringLiteral("hint"), what}});
}
void ScanJob::run()
{
Camera camera;
QString error;
if (!camera.open(m_settings.camera, &error)) {
finish(false, QStringLiteral("camera"), {{QStringLiteral("message"), error}});
return;
}
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("started")}, {QStringLiteral("camera"), camera.description()}});
// What the attention check compares against: this person's own level-head
// nose position and open-eye measurement, from the setup.
QList<float> noseTs, eyes;
for (const Identity &id : std::as_const(m_faces)) {
if (id.enabled) {
noseTs.append(id.noseT);
if (id.eyes > 0) {
eyes.append(id.eyes);
}
}
}
const float restingNoseT = float(median(noseTs));
const float openEyes = float(median(eyes));
const double threshold = m_settings.threshold();
const LivenessMode mode = m_settings.liveness;
QElapsedTimer clock;
clock.start();
qint64 deadline = qint64(m_settings.scanSeconds) * 1000;
bool extended = false;
LivenessAnalyzer live;
int matched = 0;
int mismatched = 0;
int attentionMisses = 0;
int qualityMisses = 0;
int sinceCheck = 0;
bool lastCheckMatched = false;
bool sawFace = false;
int readFailures = 0;
qint64 firstMatchAt = -1;
cv::Point2f lastCentre(-1, -1);
qint64 lastFaceAt = -1;
float bestSeen = -1;
const auto forgetMatch = [&] {
matched = 0;
lastCheckMatched = false;
firstMatchAt = -1;
matchedIdentity = -1;
matchedScore = 0;
matchedEmbedding.clear();
};
cv::Mat frame;
while (!m_cancel) {
const qint64 elapsed = clock.elapsed();
if (elapsed > deadline) {
// Heavy has matched and is only waiting for a sign of life: give
// the person the time to blink that the hint just asked for.
if (mode == LivenessMode::Heavy && matched >= MatchesNeeded && !extended) {
deadline += ExtraTimeMs;
extended = true;
continue;
}
break;
}
double t = 0;
if (!camera.read(frame, &t)) {
if (++readFailures > 10) {
finish(false, QStringLiteral("camera"), {{QStringLiteral("message"), QStringLiteral("the camera stopped delivering frames")}});
return;
}
continue;
}
readFailures = 0;
const std::vector<Face> faces = m_vision->detect(frame);
if (faces.empty()) {
continue;
}
const Face &face = faces.front();
const float iod = face.interocular();
const cv::Point2f centre = (face.eyeMid() + face.mouthMid()) * 0.5f;
// A face that appeared somewhere else, or after a gap, may be a
// different one. Whatever was concluded about the last one goes.
const bool jumped = lastCentre.x >= 0 && float(cv::norm(centre - lastCentre)) > JumpLimit * iod;
const bool gap = lastFaceAt >= 0 && elapsed - lastFaceAt > 400;
if (jumped || gap) {
live.reset();
forgetMatch();
}
lastCentre = centre;
lastFaceAt = elapsed;
if (!sawFace) {
sawFace = true;
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("face")}});
}
const FaceQuality quality = assessQuality(frame, face);
const LivenessFrame lf = measureFrame(frame, face, t);
live.add(lf);
const LivenessReading reading = live.reading();
if (mode != LivenessMode::Off && reading.denied) {
finish(false, QStringLiteral("spoof"), {{QStringLiteral("cue"), reading.deniedBy}});
return;
}
if (!quality.ok()) {
++qualityMisses;
if (quality.tooSmall) {
hint(QStringLiteral("closer"));
} else if (quality.tooDark) {
hint(QStringLiteral("light"));
}
continue;
}
// Whether this face looks at the screen with open eyes. Only asked of
// a face that matches: a stranger is a stranger whichever way they
// look, and should be counted as one.
const auto attentive = [&] {
if (!m_settings.attention) {
return true;
}
const bool facing = std::abs(yawDegrees(lf.pose.yaw)) <= MaxYawDegrees
&& (noseTs.isEmpty() || std::abs(lf.pose.noseT - restingNoseT) <= MaxPitchT);
// Eyes that measure as closed, next to how open they measured at
// setup. Skipped for eyes the measure does not work on.
const bool eyesOpen = !lf.eyes.valid || openEyes < 0.15f || lf.eyes.openness >= 0.45f * openEyes;
if (!facing) {
hint(QStringLiteral("look"));
}
return facing && eyesOpen;
};
float score = -1;
bool checked = false;
if (matched < MatchesNeeded || ++sinceCheck >= RecheckEvery) {
sinceCheck = 0;
checked = true;
const Embedding e = m_vision->embed(frame, face);
const FaceMatch m = FaceStore::bestMatch(m_faces, e);
score = m.score;
bestSeen = std::max(bestSeen, m.score);
if (m.identity >= 0 && m.score >= threshold) {
if (!attentive()) {
++attentionMisses;
continue;
}
++matched;
lastCheckMatched = true;
if (firstMatchAt < 0) {
firstMatchAt = elapsed;
}
if (m.score > matchedScore) {
matchedScore = m.score;
matchedIdentity = m.identity;
matchedEmbedding = e;
}
} else {
++mismatched;
// The face that matched before does not match now. Whatever
// it did to look alive was done by somebody else's face.
if (lastCheckMatched) {
live.reset();
forgetMatch();
}
lastCheckMatched = false;
}
} else if (!attentive()) {
// Between checks: a matched face that looks away or closes its
// eyes (a blink does both for a moment) is not counted as looking.
++attentionMisses;
continue;
}
if (m_verbose) {
QJsonObject info{
{QStringLiteral("event"), QStringLiteral("frame")},
{QStringLiteral("t"), qint64(t)},
{QStringLiteral("yaw"), double(yawDegrees(lf.pose.yaw))},
{QStringLiteral("eyes"), double(lf.eyes.openness)},
{QStringLiteral("glare"), double(reading.glare)},
{QStringLiteral("device"), double(reading.device)},
{QStringLiteral("depth"), double(reading.depth)},
{QStringLiteral("blink"), double(reading.blink)},
{QStringLiteral("matched"), matched},
};
if (checked) {
info.insert(QStringLiteral("score"), double(score));
}
Q_EMIT event(info);
}
if (matched < MatchesNeeded || !lastCheckMatched) {
continue;
}
bool alive = true;
switch (mode) {
case LivenessMode::Off:
break;
case LivenessMode::Light:
alive = live.frameCount() >= LightFramesNeeded;
break;
case LivenessMode::Heavy:
alive = reading.confirmed;
if (!alive && elapsed - firstMatchAt > AskForLifeAfterMs) {
hint(QStringLiteral("blink"));
}
break;
}
if (alive) {
const Identity &id = m_faces.at(matchedIdentity);
finish(true, QStringLiteral("ok"),
{{QStringLiteral("name"), id.name},
{QStringLiteral("id"), id.id},
{QStringLiteral("score"), double(matchedScore)},
{QStringLiteral("liveness"), reading.confirmedBy},
{QStringLiteral("ms"), clock.elapsed()}});
return;
}
}
if (m_cancel) {
finish(false, QStringLiteral("cancelled"));
} else if (matched >= MatchesNeeded) {
finish(false, QStringLiteral("liveness"));
} else if (mismatched >= 3) {
finish(false, QStringLiteral("mismatch"), {{QStringLiteral("score"), double(bestSeen)}});
} else if (attentionMisses > 0) {
finish(false, QStringLiteral("attention"));
} else if (qualityMisses > 0) {
finish(false, QStringLiteral("quality"));
} else {
finish(false, QStringLiteral("no-face"));
}
}
+43
View File
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// One attempt at recognising somebody.
#pragma once
#include "job.h"
#include "settings.h"
#include "store.h"
class ScanJob : public Job
{
Q_OBJECT
public:
ScanJob(Vision *vision, uid_t uid, const Settings &settings, const QList<Identity> &faces, const QString &purpose, bool verbose);
void run() override;
QString kind() const override
{
return QStringLiteral("verify");
}
QString purpose() const
{
return m_purpose;
}
// Set on success: which face matched and what the camera saw, so the
// daemon can learn from it (see FaceStore::adapt).
int matchedIdentity = -1;
float matchedScore = 0;
Embedding matchedEmbedding;
private:
void finish(bool ok, const QString &reason, const QJsonObject &extra = {});
void hint(const QString &what);
Settings m_settings;
QList<Identity> m_faces;
QString m_purpose;
bool m_verbose;
QSet<QString> m_hinted;
};
+676
View File
@@ -0,0 +1,676 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The protocol: one JSON object per line, both ways. A client sends one
// request; the daemon answers with events and ends with one whose "event" is
// "result". "watch" never ends.
//
// hello version
// status [user] faces, lockout, camera, settings
// cameras every camera and which one is in use
// verify user purpose [verbose]
// scan for that user. Events: started, face,
// hint, frame (verbose only), result
// enroll [name] set up a face for the caller. Asks polkit
// first. Events: authorizing, authorized,
// started, frame, pose, hint, captured, result.
// While it runs the client may send "finish"
// (stop once enough is done) or "cancel".
// list [user] the caller's faces
// remove id | rename id name | enable id | disable id | clear
// change the caller's faces (polkit)
// watch every scan for the caller, as it happens,
// for the bubble
// unlocked the caller's session was unlocked some other
// way, which ends a lockout
// cancel stop this connection's scan or setup
//
// Who may do what:
// - anybody may ask about themselves and scan for themselves. The answer
// to a scan is yes or no, which tells a process nothing it could use.
// - root may do all of it for anybody. That is sudo and the polkit helper,
// through the PAM module.
// - changing faces needs polkit on top, because a face is a way in.
#include "server.h"
#include "agentlink.h"
#include "camera.h"
#include "client.h"
#include "enrolljob.h"
#include "polkit.h"
#include "scanjob.h"
#include "store.h"
#include "system.h"
#include "userstate.h"
#include "buildconfig.h"
#include <QCoreApplication>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QLocalSocket>
namespace
{
QJsonObject result(bool ok, const QString &reason = {})
{
QJsonObject o{{QStringLiteral("event"), QStringLiteral("result")}, {QStringLiteral("ok"), ok}};
if (!reason.isEmpty()) {
o.insert(QStringLiteral("reason"), reason);
}
return o;
}
bool isFailureThatCounts(const QString &reason)
{
// A face that did not match, a fake, or a match that never showed a sign
// of life. An empty chair, a broken camera, or somebody looking away do
// not count: none of them is anybody trying to get in.
return reason == u"mismatch" || reason == u"spoof" || reason == u"liveness";
}
} // namespace
Server::Server(const ServerOptions &options, QObject *parent)
: QObject(parent)
, m_options(options)
{
m_idle.setSingleShot(true);
connect(&m_idle, &QTimer::timeout, this, [] {
qInfo("idle, exiting");
QCoreApplication::quit();
});
}
Server::~Server()
{
if (m_job) {
m_job->cancel();
}
if (m_jobThread) {
m_jobThread->wait();
}
}
bool Server::start(QString *error)
{
QDir().mkpath(m_options.stateDir);
QFile::setPermissions(m_options.stateDir, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner);
bool ok;
if (m_options.systemdFd >= 0) {
ok = m_server.listen(qintptr(m_options.systemdFd));
} else {
QDir().mkpath(QFileInfo(m_options.socketPath).absolutePath());
QLocalServer::removeServer(m_options.socketPath);
m_server.setSocketOptions(QLocalServer::WorldAccessOption);
ok = m_server.listen(m_options.socketPath);
}
if (!ok) {
*error = m_server.errorString();
return false;
}
connect(&m_server, &QLocalServer::newConnection, this, &Server::onConnection);
// Loaded up front: whoever started the daemon is about to ask for a scan.
QString visionError;
if (!ensureVision(&visionError)) {
qWarning("%s", qPrintable(visionError));
}
updateIdle();
return true;
}
bool Server::ensureVision(QString *error)
{
if (!m_visionLoaded) {
m_visionLoaded = m_vision.load(m_options.modelDir, error);
}
return m_visionLoaded;
}
Settings Server::settings() const
{
return Settings::load(m_options.configPath);
}
void Server::onConnection()
{
while (QLocalSocket *socket = m_server.nextPendingConnection()) {
auto *client = new Client(socket, this);
if (!client->credentialsKnown()) {
client->deleteLater();
continue;
}
connect(client, &Client::request, this, &Server::onRequest);
connect(client, &Client::disconnected, this, &Server::onDisconnected);
m_clients.append(client);
}
updateIdle();
}
void Server::onDisconnected(Client *client)
{
// Whoever asked for a scan has given up on it (the PAM module timed out,
// the password was typed, the setup window was closed). The camera goes
// off now rather than when the scan would have ended.
if (m_job && m_jobOwner == client) {
m_job->cancel();
}
m_clients.removeAll(client);
client->deleteLater();
updateIdle();
}
void Server::reply(Client *client, QJsonObject message, bool close)
{
client->send(message);
if (close) {
client->close();
}
}
void Server::fail(Client *client, const QString &reason, const QJsonObject &extra)
{
QJsonObject o = extra;
o.insert(QStringLiteral("event"), QStringLiteral("result"));
o.insert(QStringLiteral("ok"), false);
o.insert(QStringLiteral("reason"), reason);
reply(client, o);
}
bool Server::targetUser(Client *client, const QJsonObject &request, uid_t *uid)
{
const QString name = request.value(u"user").toString();
if (name.isEmpty()) {
*uid = client->uid();
return true;
}
const std::optional<uid_t> target = System::uidOf(name);
if (!target) {
fail(client, QStringLiteral("unknown-user"));
return false;
}
if (client->uid() != 0 && client->uid() != *target) {
fail(client, QStringLiteral("denied"));
return false;
}
*uid = *target;
return true;
}
void Server::authorize(Client *client, std::function<void()> then)
{
if (client->uid() == 0 || !m_options.askPolkit) {
then();
return;
}
client->send({{QStringLiteral("event"), QStringLiteral("authorizing")}});
QPointer<Client> guard(client);
++m_manageChecks;
Polkit::checkAuthorization(client->pid(), client->uid(), Polkit::ManageAction, this, [this, guard, then](bool ok) {
--m_manageChecks;
if (!guard) {
return;
}
if (!ok) {
fail(guard, QStringLiteral("denied"));
return;
}
guard->send({{QStringLiteral("event"), QStringLiteral("authorized")}});
then();
});
}
void Server::onRequest(Client *client, const QJsonObject &request)
{
const QString cmd = request.value(u"cmd").toString();
// Messages for a scan or setup that is already running on this
// connection.
if (cmd == u"cancel" || cmd == u"finish") {
if (m_job && m_jobOwner == client) {
if (cmd == u"cancel") {
m_job->cancel();
} else if (auto *enroll = qobject_cast<EnrollJob *>(m_job)) {
enroll->finishEarly();
}
}
return;
}
if (!client->role.isEmpty()) {
// One request per connection.
return;
}
client->role = cmd;
if (cmd == u"hello") {
reply(client, {{QStringLiteral("event"), QStringLiteral("result")}, {QStringLiteral("ok"), true}, {QStringLiteral("version"), QStringLiteral(PFU_VERSION)}});
} else if (cmd == u"status") {
handleStatus(client);
} else if (cmd == u"cameras") {
handleCameras(client);
} else if (cmd == u"verify") {
handleVerify(client, request);
} else if (cmd == u"enroll") {
handleEnroll(client, request);
} else if (cmd == u"list") {
handleList(client, request);
} else if (cmd == u"remove" || cmd == u"rename" || cmd == u"enable" || cmd == u"disable" || cmd == u"clear") {
handleChange(client, request);
} else if (cmd == u"watch") {
handleWatch(client);
} else if (cmd == u"unlocked") {
handleUnlocked(client);
} else {
fail(client, QStringLiteral("bad-request"));
}
}
// ---------------------------------------------------------------------------
// Questions
// ---------------------------------------------------------------------------
void Server::handleStatus(Client *client)
{
const uid_t uid = client->uid();
const Settings s = settings();
const FaceStore store(m_options.stateDir);
const QList<Identity> faces = store.load(uid);
const UserState state = UserState::load(m_options.stateDir, uid);
const qint64 now = QDateTime::currentSecsSinceEpoch();
int enabled = 0;
for (const Identity &id : faces) {
enabled += id.enabled;
}
QString path = s.camera;
if (path.isEmpty() || path == u"auto") {
path = Camera::autoPath();
}
QString cameraName;
bool present = false;
if (path.startsWith(u"file:") || path.startsWith(u"images:")) {
cameraName = path;
present = true;
} else {
for (const CameraInfo &c : Camera::list()) {
if (c.path == path) {
cameraName = c.name;
present = true;
}
}
}
QString modelError;
reply(client,
{{QStringLiteral("event"), QStringLiteral("result")},
{QStringLiteral("ok"), true},
{QStringLiteral("version"), QStringLiteral(PFU_VERSION)},
{QStringLiteral("user"), System::nameOf(uid)},
{QStringLiteral("faces"), enabled},
{QStringLiteral("identities"), int(faces.size())},
{QStringLiteral("lockout"), state.lockoutLeft(now)},
{QStringLiteral("lastUnlock"), state.lastUnlock},
{QStringLiteral("lastPurpose"), state.lastPurpose},
{QStringLiteral("camera"), s.camera},
{QStringLiteral("cameraPath"), path},
{QStringLiteral("cameraName"), cameraName},
{QStringLiteral("cameraPresent"), present},
{QStringLiteral("models"), ensureVision(&modelError)},
{QStringLiteral("liveness"), Settings::livenessName(s.liveness)},
{QStringLiteral("strictness"), Settings::strictnessName(s.strictness)},
{QStringLiteral("attention"), s.attention},
{QStringLiteral("scanSeconds"), s.scanSeconds},
{QStringLiteral("busy"), m_job != nullptr}});
}
void Server::handleCameras(Client *client)
{
QJsonArray list;
for (const CameraInfo &c : Camera::list()) {
list.append(QJsonObject{{QStringLiteral("path"), c.path}, {QStringLiteral("name"), c.name}, {QStringLiteral("infrared"), c.infrared}});
}
QJsonObject o = result(true);
o.insert(QStringLiteral("cameras"), list);
o.insert(QStringLiteral("selected"), settings().camera);
o.insert(QStringLiteral("auto"), Camera::autoPath());
reply(client, o);
}
void Server::handleList(Client *client, const QJsonObject &request)
{
uid_t uid;
if (!targetUser(client, request, &uid)) {
return;
}
const QList<Identity> faces = FaceStore(m_options.stateDir).load(uid);
QJsonArray list;
for (const Identity &id : faces) {
list.append(QJsonObject{
{QStringLiteral("id"), id.id},
{QStringLiteral("name"), id.name},
{QStringLiteral("created"), double(id.created)},
{QStringLiteral("enabled"), id.enabled},
{QStringLiteral("samples"), int(id.samples.size()) - id.adaptiveCount()},
{QStringLiteral("adaptive"), id.adaptiveCount()},
});
}
QJsonObject o = result(true);
o.insert(QStringLiteral("faces"), list);
reply(client, o);
}
void Server::handleWatch(Client *client)
{
reply(client, {{QStringLiteral("event"), QStringLiteral("watching")}}, false);
}
void Server::handleUnlocked(Client *client)
{
UserState state = UserState::load(m_options.stateDir, client->uid());
if (state.failures || state.lockedUntil) {
state.failures = 0;
state.lockedUntil = 0;
state.save(m_options.stateDir, client->uid());
}
reply(client, result(true));
}
// ---------------------------------------------------------------------------
// Scanning
// ---------------------------------------------------------------------------
void Server::handleVerify(Client *client, const QJsonObject &request)
{
uid_t uid;
if (!targetUser(client, request, &uid)) {
return;
}
if (m_job) {
fail(client, QStringLiteral("busy"));
return;
}
const Settings s = settings();
const qint64 now = QDateTime::currentSecsSinceEpoch();
const UserState state = UserState::load(m_options.stateDir, uid);
if (const qint64 left = state.lockoutLeft(now)) {
fail(client, QStringLiteral("lockout"), {{QStringLiteral("seconds"), left}});
return;
}
if (s.skipLidClosed && System::lidClosed()) {
fail(client, QStringLiteral("lid-closed"));
return;
}
QList<Identity> faces = FaceStore(m_options.stateDir).load(uid);
faces.erase(std::remove_if(faces.begin(), faces.end(), [](const Identity &id) {
return !id.enabled;
}),
faces.end());
if (faces.isEmpty()) {
fail(client, QStringLiteral("not-enrolled"));
return;
}
QString error;
if (!ensureVision(&error)) {
fail(client, QStringLiteral("models"), {{QStringLiteral("message"), error}});
return;
}
QString purpose = request.value(u"purpose").toString();
static const QStringList purposes = {QStringLiteral("unlock"), QStringLiteral("sudo"), QStringLiteral("polkit"), QStringLiteral("test")};
if (!purposes.contains(purpose)) {
purpose = QStringLiteral("other");
}
// The password dialog that is open right now may be the one asking
// whether faces may be changed. See m_manageChecks.
if (m_manageChecks > 0 && purpose != u"unlock" && purpose != u"test") {
fail(client, QStringLiteral("busy"));
return;
}
auto *job = new ScanJob(&m_vision, uid, s, faces, purpose, request.value(u"verbose").toBool());
// The lock screen's agent draws its own scans. Everybody else's it has
// to be told about.
if (purpose != u"unlock") {
m_agentLink = new AgentLink(uid, this);
}
broadcast(uid, {{QStringLiteral("event"), QStringLiteral("scan")}, {QStringLiteral("state"), QStringLiteral("start")}, {QStringLiteral("purpose"), purpose}});
startJob(job, client);
}
void Server::handleEnroll(Client *client, const QJsonObject &request)
{
uid_t uid;
if (!targetUser(client, request, &uid)) {
return;
}
if (m_job) {
fail(client, QStringLiteral("busy"));
return;
}
QString name = request.value(u"name").toString().trimmed().left(64);
authorize(client, [this, client, uid, name]() mutable {
if (m_job) {
fail(client, QStringLiteral("busy"));
return;
}
QString error;
if (!ensureVision(&error)) {
fail(client, QStringLiteral("models"), {{QStringLiteral("message"), error}});
return;
}
if (name.isEmpty()) {
name = System::nameOf(uid);
}
startJob(new EnrollJob(&m_vision, uid, settings(), name), client);
});
}
void Server::startJob(Job *job, Client *owner)
{
m_job = job;
m_jobOwner = owner;
connect(job, &Job::event, this, &Server::onJobEvent, Qt::QueuedConnection);
m_jobThread = QThread::create([job] {
job->run();
});
connect(m_jobThread, &QThread::finished, this, &Server::onJobDone, Qt::QueuedConnection);
m_jobThread->start();
updateIdle();
}
void Server::onJobEvent(const QJsonObject &event)
{
if (!m_job) {
return;
}
if (m_jobOwner) {
m_jobOwner->send(event);
}
// The bubble hears about scans, not about what the setup window sees.
if (auto *scan = qobject_cast<ScanJob *>(m_job)) {
const QString what = event.value(u"event").toString();
if (what == u"face" || what == u"hint") {
QJsonObject e{{QStringLiteral("event"), QStringLiteral("scan")},
{QStringLiteral("state"), what},
{QStringLiteral("purpose"), scan->purpose()}};
if (what == u"hint") {
e.insert(QStringLiteral("hint"), event.value(u"hint"));
}
broadcast(scan->uid(), e);
}
}
}
void Server::onJobDone()
{
Job *job = m_job;
m_job = nullptr;
m_jobThread->deleteLater();
m_jobThread = nullptr;
QPointer<Client> owner = m_jobOwner;
m_jobOwner = nullptr;
QJsonObject res = job->result;
const qint64 now = QDateTime::currentSecsSinceEpoch();
if (auto *scan = qobject_cast<ScanJob *>(job)) {
const Settings s = settings();
UserState state = UserState::load(m_options.stateDir, scan->uid());
const QString reason = res.value(u"reason").toString();
if (res.value(u"ok").toBool()) {
state.failures = 0;
state.lockedUntil = 0;
state.lastUnlock = now;
state.lastPurpose = scan->purpose();
// Learn from a confident match, the way Face ID keeps up with a
// beard growing in. Only well clear of the threshold, so the
// samples cannot creep towards somebody else one borderline
// unlock at a time.
if (s.adapt && scan->matchedScore >= s.threshold() + 0.08 && !scan->matchedEmbedding.empty()) {
const FaceStore store(m_options.stateDir);
QList<Identity> faces = store.load(scan->uid());
const QString id = res.value(u"id").toString();
for (Identity &identity : faces) {
if (identity.id == id && FaceStore::adapt(identity, scan->matchedEmbedding, now)) {
QString error;
if (!store.save(scan->uid(), faces, &error)) {
qWarning("could not save what was learned: %s", qPrintable(error));
}
break;
}
}
}
} else if (isFailureThatCounts(reason)) {
if (++state.failures >= s.maxFailures) {
state.failures = 0;
state.lockedUntil = now + qint64(s.lockoutMinutes) * 60;
res.insert(QStringLiteral("lockout"), state.lockoutLeft(now));
}
}
state.save(m_options.stateDir, scan->uid());
QJsonObject e{{QStringLiteral("event"), QStringLiteral("scan")},
{QStringLiteral("state"), res.value(u"ok").toBool() ? QStringLiteral("success") : QStringLiteral("failure")},
{QStringLiteral("reason"), reason},
{QStringLiteral("purpose"), scan->purpose()}};
if (res.contains(u"lockout")) {
e.insert(QStringLiteral("lockout"), res.value(u"lockout"));
}
broadcast(scan->uid(), e);
qInfo("scan for %s (%s): %s%s", qPrintable(System::nameOf(scan->uid())), qPrintable(scan->purpose()),
res.value(u"ok").toBool() ? "recognised" : "not recognised, ", res.value(u"ok").toBool() ? "" : qPrintable(reason));
} else if (auto *enroll = qobject_cast<EnrollJob *>(job)) {
if (res.value(u"ok").toBool()) {
const FaceStore store(m_options.stateDir);
QList<Identity> faces = store.load(enroll->uid());
faces.append(enroll->identity);
QString error;
if (!store.save(enroll->uid(), faces, &error)) {
res = result(false, QStringLiteral("store"));
res.insert(QStringLiteral("message"), error);
} else {
qInfo("new face \"%s\" for %s", qPrintable(enroll->identity.name), qPrintable(System::nameOf(enroll->uid())));
}
}
}
if (owner) {
reply(owner, res);
}
job->deleteLater();
updateIdle();
}
void Server::broadcast(uid_t uid, const QJsonObject &event)
{
if (m_agentLink) {
m_agentLink->send(event);
const QString state = event.value(u"state").toString();
if (state == u"success" || state == u"failure") {
m_agentLink->finish();
m_agentLink = nullptr;
}
}
for (Client *c : std::as_const(m_clients)) {
if (c->role == u"watch" && c->uid() == uid) {
c->send(event);
}
}
}
// ---------------------------------------------------------------------------
// Changing faces
// ---------------------------------------------------------------------------
void Server::handleChange(Client *client, const QJsonObject &request)
{
uid_t uid;
if (!targetUser(client, request, &uid)) {
return;
}
const QString cmd = request.value(u"cmd").toString();
const QString id = request.value(u"id").toString();
const QString name = request.value(u"name").toString().trimmed().left(64);
authorize(client, [this, client, uid, cmd, id, name] {
const FaceStore store(m_options.stateDir);
QList<Identity> faces = store.load(uid);
bool found = cmd == u"clear";
if (cmd == u"clear") {
faces.clear();
}
for (qsizetype i = 0; i < faces.size(); ++i) {
if (faces[i].id != id) {
continue;
}
found = true;
if (cmd == u"remove") {
faces.removeAt(i);
} else if (cmd == u"rename" && !name.isEmpty()) {
faces[i].name = name;
} else if (cmd == u"enable") {
faces[i].enabled = true;
} else if (cmd == u"disable") {
faces[i].enabled = false;
}
break;
}
if (!found) {
fail(client, QStringLiteral("unknown-face"));
return;
}
QString error;
if (!store.save(uid, faces, &error)) {
fail(client, QStringLiteral("store"), {{QStringLiteral("message"), error}});
return;
}
reply(client, result(true));
});
}
// ---------------------------------------------------------------------------
void Server::updateIdle()
{
if (m_options.idleSeconds > 0 && m_clients.isEmpty() && !m_job) {
m_idle.start(m_options.idleSeconds * 1000);
} else {
m_idle.stop();
}
}
+96
View File
@@ -0,0 +1,96 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The socket, and who may ask for what.
#pragma once
#include "settings.h"
#include "vision.h"
#include <QJsonObject>
#include <QList>
#include <QLocalServer>
#include <QObject>
#include <QPointer>
#include <QThread>
#include <QTimer>
#include <sys/types.h>
class AgentLink;
class Client;
class Job;
struct ServerOptions {
QString socketPath;
int systemdFd = -1;
QString stateDir;
QString configPath;
QString modelDir;
// Exit after this long with nothing to do. 0 stays up.
int idleSeconds = 0;
// Running as somebody other than root is only ever development, and only
// touches that person's own test data. polkit is not asked then.
bool askPolkit = true;
};
class Server : public QObject
{
Q_OBJECT
public:
explicit Server(const ServerOptions &options, QObject *parent = nullptr);
~Server() override;
bool start(QString *error);
private:
void onConnection();
void onRequest(Client *client, const QJsonObject &request);
void onDisconnected(Client *client);
void handleStatus(Client *client);
void handleCameras(Client *client);
void handleVerify(Client *client, const QJsonObject &request);
void handleEnroll(Client *client, const QJsonObject &request);
void handleList(Client *client, const QJsonObject &request);
void handleChange(Client *client, const QJsonObject &request);
void handleWatch(Client *client);
void handleUnlocked(Client *client);
// The user a request is about: the caller, or for root whoever it names.
// Returns false (and answers) when the caller may not act for them.
bool targetUser(Client *client, const QJsonObject &request, uid_t *uid);
void authorize(Client *client, std::function<void()> then);
void reply(Client *client, QJsonObject message, bool close = true);
void fail(Client *client, const QString &reason, const QJsonObject &extra = {});
bool ensureVision(QString *error);
Settings settings() const;
void startJob(Job *job, Client *owner);
void onJobEvent(const QJsonObject &event);
void onJobDone();
void broadcast(uid_t uid, const QJsonObject &event);
void updateIdle();
ServerOptions m_options;
QLocalServer m_server;
QList<Client *> m_clients;
Vision m_vision;
bool m_visionLoaded = false;
Job *m_job = nullptr;
QThread *m_jobThread = nullptr;
QPointer<Client> m_jobOwner;
// The session to tell about the running scan, when it is not the lock
// screen's own (see agentlink.h).
QPointer<AgentLink> m_agentLink;
QTimer m_idle;
// Asking polkit whether faces may be changed. While that question is
// open, a scan for an admin prompt is refused: the answer to "may a face
// be added" has to be the password, not a face.
int m_manageChecks = 0;
};
+82
View File
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "system.h"
#include <QDBusConnection>
#include <QDBusInterface>
#include <QDir>
#include <QFile>
#include <pwd.h>
#include <unistd.h>
#include <vector>
namespace System
{
std::optional<uid_t> uidOf(const QString &user)
{
if (user.isEmpty()) {
return std::nullopt;
}
std::vector<char> buf(16384);
passwd pw{};
passwd *result = nullptr;
if (getpwnam_r(user.toLocal8Bit().constData(), &pw, buf.data(), buf.size(), &result) != 0 || !result) {
return std::nullopt;
}
return result->pw_uid;
}
QString nameOf(uid_t uid)
{
std::vector<char> buf(16384);
passwd pw{};
passwd *result = nullptr;
if (getpwuid_r(uid, &pw, buf.data(), buf.size(), &result) != 0 || !result) {
return QString::number(uid);
}
return QString::fromLocal8Bit(result->pw_name);
}
bool lidClosed()
{
QDBusInterface logind(QStringLiteral("org.freedesktop.login1"),
QStringLiteral("/org/freedesktop/login1"),
QStringLiteral("org.freedesktop.login1.Manager"),
QDBusConnection::systemBus());
if (logind.isValid()) {
const QVariant v = logind.property("LidClosed");
if (v.isValid()) {
return v.toBool();
}
}
const QDir lids(QStringLiteral("/proc/acpi/button/lid"));
for (const QString &lid : lids.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) {
QFile state(lids.filePath(lid + QStringLiteral("/state")));
if (state.open(QIODevice::ReadOnly) && state.readAll().contains("closed")) {
return true;
}
}
return false;
}
quint64 processStartTime(pid_t pid)
{
QFile stat(QStringLiteral("/proc/%1/stat").arg(pid));
if (!stat.open(QIODevice::ReadOnly)) {
return 0;
}
const QByteArray line = stat.readAll();
// The second field is the command name in brackets and can contain
// spaces and brackets of its own. Everything after the last ')' is
// plain numbers; the start time is the 20th of them.
const qsizetype close = line.lastIndexOf(')');
if (close < 0) {
return 0;
}
const QList<QByteArray> fields = line.mid(close + 2).split(' ');
return fields.size() > 19 ? fields.at(19).toULongLong() : 0;
}
} // namespace System
+23
View File
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Small questions about the machine and its users.
#pragma once
#include <QString>
#include <optional>
#include <sys/types.h>
namespace System
{
std::optional<uid_t> uidOf(const QString &user);
QString nameOf(uid_t uid);
// Whether a laptop lid is shut. Asks logind, and falls back to ACPI.
bool lidClosed();
// When a process started, in clock ticks since boot, as polkit wants it to
// tell a process from a later one that got the same pid.
quint64 processStartTime(pid_t pid);
} // namespace System
+55
View File
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "userstate.h"
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSaveFile>
namespace
{
QString fileFor(const QString &stateDir, uid_t uid)
{
return QDir(stateDir).filePath(QStringLiteral("users/%1.state").arg(uid));
}
} // namespace
UserState UserState::load(const QString &stateDir, uid_t uid)
{
UserState s;
QFile file(fileFor(stateDir, uid));
if (!file.open(QIODevice::ReadOnly)) {
return s;
}
const QJsonObject o = QJsonDocument::fromJson(file.readAll()).object();
s.failures = o.value(u"failures").toInt();
s.lockedUntil = qint64(o.value(u"lockedUntil").toDouble());
s.lastUnlock = qint64(o.value(u"lastUnlock").toDouble());
s.lastPurpose = o.value(u"lastPurpose").toString();
return s;
}
bool UserState::save(const QString &stateDir, uid_t uid) const
{
QDir().mkpath(QDir(stateDir).filePath(QStringLiteral("users")));
QSaveFile file(fileFor(stateDir, uid));
if (!file.open(QIODevice::WriteOnly)) {
return false;
}
file.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner);
const QJsonObject o{
{QStringLiteral("failures"), failures},
{QStringLiteral("lockedUntil"), double(lockedUntil)},
{QStringLiteral("lastUnlock"), double(lastUnlock)},
{QStringLiteral("lastPurpose"), lastPurpose},
};
file.write(QJsonDocument(o).toJson(QJsonDocument::Compact));
return file.commit();
}
qint64 UserState::lockoutLeft(qint64 now) const
{
return lockedUntil > now ? lockedUntil - now : 0;
}
+27
View File
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// What the daemon remembers about each user between scans: failures in a row,
// the lockout they lead to, and the last successful unlock.
//
// Kept on disk rather than in memory. The daemon exits when it has been idle
// for a minute, and a lockout that ended with the process would be a lockout
// that waiting a minute gets around.
#pragma once
#include <QString>
#include <sys/types.h>
struct UserState {
int failures = 0;
qint64 lockedUntil = 0;
qint64 lastUnlock = 0;
QString lastPurpose;
static UserState load(const QString &stateDir, uid_t uid);
bool save(const QString &stateDir, uid_t uid) const;
// Seconds left, 0 when not locked out.
qint64 lockoutLeft(qint64 now) const;
};
+182
View File
@@ -0,0 +1,182 @@
# shellcheck shell=bash
#
# Paths, translations and output helpers.
#
# The shell side never touches the camera or the face data. Those belong to
# the daemon, and everything here that needs them asks it through
# plasma-face-unlock-ctl. What this side does write is the user's own settings
# file, and (through sudo, and only when asked) the system settings and the
# PAM files of sudo and polkit.
PFU_VERSION="@VERSION@"
PFU_NAME="plasma-face-unlock"
PFU_PRETTY="Plasma Face Unlock"
PFU_LIBDIR="${PFU_LIBDIR:-@LIBDIR@}"
PFU_LIBEXECDIR="${PFU_LIBEXECDIR:-@LIBEXECDIR@}"
PFU_LOCALEDIR="${PFU_LOCALEDIR:-@LOCALEDIR@}"
PFU_PAMDIR="${PFU_PAMDIR:-@PAMDIR@}"
PFU_CTL="${PFU_CTL:-$PFU_LIBEXECDIR/plasma-face-unlock-ctl}"
PFU_AGENT="${PFU_AGENT:-$PFU_LIBEXECDIR/plasma-face-unlock-agent}"
PFU_PAM_MODULE="${PFU_PAM_MODULE:-$PFU_PAMDIR/pam_plasma_face_unlock.so}"
PFU_XDG_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}"
PFU_CONFDIR="${PFU_CONFDIR:-${PFU_XDG_CONFIG}/${PFU_NAME}}"
PFU_CONFIG="${PFU_CONFIG:-${PFU_CONFDIR}/config}"
# The system settings. Only root writes them; see system.sh.
PFU_SYSCONFIG="${PFU_SYSCONFIG:-/etc/${PFU_NAME}/config}"
PFU_UNIT_SOCKET="plasma-face-unlockd.socket"
PFU_UNIT_AGENT="plasma-face-unlock-agent.service"
# ---------------------------------------------------------------------------
# Translations
# ---------------------------------------------------------------------------
export TEXTDOMAIN="plasma-face-unlock"
export TEXTDOMAINDIR="${PFU_LOCALEDIR}"
pfu_ui_locale() {
local l="${PFU_UI_LOCALE:-}"
if [[ -z $l ]]; then
l="${LC_ALL:-}"
[[ -z $l ]] && l="${LC_MESSAGES:-}"
[[ -z $l ]] && l="${LANG:-}"
fi
# systemd writes /etc/locale.conf and most distributions use it; Debian and
# Ubuntu keep the same LANG= line in /etc/default/locale instead.
if [[ -z $l ]]; then
local f
for f in /etc/locale.conf /etc/default/locale; do
[[ -r $f ]] || continue
l="$(sed -n 's/^LANG=//p' "$f" | tr -d '"' | head -n1)"
[[ -n $l ]] && break
done
fi
printf '%s\n' "${l:-C}"
}
# Every gettext lookup is a fork and the settings screen redraws a screenful of
# labels per keypress, so results are memoized.
declare -A PFU_MSG_CACHE=()
PFU_MSG_RESULT=''
# pfu_msg_into <locale> <msgid>
# Plain lookup with the result in PFU_MSG_RESULT and no printf formatting, for
# callers that would otherwise pay a fork per label per frame.
pfu_msg_into() {
local locale="$1" msgid="$2" cachekey
cachekey="${locale}"$'\x1f'"${msgid}"
if [[ -n ${PFU_MSG_CACHE[$cachekey]+set} ]]; then
PFU_MSG_RESULT="${PFU_MSG_CACHE[$cachekey]}"
return 0
fi
PFU_MSG_RESULT="$(LC_ALL="$locale" LANGUAGE="${locale%%.*}" gettext -- "$msgid" 2>/dev/null)"
[[ -n $PFU_MSG_RESULT ]] || PFU_MSG_RESULT="$msgid"
PFU_MSG_CACHE[$cachekey]="$PFU_MSG_RESULT"
return 0
}
# pfu_msg_in <locale> <msgid> [printf args...]
pfu_msg_in() {
local locale="$1" msgid="$2"
shift 2
pfu_msg_into "$locale" "$msgid"
# With no arguments the message is plain text, not a format string. Feeding
# it to printf anyway would turn a literal percent sign in a translation
# into an invalid conversion.
if (( $# == 0 )); then
printf '%s' "$PFU_MSG_RESULT"
return
fi
# shellcheck disable=SC2059 # the format string is the translated message
printf -- "$PFU_MSG_RESULT" "$@"
}
PFU_LOCALE_CACHED=''
# pfu_msg <msgid> [printf args...]
pfu_msg() {
[[ -n $PFU_LOCALE_CACHED ]] || PFU_LOCALE_CACHED="$(pfu_ui_locale)"
pfu_msg_in "$PFU_LOCALE_CACHED" "$@"
}
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
# Decided once, while stdout is still whatever the process was started with:
# testing -t 1 at the point of use is wrong for anything called through $(...),
# which sees a pipe and would conclude nobody is watching.
PFU_INTERACTIVE=''
[[ -t 1 ]] && PFU_INTERACTIVE=1
if [[ -n $PFU_INTERACTIVE && -z ${NO_COLOR:-} ]]; then
PFU_C_RESET=$'\033[0m'
PFU_C_BOLD=$'\033[1m'
PFU_C_DIM=$'\033[2m'
PFU_C_BLUE=$'\033[38;2;52;153;255m'
PFU_C_GREEN=$'\033[32m'
PFU_C_YELLOW=$'\033[33m'
PFU_C_RED=$'\033[31m'
else
PFU_C_RESET='' PFU_C_BOLD='' PFU_C_DIM='' PFU_C_BLUE=''
PFU_C_GREEN='' PFU_C_YELLOW='' PFU_C_RED=''
fi
# Set by pfu_bad and pfu_note. The menu redraws straight after an action, which
# would wipe the screen; this marks that something was printed the user still
# has to read.
PFU_UI_NEEDS_ACK=''
pfu_say() { printf '%s\n' "$*"; }
pfu_head() { printf '\n%s%s%s\n\n' "$PFU_C_BOLD$PFU_C_BLUE" "$*" "$PFU_C_RESET"; }
pfu_ok() { printf '%s✔%s %s\n' "$PFU_C_GREEN" "$PFU_C_RESET" "$*"; }
pfu_bad() { PFU_UI_NEEDS_ACK=1; printf '%s✘%s %s\n' "$PFU_C_RED" "$PFU_C_RESET" "$*" >&2; }
pfu_note() { PFU_UI_NEEDS_ACK=1; printf '%s•%s %s\n' "$PFU_C_DIM" "$PFU_C_RESET" "$*"; }
pfu_have() { command -v "$1" > /dev/null 2>&1; }
# Human-readable "x minutes ago" for a unix timestamp. 0 or empty yields the
# translated "never".
#
# Written with [[ ]] and a variable for each number on purpose: xgettext reads
# the < of an (( )) as a redirection and loses every string after it.
pfu_time_ago() {
local ts="$1" now delta n
if [[ ! $ts =~ ^[0-9]+$ || $ts -eq 0 ]]; then
pfu_msg "never"
printf '\n'
return
fi
now="$(date +%s)"
delta=$(( now - ts ))
[[ $delta -lt 0 ]] && delta=0
if [[ $delta -lt 60 ]]; then
pfu_msg "just now"
elif [[ $delta -lt 3600 ]]; then
n=$(( delta / 60 ))
pfu_msg "%d minutes ago" "$n"
elif [[ $delta -lt 86400 ]]; then
n=$(( delta / 3600 ))
pfu_msg "%d hours ago" "$n"
else
n=$(( delta / 86400 ))
pfu_msg "%d days ago" "$n"
fi
printf '\n'
}
+132
View File
@@ -0,0 +1,132 @@
# shellcheck shell=bash
#
# Reading and writing the settings files.
#
# Two of them, in the same format: ~/.config/plasma-face-unlock/config for
# what this user wants from the lock screen and the bubble, and
# /etc/plasma-face-unlock/config for what the daemon does (which camera, how
# strict), which only root writes. Neither is meant to be edited by hand:
# every option is in the menu.
#
# Both are parsed rather than sourced. One "Key=Value" per line, '#'
# comments. The daemon and the agent read them with the same rules (see
# src/core/keyvalue.cpp).
declare -A PFU_KV_CACHE=()
# _pfu_kv_lookup <file> <Key> [default]
# Result in PFU_KV_VALUE. Assigning rather than printing matters on the
# settings screen, which reads every key on every frame: a command
# substitution there is a fork, and forks are the whole cost of a redraw.
PFU_KV_VALUE=''
_pfu_kv_lookup() {
local file="$1" key="$2" default="${3:-}" val='' line content
PFU_KV_VALUE="$default"
[[ -r $file ]] || return 0
if [[ -n ${PFU_KV_CACHE[$file]+set} ]]; then
content="${PFU_KV_CACHE[$file]}"
else
content="$(< "$file")"
PFU_KV_CACHE[$file]="$content"
fi
while IFS= read -r line; do
[[ $line == *"$key"* ]] || continue
[[ $line =~ ^[[:space:]]*"$key"[[:space:]]*=(.*)$ ]] || continue
val="${BASH_REMATCH[1]}"
done <<< "$content"
val="${val%%#*}"
val="${val#"${val%%[![:space:]]*}"}"
val="${val%"${val##*[![:space:]]}"}"
val="${val%\"}"
val="${val#\"}"
[[ -n $val ]] && PFU_KV_VALUE="$val"
return 0
}
pfu_is_true() {
case "${1,,}" in
yes|y|true|1|on|enabled) return 0 ;;
*) return 1 ;;
esac
}
# pfu_kv_set <file> <Key> <Value> <header line>
pfu_kv_set() {
local file="$1" key="$2" value="$3" header="$4" tmp
if [[ ! -e $file ]]; then
mkdir -p "$(dirname "$file")" || return 1
{
printf '# %s\n' "$header"
printf '#\n'
printf '# Written by `%s`. Nothing here needs editing by hand:\n' "$PFU_NAME"
printf '# every option is in the menu.\n'
} > "$file" || return 1
fi
[[ -w $file ]] || return 1
tmp="$(mktemp "${file}.XXXXXX")" || return 1
chmod --reference="$file" "$tmp" 2>/dev/null || chmod 0644 "$tmp"
if grep -qE "^[[:space:]]*#?[[:space:]]*${key}[[:space:]]*=" "$file"; then
awk -v key="$key" -v value="$value" '
!done && $0 ~ "^[[:space:]]*#?[[:space:]]*" key "[[:space:]]*=" {
print key "=" value; done = 1; next
}
# drop any further occurrences so the file cannot grow duplicates
$0 ~ "^[[:space:]]*" key "[[:space:]]*=" { next }
{ print }
' "$file" > "$tmp" || { rm -f "$tmp"; return 1; }
else
cat "$file" > "$tmp" || { rm -f "$tmp"; return 1; }
printf '%s=%s\n' "$key" "$value" >> "$tmp"
fi
# Replaced, not rewritten in place: the agent watches the directory and
# picks up the new file the moment it lands.
mv -f "$tmp" "$file"
unset 'PFU_KV_CACHE[$file]'
}
# ---------------------------------------------------------------------------
# This user's settings
# ---------------------------------------------------------------------------
pfu_config_get() {
_pfu_kv_lookup "$PFU_CONFIG" "$@"
printf '%s\n' "$PFU_KV_VALUE"
}
pfu_config_set() {
pfu_kv_set "$PFU_CONFIG" "$1" "$2" "$PFU_PRETTY"
}
# ---------------------------------------------------------------------------
# The system settings (read by anybody, written by root)
# ---------------------------------------------------------------------------
pfu_sys_get() {
_pfu_kv_lookup "$PFU_SYSCONFIG" "$@"
printf '%s\n' "$PFU_KV_VALUE"
}
# pfu_config_load
# Everything the menu shows, resolved once per screen.
pfu_config_load() {
PFU_KV_CACHE=()
_pfu_kv_lookup "$PFU_CONFIG" Enabled no; CFG_ENABLED=no; pfu_is_true "$PFU_KV_VALUE" && CFG_ENABLED=yes
_pfu_kv_lookup "$PFU_CONFIG" LockScreen yes; CFG_LOCK=no; pfu_is_true "$PFU_KV_VALUE" && CFG_LOCK=yes
_pfu_kv_lookup "$PFU_CONFIG" Sudo no; CFG_SUDO=no; pfu_is_true "$PFU_KV_VALUE" && CFG_SUDO=yes
_pfu_kv_lookup "$PFU_CONFIG" Polkit no; CFG_POLKIT=no; pfu_is_true "$PFU_KV_VALUE" && CFG_POLKIT=yes
_pfu_kv_lookup "$PFU_SYSCONFIG" Liveness heavy; CFG_LIVENESS="$PFU_KV_VALUE"
_pfu_kv_lookup "$PFU_SYSCONFIG" Camera auto; CFG_CAMERA="$PFU_KV_VALUE"
return 0
}
+185
View File
@@ -0,0 +1,185 @@
# shellcheck shell=bash
#
# Asking the daemon.
#
# Through plasma-face-unlock-ctl, which prints every message as a line of tab
# separated key=value pairs (see src/ctl/main.cpp). What comes back is parsed
# into plain variables and arrays here, so the rest of the shell code never
# sees the wire format.
# _pfu_fields <line>
# Splits one line of ctl output into the associative array PFU_F.
declare -A PFU_F=()
_pfu_fields() {
local line="$1" field
local -a parts
PFU_F=()
IFS=$'\t' read -r -a parts <<< "$line"
for field in "${parts[@]}"; do
PFU_F["${field%%=*}"]="${field#*=}"
done
}
pfu_ctl() {
"$PFU_CTL" "$@"
}
# pfu_status_load
# PFU_ST_REACHABLE is yes when the daemon answered at all. Everything else
# is only filled in then.
pfu_status_load() {
local out
PFU_ST_REACHABLE=no
PFU_ST_FACES=0
PFU_ST_LOCKOUT=0
PFU_ST_LAST=0
PFU_ST_PURPOSE=''
PFU_ST_CAMERA=''
PFU_ST_CAMERA_NAME=''
PFU_ST_CAMERA_PRESENT=no
PFU_ST_MODELS=no
out="$(pfu_ctl status 2>/dev/null | tail -n1)"
_pfu_fields "$out"
[[ ${PFU_F[ok]:-} == true ]] || return 1
PFU_ST_REACHABLE=yes
PFU_ST_FACES="${PFU_F[faces]:-0}"
PFU_ST_LOCKOUT="${PFU_F[lockout]:-0}"
PFU_ST_LAST="${PFU_F[lastUnlock]:-0}"
PFU_ST_PURPOSE="${PFU_F[lastPurpose]:-}"
PFU_ST_CAMERA="${PFU_F[cameraPath]:-}"
PFU_ST_CAMERA_NAME="${PFU_F[cameraName]:-}"
PFU_ST_CAMERA_PRESENT="${PFU_F[cameraPresent]:-false}"
PFU_ST_MODELS="${PFU_F[models]:-false}"
return 0
}
# pfu_faces_load
# The caller's faces, into parallel arrays.
pfu_faces_load() {
local line
PFU_FACE_IDS=() PFU_FACE_NAMES=() PFU_FACE_ON=() PFU_FACE_SAMPLES=() PFU_FACE_LEARNED=() PFU_FACE_CREATED=()
while IFS= read -r line; do
_pfu_fields "$line"
[[ ${PFU_F[event]:-} == item ]] || continue
PFU_FACE_IDS+=("${PFU_F[id]}")
PFU_FACE_NAMES+=("${PFU_F[name]}")
PFU_FACE_ON+=("${PFU_F[enabled]}")
PFU_FACE_SAMPLES+=("${PFU_F[samples]:-0}")
PFU_FACE_LEARNED+=("${PFU_F[adaptive]:-0}")
PFU_FACE_CREATED+=("${PFU_F[created]:-0}")
done < <(pfu_ctl list 2>/dev/null)
}
# pfu_cameras_load
pfu_cameras_load() {
local line
PFU_CAM_PATHS=() PFU_CAM_NAMES=() PFU_CAM_IR=()
PFU_CAM_AUTO=''
while IFS= read -r line; do
_pfu_fields "$line"
case "${PFU_F[event]:-}" in
item)
PFU_CAM_PATHS+=("${PFU_F[path]}")
PFU_CAM_NAMES+=("${PFU_F[name]}")
PFU_CAM_IR+=("${PFU_F[infrared]}")
;;
result)
PFU_CAM_AUTO="${PFU_F[auto]:-}"
;;
esac
done < <(pfu_ctl cameras 2>/dev/null)
}
# pfu_reason_text <reason>
# What a daemon answer means, for people.
pfu_reason_text() {
case "$1" in
mismatch) pfu_msg "The face did not match." ;;
spoof) pfu_msg "That looked like a photo or a screen." ;;
liveness) pfu_msg "The face matched, but did not blink or move." ;;
attention) pfu_msg "The face was not looking at the screen." ;;
quality) pfu_msg "The picture was too dark, too blurry or too far away." ;;
no-face) pfu_msg "No face in view." ;;
lockout) pfu_msg "Face unlock is paused after too many tries. Unlock once with your password." ;;
not-enrolled) pfu_msg "No face is set up yet." ;;
camera) pfu_msg "The camera could not be used." ;;
models) pfu_msg "The recognition models are missing. Reinstall the package." ;;
lid-closed) pfu_msg "The lid is closed." ;;
busy) pfu_msg "The camera is busy with another scan." ;;
unreachable) pfu_msg "The face unlock service is not running." ;;
denied) pfu_msg "Not allowed." ;;
*) pfu_msg "Something went wrong (%s)." "$1" ;;
esac
printf '\n'
}
# pfu_test
# One scan, with everything the checks see on one line that keeps updating.
# The bubble shows it too, if the agent is running.
pfu_test() {
local line started=0 shown='' score='-' matched=0
pfu_say " $(pfu_msg "Look at the camera. Blink once, or turn your head a little.")"
printf '\n'
while IFS= read -r line; do
_pfu_fields "$line"
case "${PFU_F[event]:-}" in
started)
started=1
;;
face)
printf '\r\033[K %s\n' "$(pfu_msg "Face found.")"
;;
hint)
case "${PFU_F[hint]}" in
blink) printf '\r\033[K %s\n' "$(pfu_msg "Recognised. Now blink once, or turn your head a little.")" ;;
look) printf '\r\033[K %s\n' "$(pfu_msg "Look at the screen.")" ;;
closer) printf '\r\033[K %s\n' "$(pfu_msg "Move closer to the camera.")" ;;
light) printf '\r\033[K %s\n' "$(pfu_msg "It is too dark to see your face.")" ;;
esac
;;
frame)
[[ -n ${PFU_F[score]:-} ]] && score="${PFU_F[score]}"
matched="${PFU_F[matched]:-0}"
# match, turn of the head, eyes, and how close each photo check is
# to firing, as numbers for anybody tuning it.
printf -v shown ' %s %-6s %s %5s° %s %-5s %s %-4s %s %-4s %s %-4s %s %-4s' \
"$(pfu_msg "match")" "$score" "$(pfu_msg "turn")" "${PFU_F[yaw]:-0}" \
"$(pfu_msg "eyes")" "${PFU_F[eyes]:-0}" \
"$(pfu_msg "depth")" "${PFU_F[depth]:-0}" "$(pfu_msg "blink")" "${PFU_F[blink]:-0}" \
"$(pfu_msg "glare")" "${PFU_F[glare]:-0}" "$(pfu_msg "edge")" "${PFU_F[device]:-0}"
[[ -n $PFU_INTERACTIVE ]] && printf '\r\033[K%s%s%s' "$PFU_C_DIM" "$shown" "$PFU_C_RESET"
;;
result)
printf '\r\033[K'
if [[ ${PFU_F[ok]} == true ]]; then
local how=''
case "${PFU_F[liveness]:-}" in
blink) how="$(pfu_msg "blink")" ;;
depth) how="$(pfu_msg "head turn")" ;;
esac
pfu_ok "$(pfu_msg "Recognised as %s (match %s) in %s ms." "${PFU_F[name]}" "${PFU_F[score]}" "${PFU_F[ms]}")"
[[ -n $how ]] && pfu_say " $(pfu_msg "Sign of life: %s" "$how")"
else
pfu_bad "$(pfu_reason_text "${PFU_F[reason]}")"
[[ -n ${PFU_F[message]:-} ]] && pfu_say " ${PFU_F[message]}"
if [[ -n ${PFU_F[lockout]:-} ]]; then
pfu_note "$(pfu_msg "That was too many tries. Face unlock is paused until you unlock with your password.")"
fi
fi
(( started )) || true
return 0
;;
esac
done < <(pfu_ctl test 2>/dev/null)
printf '\n'
pfu_bad "$(pfu_reason_text unreachable)"
return 1
}
+584
View File
@@ -0,0 +1,584 @@
# shellcheck shell=bash
#
# The interactive front end.
#
# This is the whole configuration interface. There are two files behind it
# and the face data behind the daemon, but no part of the program ever asks
# anybody to open one: one switch on the front screen, the faces, a settings
# list, and a way to try it.
# Terminal mode.
#
# bash flips the terminal into non-canonical mode for each `read -sn1` and back
# out again in between. That gap matters: in canonical mode DEL is the ERASE
# character, so the line discipline eats it instead of delivering it, and a
# backspace typed while the interface was between reads simply vanishes.
# Holding non-canonical mode for the whole interface removes the gap.
PFU_TERM_SAVED=''
pfu_ui_term_raw() {
pfu_have stty || return 0
[[ -t 0 ]] || return 0
[[ -n $PFU_TERM_SAVED ]] && return 0
PFU_TERM_SAVED="$(stty -g 2>/dev/null)" || { PFU_TERM_SAVED=''; return 0; }
stty -icanon -echo min 1 time 0 2>/dev/null || true
}
pfu_ui_term_restore() {
[[ -n $PFU_TERM_SAVED ]] || return 0
stty "$PFU_TERM_SAVED" 2>/dev/null || true
PFU_TERM_SAVED=''
}
# Runs an action with the terminal handed back to normal line mode, so anything
# it prints or prompts for (sudo's password prompt, above all) behaves the way
# a program expects.
pfu_ui_cooked() {
pfu_ui_term_restore
"$@"
local rc=$?
pfu_ui_term_raw
return $rc
}
# pfu_read_key
# One keypress, resolved to a symbolic name. Arrow keys arrive as ESC [ A, so
# the tail of the sequence is consumed here rather than being mistaken for
# three separate presses.
pfu_read_key() {
local k rest
IFS= read -rsn1 k || return 1
case "$k" in
$'\e')
if IFS= read -rsn2 -t 0.05 rest; then
case "$rest" in
'[A') printf 'up\n' ;;
'[B') printf 'down\n' ;;
'[C') printf 'right\n' ;;
'[D') printf 'left\n' ;;
*) printf 'escape\n' ;;
esac
else
printf 'escape\n'
fi
;;
''|$'\r') printf 'enter\n' ;;
$'\x7f'|$'\b') printf 'backspace\n' ;;
' ') printf 'space\n' ;;
*) printf '%s\n' "$k" ;;
esac
}
# pfu_ui_read_line <initial>
# A minimal line editor built on pfu_read_key, with the result in
# PFU_LINE_RESULT. This exists instead of bash's own `read -r` because mixing
# line mode into a single-key interface breaks it: after one cooked-mode read
# the following `read -sn1` stops receiving keystrokes entirely.
PFU_LINE_RESULT=''
pfu_ui_read_line() {
local buf="${1:-}" key
PFU_LINE_RESULT=''
printf '%s' "$buf"
while true; do
key="$(pfu_read_key)" || { printf '\n'; return 1; }
case "$key" in
enter)
printf '\n'
PFU_LINE_RESULT="$buf"
return 0
;;
escape)
printf '\n'
return 1
;;
backspace)
if [[ -n $buf ]]; then
buf="${buf%?}"
printf '\b \b'
fi
;;
space)
buf+=' '
printf ' '
;;
up|down|left|right) ;;
*)
[[ ${#key} -eq 1 ]] || continue
buf+="$key"
printf '%s' "$key"
;;
esac
done
}
pfu_pause() {
printf '\n %s' "$(pfu_msg "Press any key to continue...")"
read -rsn1 _ || true
printf '\n'
}
# pfu_ui_confirm <question>
pfu_ui_confirm() {
local key
printf '\n %s %s ' "$1" "$(pfu_msg "[y/N]")"
key="$(pfu_read_key)" || return 1
printf '%s\n' "$key"
[[ $key == [yYjJ] ]]
}
# _pfu_row <label> <value>
# printf's %-28s pads by bytes, so a label containing "ü" comes out one column
# short. ${#s} counts characters in a UTF-8 locale, so the padding is computed
# here instead.
_pfu_row() {
local label="$1" value="$2" pad
pad=$(( 30 - ${#label} ))
(( pad < 0 )) && pad=0
printf ' %s%*s %s\n' "$label" "$pad" '' "$value"
}
_pfu_onoff() {
if [[ $1 == yes ]]; then
printf '%s%s%s' "$PFU_C_GREEN" "$(pfu_msg "ON")" "$PFU_C_RESET"
else
printf '%s%s%s' "$PFU_C_DIM" "$(pfu_msg "OFF")" "$PFU_C_RESET"
fi
}
_pfu_small_onoff() {
if [[ $1 == yes ]]; then
printf '%s%s%s' "$PFU_C_GREEN" "$(pfu_msg "on")" "$PFU_C_RESET"
else
printf '%s%s%s' "$PFU_C_DIM" "$(pfu_msg "off")" "$PFU_C_RESET"
fi
}
# pfu_value_label <Key> <value>
# How a setting's value reads in the menu.
pfu_value_label() {
case "$1:$2" in
Liveness:heavy) pfu_msg "strict (blink or turn your head)" ;;
Liveness:light) pfu_msg "basic (screens and phone edges)" ;;
Liveness:off) pfu_msg "off" ;;
Strictness:normal) pfu_msg "normal" ;;
Strictness:strict) pfu_msg "strict" ;;
Strictness:relaxed) pfu_msg "relaxed" ;;
BubbleStyle:full) pfu_msg "island with the face" ;;
BubbleStyle:minimal) pfu_msg "small pill with a lock" ;;
ScanSeconds:*) pfu_msg "%s seconds" "$2" ;;
Camera:auto) pfu_msg "automatic" ;;
*) printf '%s' "$2" ;;
esac
}
# ---------------------------------------------------------------------------
# Status
# ---------------------------------------------------------------------------
# pfu_ui_status
# Shared by the `status` subcommand and the menu header.
pfu_ui_status() {
local names='' i camera
pfu_config_load
pfu_status_load
_pfu_row "$(pfu_msg "Face unlock")" "$(_pfu_onoff "$CFG_ENABLED")"
printf '\n'
if [[ $PFU_ST_REACHABLE != yes ]]; then
_pfu_row "$(pfu_msg "Service")" "${PFU_C_YELLOW}$(pfu_msg "not running")${PFU_C_RESET}"
if [[ $CFG_ENABLED == yes ]]; then
printf '\n %s%s%s\n' "$PFU_C_DIM" "$(pfu_msg "Turning face unlock on again starts it.")" "$PFU_C_RESET"
fi
return 0
fi
pfu_faces_load
for i in "${!PFU_FACE_NAMES[@]}"; do
[[ ${PFU_FACE_ON[i]} == true ]] || continue
names="${names:+$names, }${PFU_FACE_NAMES[i]}"
done
if (( ${#PFU_FACE_IDS[@]} == 0 )); then
_pfu_row "$(pfu_msg "Faces")" "${PFU_C_YELLOW}$(pfu_msg "none set up yet")${PFU_C_RESET}"
else
_pfu_row "$(pfu_msg "Faces")" "${PFU_ST_FACES} ${PFU_C_DIM}(${names:-$(pfu_msg "all turned off")})${PFU_C_RESET}"
fi
if [[ $PFU_ST_CAMERA_PRESENT == true ]]; then
camera="${PFU_ST_CAMERA_NAME:-$PFU_ST_CAMERA}"
else
camera="${PFU_C_YELLOW}$(pfu_msg "none found")${PFU_C_RESET}"
fi
_pfu_row "$(pfu_msg "Camera")" "$camera"
_pfu_row "$(pfu_msg "Lock screen")" "$(_pfu_small_onoff "$( [[ $CFG_ENABLED == yes && $CFG_LOCK == yes ]] && echo yes || echo no)")"
_pfu_row "$(pfu_msg "sudo")" "$(_pfu_small_onoff "$(pfu_pam_enabled sudo && echo yes || echo no)")"
_pfu_row "$(pfu_msg "Admin prompts")" "$(_pfu_small_onoff "$(pfu_pam_enabled polkit-1 && echo yes || echo no)")"
_pfu_row "$(pfu_msg "Photo check")" "$(pfu_value_label Liveness "$CFG_LIVENESS")"
if (( PFU_ST_LOCKOUT > 0 )); then
_pfu_row "$(pfu_msg "Paused")" "${PFU_C_YELLOW}$(pfu_msg "for %d more minutes, or until the password is used" "$(( (PFU_ST_LOCKOUT + 59) / 60 ))")${PFU_C_RESET}"
fi
_pfu_row "$(pfu_msg "Last unlock")" "$(pfu_time_ago "$PFU_ST_LAST")"
if [[ $PFU_ST_MODELS != true ]]; then
printf '\n %s%s%s\n' "$PFU_C_RED" "$(pfu_msg "The recognition models are missing. Reinstall the package.")" "$PFU_C_RESET"
fi
}
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
# Format: scope|Key|type|default|label-msgid|choices
# scope user (this user's file), sys (the system file, through sudo) or
# pam (the service of that name, through sudo)
# type bool, choice (cycles through the choices) or camera
PFU_SETTINGS=(
"user|LockScreen|bool|yes|Unlock the lock screen"
"user|ScanOnWake|bool|yes|Look when somebody comes back to the screen"
"user|ScanOnLock|bool|no|Look right after the screen locks"
"pam|sudo|bool|no|Use for sudo in a terminal"
"pam|polkit-1|bool|no|Use for admin prompts"
"sys|Liveness|choice|heavy|Photo check|heavy,light,off"
"sys|Strictness|choice|normal|How closely a face has to match|normal,strict,relaxed"
"sys|Attention|bool|yes|Only while looking at the screen"
"sys|Camera|camera|auto|Camera"
"sys|ScanSeconds|choice|5|How long one look lasts|3,4,5,6,8,10"
"sys|Adapt|bool|yes|Learn from every unlock"
"sys|SkipLidClosed|bool|yes|Not while the lid is closed"
"user|Bubble|bool|yes|Show the bubble at the top"
"user|BubbleStyle|choice|full|Bubble style|full,minimal"
"user|BubbleForPrompts|bool|yes|Bubble for sudo and admin prompts too"
)
# _pfu_setting_value <scope> <Key> <default>
# The current value, in PFU_SETTING_VALUE.
PFU_SETTING_VALUE=''
_pfu_setting_value() {
case "$1" in
user) _pfu_kv_lookup "$PFU_CONFIG" "$2" "$3"; PFU_SETTING_VALUE="$PFU_KV_VALUE" ;;
sys) _pfu_kv_lookup "$PFU_SYSCONFIG" "$2" "$3"; PFU_SETTING_VALUE="$PFU_KV_VALUE" ;;
pam) if pfu_pam_enabled "$2"; then PFU_SETTING_VALUE=yes; else PFU_SETTING_VALUE=no; fi ;;
esac
}
# _pfu_next_choice <current> <a,b,c>
_pfu_next_choice() {
local current="$1" list="$2" first='' found=0 c
local -a choices
IFS=',' read -r -a choices <<< "$list"
for c in "${choices[@]}"; do
[[ -z $first ]] && first="$c"
if (( found )); then
printf '%s\n' "$c"
return
fi
[[ $c == "$current" ]] && found=1
done
printf '%s\n' "$first"
}
# _pfu_next_camera <current>
_pfu_next_camera() {
local current="$1" i
pfu_cameras_load
local -a all=(auto "${PFU_CAM_PATHS[@]}")
for i in "${!all[@]}"; do
if [[ ${all[i]} == "$current" ]]; then
printf '%s\n' "${all[(i + 1) % ${#all[@]}]}"
return
fi
done
printf 'auto\n'
}
_pfu_camera_label() {
local value="$1" i
if [[ $value == auto ]]; then
for i in "${!PFU_CAM_PATHS[@]}"; do
if [[ ${PFU_CAM_PATHS[i]} == "$PFU_CAM_AUTO" ]]; then
pfu_msg "automatic (%s)" "${PFU_CAM_NAMES[i]}"
return
fi
done
pfu_msg "automatic"
return
fi
for i in "${!PFU_CAM_PATHS[@]}"; do
if [[ ${PFU_CAM_PATHS[i]} == "$value" ]]; then
printf '%s' "${PFU_CAM_NAMES[i]}"
[[ ${PFU_CAM_IR[i]} == true ]] && printf ' %s' "$(pfu_msg "(infrared)")"
return
fi
done
printf '%s %s' "$value" "$(pfu_msg "(not connected)")"
}
# _pfu_setting_change <scope> <Key> <type> <current> <choices>
_pfu_setting_change() {
local scope="$1" key="$2" type="$3" current="$4" choices="$5" next
case "$type" in
bool) if pfu_is_true "$current"; then next=no; else next=yes; fi ;;
choice) next="$(_pfu_next_choice "$current" "$choices")" ;;
camera) next="$(_pfu_next_camera "$current")" ;;
esac
case "$scope" in
user)
pfu_config_set "$key" "$next" || { pfu_bad "$(pfu_msg "Could not save the setting.")"; pfu_pause; }
;;
sys)
printf '\n'
pfu_ui_cooked pfu_root set "$key" "$next" || pfu_pause
PFU_KV_CACHE=()
;;
pam)
printf '\n'
if [[ $next == yes ]]; then
pfu_ui_cooked pfu_root pam-enable "$key" || pfu_pause
else
pfu_ui_cooked pfu_root pam-disable "$key" || pfu_pause
fi
# Remembered, so that turning face unlock off and on again brings
# it back.
case "$key" in
sudo) pfu_config_set Sudo "$next" ;;
polkit-1) pfu_config_set Polkit "$next" ;;
esac
;;
esac
}
# pfu_ui_settings
# A cursor list rather than a numbered menu. The frame is assembled in memory
# and written once, and everything constant is resolved before the loop.
pfu_ui_settings() {
local count=${#PFU_SETTINGS[@]}
local -a scopes=() keys=() types=() defaults=() labels=() choices=() values=()
local spec scope key type default label choice locale i frame row pad dirty=1 cursor=0 shown
locale="$(pfu_ui_locale)"
for spec in "${PFU_SETTINGS[@]}"; do
IFS='|' read -r scope key type default label choice <<< "$spec"
scopes+=("$scope"); keys+=("$key"); types+=("$type"); defaults+=("$default"); choices+=("$choice")
pfu_msg_into "$locale" "$label"
labels+=("$PFU_MSG_RESULT")
done
local title hint legend l_on l_off
pfu_msg_into "$locale" "Settings"; title="$PFU_MSG_RESULT"
pfu_msg_into "$locale" "Up/Down: select, Space or Right: change, q: back"; hint="$PFU_MSG_RESULT"
pfu_msg_into "$locale" "Settings marked * are for the whole computer and ask for your password."; legend="$PFU_MSG_RESULT"
pfu_msg_into "$locale" "ON"; l_on="$PFU_MSG_RESULT"
pfu_msg_into "$locale" "OFF"; l_off="$PFU_MSG_RESULT"
local clearseq
clearseq="$(clear 2>/dev/null)" || clearseq=$'\033[H\033[2J'
pfu_cameras_load
while true; do
if (( dirty )); then
PFU_KV_CACHE=()
for i in "${!keys[@]}"; do
_pfu_setting_value "${scopes[i]}" "${keys[i]}" "${defaults[i]}"
values[i]="$PFU_SETTING_VALUE"
done
dirty=0
fi
frame="$clearseq"$'\n'"${PFU_C_BOLD}${PFU_C_BLUE} ${title}${PFU_C_RESET}"$'\n\n'
local marker selected="${PFU_C_BLUE}▸${PFU_C_RESET} " star
for i in "${!keys[@]}"; do
case "${types[i]}" in
bool)
if pfu_is_true "${values[i]}"; then
shown="${PFU_C_GREEN}${l_on}${PFU_C_RESET}"
else
shown="${PFU_C_DIM}${l_off}${PFU_C_RESET}"
fi
;;
camera) shown="$(_pfu_camera_label "${values[i]}")" ;;
*) shown="$(pfu_value_label "${keys[i]}" "${values[i]}")" ;;
esac
star=' '
[[ ${scopes[i]} != user ]] && star='*'
pad=$(( 44 - ${#labels[i]} ))
(( pad < 0 )) && pad=0
if (( i == cursor )); then marker="$selected"; else marker=' '; fi
printf -v row ' %s%s%s%*s %s' "$marker" "${labels[i]}" "${PFU_C_DIM}${star}${PFU_C_RESET}" "$pad" '' "$shown"
frame+="$row"$'\n'
# A gap between the groups: the lock screen, other prompts, how it
# checks, the bubble.
case "${keys[i]}" in
ScanOnLock|polkit-1|SkipLidClosed) frame+=$'\n' ;;
esac
done
frame+=$'\n'" ${PFU_C_DIM}${legend}${PFU_C_RESET}"$'\n'
frame+=" ${PFU_C_DIM}${hint}${PFU_C_RESET}"$'\n'
printf '%s' "$frame"
key="$(pfu_read_key)" || return 0
case "$key" in
up|k) cursor=$(( (cursor - 1 + count) % count )) ;;
down|j) cursor=$(( (cursor + 1) % count )) ;;
space|enter|right|l)
_pfu_setting_change "${scopes[cursor]}" "${keys[cursor]}" "${types[cursor]}" "${values[cursor]}" "${choices[cursor]}"
dirty=1
;;
q|Q|escape) return 0 ;;
*) ;;
esac
done
}
# ---------------------------------------------------------------------------
# Faces
# ---------------------------------------------------------------------------
pfu_ui_faces() {
local key frame row pad i cursor=0 count locale shown
locale="$(pfu_ui_locale)"
local title hint empty l_on l_off
pfu_msg_into "$locale" "Faces"; title="$PFU_MSG_RESULT"
pfu_msg_into "$locale" "Up/Down: select, Space: on/off, r: rename, d: delete, a: add, q: back"; hint="$PFU_MSG_RESULT"
pfu_msg_into "$locale" "No face is set up yet. Press a to add one."; empty="$PFU_MSG_RESULT"
pfu_msg_into "$locale" "on"; l_on="$PFU_MSG_RESULT"
pfu_msg_into "$locale" "off"; l_off="$PFU_MSG_RESULT"
local clearseq
clearseq="$(clear 2>/dev/null)" || clearseq=$'\033[H\033[2J'
while true; do
pfu_faces_load
count=${#PFU_FACE_IDS[@]}
(( cursor >= count )) && cursor=$(( count > 0 ? count - 1 : 0 ))
frame="$clearseq"$'\n'"${PFU_C_BOLD}${PFU_C_BLUE} ${title}${PFU_C_RESET}"$'\n\n'
if (( count == 0 )); then
frame+=" ${PFU_C_DIM}${empty}${PFU_C_RESET}"$'\n'
fi
local marker selected="${PFU_C_BLUE}▸${PFU_C_RESET} " samples
for i in "${!PFU_FACE_IDS[@]}"; do
if [[ ${PFU_FACE_ON[i]} == true ]]; then shown="${PFU_C_GREEN}${l_on}${PFU_C_RESET}"; else shown="${PFU_C_DIM}${l_off}${PFU_C_RESET}"; fi
samples="$(pfu_msg "%s samples, %s learned" "${PFU_FACE_SAMPLES[i]}" "${PFU_FACE_LEARNED[i]}")"
pad=$(( 30 - ${#PFU_FACE_NAMES[i]} ))
(( pad < 0 )) && pad=0
if (( i == cursor )); then marker="$selected"; else marker=' '; fi
printf -v row ' %s%s%*s %s %s%s%s' "$marker" "${PFU_FACE_NAMES[i]}" "$pad" '' "$shown" "$PFU_C_DIM" "$samples" "$PFU_C_RESET"
frame+="$row"$'\n'
done
frame+=$'\n'" ${PFU_C_DIM}${hint}${PFU_C_RESET}"$'\n'
printf '%s' "$frame"
key="$(pfu_read_key)" || return 0
case "$key" in
up|k) (( count )) && cursor=$(( (cursor - 1 + count) % count )) ;;
down|j) (( count )) && cursor=$(( (cursor + 1) % count )) ;;
space|enter)
(( count )) || continue
if [[ ${PFU_FACE_ON[cursor]} == true ]]; then
pfu_ctl disable "${PFU_FACE_IDS[cursor]}" > /dev/null
else
pfu_ctl enable "${PFU_FACE_IDS[cursor]}" > /dev/null
fi
;;
r|R)
(( count )) || continue
printf '\n %s ' "$(pfu_msg "New name:")"
if pfu_ui_read_line "${PFU_FACE_NAMES[cursor]}" && [[ -n $PFU_LINE_RESULT ]]; then
pfu_ctl rename "${PFU_FACE_IDS[cursor]}" "$PFU_LINE_RESULT" > /dev/null
fi
;;
d|D)
(( count )) || continue
if pfu_ui_confirm "$(pfu_msg "Delete \"%s\"?" "${PFU_FACE_NAMES[cursor]}")"; then
pfu_ctl remove "${PFU_FACE_IDS[cursor]}" > /dev/null
fi
;;
a|A)
pfu_ui_cooked pfu_do_setup
[[ -n $PFU_UI_NEEDS_ACK ]] && pfu_pause
PFU_UI_NEEDS_ACK=''
;;
q|Q|escape) return 0 ;;
*) ;;
esac
done
}
# ---------------------------------------------------------------------------
# The menu
# ---------------------------------------------------------------------------
pfu_ui_menu() {
local choice
pfu_ui_term_raw
trap 'pfu_ui_term_restore' EXIT INT TERM
while true; do
clear 2>/dev/null || true
pfu_head " $PFU_PRETTY"
pfu_ui_status
printf '\n'
printf ' [1] %s\n' "$(pfu_msg "Turn face unlock on or off")"
printf ' [2] %s\n' "$(pfu_msg "Add a face")"
printf ' [3] %s\n' "$(pfu_msg "Faces")"
printf ' [4] %s\n' "$(pfu_msg "Settings")"
printf ' [5] %s\n' "$(pfu_msg "Try it")"
printf ' [q] %s\n' "$(pfu_msg "Quit")"
printf '\n > '
choice="$(pfu_read_key)" || {
printf '\n'; pfu_ui_term_restore; trap - EXIT INT TERM; return 0
}
case "$choice" in
enter|space|up|down|left|right|escape) choice='' ;;
esac
printf '%s\n' "$choice"
PFU_UI_NEEDS_ACK=''
case "$choice" in
1)
pfu_config_load
if [[ $CFG_ENABLED == yes ]]; then
pfu_ui_cooked pfu_do_disable
else
pfu_ui_cooked pfu_do_enable
fi
[[ -n $PFU_UI_NEEDS_ACK ]] && pfu_pause
;;
2)
pfu_ui_cooked pfu_do_setup
[[ -n $PFU_UI_NEEDS_ACK ]] && pfu_pause
;;
3) pfu_ui_faces ;;
4) pfu_ui_settings ;;
5)
printf '\n'
pfu_ui_cooked pfu_test
pfu_pause
;;
q|Q) pfu_ui_term_restore; trap - EXIT INT TERM; return 0 ;;
# Anything else (Enter, arrow keys, stray characters) just
# redraws. Escape is deliberately not a quit key, so a mistyped
# arrow key cannot close the menu.
*) ;;
esac
done
}
+163
View File
@@ -0,0 +1,163 @@
# shellcheck shell=bash
#
# Putting face unlock in front of sudo and polkit's admin prompts, and taking
# it out again.
#
# Two services and nothing else. The lock screen does not go through PAM at all
# (see src/agent/lockcontroller.h), and the login screen stays with the
# password: logging in is what unlocks the wallet, and a face has no password
# to hand it.
#
# The line that goes in:
#
# -auth sufficient /usr/lib/security/pam_plasma_face_unlock.so
#
# "sufficient": a match lets the person in, anything else falls through to the
# lines below as if this one were not there. The dash makes PAM skip it
# quietly if the module ever goes missing, say because the package was removed
# without turning this off first. sudo keeps working either way.
#
# Where the service's file is:
# - /etc/pam.d/<service> exists: the line goes in before its first auth
# line, with a comment saying where it came from.
# - only the distribution's copy in /usr/lib/pam.d exists (Arch keeps
# polkit-1 there): a small /etc/pam.d/<service> is written that puts the
# line first and includes the distribution's file for everything else, so
# an update to that file still counts.
# Undoing takes out exactly those lines, or that file.
PFU_PAM_MARK="# plasma-face-unlock: the face first, the password if that does not work"
PFU_PAM_WRAPPER_MARK="# Written by plasma-face-unlock."
PFU_PAM_SERVICES=(sudo polkit-1)
# Overridable for the tests only; the root side never takes them from the
# environment (see the top of plasma-face-unlock).
PFU_PAM_ETC_DIR="${PFU_PAM_ETC_DIR:-/etc/pam.d}"
read -r -a PFU_PAM_VENDOR_DIRS <<< "${PFU_PAM_VENDOR_DIRS:-/usr/lib/pam.d /usr/share/pam.d /lib/pam.d}"
pfu_pam_etc() {
printf '%s/%s\n' "$PFU_PAM_ETC_DIR" "$1"
}
# pfu_pam_vendor <service>
# The distribution's own copy, when it keeps one outside /etc.
pfu_pam_vendor() {
local dir
for dir in "${PFU_PAM_VENDOR_DIRS[@]}"; do
if [[ -f $dir/$1 ]]; then
printf '%s\n' "$dir/$1"
return 0
fi
done
return 1
}
pfu_pam_line() {
printf -- '-auth sufficient %s\n' "$PFU_PAM_MODULE"
}
# pfu_pam_enabled <service>
pfu_pam_enabled() {
local file
file="$(pfu_pam_etc "$1")"
[[ -r $file ]] && grep -q 'pam_plasma_face_unlock\.so' "$file"
}
# pfu_pam_insert <file>
# Prints the file with the line added before its first auth line. Debian and
# Ubuntu have none in sudo's file, only "@include common-auth", and that
# counts as one: after it the password has already been asked for. A file
# with neither (which would be odd for either service) gets it at the end.
pfu_pam_insert() {
awk -v mark="$PFU_PAM_MARK" -v line="$(pfu_pam_line)" '
!done && ($0 ~ /^[[:space:]]*-?auth[[:space:]]/ || $0 ~ /^[[:space:]]*@include[[:space:]]+common-auth([[:space:]]|$)/) {
print mark
print line
done = 1
}
{ print }
END {
if (!done) {
print mark
print line
}
}
' "$1"
}
# pfu_pam_remove_lines <file>
# Prints the file without our comment and our line.
pfu_pam_remove_lines() {
awk -v mark="$PFU_PAM_MARK" '
$0 == mark { next }
/pam_plasma_face_unlock\.so/ { next }
{ print }
' "$1"
}
pfu_pam_wrapper() {
local vendor="$1"
printf '#%%PAM-1.0\n'
printf '%s The face first, then everything\n' "$PFU_PAM_WRAPPER_MARK"
printf '# %s does for this service. Removed again by\n' "$vendor"
printf '# "plasma-face-unlock disable" or from its settings.\n\n'
pfu_pam_line
printf 'auth include %s\n' "$vendor"
printf 'account include %s\n' "$vendor"
printf 'password include %s\n' "$vendor"
printf 'session include %s\n' "$vendor"
}
# _pfu_pam_replace <file> <content>
# Writes the new file next to the old one and swaps it in, with the old one's
# owner and mode. A half-written PAM file is a locked-out machine.
_pfu_pam_replace() {
local file="$1" content="$2" tmp
tmp="$(mktemp "${file}.pfu.XXXXXX")" || return 1
printf '%s\n' "$content" > "$tmp" || { rm -f "$tmp"; return 1; }
if [[ -e $file ]]; then
chown --reference="$file" "$tmp" 2>/dev/null
chmod --reference="$file" "$tmp" 2>/dev/null
else
chmod 0644 "$tmp"
fi
mv -f "$tmp" "$file"
}
# pfu_pam_enable <service> (root)
pfu_pam_enable() {
local service="$1" file vendor content
file="$(pfu_pam_etc "$service")"
[[ -e $PFU_PAM_MODULE ]] || { pfu_bad "$(pfu_msg "The PAM module is not installed at %s." "$PFU_PAM_MODULE")"; return 1; }
pfu_pam_enabled "$service" && return 0
if [[ -f $file ]]; then
content="$(pfu_pam_insert "$file")" || return 1
elif vendor="$(pfu_pam_vendor "$service")"; then
content="$(pfu_pam_wrapper "$vendor")"
else
pfu_bad "$(pfu_msg "There is no PAM configuration for %s on this system." "$service")"
return 1
fi
_pfu_pam_replace "$file" "$content"
}
# pfu_pam_disable <service> (root)
pfu_pam_disable() {
local service="$1" file content
file="$(pfu_pam_etc "$service")"
pfu_pam_enabled "$service" || return 0
if head -n 3 "$file" | grep -qF "$PFU_PAM_WRAPPER_MARK"; then
# Ours from the first line to the last. Without it the distribution's
# own copy is in charge again.
rm -f -- "$file"
return
fi
content="$(pfu_pam_remove_lines "$file")" || return 1
_pfu_pam_replace "$file" "$content"
}
+122
View File
@@ -0,0 +1,122 @@
# shellcheck shell=bash
#
# The few things that need root, and how the menu gets them done.
#
# The menu runs as the user. When it needs root it runs this same program
# again through sudo (or run0, or doas) with --root and one verb, the way
# cachy-auto-update does, so the password is typed into the terminal the user
# is already looking at. The verbs are all there is: there is no way to hand
# the root side a command of one's own.
#
# --root set <Key> <Value> one line of /etc/plasma-face-unlock/config
# --root pam-enable <service> sudo or polkit-1, see pam.sh
# --root pam-disable <service>
# --root socket-enable start the daemon's socket, and at boot
# --root socket-disable
PFU_SELF="${PFU_SELF:-$(readlink -f "${BASH_SOURCE[1]:-$0}")}"
# What each system setting may be set to. Anything else is refused on the root
# side, whatever the menu sent.
declare -A PFU_SYS_VALID=(
[Camera]='^(auto|/dev/video[0-9]+)$'
[Liveness]='^(off|light|heavy)$'
[Strictness]='^(relaxed|normal|strict)$'
[Attention]='^(yes|no)$'
[ScanSeconds]='^([2-9]|1[0-5])$'
[Adapt]='^(yes|no)$'
[SkipLidClosed]='^(yes|no)$'
[MaxFailures]='^([1-9]|1[0-9]|20)$'
[LockoutMinutes]='^[1-9][0-9]{0,3}$'
)
# pfu_root <verb> [args...]
pfu_root() {
local candidate
if [[ $EUID -eq 0 ]]; then
pfu_root_verb "$@"
return
fi
for candidate in sudo run0 doas; do
if pfu_have "$candidate"; then
"$candidate" "$PFU_SELF" --root "$@"
return
fi
done
pfu_bad "$(pfu_msg "This needs root, and neither sudo, run0 nor doas is installed.")"
return 1
}
pfu_root_verb() {
local verb="${1:-}"
[[ $# -gt 0 ]] && shift
if [[ $EUID -ne 0 ]]; then
pfu_bad "$(pfu_msg "This command has to run as root.")"
return 2
fi
case "$verb" in
set)
local key="${1:-}" value="${2:-}"
if [[ -z ${PFU_SYS_VALID[$key]+set} || ! $value =~ ${PFU_SYS_VALID[$key]} ]]; then
pfu_bad "$(pfu_msg "Not a valid setting: %s=%s" "$key" "$value")"
return 1
fi
pfu_kv_set "$PFU_SYSCONFIG" "$key" "$value" "$PFU_PRETTY (system settings)" || return 1
chmod 0644 "$PFU_SYSCONFIG"
;;
pam-enable|pam-disable)
local service="${1:-}" known=0 s
for s in "${PFU_PAM_SERVICES[@]}"; do
[[ $s == "$service" ]] && known=1
done
(( known )) || { pfu_bad "$(pfu_msg "Not a service this can be used for: %s" "$service")"; return 1; }
if [[ $verb == pam-enable ]]; then
pfu_pam_enable "$service"
else
pfu_pam_disable "$service"
fi
;;
socket-enable)
systemctl enable --now "$PFU_UNIT_SOCKET" > /dev/null 2>&1
;;
socket-disable)
systemctl disable --now "$PFU_UNIT_SOCKET" > /dev/null 2>&1
;;
*)
pfu_bad "$(pfu_msg "Unknown command: %s" "$verb")"
return 1
;;
esac
}
# ---------------------------------------------------------------------------
# The two services
# ---------------------------------------------------------------------------
pfu_socket_enabled() {
systemctl is-enabled --quiet "$PFU_UNIT_SOCKET" 2>/dev/null
}
pfu_agent_available() {
pfu_have systemctl && [[ -n ${XDG_RUNTIME_DIR:-} ]]
}
pfu_agent_enabled() {
systemctl --user is-enabled --quiet "$PFU_UNIT_AGENT" 2>/dev/null
}
pfu_agent_running() {
systemctl --user is-active --quiet "$PFU_UNIT_AGENT" 2>/dev/null
}
pfu_agent_enable() {
systemctl --user daemon-reload > /dev/null 2>&1 || true
systemctl --user enable --now "$PFU_UNIT_AGENT" > /dev/null 2>&1
}
pfu_agent_disable() {
systemctl --user disable --now "$PFU_UNIT_AGENT" > /dev/null 2>&1 || true
}
+350
View File
@@ -0,0 +1,350 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// pam_plasma_face_unlock: face unlock for sudo and for admin prompts.
//
// All the vision is in the daemon. This asks it to scan for the user and
// turns the answer into a PAM result, and it is meant to sit first in a stack
// as "auth sufficient": a match lets the person in, anything else falls
// through to the password as if the module was not there.
//
// It refuses to be useful in exactly the places where a camera is the wrong
// witness:
// - a remote login (SSH, a remote host in PAM_RHOST). Whoever is in front
// of the camera is not the person typing.
// - a user who is not sitting at the machine right now, with an active
// session on a seat.
// In both cases it returns PAM_IGNORE without touching the camera.
//
// Options:
// purpose=sudo|polkit|other what the bubble says (default: from the
// service name)
// socket=PATH the daemon's socket (for development)
// timeout=SECONDS give up waiting for the daemon after this
// debug log to the auth log what happened
#define _GNU_SOURCE
#define PAM_SM_AUTH
#include "buildconfig.h"
#include <security/pam_ext.h>
#include <security/pam_modules.h>
#include <errno.h>
#include <libintl.h>
#include <poll.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <syslog.h>
#include <time.h>
#include <unistd.h>
#ifdef HAVE_SYSTEMD
#include <pwd.h>
#include <systemd/sd-login.h>
#endif
#define DOMAIN PFU_NAME
#define _(s) dgettext(DOMAIN, s)
struct options {
const char *socket;
const char *purpose;
int timeout;
bool debug;
};
static void parse_options(struct options *o, int argc, const char **argv)
{
o->socket = PFU_SOCKET;
o->purpose = NULL;
o->timeout = 25;
o->debug = false;
for (int i = 0; i < argc; ++i) {
if (strncmp(argv[i], "socket=", 7) == 0) {
o->socket = argv[i] + 7;
} else if (strncmp(argv[i], "purpose=", 8) == 0) {
o->purpose = argv[i] + 8;
} else if (strncmp(argv[i], "timeout=", 8) == 0) {
o->timeout = atoi(argv[i] + 8);
if (o->timeout < 3 || o->timeout > 120) {
o->timeout = 25;
}
} else if (strcmp(argv[i], "debug") == 0) {
o->debug = true;
}
}
}
static long long now_ms(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
}
static bool nonempty(const char *s)
{
return s && *s;
}
// Whoever types this is not whoever sits in front of the camera.
static bool is_remote(pam_handle_t *pamh)
{
const void *rhost = NULL;
if (pam_get_item(pamh, PAM_RHOST, &rhost) == PAM_SUCCESS && nonempty(rhost) && strcmp(rhost, "localhost") != 0) {
return true;
}
static const char *const vars[] = {"SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"};
for (size_t i = 0; i < sizeof(vars) / sizeof(vars[0]); ++i) {
if (nonempty(getenv(vars[i])) || nonempty(pam_getenv(pamh, vars[i]))) {
return true;
}
}
#ifdef HAVE_SYSTEMD
char *session = NULL;
if (sd_pid_get_session(0, &session) >= 0 && session) {
const bool remote = sd_session_is_remote(session) > 0;
free(session);
if (remote) {
return true;
}
}
#endif
return false;
}
// The person has to be at the machine: an active session on a seat.
static bool is_at_seat(const char *user)
{
#ifdef HAVE_SYSTEMD
struct passwd pw, *result = NULL;
char buf[4096];
if (getpwnam_r(user, &pw, buf, sizeof(buf), &result) != 0 || !result) {
return false;
}
// The number of seats the user is active on, whatever they are called.
return sd_uid_get_seats(result->pw_uid, 1, NULL) > 0;
#else
(void)user;
return true;
#endif
}
static int connect_daemon(const struct options *o, pam_handle_t *pamh)
{
struct sockaddr_un addr = {.sun_family = AF_UNIX};
if (strlen(o->socket) >= sizeof(addr.sun_path)) {
return -1;
}
strcpy(addr.sun_path, o->socket);
const int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
if (fd < 0) {
return -1;
}
struct timeval tv = {.tv_sec = 3};
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) {
if (o->debug) {
pam_syslog(pamh, LOG_DEBUG, "cannot reach the daemon at %s: %s", o->socket, strerror(errno));
}
close(fd);
return -1;
}
// Only root may answer for root. When this runs as somebody else (the
// tests), a daemon of that same user is no less trusted than the process
// asking.
struct ucred cred;
socklen_t len = sizeof(cred);
if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &len) != 0 || (cred.uid != 0 && cred.uid != geteuid())) {
pam_syslog(pamh, LOG_WARNING, "refusing a face unlock daemon that does not run as root");
close(fd);
return -1;
}
return fd;
}
// The daemon's answers are compact JSON objects with plain values. It runs as
// root and is the one party here that is trusted, so this only has to be
// correct for well formed input and safe for anything else.
static bool json_string(const char *line, const char *key, char *out, size_t size)
{
char pattern[64];
snprintf(pattern, sizeof(pattern), "\"%s\":\"", key);
const char *p = strstr(line, pattern);
if (!p || size == 0) {
return false;
}
p += strlen(pattern);
size_t n = 0;
while (*p && *p != '"' && n + 1 < size) {
if (*p == '\\' && p[1]) {
++p;
}
out[n++] = *p++;
}
out[n] = '\0';
return true;
}
static bool json_true(const char *line, const char *key)
{
char pattern[64];
snprintf(pattern, sizeof(pattern), "\"%s\":true", key);
return strstr(line, pattern) != NULL;
}
static void say(pam_handle_t *pamh, int flags, const char *message)
{
if (!(flags & PAM_SILENT)) {
pam_info(pamh, "%s", message);
}
}
__attribute__((visibility("default"))) PAM_EXTERN int pam_sm_authenticate(pam_handle_t *pamh, int flags, int argc, const char **argv)
{
struct options o;
parse_options(&o, argc, argv);
bindtextdomain(DOMAIN, PFU_LOCALEDIR);
const char *user = NULL;
if (pam_get_user(pamh, &user, NULL) != PAM_SUCCESS || !nonempty(user)) {
return PAM_IGNORE;
}
const void *service = NULL;
pam_get_item(pamh, PAM_SERVICE, &service);
const char *purpose = o.purpose;
if (!purpose) {
purpose = !service ? "other"
: strncmp(service, "sudo", 4) == 0 ? "sudo"
: strcmp(service, "polkit-1") == 0 ? "polkit"
: "other";
}
if (is_remote(pamh)) {
if (o.debug) {
pam_syslog(pamh, LOG_DEBUG, "remote session, not scanning for %s", user);
}
return PAM_IGNORE;
}
if (!is_at_seat(user)) {
if (o.debug) {
pam_syslog(pamh, LOG_DEBUG, "%s is not at the machine, not scanning", user);
}
return PAM_IGNORE;
}
const int fd = connect_daemon(&o, pamh);
if (fd < 0) {
return PAM_IGNORE;
}
char request[512];
const int len = snprintf(request, sizeof(request), "{\"cmd\":\"verify\",\"user\":\"%s\",\"purpose\":\"%s\"}\n", user, purpose);
if (len <= 0 || (size_t)len >= sizeof(request) || strpbrk(user, "\"\\") || write(fd, request, (size_t)len) != len) {
close(fd);
return PAM_IGNORE;
}
const long long deadline = now_ms() + (long long)o.timeout * 1000;
char buf[8192];
size_t used = 0;
int rc = PAM_AUTHINFO_UNAVAIL;
bool done = false;
bool told = false;
while (!done) {
const long long left = deadline - now_ms();
if (left <= 0) {
break;
}
struct pollfd pfd = {.fd = fd, .events = POLLIN};
const int ready = poll(&pfd, 1, (int)left);
if (ready < 0 && errno == EINTR) {
// Ctrl+C in sudo. Stop the camera and go on to the password.
break;
}
if (ready <= 0) {
break;
}
const ssize_t got = read(fd, buf + used, sizeof(buf) - 1 - used);
if (got <= 0) {
break;
}
used += (size_t)got;
buf[used] = '\0';
char *line = buf;
char *nl;
while ((nl = strchr(line, '\n'))) {
*nl = '\0';
char event[32] = "", value[128] = "";
json_string(line, "event", event, sizeof(event));
if (strcmp(event, "started") == 0 && !told) {
told = true;
say(pamh, flags, _("Look at the camera to unlock."));
} else if (strcmp(event, "hint") == 0 && json_string(line, "hint", value, sizeof(value))) {
if (strcmp(value, "blink") == 0) {
say(pamh, flags, _("Blink, or turn your head a little."));
} else if (strcmp(value, "look") == 0) {
say(pamh, flags, _("Look at the screen."));
} else if (strcmp(value, "closer") == 0) {
say(pamh, flags, _("Move closer to the camera."));
} else if (strcmp(value, "light") == 0) {
say(pamh, flags, _("It is too dark to see your face."));
}
} else if (strcmp(event, "result") == 0) {
done = true;
json_string(line, "reason", value, sizeof(value));
if (json_true(line, "ok")) {
rc = PAM_SUCCESS;
pam_syslog(pamh, LOG_NOTICE, "face recognised for %s (%s)", user, purpose);
} else if (strcmp(value, "lockout") == 0 || strstr(line, "\"lockout\":")) {
rc = PAM_AUTH_ERR;
say(pamh, flags, _("Face unlock is paused after too many tries. Use your password."));
} else if (strcmp(value, "mismatch") == 0 || strcmp(value, "spoof") == 0 || strcmp(value, "liveness") == 0) {
rc = PAM_AUTH_ERR;
pam_syslog(pamh, LOG_NOTICE, "face not recognised for %s (%s)", user, value);
say(pamh, flags, _("Face not recognised."));
} else if (strcmp(value, "no-face") == 0 || strcmp(value, "attention") == 0 || strcmp(value, "quality") == 0) {
rc = PAM_AUTH_ERR;
} else {
// Not set up, no camera, lid shut, busy: face unlock is
// simply not available right now.
rc = PAM_AUTHINFO_UNAVAIL;
if (o.debug) {
pam_syslog(pamh, LOG_DEBUG, "face unlock unavailable for %s: %s", user, value);
}
}
break;
}
line = nl + 1;
}
// Keep what is left of an unfinished line.
used = strlen(line);
memmove(buf, line, used + 1);
if (used >= sizeof(buf) - 1) {
break;
}
}
close(fd);
return rc;
}
__attribute__((visibility("default"))) PAM_EXTERN int pam_sm_setcred(pam_handle_t *pamh, int flags, int argc, const char **argv)
{
(void)pamh;
(void)flags;
(void)argc;
(void)argv;
return PAM_IGNORE;
}
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env bash
#
# plasma-face-unlock: face unlock for KDE Plasma
#
# Look at the screen and it unlocks, the way a phone does. The lock screen,
# sudo in a terminal and the admin password prompts can all take a face
# instead of a password, with a check that it is a face and not a photo of one.
#
# This is the front end: the menu and the commands. The camera, the face data
# and the decision are the daemon's (plasma-face-unlockd, running as root),
# the lock screen and the bubble at the top of the screen are the agent's
# (plasma-face-unlock-agent, in the session), and sudo and polkit reach the
# daemon through a PAM module. See the man page for how the pieces fit.
#
# Copyright (C) 2026 Felitendo
# SPDX-License-Identifier: GPL-3.0-or-later
set -uo pipefail
PFU_SELF="$(readlink -f "${BASH_SOURCE[0]}")"
# Run as root (through sudo from the menu), only what was installed counts.
# An environment that points the libraries somewhere else is ignored then.
if [[ $EUID -eq 0 ]]; then
unset PFU_LIBDIR PFU_LIBEXECDIR PFU_LOCALEDIR PFU_PAMDIR PFU_CTL PFU_AGENT PFU_PAM_MODULE PFU_SYSCONFIG \
PFU_PAM_ETC_DIR PFU_PAM_VENDOR_DIRS
fi
PFU_LIBDIR="${PFU_LIBDIR:-@LIBDIR@}"
for _mod in common config daemon pam system menu; do
# shellcheck source=/dev/null
if ! source "$PFU_LIBDIR/$_mod.sh"; then
printf 'plasma-face-unlock: cannot load %s/%s.sh\n' "$PFU_LIBDIR" "$_mod" >&2
exit 14
fi
done
unset _mod
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
# The daemon is started by its socket. Enabling the socket is the one thing
# that needs root once per machine.
pfu_ensure_service() {
pfu_status_load && return 0
if ! pfu_socket_enabled; then
pfu_say " $(pfu_msg "Face unlock's service has to be switched on once for this computer. That needs your password.")"
pfu_root socket-enable || return 1
sleep 0.3
fi
pfu_status_load && return 0
pfu_bad "$(pfu_reason_text unreachable)"
pfu_note "$(pfu_msg "Check it with: systemctl status %s" "$PFU_UNIT_SOCKET")"
return 1
}
pfu_do_setup() {
local name="${1:-}" rc
pfu_ensure_service || return 1
if [[ -z ${WAYLAND_DISPLAY:-} && -z ${DISPLAY:-} ]]; then
pfu_bad "$(pfu_msg "Setting up a face needs the camera picture on screen. Run this inside your Plasma session.")"
return 1
fi
pfu_say " $(pfu_msg "The setup window is open. Follow it there.")"
if [[ -n $name ]]; then
"$PFU_AGENT" --enroll --name "$name" > /dev/null 2>&1
else
"$PFU_AGENT" --enroll > /dev/null 2>&1
fi
rc=$?
if (( rc == 0 )); then
pfu_ok "$(pfu_msg "The face is set up.")"
pfu_config_load
if [[ $CFG_ENABLED != yes ]]; then
pfu_note "$(pfu_msg "Face unlock is still off. Turn it on with [1] or \`%s enable\`." "$PFU_NAME")"
fi
return 0
fi
pfu_note "$(pfu_msg "No face was added.")"
return 1
}
pfu_do_enable() {
pfu_ensure_service || return 1
pfu_faces_load
if (( ${#PFU_FACE_IDS[@]} == 0 )); then
pfu_say " $(pfu_msg "First, set up your face.")"
pfu_do_setup || return 1
fi
pfu_config_set Enabled yes || { pfu_bad "$(pfu_msg "Could not save the setting.")"; return 1; }
pfu_config_load
if pfu_agent_available; then
pfu_agent_enable || pfu_bad "$(pfu_msg "Could not start the lock screen agent.")"
else
pfu_note "$(pfu_msg "No systemd user session, so the lock screen agent was not started.")"
fi
# What was on the last time face unlock was on.
[[ $CFG_SUDO == yes ]] && ! pfu_pam_enabled sudo && pfu_root pam-enable sudo
[[ $CFG_POLKIT == yes ]] && ! pfu_pam_enabled polkit-1 && pfu_root pam-enable polkit-1
pfu_ok "$(pfu_msg "Face unlock is on. Lock the screen and look at it to try.")"
if [[ $CFG_SUDO != yes && $CFG_POLKIT != yes ]]; then
pfu_note "$(pfu_msg "It can do sudo and admin prompts too. See Settings.")"
fi
}
pfu_do_disable() {
pfu_config_set Enabled no || { pfu_bad "$(pfu_msg "Could not save the setting.")"; return 1; }
pfu_agent_available && pfu_agent_disable
local service
for service in "${PFU_PAM_SERVICES[@]}"; do
if pfu_pam_enabled "$service"; then
pfu_root pam-disable "$service" || true
fi
done
pfu_ok "$(pfu_msg "Face unlock is off. Your faces are kept; delete them under Faces.")"
}
pfu_do_status() {
pfu_head " $PFU_PRETTY"
pfu_ui_status
printf '\n'
}
pfu_do_faces() {
local i state
pfu_status_load || { pfu_bad "$(pfu_reason_text unreachable)"; return 1; }
pfu_faces_load
if (( ${#PFU_FACE_IDS[@]} == 0 )); then
pfu_note "$(pfu_msg "No face is set up yet.")"
return 0
fi
for i in "${!PFU_FACE_IDS[@]}"; do
if [[ ${PFU_FACE_ON[i]} == true ]]; then state="$(pfu_msg "on")"; else state="$(pfu_msg "off")"; fi
printf ' %s %-24s %-4s %s\n' "${PFU_FACE_IDS[i]}" "${PFU_FACE_NAMES[i]}" "$state" \
"$(pfu_msg "%s samples, %s learned" "${PFU_FACE_SAMPLES[i]}" "${PFU_FACE_LEARNED[i]}")"
done
}
pfu_do_remove() {
local id="${1:-}" line
[[ -n $id ]] || { pfu_bad "$(pfu_msg "Which face? See \`%s faces\` for the ids." "$PFU_NAME")"; return 1; }
line="$(pfu_ctl remove "$id" | tail -n1)"
_pfu_fields "$line"
if [[ ${PFU_F[ok]:-} == true ]]; then
pfu_ok "$(pfu_msg "Deleted.")"
else
pfu_bad "$(pfu_reason_text "${PFU_F[reason]:-unreachable}")"
return 1
fi
}
pfu_do_help() {
cat <<- EOF
$PFU_PRETTY $PFU_VERSION
$(pfu_msg "Usage: plasma-face-unlock [command]")
$(pfu_msg "Commands:")
enable $(pfu_msg "Turn face unlock on")
disable $(pfu_msg "Turn it off (faces are kept)")
setup [NAME] $(pfu_msg "Add a face")
faces $(pfu_msg "List the faces")
remove ID $(pfu_msg "Delete a face")
test $(pfu_msg "Look at the camera and see what it sees")
status $(pfu_msg "Show what is on")
-h, --help $(pfu_msg "Show this help")
-V, --version $(pfu_msg "Show the version")
$(pfu_msg "Without a command an interactive menu is shown.")
EOF
}
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
main() {
local cmd="${1:-}"
[[ $# -gt 0 ]] && shift
case "$cmd" in
--root) pfu_root_verb "$@"; return ;;
esac
# Face data and settings are per user; root has neither a lock screen
# nor a face of its own to set up.
if [[ $EUID -eq 0 && -z ${PFU_ALLOW_ROOT:-} ]]; then
pfu_bad "$(pfu_msg "Run this as your own user, not as root. It asks for your password when it needs to.")"
exit 2
fi
case "$cmd" in
enable) pfu_do_enable ;;
disable) pfu_do_disable ;;
setup|add|enroll) pfu_do_setup "$@" ;;
faces|list) pfu_do_faces ;;
remove|delete) pfu_do_remove "$@" ;;
test|try) pfu_ensure_service && pfu_test ;;
status) pfu_do_status ;;
-h|--help|help) pfu_do_help ;;
-V|--version) printf '%s %s\n' "$PFU_NAME" "$PFU_VERSION" ;;
'') pfu_ui_menu ;;
*)
pfu_bad "$(pfu_msg "Unknown command: %s" "$cmd")"
printf '\n'
pfu_do_help
exit 1
;;
esac
}
main "$@"
+12
View File
@@ -0,0 +1,12 @@
// Filled in by CMake. The one place the compiled programs learn where the
// rest of the installation is.
#pragma once
#define PFU_VERSION "@PFU_VERSION@"
#define PFU_NAME "plasma-face-unlock"
#define PFU_LIBEXECDIR "@PFU_LIBEXECDIR@"
#define PFU_MODELDIR "@PFU_MODELDIR@"
#define PFU_LOCALEDIR "@PFU_LOCALEDIR@"
#define PFU_SOCKET "@PFU_SOCKET@"
#define PFU_STATEDIR "@PFU_STATEDIR@"
#define PFU_CONFIG "@PFU_CONFIG@"
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more