feat: add plasma-face-unlock
This commit is contained in:
commit
f671acc93b
105 files changed
+13862
No files matched your search
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
@@ -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']
|
||||
@@ -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
|
||||
@@ -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
@@ -0,0 +1,10 @@
|
||||
# build artifacts
|
||||
build/
|
||||
po/*.mo
|
||||
doc/*.1
|
||||
|
||||
# the networks, fetched by `make models`
|
||||
models/
|
||||
|
||||
# packages
|
||||
dist/
|
||||
@@ -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
@@ -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()
|
||||
@@ -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>.
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Executable
+75
@@ -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"
|
||||
Executable
+43
@@ -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
|
||||
Executable
+36
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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
|
||||
Executable
+88
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ""
|
||||
Executable
+38
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 {};
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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())}};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 "$@"
|
||||
@@ -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
Reference in new issue
Block a user