Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ce9d77c33 | ||
|
|
31b223db38 | ||
|
|
f572dfbc3d | ||
|
|
e4b76b0eaf | ||
|
|
c057bd1ad4 | ||
|
|
7317990620 | ||
|
|
9dbd22478f | ||
|
|
39e8a7a588 | ||
|
|
ff0068907b | ||
|
|
8224b6f1d7 | ||
|
|
b8cdd255ed | ||
|
|
b24703eeea | ||
|
|
b37996d518 | ||
|
|
1da82f21fc | ||
|
|
5699158bb1 | ||
|
|
c228291520 | ||
|
|
c693ca216c | ||
|
|
2a9b0d3f91 | ||
|
|
f232f796fc | ||
|
|
0824c188fe | ||
|
|
9586f4cd3c | ||
|
|
a6542de4a0 | ||
|
|
def308425c | ||
|
|
eaec9325af | ||
|
|
bccd1f5510 | ||
|
|
b75c2868fe | ||
|
|
6e6a6e6d03 | ||
|
|
44449f103b | ||
|
|
0d5a7e9ea4 | ||
|
|
7f6ac327cc | ||
|
|
a8e962f4b5 | ||
|
|
8d11676740 | ||
|
|
b9c3b324a3 | ||
|
|
72e8231344 | ||
|
|
75a4943e69 | ||
|
|
c972ce3c5b |
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
buy_me_a_coffee: felitendo
|
||||
ko_fi: felitendo
|
||||
@@ -0,0 +1,35 @@
|
||||
name: 💡 Feature request
|
||||
description: An idea to make face-unlock better.
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Before you start
|
||||
options:
|
||||
- label: I searched the issues and this is not requested yet.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What problem would this solve?
|
||||
description: What are you trying to do, and what gets in the way?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: idea
|
||||
attributes:
|
||||
label: What would you like?
|
||||
description: How it could work. A rough idea is fine.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Other ways you thought of
|
||||
- type: textarea
|
||||
id: more
|
||||
attributes:
|
||||
label: Anything else?
|
||||
description: Mockups, screenshots, or other tools that do it well.
|
||||
@@ -0,0 +1,97 @@
|
||||
name: 🐛 Bug report
|
||||
description: Something does not work as it should.
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Thanks for taking the time! The more you fill in, the faster it can be fixed.
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Before you start
|
||||
options:
|
||||
- label: I searched the issues and this is not reported yet.
|
||||
required: true
|
||||
- label: I use the latest version.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: What did you do, and what went wrong?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
placeholder: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: What did you expect?
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: where
|
||||
attributes:
|
||||
label: Where does it happen?
|
||||
multiple: true
|
||||
options:
|
||||
- Lock screen
|
||||
- sudo
|
||||
- Admin prompts
|
||||
- Setting up a face
|
||||
- The menu
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: camera
|
||||
attributes:
|
||||
label: Camera
|
||||
description: The model, and if it is an infrared camera.
|
||||
placeholder: Logitech C920, not infrared
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: The output of `face-unlock --version`.
|
||||
placeholder: face-unlock 2.0.0
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: install
|
||||
attributes:
|
||||
label: How did you install it?
|
||||
options:
|
||||
- AUR (Arch, CachyOS, EndeavourOS, Manjaro)
|
||||
- Fedora package
|
||||
- Debian or Ubuntu package
|
||||
- Built from source
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: system
|
||||
attributes:
|
||||
label: System
|
||||
description: Distribution, desktop and its version, and on Hyprland or Niri the lock screen.
|
||||
placeholder: CachyOS, Hyprland 0.56, hyprlock
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Status and logs
|
||||
description: The output of `face-unlock status`. If a scan goes wrong, also `journalctl -b -u face-unlockd` and `journalctl --user -b -u face-unlock-agent`.
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: more
|
||||
attributes:
|
||||
label: Anything else?
|
||||
description: Screenshots, videos or anything else that could help.
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -80,7 +80,8 @@ while IFS=$'\t' read -r sha subject body; do
|
||||
perf:* | perf\(*) kind=enh ;;
|
||||
docs:* | docs\(*) kind=docs ;;
|
||||
chore* | ci:* | ci\(* | build* | refactor* | test* | style*) kind=maint ;;
|
||||
# Older commits without a prefix, sorted by their first word.
|
||||
# Older commits without a prefix, sorted by what they say.
|
||||
*README* | *readme* | *Readme*) kind=docs ;;
|
||||
Add\ * | Put\ * | Introduce\ *) kind=feat ;;
|
||||
Fix\ * | Repair\ * | Recover\ * | Stop\ *) kind=fix ;;
|
||||
*\ *) kind=enh ;;
|
||||
@@ -124,7 +125,7 @@ $entry
|
||||
|
||||
If $name is useful to you, you can buy me a coffee. It keeps these tools going. Found a bug or have an idea? Tell me in the [issues](https://github.com/$repo/issues).
|
||||
|
||||
<a href="https://buymeacoffee.com/felitendo"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="48"></a>
|
||||
<a href="https://ko-fi.com/felitendo"><img src="https://storage.ko-fi.com/cdn/kofi5.png?v=6" alt="Buy me a coffee on Ko-fi" height="48"></a>
|
||||
|
||||
----
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
check:
|
||||
name: build, tests and shellcheck
|
||||
runs-on: ubuntu-latest
|
||||
# Arch has every Plasma 6 and Qt 6 development package this needs, and the
|
||||
# Arch has every Qt 6 and KDE Frameworks 6 package this needs, and the
|
||||
# newest OpenCV, which is the one that moved things around.
|
||||
container: archlinux:latest
|
||||
steps:
|
||||
|
||||
@@ -24,12 +24,12 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
# Plasma 6 arrived in Debian with trixie.
|
||||
# Qt 6 and KDE Frameworks 6 arrived in Debian with trixie.
|
||||
- name: Debian 13
|
||||
image: debian:trixie
|
||||
codename: trixie
|
||||
suffix: "~deb13"
|
||||
- name: Kubuntu 26.04
|
||||
- name: Ubuntu 26.04
|
||||
image: ubuntu:26.04
|
||||
codename: resolute
|
||||
suffix: "~ubuntu26.04"
|
||||
@@ -116,8 +116,8 @@ jobs:
|
||||
|
||||
- name: Look inside what was built
|
||||
run: |
|
||||
rpm -qip dist/plasma-face-unlock-[0-9]*.rpm
|
||||
rpm -qlp dist/plasma-face-unlock-[0-9]*.rpm
|
||||
rpm -qip dist/face-unlock-[0-9]*.rpm
|
||||
rpm -qlp dist/face-unlock-[0-9]*.rpm
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -239,7 +239,7 @@ jobs:
|
||||
- name: Debian 13
|
||||
image: debian:trixie
|
||||
codename: trixie
|
||||
- name: Kubuntu 26.04
|
||||
- name: Ubuntu 26.04
|
||||
image: ubuntu:26.04
|
||||
codename: resolute
|
||||
container: ${{ matrix.image }}
|
||||
@@ -254,13 +254,13 @@ jobs:
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y --no-install-recommends gpg
|
||||
install -d -m 0755 /etc/apt/keyrings
|
||||
gpg --dearmor -o /etc/apt/keyrings/plasma-face-unlock.gpg < pages/KEY.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/plasma-face-unlock.gpg] file://$PWD/pages/deb/${{ matrix.codename }} ./" \
|
||||
> /etc/apt/sources.list.d/plasma-face-unlock.list
|
||||
gpg --dearmor -o /etc/apt/keyrings/face-unlock.gpg < pages/KEY.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/face-unlock.gpg] file://$PWD/pages/deb/${{ matrix.codename }} ./" \
|
||||
> /etc/apt/sources.list.d/face-unlock.list
|
||||
apt-get update
|
||||
apt-get install -y plasma-face-unlock
|
||||
apt-get install -y face-unlock
|
||||
useradd -m tester
|
||||
runuser -u tester -- plasma-face-unlock --version
|
||||
runuser -u tester -- face-unlock --version
|
||||
|
||||
verify-dnf:
|
||||
name: Install from the RPM repository
|
||||
@@ -281,15 +281,15 @@ jobs:
|
||||
run: |
|
||||
rpm --import pages/KEY.gpg
|
||||
rpm --import signer/rpm-signer.asc
|
||||
cat > /etc/yum.repos.d/plasma-face-unlock.repo <<EOF
|
||||
[plasma-face-unlock]
|
||||
name=plasma-face-unlock
|
||||
cat > /etc/yum.repos.d/face-unlock.repo <<EOF
|
||||
[face-unlock]
|
||||
name=face-unlock
|
||||
baseurl=file://$PWD/pages/rpm
|
||||
enabled=1
|
||||
gpgcheck=1
|
||||
repo_gpgcheck=1
|
||||
gpgkey=file://$PWD/pages/KEY.gpg
|
||||
EOF
|
||||
dnf install -y plasma-face-unlock util-linux
|
||||
dnf install -y face-unlock util-linux
|
||||
useradd -m tester
|
||||
runuser -u tester -- plasma-face-unlock --version
|
||||
runuser -u tester -- face-unlock --version
|
||||
+26
-2
@@ -1,6 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
What each release brings, newest first. The [GitHub releases](https://github.com/LoonixTools/plasma-face-unlock/releases) add every commit that went into it.
|
||||
What each release brings, newest first. The [GitHub releases](https://github.com/LoonixTools/face-unlock/releases) add every commit that went into it.
|
||||
|
||||
## v2.0.0
|
||||
|
||||
_2026-09-26_
|
||||
|
||||
Welcome to face-unlock `v2.0.0`! plasma-face-unlock has a new name, because it no longer needs Plasma: it now works on GNOME, Hyprland and Niri too.
|
||||
|
||||
<p align="center">
|
||||
<img width="480" alt="The window that asks which lock screen to use on Hyprland: face-unlock's own with the bubble, or your own hyprlock with a line of text at the top" src="https://raw.githubusercontent.com/LoonixTools/face-unlock/v2.0.0/res/screenshots/lock-choice.png">
|
||||
</p>
|
||||
|
||||
### 🚨 Breaking changes
|
||||
|
||||
- plasma-face-unlock is now called face-unlock, and so is the command. Your faces and settings move over on their own.
|
||||
- On Arch, `yay -S face-unlock` replaces the old package.
|
||||
- On Fedora, Debian and Ubuntu the repository moved too. Remove the old `plasma-face-unlock` repository file and add the new one from the [install steps](https://github.com/LoonixTools/face-unlock#install).
|
||||
|
||||
### Highlights
|
||||
|
||||
- Works on GNOME, Hyprland and Niri
|
||||
- Unlock hyprlock, swaylock, gtklock and waylock with your face
|
||||
- A lock screen of its own, with the bubble and your wallpaper
|
||||
- 12 new languages
|
||||
- A warning when no polkit agent runs
|
||||
|
||||
## v1.0.1
|
||||
|
||||
@@ -17,7 +41,7 @@ _2026-09-23_
|
||||
Welcome to the very first release of plasma-face-unlock! It brings Face ID to KDE Plasma: look at the screen and it unlocks. The lock screen, sudo and admin prompts can all take your face instead of a password.
|
||||
|
||||
<p align="center">
|
||||
<img width="480" alt="The bubble drops down over the lock screen, finds the face and shows a green tick" src="https://raw.githubusercontent.com/LoonixTools/plasma-face-unlock/v1.0.0/res/screenshots/unlock.webp">
|
||||
<img width="480" alt="The bubble drops down over the lock screen, finds the face and shows a green tick" src="https://raw.githubusercontent.com/LoonixTools/face-unlock/v1.0.0/res/screenshots/unlock.webp">
|
||||
</p>
|
||||
|
||||
### Highlights
|
||||
|
||||
@@ -14,10 +14,14 @@
|
||||
- Subject as short as possible. Imperative, lowercase, no trailing period.
|
||||
- Body only when something genuinely cannot be inferred from the diff.
|
||||
- Never add `Co-Authored-By`, "Generated with" or any other AI attribution to commits or PR descriptions.
|
||||
- Do not commit or push before I have checked the changes locally and said they are fine. Then
|
||||
commit and push. Several attempts at the same thing make one commit, and attempts that did not
|
||||
work make none. Different things done in one session get a commit each. This also goes for
|
||||
releases and tags.
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/plasma-face-unlock` and `src/lib/*.sh`: the command and its menu, plain bash.
|
||||
- `src/face-unlock` and `src/lib/*.sh`: the command and its menu, plain bash.
|
||||
- `src/core`: camera, detection, recognition, liveness, face store. Used by the daemon and the tests.
|
||||
- `src/daemon`: the root service. It owns the camera and the face data.
|
||||
- `src/agent`: the Qt/QML program in the session: bubble, lock screen, setup window.
|
||||
@@ -45,10 +49,10 @@ A minor or major release (has `### Highlights`, gets a heading and the support s
|
||||
|
||||
_2026-09-24_
|
||||
|
||||
Welcome to plasma-face-unlock `v1.4.0`! One or two sentences on what this release is about.
|
||||
Welcome to face-unlock `v1.4.0`! One or two sentences on what this release is about.
|
||||
|
||||
<p align="center">
|
||||
<img width="480" alt="What the picture shows" src="https://raw.githubusercontent.com/LoonixTools/plasma-face-unlock/v1.4.0/<path>">
|
||||
<img width="480" alt="What the picture shows" src="https://raw.githubusercontent.com/LoonixTools/face-unlock/v1.4.0/<path>">
|
||||
</p>
|
||||
|
||||
### 🚨 Breaking changes
|
||||
@@ -82,7 +86,10 @@ Never test against the real setup: enrolling and the PAM files belong to the use
|
||||
PAM file editing against copies of real PAM files. No camera, no root.
|
||||
- Without a camera, point the daemon at pictures or a video: `Camera=images:<dir>` or
|
||||
`Camera=file:<video>` in the config passed to `--config`.
|
||||
- A development daemon needs no root: `plasma-face-unlockd --socket $XDG_RUNTIME_DIR/pfu/socket
|
||||
- A development daemon needs no root: `face-unlockd --socket $XDG_RUNTIME_DIR/fu/socket
|
||||
--state-dir DIR --config FILE --models DIR`. It skips polkit when it does not run as root. Point
|
||||
the other parts at it with `PFU_SOCKET`.
|
||||
- `make check` after every change. New strings: `po/update-pot.sh`, then translate them in `po/de.po`.
|
||||
the other parts at it with `FU_SOCKET`.
|
||||
- face-unlock's own lock screen checks the password with PAM. `FU_PAM_CONFDIR=DIR` points it at a
|
||||
`face-unlock-lock` file of its own (pam_permit, pam_deny), so no test counts as a wrong password
|
||||
for the real account.
|
||||
- `make check` after every change. New strings: `po/update-pot.sh`, then translate them in every `po/*.po`.
|
||||
+98
-59
@@ -1,4 +1,4 @@
|
||||
# plasma-face-unlock: the compiled half
|
||||
# face-unlock: the compiled half
|
||||
#
|
||||
# The Makefile is the entry point and calls this. Everything that has to be
|
||||
# compiled lives here: the daemon, the agent, the client the shell code uses,
|
||||
@@ -6,9 +6,9 @@
|
||||
# installed by the Makefile, which is where the paths come from.
|
||||
|
||||
cmake_minimum_required(VERSION 3.22)
|
||||
project(plasma-face-unlock VERSION 1.0.1 LANGUAGES C CXX)
|
||||
project(face-unlock VERSION 2.0.0 LANGUAGES C CXX)
|
||||
|
||||
set(PFU_VERSION "${PROJECT_VERSION}" CACHE STRING "Version reported by every binary")
|
||||
set(FU_VERSION "${PROJECT_VERSION}" CACHE STRING "Version reported by every binary")
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
@@ -21,19 +21,19 @@ endif()
|
||||
|
||||
include(GNUInstallDirs)
|
||||
|
||||
option(PFU_BUILD_AGENT "Build the Plasma agent (bubble, lock screen, set up window)" ON)
|
||||
option(PFU_BUILD_PAM "Build the PAM module" ON)
|
||||
option(PFU_BUILD_TESTS "Build the tests" ON)
|
||||
option(FU_BUILD_AGENT "Build the agent (bubble, lock screen, set up window)" ON)
|
||||
option(FU_BUILD_PAM "Build the PAM module" ON)
|
||||
option(FU_BUILD_TESTS "Build the tests" ON)
|
||||
|
||||
# Where things go at run time. The defaults suit a normal install into /usr;
|
||||
# the Makefile passes its own values so the two never disagree.
|
||||
set(PFU_LIBEXECDIR "${CMAKE_INSTALL_PREFIX}/lib/plasma-face-unlock" CACHE PATH "Helper programs")
|
||||
set(PFU_MODELDIR "${CMAKE_INSTALL_FULL_DATADIR}/plasma-face-unlock/models" CACHE PATH "Neural network models")
|
||||
set(PFU_LOCALEDIR "${CMAKE_INSTALL_FULL_LOCALEDIR}" CACHE PATH "Translations")
|
||||
set(PFU_SOCKET "/run/plasma-face-unlock/socket" CACHE STRING "The daemon's socket")
|
||||
set(PFU_STATEDIR "/var/lib/plasma-face-unlock" CACHE PATH "Face data")
|
||||
set(PFU_CONFIG "/etc/plasma-face-unlock/config" CACHE FILEPATH "System settings")
|
||||
set(PFU_PAMDIR "${CMAKE_INSTALL_PREFIX}/lib/security" CACHE PATH "Where PAM modules live")
|
||||
set(FU_LIBEXECDIR "${CMAKE_INSTALL_PREFIX}/lib/face-unlock" CACHE PATH "Helper programs")
|
||||
set(FU_MODELDIR "${CMAKE_INSTALL_FULL_DATADIR}/face-unlock/models" CACHE PATH "Neural network models")
|
||||
set(FU_LOCALEDIR "${CMAKE_INSTALL_FULL_LOCALEDIR}" CACHE PATH "Translations")
|
||||
set(FU_SOCKET "/run/face-unlock/socket" CACHE STRING "The daemon's socket")
|
||||
set(FU_STATEDIR "/var/lib/face-unlock" CACHE PATH "Face data")
|
||||
set(FU_CONFIG "/etc/face-unlock/config" CACHE FILEPATH "System settings")
|
||||
set(FU_PAMDIR "${CMAKE_INSTALL_PREFIX}/lib/security" CACHE PATH "Where PAM modules live")
|
||||
|
||||
configure_file(src/shared/buildconfig.h.in ${CMAKE_CURRENT_BINARY_DIR}/buildconfig.h @ONLY)
|
||||
include_directories(${CMAKE_CURRENT_BINARY_DIR} src/shared)
|
||||
@@ -47,35 +47,35 @@ add_compile_options(-Wall -Wextra -Wno-unused-parameter)
|
||||
find_package(Qt6 6.5 REQUIRED COMPONENTS Core DBus Network)
|
||||
# OpenCV 5 split the contour and transform helpers into "geometry".
|
||||
find_package(OpenCV REQUIRED COMPONENTS core)
|
||||
set(PFU_OPENCV_MODULES core imgproc imgcodecs videoio objdetect dnn)
|
||||
set(FU_OPENCV_MODULES core imgproc imgcodecs videoio objdetect dnn)
|
||||
if(OpenCV_VERSION_MAJOR GREATER_EQUAL 5)
|
||||
list(APPEND PFU_OPENCV_MODULES geometry)
|
||||
list(APPEND FU_OPENCV_MODULES geometry)
|
||||
endif()
|
||||
find_package(OpenCV REQUIRED COMPONENTS ${PFU_OPENCV_MODULES})
|
||||
find_package(OpenCV REQUIRED COMPONENTS ${FU_OPENCV_MODULES})
|
||||
|
||||
# The settings file reader, shared with the agent (which has no use for
|
||||
# OpenCV).
|
||||
add_library(pfu_common STATIC src/core/keyvalue.cpp)
|
||||
target_include_directories(pfu_common PUBLIC src/core)
|
||||
target_link_libraries(pfu_common PUBLIC Qt6::Core)
|
||||
set_target_properties(pfu_common PROPERTIES POSITION_INDEPENDENT_CODE ON)
|
||||
add_library(fu_common STATIC src/core/keyvalue.cpp)
|
||||
target_include_directories(fu_common PUBLIC src/core)
|
||||
target_link_libraries(fu_common PUBLIC Qt6::Core)
|
||||
set_target_properties(fu_common PROPERTIES POSITION_INDEPENDENT_CODE ON)
|
||||
|
||||
add_library(pfu_core STATIC
|
||||
add_library(fu_core STATIC
|
||||
src/core/settings.cpp
|
||||
src/core/camera.cpp
|
||||
src/core/vision.cpp
|
||||
src/core/liveness.cpp
|
||||
src/core/store.cpp
|
||||
)
|
||||
target_include_directories(pfu_core PUBLIC src/core ${OpenCV_INCLUDE_DIRS})
|
||||
target_link_libraries(pfu_core PUBLIC pfu_common Qt6::Core ${OpenCV_LIBS})
|
||||
set_target_properties(pfu_core PROPERTIES POSITION_INDEPENDENT_CODE ON)
|
||||
target_include_directories(fu_core PUBLIC src/core ${OpenCV_INCLUDE_DIRS})
|
||||
target_link_libraries(fu_core PUBLIC fu_common Qt6::Core ${OpenCV_LIBS})
|
||||
set_target_properties(fu_core PROPERTIES POSITION_INDEPENDENT_CODE ON)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The daemon
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
add_executable(plasma-face-unlockd
|
||||
add_executable(face-unlockd
|
||||
src/daemon/job.h
|
||||
src/daemon/agentlink.cpp
|
||||
src/daemon/main.cpp
|
||||
@@ -87,38 +87,40 @@ add_executable(plasma-face-unlockd
|
||||
src/daemon/userstate.cpp
|
||||
src/daemon/system.cpp
|
||||
)
|
||||
target_link_libraries(plasma-face-unlockd PRIVATE pfu_core Qt6::DBus Qt6::Network)
|
||||
install(TARGETS plasma-face-unlockd DESTINATION ${PFU_LIBEXECDIR})
|
||||
target_link_libraries(face-unlockd PRIVATE fu_core Qt6::DBus Qt6::Network)
|
||||
install(TARGETS face-unlockd DESTINATION ${FU_LIBEXECDIR})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The client the shell code uses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
add_executable(plasma-face-unlock-ctl src/ctl/main.cpp)
|
||||
target_link_libraries(plasma-face-unlock-ctl PRIVATE Qt6::Core Qt6::Network)
|
||||
install(TARGETS plasma-face-unlock-ctl DESTINATION ${PFU_LIBEXECDIR})
|
||||
add_executable(face-unlock-ctl src/ctl/main.cpp)
|
||||
target_link_libraries(face-unlock-ctl PRIVATE Qt6::Core Qt6::Network)
|
||||
install(TARGETS face-unlock-ctl DESTINATION ${FU_LIBEXECDIR})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The PAM module
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if(PFU_BUILD_PAM)
|
||||
find_path(PAM_INCLUDE_DIR security/pam_modules.h REQUIRED)
|
||||
find_library(PAM_LIBRARY pam REQUIRED)
|
||||
# The PAM module, and the agent's own lock screen, which checks the password.
|
||||
find_path(PAM_INCLUDE_DIR security/pam_modules.h REQUIRED)
|
||||
find_library(PAM_LIBRARY pam REQUIRED)
|
||||
|
||||
if(FU_BUILD_PAM)
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(SYSTEMD IMPORTED_TARGET libsystemd)
|
||||
|
||||
add_library(pam_plasma_face_unlock MODULE src/pam/pam_plasma_face_unlock.c)
|
||||
set_target_properties(pam_plasma_face_unlock PROPERTIES PREFIX "" C_VISIBILITY_PRESET hidden)
|
||||
target_include_directories(pam_plasma_face_unlock PRIVATE ${PAM_INCLUDE_DIR})
|
||||
target_link_libraries(pam_plasma_face_unlock PRIVATE ${PAM_LIBRARY})
|
||||
add_library(pam_face_unlock MODULE src/pam/pam_face_unlock.c)
|
||||
set_target_properties(pam_face_unlock PROPERTIES PREFIX "" C_VISIBILITY_PRESET hidden)
|
||||
target_include_directories(pam_face_unlock PRIVATE ${PAM_INCLUDE_DIR})
|
||||
target_link_libraries(pam_face_unlock PRIVATE ${PAM_LIBRARY})
|
||||
if(SYSTEMD_FOUND)
|
||||
target_compile_definitions(pam_plasma_face_unlock PRIVATE HAVE_SYSTEMD=1)
|
||||
target_link_libraries(pam_plasma_face_unlock PRIVATE PkgConfig::SYSTEMD)
|
||||
target_compile_definitions(pam_face_unlock PRIVATE HAVE_SYSTEMD=1)
|
||||
target_link_libraries(pam_face_unlock PRIVATE PkgConfig::SYSTEMD)
|
||||
endif()
|
||||
install(TARGETS pam_plasma_face_unlock DESTINATION ${PFU_PAMDIR})
|
||||
install(TARGETS pam_face_unlock DESTINATION ${FU_PAMDIR})
|
||||
|
||||
if(PFU_BUILD_TESTS)
|
||||
if(FU_BUILD_TESTS)
|
||||
add_executable(pam_harness tests/pam_harness.c)
|
||||
target_include_directories(pam_harness PRIVATE ${PAM_INCLUDE_DIR})
|
||||
target_link_libraries(pam_harness PRIVATE ${PAM_LIBRARY})
|
||||
@@ -129,7 +131,7 @@ endif()
|
||||
# The agent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if(PFU_BUILD_AGENT)
|
||||
if(FU_BUILD_AGENT)
|
||||
find_package(Qt6 6.5 REQUIRED COMPONENTS Gui Quick WaylandClient)
|
||||
# The bubble needs the Wayland surface of its window, which only the
|
||||
# private API hands out. It is the same one Plasma itself uses for this.
|
||||
@@ -142,38 +144,48 @@ if(PFU_BUILD_AGENT)
|
||||
find_package(LayerShellQt REQUIRED)
|
||||
find_package(KF6I18n REQUIRED)
|
||||
|
||||
qt_add_executable(plasma-face-unlock-agent
|
||||
qt_add_executable(face-unlock-agent
|
||||
src/agent/main.cpp
|
||||
src/agent/daemonclient.cpp
|
||||
src/agent/userconfig.cpp
|
||||
src/agent/agentsocket.cpp
|
||||
src/agent/bubblewindow.cpp
|
||||
src/agent/bubblecontroller.cpp
|
||||
src/agent/bubbleservice.cpp
|
||||
src/agent/lockcontroller.cpp
|
||||
src/agent/lockwatcher.cpp
|
||||
src/agent/lockers.cpp
|
||||
src/agent/inputwatcher.cpp
|
||||
src/agent/wayland.cpp
|
||||
src/agent/sessionlock.cpp
|
||||
src/agent/lockscreencontroller.cpp
|
||||
src/agent/wallpaper.cpp
|
||||
src/agent/locktext.cpp
|
||||
src/agent/lockpreview.cpp
|
||||
src/agent/enrollcontroller.cpp
|
||||
)
|
||||
target_include_directories(plasma-face-unlock-agent PRIVATE src/agent)
|
||||
target_include_directories(face-unlock-agent PRIVATE src/agent)
|
||||
if(LayerShellQt_VERSION VERSION_GREATER_EQUAL 6.6)
|
||||
target_compile_definitions(plasma-face-unlock-agent PRIVATE PFU_LAYERSHELL_HAS_SCREEN)
|
||||
target_compile_definitions(face-unlock-agent PRIVATE FU_LAYERSHELL_HAS_SCREEN)
|
||||
endif()
|
||||
|
||||
qt6_generate_wayland_protocol_client_sources(plasma-face-unlock-agent
|
||||
qt6_generate_wayland_protocol_client_sources(face-unlock-agent
|
||||
FILES ${CMAKE_CURRENT_SOURCE_DIR}/protocols/kde-lockscreen-overlay-v1.xml
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/protocols/ext-idle-notify-v1.xml)
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/protocols/ext-idle-notify-v1.xml
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/protocols/ext-session-lock-v1.xml)
|
||||
|
||||
# The QML files sit next to the module's qmldir in the resources, so they
|
||||
# see each other (and the Theme singleton) without importing anything.
|
||||
# Under /qt/qml, which the engine searches, or an installed agent finds no
|
||||
# types (the build directory only works by its PlasmaFaceUnlock/qmldir).
|
||||
set(PFU_QML_FILES Theme FaceGlyph LockGlyph Checkmark Bubble TickRing Enroll PillButton)
|
||||
foreach(f ${PFU_QML_FILES})
|
||||
# types (the build directory only works by its FaceUnlock/qmldir).
|
||||
set(FU_QML_FILES Theme FaceGlyph LockGlyph Checkmark Bubble TickRing Enroll PillButton LockScreen LockChoice LockPreview)
|
||||
foreach(f ${FU_QML_FILES})
|
||||
set_source_files_properties(src/agent/qml/${f}.qml PROPERTIES QT_RESOURCE_ALIAS ${f}.qml)
|
||||
endforeach()
|
||||
set_source_files_properties(src/agent/qml/Theme.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
|
||||
|
||||
qt_add_qml_module(plasma-face-unlock-agent
|
||||
URI PlasmaFaceUnlock
|
||||
qt_add_qml_module(face-unlock-agent
|
||||
URI FaceUnlock
|
||||
VERSION 1.0
|
||||
RESOURCE_PREFIX /qt/qml
|
||||
SOURCES
|
||||
@@ -188,33 +200,60 @@ if(PFU_BUILD_AGENT)
|
||||
src/agent/qml/TickRing.qml
|
||||
src/agent/qml/Enroll.qml
|
||||
src/agent/qml/PillButton.qml
|
||||
src/agent/qml/LockScreen.qml
|
||||
src/agent/qml/LockChoice.qml
|
||||
src/agent/qml/LockPreview.qml
|
||||
)
|
||||
|
||||
target_link_libraries(plasma-face-unlock-agent PRIVATE
|
||||
target_link_libraries(face-unlock-agent PRIVATE
|
||||
Qt6::Gui Qt6::GuiPrivate Qt6::Quick Qt6::DBus Qt6::Network
|
||||
Qt6::WaylandClient Qt6::WaylandClientPrivate
|
||||
LayerShellQt::Interface
|
||||
KF6::I18n KF6::I18nQml
|
||||
pfu_common
|
||||
fu_common
|
||||
${PAM_LIBRARY}
|
||||
)
|
||||
install(TARGETS plasma-face-unlock-agent DESTINATION ${PFU_LIBEXECDIR})
|
||||
target_include_directories(face-unlock-agent PRIVATE ${PAM_INCLUDE_DIR})
|
||||
install(TARGETS face-unlock-agent DESTINATION ${FU_LIBEXECDIR})
|
||||
endif()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if(PFU_BUILD_TESTS)
|
||||
if(FU_BUILD_TESTS)
|
||||
enable_testing()
|
||||
|
||||
add_executable(test_liveness tests/test_liveness.cpp)
|
||||
target_link_libraries(test_liveness PRIVATE pfu_core)
|
||||
target_link_libraries(test_liveness PRIVATE fu_core)
|
||||
add_test(NAME liveness COMMAND test_liveness)
|
||||
|
||||
add_executable(test_store tests/test_store.cpp)
|
||||
target_link_libraries(test_store PRIVATE pfu_core)
|
||||
target_link_libraries(test_store PRIVATE fu_core)
|
||||
add_test(NAME store COMMAND test_store)
|
||||
|
||||
add_executable(test_images tests/test_images.cpp)
|
||||
target_link_libraries(test_images PRIVATE pfu_core)
|
||||
target_link_libraries(test_images PRIVATE fu_core)
|
||||
|
||||
if(FU_BUILD_AGENT)
|
||||
add_executable(test_lockscreen tests/test_lockscreen.cpp src/agent/lockscreencontroller.cpp)
|
||||
target_include_directories(test_lockscreen PRIVATE src/agent ${PAM_INCLUDE_DIR})
|
||||
target_link_libraries(test_lockscreen PRIVATE Qt6::Core KF6::I18n ${PAM_LIBRARY})
|
||||
add_test(NAME lockscreen COMMAND test_lockscreen)
|
||||
|
||||
add_executable(test_wallpaper tests/test_wallpaper.cpp src/agent/wallpaper.cpp)
|
||||
target_include_directories(test_wallpaper PRIVATE src/agent)
|
||||
target_link_libraries(test_wallpaper PRIVATE Qt6::Core)
|
||||
add_test(NAME wallpaper COMMAND test_wallpaper)
|
||||
|
||||
add_executable(test_lockpreview tests/test_lockpreview.cpp src/agent/lockpreview.cpp)
|
||||
target_include_directories(test_lockpreview PRIVATE src/agent)
|
||||
target_link_libraries(test_lockpreview PRIVATE Qt6::Gui KF6::I18n)
|
||||
add_test(NAME lockpreview COMMAND test_lockpreview)
|
||||
|
||||
add_executable(test_lockers tests/test_lockers.cpp src/agent/lockers.cpp)
|
||||
target_include_directories(test_lockers PRIVATE src/agent)
|
||||
target_link_libraries(test_lockers PRIVATE Qt6::Core)
|
||||
add_test(NAME lockers COMMAND test_lockers)
|
||||
endif()
|
||||
endif()
|
||||
@@ -1,4 +1,4 @@
|
||||
# plasma-face-unlock: build and install
|
||||
# face-unlock: build and install
|
||||
#
|
||||
# The shell front end installs as it is, like middleclick-autoscroll. The rest
|
||||
# is compiled by CMake (the daemon, the agent, the PAM module, the client the
|
||||
@@ -9,20 +9,22 @@
|
||||
# Overridable so a packager can pass the version it is actually building
|
||||
# (`make VERSION=$pkgver`). The literal below is the fallback for builds
|
||||
# straight from a checkout, and is what a release tag has to carry.
|
||||
VERSION ?= 1.0.1
|
||||
VERSION ?= 2.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
|
||||
LIBDIR ?= $(DATADIR)/face-unlock/lib
|
||||
LIBEXECDIR ?= $(PREFIX)/lib/face-unlock
|
||||
MODELDIR ?= $(DATADIR)/face-unlock/models
|
||||
LOCALEDIR ?= $(DATADIR)/locale
|
||||
MANDIR ?= $(DATADIR)/man
|
||||
APPDIR ?= $(DATADIR)/applications
|
||||
POLKITDIR ?= $(DATADIR)/polkit-1/actions
|
||||
ICONDIR ?= $(DATADIR)/icons/hicolor/scalable/apps
|
||||
GNOMEEXTDIR ?= $(DATADIR)/gnome-shell/extensions
|
||||
GNOMEEXT := face-unlock@loonixtools.github.io
|
||||
|
||||
# Where systemd looks for units, asked of systemd itself for a normal install.
|
||||
# A build with a prefix of its own keeps them under that prefix.
|
||||
@@ -44,11 +46,14 @@ endif
|
||||
|
||||
BUILDDIR ?= build
|
||||
CMAKE ?= cmake
|
||||
# One compiler per core. A bare --parallel lets make start them all at once,
|
||||
# and a few dozen Qt and OpenCV files at once can use up the memory.
|
||||
JOBS ?= $(shell nproc 2>/dev/null || echo 2)
|
||||
CMAKE_FLAGS ?=
|
||||
|
||||
LINGUAS := de
|
||||
LINGUAS := de es fr it ja ko nl pl pt_BR ru tr uk zh_CN
|
||||
MOFILES := $(patsubst %,po/%.mo,$(LINGUAS))
|
||||
MANPAGE := doc/plasma-face-unlock.1
|
||||
MANPAGE := doc/face-unlock.1
|
||||
|
||||
LIBS := $(wildcard src/lib/*.sh)
|
||||
|
||||
@@ -85,13 +90,13 @@ native:
|
||||
$(CMAKE) -S . -B $(BUILDDIR) \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX=$(PREFIX) \
|
||||
-DPFU_VERSION=$(VERSION) \
|
||||
-DPFU_LIBEXECDIR=$(LIBEXECDIR) \
|
||||
-DPFU_MODELDIR=$(MODELDIR) \
|
||||
-DPFU_LOCALEDIR=$(LOCALEDIR) \
|
||||
-DPFU_PAMDIR=$(PAMDIR) \
|
||||
-DFU_VERSION=$(VERSION) \
|
||||
-DFU_LIBEXECDIR=$(LIBEXECDIR) \
|
||||
-DFU_MODELDIR=$(MODELDIR) \
|
||||
-DFU_LOCALEDIR=$(LOCALEDIR) \
|
||||
-DFU_PAMDIR=$(PAMDIR) \
|
||||
$(CMAKE_FLAGS)
|
||||
$(CMAKE) --build $(BUILDDIR) --parallel
|
||||
$(CMAKE) --build $(BUILDDIR) --parallel $(JOBS)
|
||||
|
||||
models: $(addprefix models/,$(MODELS))
|
||||
|
||||
@@ -108,7 +113,7 @@ else
|
||||
@echo "msgfmt not found, skipping $@"
|
||||
endif
|
||||
|
||||
$(MANPAGE): doc/plasma-face-unlock.1.scd
|
||||
$(MANPAGE): doc/face-unlock.1.scd
|
||||
ifdef SCDOC
|
||||
$(SCDOC) < $< > $@
|
||||
else
|
||||
@@ -116,13 +121,15 @@ else
|
||||
endif
|
||||
|
||||
# Syntax-check every shell file, and run shellcheck when it is available.
|
||||
# SC2034 is off: the files share their variables, and shellcheck looks at one
|
||||
# file at a time.
|
||||
check:
|
||||
@set -e; for f in src/plasma-face-unlock $(LIBS) tests/*.sh; do \
|
||||
@set -e; for f in src/face-unlock $(LIBS) tests/*.sh; do \
|
||||
bash -n "$$f" && echo "ok $$f"; \
|
||||
done
|
||||
@if command -v shellcheck >/dev/null 2>&1; then \
|
||||
shellcheck -x -e SC1090,SC1091 src/plasma-face-unlock $(LIBS) tests/*.sh; \
|
||||
echo "ok shellcheck"; \
|
||||
shellcheck -x -e SC1090,SC1091,SC2034 src/face-unlock $(LIBS) tests/*.sh \
|
||||
&& echo "ok shellcheck"; \
|
||||
else \
|
||||
echo "shellcheck not found, skipped"; \
|
||||
fi
|
||||
@@ -144,7 +151,7 @@ install: build
|
||||
DESTDIR="$(DESTDIR)" $(CMAKE) --install $(BUILDDIR)
|
||||
|
||||
# the command
|
||||
install -Dm755 src/plasma-face-unlock "$(DESTDIR)$(BINDIR)/plasma-face-unlock"
|
||||
install -Dm755 src/face-unlock "$(DESTDIR)$(BINDIR)/face-unlock"
|
||||
install -d "$(DESTDIR)$(LIBDIR)"
|
||||
install -Dm644 -t "$(DESTDIR)$(LIBDIR)" $(LIBS)
|
||||
sed -i -e 's|@VERSION@|$(VERSION)|g' \
|
||||
@@ -152,7 +159,7 @@ install: build
|
||||
-e 's|@LIBEXECDIR@|$(LIBEXECDIR)|g' \
|
||||
-e 's|@LOCALEDIR@|$(LOCALEDIR)|g' \
|
||||
-e 's|@PAMDIR@|$(PAMDIR)|g' \
|
||||
"$(DESTDIR)$(BINDIR)/plasma-face-unlock" \
|
||||
"$(DESTDIR)$(BINDIR)/face-unlock" \
|
||||
"$(DESTDIR)$(LIBDIR)"/*.sh
|
||||
|
||||
# the models
|
||||
@@ -161,50 +168,54 @@ install: build
|
||||
done
|
||||
|
||||
# the daemon's socket and service, the agent's user service
|
||||
install -Dm644 res/systemd/plasma-face-unlockd.socket "$(DESTDIR)$(SYSTEMUNITDIR)/plasma-face-unlockd.socket"
|
||||
install -Dm644 res/systemd/plasma-face-unlockd.service "$(DESTDIR)$(SYSTEMUNITDIR)/plasma-face-unlockd.service"
|
||||
install -Dm644 res/systemd/plasma-face-unlock-agent.service "$(DESTDIR)$(USERUNITDIR)/plasma-face-unlock-agent.service"
|
||||
install -Dm644 res/systemd/face-unlockd.socket "$(DESTDIR)$(SYSTEMUNITDIR)/face-unlockd.socket"
|
||||
install -Dm644 res/systemd/face-unlockd.service "$(DESTDIR)$(SYSTEMUNITDIR)/face-unlockd.service"
|
||||
install -Dm644 res/systemd/face-unlock-agent.service "$(DESTDIR)$(USERUNITDIR)/face-unlock-agent.service"
|
||||
sed -i -e 's|@LIBEXECDIR@|$(LIBEXECDIR)|g' \
|
||||
"$(DESTDIR)$(SYSTEMUNITDIR)/plasma-face-unlockd.service" \
|
||||
"$(DESTDIR)$(USERUNITDIR)/plasma-face-unlock-agent.service"
|
||||
"$(DESTDIR)$(SYSTEMUNITDIR)/face-unlockd.service" \
|
||||
"$(DESTDIR)$(USERUNITDIR)/face-unlock-agent.service"
|
||||
|
||||
# the desktop file KWin looks for before it lets the bubble above the
|
||||
# lock screen, the polkit action, the icon
|
||||
install -Dm644 res/applications/io.github.loonixtools.plasma-face-unlock-agent.desktop \
|
||||
"$(DESTDIR)$(APPDIR)/io.github.loonixtools.plasma-face-unlock-agent.desktop"
|
||||
sed -i -e 's|@LIBEXECDIR@|$(LIBEXECDIR)|g' "$(DESTDIR)$(APPDIR)/io.github.loonixtools.plasma-face-unlock-agent.desktop"
|
||||
install -Dm644 res/polkit/io.github.loonixtools.plasma-face-unlock.policy \
|
||||
"$(DESTDIR)$(POLKITDIR)/io.github.loonixtools.plasma-face-unlock.policy"
|
||||
install -Dm644 res/plasma-face-unlock.svg "$(DESTDIR)$(ICONDIR)/plasma-face-unlock.svg"
|
||||
install -Dm644 res/applications/io.github.loonixtools.face-unlock-agent.desktop \
|
||||
"$(DESTDIR)$(APPDIR)/io.github.loonixtools.face-unlock-agent.desktop"
|
||||
sed -i -e 's|@LIBEXECDIR@|$(LIBEXECDIR)|g' "$(DESTDIR)$(APPDIR)/io.github.loonixtools.face-unlock-agent.desktop"
|
||||
install -Dm644 res/polkit/io.github.loonixtools.face-unlock.policy \
|
||||
"$(DESTDIR)$(POLKITDIR)/io.github.loonixtools.face-unlock.policy"
|
||||
install -Dm644 res/face-unlock.svg "$(DESTDIR)$(ICONDIR)/face-unlock.svg"
|
||||
|
||||
# the GNOME Shell extension that draws the bubble on GNOME
|
||||
install -Dm644 -t "$(DESTDIR)$(GNOMEEXTDIR)/$(GNOMEEXT)" res/gnome-shell/$(GNOMEEXT)/*
|
||||
|
||||
# translations
|
||||
@for l in $(LINGUAS); do \
|
||||
if [ -f "po/$$l.mo" ]; then \
|
||||
install -Dm644 "po/$$l.mo" \
|
||||
"$(DESTDIR)$(LOCALEDIR)/$$l/LC_MESSAGES/plasma-face-unlock.mo"; \
|
||||
"$(DESTDIR)$(LOCALEDIR)/$$l/LC_MESSAGES/face-unlock.mo"; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
# documentation
|
||||
@if [ -f $(MANPAGE) ]; then \
|
||||
install -Dm644 $(MANPAGE) "$(DESTDIR)$(MANDIR)/man1/plasma-face-unlock.1"; \
|
||||
install -Dm644 $(MANPAGE) "$(DESTDIR)$(MANDIR)/man1/face-unlock.1"; \
|
||||
fi
|
||||
install -Dm644 README.md "$(DESTDIR)$(DATADIR)/doc/plasma-face-unlock/README.md"
|
||||
install -Dm644 README.md "$(DESTDIR)$(DATADIR)/doc/face-unlock/README.md"
|
||||
|
||||
uninstall:
|
||||
rm -f "$(DESTDIR)$(BINDIR)/plasma-face-unlock"
|
||||
rm -rf "$(DESTDIR)$(DATADIR)/plasma-face-unlock"
|
||||
rm -f "$(DESTDIR)$(BINDIR)/face-unlock"
|
||||
rm -rf "$(DESTDIR)$(DATADIR)/face-unlock"
|
||||
rm -rf "$(DESTDIR)$(LIBEXECDIR)"
|
||||
rm -f "$(DESTDIR)$(PAMDIR)/pam_plasma_face_unlock.so"
|
||||
rm -f "$(DESTDIR)$(SYSTEMUNITDIR)/plasma-face-unlockd.socket"
|
||||
rm -f "$(DESTDIR)$(SYSTEMUNITDIR)/plasma-face-unlockd.service"
|
||||
rm -f "$(DESTDIR)$(USERUNITDIR)/plasma-face-unlock-agent.service"
|
||||
rm -f "$(DESTDIR)$(APPDIR)/io.github.loonixtools.plasma-face-unlock-agent.desktop"
|
||||
rm -f "$(DESTDIR)$(POLKITDIR)/io.github.loonixtools.plasma-face-unlock.policy"
|
||||
rm -f "$(DESTDIR)$(ICONDIR)/plasma-face-unlock.svg"
|
||||
rm -f "$(DESTDIR)$(MANDIR)/man1/plasma-face-unlock.1"
|
||||
rm -rf "$(DESTDIR)$(DATADIR)/doc/plasma-face-unlock"
|
||||
@for l in $(LINGUAS); do rm -f "$(DESTDIR)$(LOCALEDIR)/$$l/LC_MESSAGES/plasma-face-unlock.mo"; done
|
||||
rm -f "$(DESTDIR)$(PAMDIR)/pam_face_unlock.so"
|
||||
rm -f "$(DESTDIR)$(SYSTEMUNITDIR)/face-unlockd.socket"
|
||||
rm -f "$(DESTDIR)$(SYSTEMUNITDIR)/face-unlockd.service"
|
||||
rm -f "$(DESTDIR)$(USERUNITDIR)/face-unlock-agent.service"
|
||||
rm -f "$(DESTDIR)$(APPDIR)/io.github.loonixtools.face-unlock-agent.desktop"
|
||||
rm -f "$(DESTDIR)$(POLKITDIR)/io.github.loonixtools.face-unlock.policy"
|
||||
rm -f "$(DESTDIR)$(ICONDIR)/face-unlock.svg"
|
||||
rm -rf "$(DESTDIR)$(GNOMEEXTDIR)/$(GNOMEEXT)"
|
||||
rm -f "$(DESTDIR)$(MANDIR)/man1/face-unlock.1"
|
||||
rm -rf "$(DESTDIR)$(DATADIR)/doc/face-unlock"
|
||||
@for l in $(LINGUAS); do rm -f "$(DESTDIR)$(LOCALEDIR)/$$l/LC_MESSAGES/face-unlock.mo"; done
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILDDIR) po/*.mo $(MANPAGE)
|
||||
@@ -1,24 +1,25 @@
|
||||
<p align="center">
|
||||
<img width="200" src="res/plasma-face-unlock.svg" alt="plasma-face-unlock">
|
||||
<img width="200" src="res/face-unlock.svg" alt="face-unlock">
|
||||
</p>
|
||||
|
||||
<h1 align="center">plasma-face-unlock</h1>
|
||||
<h1 align="center">face-unlock</h1>
|
||||
|
||||
<h3 align="center">Face ID for KDE Plasma.</h3>
|
||||
<h3 align="center">Face ID for Linux.</h3>
|
||||
|
||||
<p align="center">
|
||||
Look at the screen and it unlocks: the lock screen, sudo and admin prompts. A photo on a phone does not fool it.
|
||||
Unlock the lock screen, sudo and admin prompts with your face.<br>
|
||||
On KDE Plasma, GNOME, Hyprland and Niri.
|
||||
</p>
|
||||
|
||||
<h5 align="center">
|
||||
<a href="#install">Install</a> |
|
||||
<a href="#how-to-use">How to use</a> |
|
||||
<a href="#is-it-safe">Is it safe?</a> |
|
||||
<a href="https://github.com/LoonixTools/plasma-face-unlock/issues">Report a bug</a>
|
||||
<a href="https://github.com/LoonixTools/face-unlock/issues">Report a bug</a>
|
||||
</h5>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://buymeacoffee.com/felitendo"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="48"></a>
|
||||
<a href="https://ko-fi.com/felitendo"><img src="https://storage.ko-fi.com/cdn/kofi5.png?v=6" alt="Buy me a coffee on Ko-fi" height="48"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -26,122 +27,143 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="res/screenshots/bubble.png" alt="The bubble above the lock screen: looking, recognised, not recognised" width="720">
|
||||
<img src="res/screenshots/bubble.png" alt="The bubble above the Plasma lock screen: looking, recognised, not recognised" width="720">
|
||||
</p>
|
||||
|
||||
Come back to your locked screen and look at it. A bubble drops down at the top, finds your face,
|
||||
and you are in. It works for `sudo` and Plasma's admin prompts too, if you want. Everything runs on
|
||||
your computer, and your face is saved as numbers, never as a picture.
|
||||
|
||||
## Install
|
||||
|
||||
**Arch, CachyOS, EndeavourOS, Manjaro** (AUR)
|
||||
<details>
|
||||
<summary><b>Arch</b>, CachyOS, EndeavourOS, Manjaro</summary>
|
||||
|
||||
```bash
|
||||
yay -S plasma-face-unlock
|
||||
yay -S face-unlock
|
||||
```
|
||||
|
||||
**Fedora**
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Fedora</b></summary>
|
||||
|
||||
```bash
|
||||
sudo curl -fsSL -o /etc/yum.repos.d/plasma-face-unlock.repo \
|
||||
https://loonixtools.github.io/plasma-face-unlock/plasma-face-unlock.repo
|
||||
sudo dnf install plasma-face-unlock
|
||||
sudo curl -fsSL -o /etc/yum.repos.d/face-unlock.repo \
|
||||
https://loonixtools.github.io/face-unlock/face-unlock.repo
|
||||
sudo dnf install face-unlock
|
||||
```
|
||||
|
||||
**Debian, Kubuntu**
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Debian</b>, Ubuntu</summary>
|
||||
|
||||
```bash
|
||||
codename="$(sed -n 's/^VERSION_CODENAME=//p' /etc/os-release)"
|
||||
sudo install -d -m 0755 /etc/apt/keyrings
|
||||
curl -fsSL https://loonixtools.github.io/plasma-face-unlock/KEY.gpg \
|
||||
| sudo gpg --dearmor -o /etc/apt/keyrings/plasma-face-unlock.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/plasma-face-unlock.gpg] https://loonixtools.github.io/plasma-face-unlock/deb/$codename ./" \
|
||||
| sudo tee /etc/apt/sources.list.d/plasma-face-unlock.list
|
||||
sudo apt update && sudo apt install plasma-face-unlock
|
||||
curl -fsSL https://loonixtools.github.io/face-unlock/KEY.gpg \
|
||||
| sudo gpg --dearmor -o /etc/apt/keyrings/face-unlock.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/face-unlock.gpg] https://loonixtools.github.io/face-unlock/deb/$codename ./" \
|
||||
| sudo tee /etc/apt/sources.list.d/face-unlock.list
|
||||
sudo apt update && sudo apt install face-unlock
|
||||
```
|
||||
|
||||
The packages are built for the current release of each. Updates then come with your normal system
|
||||
updates.
|
||||
</details>
|
||||
|
||||
You need Plasma 6 on Wayland and a camera. Infrared cameras (the Windows Hello kind) work too.
|
||||
- **KDE Plasma:** works out of the box.
|
||||
- **GNOME:** log out and back in once after installing.
|
||||
- **Hyprland and Niri:** the bubble only shows on face-unlock's own lock screen.
|
||||
hyprlock shows a line of text at the top instead. You also need a polkit
|
||||
agent. If none runs, the menu offers to install one.
|
||||
|
||||
<details>
|
||||
<summary>Hyprland and Niri: which lock screen?</summary>
|
||||
|
||||
<p align="center">
|
||||
<img src="res/screenshots/lock-choice.png" alt="The window that asks which lock screen to use: face-unlock's with the bubble on the left, the user's own hyprlock with a line of text at the top on the right" width="560">
|
||||
</p>
|
||||
|
||||
When you turn face unlock on, a window asks which lock screen you want. You
|
||||
can change it later under **Settings**.
|
||||
|
||||
- **face-unlock's own:** the bubble, the time and your wallpaper. You lock with
|
||||
`face-unlock lock`, and the menu shows where to put that.
|
||||
- **Yours** (hyprlock, swaylock, gtklock or waylock): press Enter on the empty
|
||||
password field to scan. hyprlock and swaylock also scan when you come back.
|
||||
|
||||
Hyprland without uwsm needs a line in its config. The menu shows it.
|
||||
|
||||
</details>
|
||||
|
||||
## How to use
|
||||
|
||||
```bash
|
||||
plasma-face-unlock
|
||||
face-unlock
|
||||
```
|
||||
|
||||
This opens a menu:
|
||||
|
||||
<p align="center">
|
||||
<img src="res/screenshots/menu.png" alt="The plasma-face-unlock menu in Konsole: face unlock on, one face, lock screen, sudo and admin prompts on" width="560">
|
||||
<img src="res/screenshots/menu.png" alt="The face-unlock menu in Konsole: face unlock on, one face, lock screen, sudo and admin prompts on" width="560">
|
||||
</p>
|
||||
|
||||
Press **1** and follow the setup window: look at the camera, then turn your head slowly in a circle
|
||||
until the ring is full. Then lock the screen and look at it. **2** adds another face, for example
|
||||
with glasses, and **5** runs one test scan that shows what the camera sees. Try that first when
|
||||
something does not work.
|
||||
Press **1** and look at the camera. Then lock the screen and look at it.
|
||||
|
||||
**4** opens the settings. The text at the bottom says what the selected one does:
|
||||
<details>
|
||||
<summary>Settings</summary>
|
||||
|
||||
<p align="center">
|
||||
<img src="res/screenshots/settings.png" alt="The settings in Konsole, grouped into lock screen, password prompts, recognition and bubble, with the photo check explained at the bottom" width="680">
|
||||
</p>
|
||||
|
||||
Without the menu: `plasma-face-unlock enable`, `disable`, `setup [NAME]`, `faces`, `remove ID`,
|
||||
`test` and `status`.
|
||||
</details>
|
||||
|
||||
## Is it safe?
|
||||
|
||||
It is a convenience, not a security upgrade. Face ID on a phone sees your face in 3D. A webcam only
|
||||
sees a flat picture.
|
||||
It is a convenience upgrade, not extra security. Your webcam only sees a flat picture, so
|
||||
it cannot always differentiate your face from a good fake. But the tool does everything in its power to prevent that:
|
||||
|
||||
- You do not have to blink. A photo on a phone or tablet, or a glossy print, is still refused:
|
||||
it gives itself away by its reflection and its straight edges.
|
||||
- A matte printed photo can get past that. Set the photo check to *strict* in the settings: then
|
||||
the face has to blink or turn a little, and a photo can do neither.
|
||||
- A video of you can still get in.
|
||||
- Five failed tries in a row pause it for 15 minutes, or until you use your password.
|
||||
- Only root can read your face data. Adding or deleting a face always needs your password.
|
||||
- sudo and admin prompts never take a face over SSH.
|
||||
- Photos and videos on a phone, tablet or glossy screen are caught.
|
||||
- A matte printed photo is caught when the photo check is set to *strict*.
|
||||
|
||||
If the computer guards something important, leave sudo and admin prompts off.
|
||||
But a video of you on a big matte screen could get in.
|
||||
|
||||
## How it works
|
||||
After five failed attempts, face unlock pauses for 15 minutes. Your face is kept as
|
||||
numbers, not pictures, and only root can read them. sudo over SSH always asks
|
||||
for the password.
|
||||
|
||||
## More
|
||||
|
||||
<details>
|
||||
<summary>How it works</summary>
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `plasma-face-unlockd` | Runs as root when needed. It owns the camera and the face data and decides. |
|
||||
| `plasma-face-unlock-agent` | Runs in your session. It watches the lock screen and draws the bubble. |
|
||||
| `pam_plasma_face_unlock.so` | Lets sudo and admin prompts ask the daemon. No match: you type your password as usual. |
|
||||
| `plasma-face-unlock` | The menu. |
|
||||
| `face-unlockd` | The root service. Owns the camera and the face data. |
|
||||
| `face-unlock-agent` | Runs in your session. Watches the lock screen, draws the bubble. |
|
||||
| `pam_face_unlock.so` | Lets sudo and admin prompts ask the service. |
|
||||
| `face-unlock` | The menu. |
|
||||
|
||||
Two small networks from the OpenCV model zoo find the face (YuNet) and turn it into numbers (SFace).
|
||||
They run on the CPU in a few milliseconds. The lock screen is unlocked through logind, the same way
|
||||
`loginctl unlock-session` does it. `man plasma-face-unlock` has all the details.
|
||||
Two small networks from the OpenCV model zoo run on the CPU: YuNet finds the face, SFace turns it
|
||||
into numbers. All details: `man face-unlock`.
|
||||
|
||||
## Build from source
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Build from source</summary>
|
||||
|
||||
```bash
|
||||
make models # downloads the two networks and checks them
|
||||
make models
|
||||
make
|
||||
make test
|
||||
sudo make install
|
||||
```
|
||||
|
||||
You need CMake, a C++20 compiler, Qt 6, LayerShellQt, KI18n, OpenCV 4.5.4 or newer (with DNN),
|
||||
Linux-PAM and libsystemd. `scdoc` and `msgfmt` are optional (man page, translations).
|
||||
[packaging/README.md](packaging/README.md) explains releases.
|
||||
Needs CMake, a C++20 compiler, Qt 6, LayerShellQt, KI18n, OpenCV 4.5.4+ (with DNN), Linux-PAM and
|
||||
libsystemd.
|
||||
|
||||
</details>
|
||||
|
||||
## Credits
|
||||
|
||||
- [Glance](https://github.com/jonnyoo/glance) by Jonathan Zhou (MIT): face unlock for the Mac. The
|
||||
idea, the look of the bubble and the photo check come from there. The code here is new.
|
||||
- [YuNet](https://github.com/opencv/opencv_zoo/tree/main/models/face_detection_yunet) (MIT) and
|
||||
[SFace](https://github.com/opencv/opencv_zoo/tree/main/models/face_recognition_sface) (Apache-2.0)
|
||||
from the OpenCV model zoo.
|
||||
|
||||
## License
|
||||
- [Glance](https://github.com/jonnyoo/glance) by Jonathan Zhou: the idea and the look of the bubble.
|
||||
- [YuNet](https://github.com/opencv/opencv_zoo/tree/main/models/face_detection_yunet) and
|
||||
[SFace](https://github.com/opencv/opencv_zoo/tree/main/models/face_recognition_sface) from the
|
||||
OpenCV model zoo.
|
||||
|
||||
GPL-3.0-or-later.
|
||||
@@ -1,19 +1,20 @@
|
||||
plasma-face-unlock(1)
|
||||
face-unlock(1)
|
||||
|
||||
# NAME
|
||||
|
||||
plasma-face-unlock - face unlock for KDE Plasma
|
||||
face-unlock - face unlock for Plasma, GNOME, Hyprland and Niri
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
*plasma-face-unlock* [_command_]
|
||||
*face-unlock* [_command_]
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
Look at the screen and it unlocks, the way a phone does. The lock screen, sudo
|
||||
in a terminal and the admin password prompts of Plasma can all take a face
|
||||
instead of a password. A photo of somebody on a phone or tablet does not get
|
||||
in, and with the strict photo check a printed one does not either.
|
||||
in a terminal and the admin password prompts can all take a face instead of a
|
||||
password, on KDE Plasma, GNOME, Hyprland and Niri (see *DESKTOPS*). A photo of
|
||||
somebody on a phone or tablet does not get in, and with the strict photo check
|
||||
a printed one does not either.
|
||||
|
||||
A bubble at the top of the screen shows what is going on: it drops down when
|
||||
the camera starts looking, the face in it looks around, and when it recognises
|
||||
@@ -31,7 +32,9 @@ It is a convenience, not a security upgrade. See *HOW SAFE IS THIS*.
|
||||
*enable*
|
||||
Turn face unlock on for this user. Sets up a face first if there is none,
|
||||
starts the lock screen agent, and turns sudo and admin prompts back on if
|
||||
they were on before.
|
||||
they were on before. On Hyprland and Niri it also puts the face into the
|
||||
lock screen's password check (*Lock screens*), unless that was turned
|
||||
off. On GNOME it switches on the extension that draws the bubble.
|
||||
|
||||
*disable*
|
||||
Turn it off: the agent stops and sudo and the admin prompts go back to the
|
||||
@@ -56,6 +59,12 @@ It is a convenience, not a security upgrade. See *HOW SAFE IS THIS*.
|
||||
*status*
|
||||
What is on, and when the last unlock was.
|
||||
|
||||
*lock*
|
||||
Lock the screen. On Hyprland, Niri and other compositors whose lock
|
||||
screen is a program of its own, with face-unlock's own lock screen,
|
||||
which shows the bubble (see *DESKTOPS*). Elsewhere with the desktop's
|
||||
lock screen. Returns once the screen is locked.
|
||||
|
||||
*-h*, *--help*
|
||||
Show a summary of the commands.
|
||||
|
||||
@@ -64,23 +73,23 @@ It is a convenience, not a security upgrade. See *HOW SAFE IS THIS*.
|
||||
|
||||
# THE PIECES
|
||||
|
||||
*plasma-face-unlockd*
|
||||
*face-unlockd*
|
||||
The daemon, running as root and started by its socket
|
||||
(_plasma-face-unlockd.socket_). It is the only thing that opens the camera
|
||||
(_face-unlockd.socket_). It is the only thing that opens the camera
|
||||
and the only thing that can read the face data. It exits after a minute
|
||||
with nothing to do.
|
||||
|
||||
*plasma-face-unlock-agent*
|
||||
Runs in the Plasma session as a user service
|
||||
(_plasma-face-unlock-agent.service_). It watches the lock screen, asks the
|
||||
*face-unlock-agent*
|
||||
Runs in the desktop session as a user service
|
||||
(_face-unlock-agent.service_). It watches the lock screen, asks the
|
||||
daemon to scan when somebody comes back, unlocks the session when the face
|
||||
matches, draws the bubble, and is the setup window.
|
||||
|
||||
*pam_plasma_face_unlock.so*
|
||||
*pam_face_unlock.so*
|
||||
The PAM module for sudo and admin prompts. It asks the daemon and turns
|
||||
the answer into a PAM result.
|
||||
|
||||
*plasma-face-unlock-ctl*
|
||||
*face-unlock-ctl*
|
||||
How this command talks to the daemon.
|
||||
|
||||
# RECOGNITION
|
||||
@@ -171,14 +180,16 @@ prompts off, or face unlock altogether.
|
||||
|
||||
Plasma's lock screen runs a fingerprint stack next to the password, but starts
|
||||
it once per lock, gives up for good after the first failure and labels it for
|
||||
fingerprints. So face unlock does not go through the lock screen's PAM at all.
|
||||
The agent watches for the screen to lock (org.freedesktop.ScreenSaver) and
|
||||
scans when somebody comes back:
|
||||
fingerprints. GNOME's, hyprlock and swaylock only ask their PAM stack once the
|
||||
password is typed. So face unlock does not go through the lock screen's PAM at
|
||||
all. The agent watches for the screen to lock (see *DESKTOPS*) and scans when
|
||||
somebody comes back:
|
||||
|
||||
- on any key or mouse movement after the screen locked, once 1.5 seconds have
|
||||
passed (the key that locked it does not count). Enter on the empty password
|
||||
field is a key like any other. This works while a video or an app keeps the
|
||||
screen on, too (ext_idle_notifier_v1, input notification);
|
||||
screen on, too (ext_idle_notifier_v1 with its input notification, and
|
||||
Mutter's IdleMonitor on GNOME);
|
||||
- when the machine wakes from sleep;
|
||||
- right after locking, if *Scan right after locking* is on. Off by
|
||||
default: whoever locks their screen on purpose is usually still in front of
|
||||
@@ -188,24 +199,101 @@ After a scan that did not get anybody in, the next one waits for the person to
|
||||
be still for two seconds and then touch something again, so typing the password
|
||||
does not start a scan with every key.
|
||||
|
||||
Unlocked through logind, the lock screen cuts off its own password prompt and
|
||||
counts that as a wrong password. After a face unlock the daemon resets the
|
||||
When the face matches, the agent unlocks the session the way its lock screen
|
||||
wants it: through logind, as *loginctl unlock-session* does, on Plasma and
|
||||
GNOME, by itself on its own lock screen, and with SIGUSR1 for hyprlock,
|
||||
swaylock and gtklock after 4.0.0. Any other lock screen opens itself, through
|
||||
PAM (see *DESKTOPS*).
|
||||
|
||||
Unlocked through logind, Plasma's lock screen cuts off its own password prompt
|
||||
and counts that as a wrong password. After a face unlock the daemon resets the
|
||||
failed logins of *pam_faillock*(8), as a correct password does, so face unlocks
|
||||
never lock the account.
|
||||
|
||||
The bubble is a layer-shell surface that KWin keeps above the lock screen
|
||||
(kde_lockscreen_overlay_v1). KWin only allows that for a program whose desktop
|
||||
file asks for it, which is
|
||||
_io.github.loonixtools.plasma-face-unlock-agent.desktop_.
|
||||
On Plasma the bubble is a layer-shell surface that KWin keeps above the lock
|
||||
screen (kde_lockscreen_overlay_v1). KWin only allows that for a program whose
|
||||
desktop file asks for it, which is
|
||||
_io.github.loonixtools.face-unlock-agent.desktop_. How it gets there elsewhere
|
||||
is under *DESKTOPS*.
|
||||
|
||||
# SUDO AND ADMIN PROMPTS
|
||||
# DESKTOPS
|
||||
|
||||
All of them on Wayland. sudo and admin prompts work the same everywhere, and
|
||||
so does the bubble, above the lock screen too.
|
||||
|
||||
*KDE Plasma 6*
|
||||
The lock is seen through org.freedesktop.ScreenSaver and logind, and
|
||||
the bubble shows above the lock screen (kde_lockscreen_overlay_v1).
|
||||
|
||||
*GNOME*
|
||||
The lock is seen through logind, where GNOME sets *LockedHint*. GNOME
|
||||
lets no program draw above its windows, so a GNOME Shell extension
|
||||
(_face-unlock@loonixtools.github.io_) draws the bubble, from what the
|
||||
agent says on the session bus. *enable* switches it on; GNOME loads an
|
||||
extension that was installed after the login at the next one.
|
||||
|
||||
*Hyprland*, *Niri* and other compositors with ext-session-lock
|
||||
The lock screen is a program of the user's choice, and nothing but it
|
||||
can open it. So the face goes into its password check, the way it goes
|
||||
into sudo's: *Lock screens* under *Settings* puts the PAM module in
|
||||
front of the stacks of hyprlock, swaylock, gtklock and waylock, where
|
||||
installed (see *SUDO, ADMIN PROMPTS AND LOCK SCREENS*). Enter on the
|
||||
empty password field starts a scan, and a match lets the lock screen
|
||||
open itself. hyprlock asks PAM the moment it starts; that first time
|
||||
is skipped, so a screen locked on purpose does not open again at once.
|
||||
|
||||
hyprlock, swaylock and gtklock (after 4.0.0) can also be opened from
|
||||
outside: the agent finds them among the user's processes (niri also
|
||||
sets *LockedHint*), scans when somebody comes back, and opens them with
|
||||
SIGUSR1. It only does so once the lock screen handles SIGUSR1: before
|
||||
it has locked, the signal would kill it. Those lock screens cover the
|
||||
bubble. hyprlock shows it as a line of text instead: a label in
|
||||
_~/.config/hypr/hyprlock.conf_ (a *source* line under a face-unlock
|
||||
comment) reads what the agent writes, and SIGUSR2 makes hyprlock read
|
||||
it again. gtklock shows the messages of the PAM module; swaylock and
|
||||
waylock show none.
|
||||
|
||||
*enable* asks once in a window which way to go: face-unlock's lock
|
||||
screen, or the user's own with that line of text. The window shows the
|
||||
screen both ways, the user's own rebuilt from the config of hyprlock or
|
||||
swaylock. *Which lock screen* under *Settings* opens it again.
|
||||
|
||||
For the bubble on the lock screen there is face-unlock's own: *lock*.
|
||||
It shows the time, a password field (PAM service _face-unlock-lock_
|
||||
when an administrator wrote one, else the distribution's password-auth,
|
||||
common-auth or login) and the bubble, and a face opens it straight
|
||||
away. Behind it is the picture on the desktop, as swaybg, awww (swww),
|
||||
hyprpaper or wpaperd show it. *Wallpaper* under *Settings* puts another
|
||||
picture there, one at random from a folder, or _none_; *Blur the
|
||||
wallpaper* blurs it. If the agent dies while this screen is locked, the
|
||||
compositor keeps it locked, and the agent takes the lock back when it
|
||||
starts again. It needs Qt 6.10 or newer: with older Qt (Debian 13) the
|
||||
menu does not offer it, and *lock* asks logind to lock.
|
||||
|
||||
Hyprland only starts the agent's user service under uwsm. Without it,
|
||||
*enable* and *status* show what to add to its config.
|
||||
|
||||
Hyprland and Niri come without a polkit agent. One has to run for admin
|
||||
prompts and for setting up a face, for example hyprpolkitagent. The menu warns
|
||||
when none runs, and *p* installs one: hyprpolkitagent where the distribution
|
||||
has it, else KDE's agent. It starts it too, now and with the session.
|
||||
|
||||
# SUDO, ADMIN PROMPTS AND LOCK SCREENS
|
||||
|
||||
Turned on under *Settings*, one line goes in front of the service's PAM stack:
|
||||
|
||||
```
|
||||
-auth sufficient /usr/lib/security/pam_plasma_face_unlock.so
|
||||
-auth sufficient /usr/lib/security/pam_face_unlock.so
|
||||
```
|
||||
|
||||
For a lock screen (hyprlock, swaylock, gtklock, waylock) it ends in
|
||||
*lockscreen*. The scan then counts as a lock screen's, and a lock screen less
|
||||
than two seconds old gets none. Enter on the empty password field counts as a
|
||||
wrong password for *pam_faillock*(8) before the face is tried; a match takes
|
||||
that back, as a correct password does. A lock screen that checks the password
|
||||
with *login* or *system-auth* directly is left alone: those also let people
|
||||
log in.
|
||||
|
||||
A match lets the person in; anything else falls through to the password as if
|
||||
the line were not there. The dash makes PAM skip it quietly if the module ever
|
||||
goes missing, so sudo keeps working even when the package was removed without
|
||||
@@ -232,29 +320,42 @@ A laptop with its lid shut is not scanned (*Not when the lid is closed*).
|
||||
|
||||
# FILES
|
||||
|
||||
_~/.config/plasma-face-unlock/config_
|
||||
_~/.config/face-unlock/config_
|
||||
This user's settings: the lock screen, the bubble and its animation
|
||||
speed. Written by the menu.
|
||||
|
||||
_/etc/plasma-face-unlock/config_
|
||||
_/etc/face-unlock/config_
|
||||
The system settings: camera, photo check, strictness, attention, scan
|
||||
length. Written by the menu through sudo.
|
||||
|
||||
_/var/lib/plasma-face-unlock/users/<uid>.json_
|
||||
_/var/lib/face-unlock/users/<uid>.json_
|
||||
The face data: numbers, no pictures. Root only.
|
||||
|
||||
_/var/lib/plasma-face-unlock/users/<uid>.state_
|
||||
_/var/lib/face-unlock/users/<uid>.state_
|
||||
Failed scans in a row, the pause they lead to, the last unlock.
|
||||
|
||||
_/usr/share/plasma-face-unlock/models/_
|
||||
_/usr/share/face-unlock/models/_
|
||||
The two networks.
|
||||
|
||||
_/run/plasma-face-unlock/socket_
|
||||
_/run/face-unlock/socket_
|
||||
The daemon's socket.
|
||||
|
||||
_$XDG_RUNTIME_DIR/plasma-face-unlock/agent.socket_
|
||||
_$XDG_RUNTIME_DIR/face-unlock/agent.socket_
|
||||
Where the daemon tells the agent about scans it did not start, for the
|
||||
bubble.
|
||||
bubble, and where *lock* asks it to lock.
|
||||
|
||||
_$XDG_RUNTIME_DIR/face-unlock/locked_
|
||||
There while face-unlock's own lock screen is up.
|
||||
|
||||
_$XDG_RUNTIME_DIR/face-unlock/lock-text_
|
||||
What hyprlock shows at the top when the user keeps their own lock
|
||||
screen.
|
||||
|
||||
_~/.config/face-unlock/hyprlock.conf_
|
||||
The label for that, which _~/.config/hypr/hyprlock.conf_ sources.
|
||||
|
||||
_/usr/share/gnome-shell/extensions/face-unlock@loonixtools.github.io/_
|
||||
The GNOME Shell extension that draws the bubble on GNOME.
|
||||
|
||||
# ENVIRONMENT
|
||||
|
||||
@@ -263,17 +364,18 @@ _$XDG_RUNTIME_DIR/plasma-face-unlock/agent.socket_
|
||||
|
||||
# REQUIREMENTS
|
||||
|
||||
KDE Plasma 6 on Wayland, a camera, OpenCV 4.5.4 or newer with its DNN module,
|
||||
Qt 6, LayerShellQt, KI18n, systemd, polkit and Linux-PAM.
|
||||
KDE Plasma 6, GNOME, Hyprland or Niri on Wayland, a camera, OpenCV 4.5.4 or
|
||||
newer with its DNN module, Qt 6, LayerShellQt, KI18n, systemd, polkit and
|
||||
Linux-PAM.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
*loginctl*(1), *pam*(8), *polkit*(8), *systemctl*(1)
|
||||
*loginctl*(1), *pam*(8), *polkit*(8), *systemctl*(1), *swaylock*(1)
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Felitendo. Source and issue tracker at
|
||||
https://github.com/LoonixTools/plasma-face-unlock
|
||||
https://github.com/LoonixTools/face-unlock
|
||||
|
||||
The liveness model and the look of the bubble follow Glance by Jonathan Zhou
|
||||
(https://github.com/jonnyoo/glance, MIT). The models are YuNet and SFace from
|
||||
+8
-8
@@ -7,24 +7,24 @@ 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 |
|
||||
| `rpm/face-unlock.spec` | the RPM spec |
|
||||
| `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 |
|
||||
|
||||
The AUR package lives in [Felitendo/PKGBUILDS](https://github.com/Felitendo/PKGBUILDS/tree/main/plasma-face-unlock).
|
||||
The AUR package lives in [Felitendo/PKGBUILDS](https://github.com/Felitendo/PKGBUILDS/tree/main/face-unlock).
|
||||
Its CI notices a new GitHub release, updates the checksum and pushes to the AUR.
|
||||
|
||||
Unlike the shell-only LoonixTools, this one is compiled. The packages are per
|
||||
architecture (amd64 and x86_64), and they need the Plasma 6 and Qt 6
|
||||
architecture (amd64 and x86_64), and they need the Qt 6 and KDE Frameworks 6
|
||||
development packages to build: Debian 13 (trixie) and current Fedora have
|
||||
them. The Debian package's library dependencies are read off the binaries by
|
||||
`dpkg-shlibdeps`; RPM does the same on its own.
|
||||
|
||||
The program uses Qt's private API, so a package only fits the Qt it was built
|
||||
against. The `.deb` is therefore built twice, in Debian 13 and in Ubuntu 26.04
|
||||
(for Kubuntu), with a suffix on the version (`~deb13`, `~ubuntu26.04`), and each
|
||||
(for Ubuntu and Kubuntu), with a suffix on the version (`~deb13`, `~ubuntu26.04`), and each
|
||||
gets an APT repository of its own: `deb/trixie` and `deb/resolute`. The RPM is
|
||||
built on the current Fedora.
|
||||
|
||||
@@ -34,7 +34,7 @@ checksums in the Makefile. The RPM spec and the PKGBUILD list them as sources
|
||||
of their own, with the same checksums.
|
||||
|
||||
Neither the deb nor the rpm switches anything on at install time. The daemon's
|
||||
socket is enabled by `plasma-face-unlock` the first time somebody turns it on,
|
||||
socket is enabled by `face-unlock` the first time somebody turns it on,
|
||||
the agent is a user service each user enables, and the PAM files are only
|
||||
touched when somebody asks for sudo or admin prompts. Removing a package
|
||||
disables the socket.
|
||||
@@ -52,7 +52,7 @@ argument.
|
||||
## Making a release
|
||||
|
||||
1. Bump `VERSION` in the Makefile. The compiled programs get it from there
|
||||
too (`-DPFU_VERSION`).
|
||||
too (`-DFU_VERSION`).
|
||||
2. Add the release to `CHANGELOG.md`, in the format CLAUDE.md describes.
|
||||
3. Commit, then `git tag vX.Y.Z && git push --tags`.
|
||||
|
||||
@@ -75,9 +75,9 @@ repositories. Nothing is pushed and no release is made.
|
||||
|
||||
```bash
|
||||
gpg --batch --passphrase '' --quick-generate-key \
|
||||
'plasma-face-unlock repository <felitendoyt@gmail.com>' rsa4096 sign never
|
||||
'face-unlock repository <felitendoyt@gmail.com>' rsa4096 sign never
|
||||
|
||||
gpg --armor --export-secret-keys 'plasma-face-unlock repository' \
|
||||
gpg --armor --export-secret-keys 'face-unlock repository' \
|
||||
| gh secret set GPG_PRIVATE_KEY
|
||||
```
|
||||
|
||||
|
||||
+12
-2
@@ -21,7 +21,7 @@ version="${1:-$(make -s -C "$here" version)}"
|
||||
# The package depends on the exact Qt of the distribution it is built on, so
|
||||
# each one gets a build of its own, told apart by a suffix: ~deb13, ~ubuntu26.04.
|
||||
debversion="$version${DEB_SUFFIX:-}"
|
||||
name=plasma-face-unlock
|
||||
name=face-unlock
|
||||
|
||||
# A package without its man page or its translations is not a package this
|
||||
# should be quietly willing to produce.
|
||||
@@ -63,11 +63,21 @@ cat > "$root/DEBIAN/prerm" <<'SH'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
if [ "$1" = remove ] && [ -d /run/systemd/system ]; then
|
||||
systemctl disable --now plasma-face-unlockd.socket plasma-face-unlockd.service >/dev/null 2>&1 || true
|
||||
systemctl disable --now face-unlockd.socket face-unlockd.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
SH
|
||||
chmod 755 "$root/DEBIAN/prerm"
|
||||
|
||||
# Faces, settings and PAM lines of plasma-face-unlock, the old name.
|
||||
cat > "$root/DEBIAN/postinst" <<'SH'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
if [ "$1" = configure ]; then
|
||||
face-unlock --root migrate || true
|
||||
fi
|
||||
SH
|
||||
chmod 755 "$root/DEBIAN/postinst"
|
||||
|
||||
( cd "$root" && find . -type f ! -path './DEBIAN/*' -printf '%P\0' \
|
||||
| LC_ALL=C sort -z | xargs -0 md5sum > DEBIAN/md5sums )
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ set -euo pipefail
|
||||
|
||||
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
version="${1:-$(make -s -C "$here" version)}"
|
||||
name=plasma-face-unlock
|
||||
name=face-unlock
|
||||
|
||||
command -v rpmbuild > /dev/null || { echo "$0: rpmbuild is not installed" >&2; exit 1; }
|
||||
|
||||
|
||||
+13
-8
@@ -1,17 +1,22 @@
|
||||
Package: plasma-face-unlock
|
||||
Package: face-unlock
|
||||
Version: @VERSION@
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Architecture: @ARCH@
|
||||
Maintainer: Felitendo <felitendoyt@gmail.com>
|
||||
Depends: @DEPENDS@, bash (>= 4.2), coreutils, grep, sed, mawk | gawk, systemd, polkitd | policykit-1, qml6-module-qtquick, qml6-module-qtquick-shapes, qml6-module-qtquick-effects, qml6-module-qtquick-window, qml6-module-qtqml-workerscript
|
||||
Recommends: gettext-base, kscreenlocker
|
||||
Homepage: https://github.com/LoonixTools/plasma-face-unlock
|
||||
Description: face unlock for KDE Plasma
|
||||
Recommends: gettext-base
|
||||
Replaces: plasma-face-unlock
|
||||
Conflicts: plasma-face-unlock
|
||||
Homepage: https://github.com/LoonixTools/face-unlock
|
||||
Description: face unlock for Plasma, GNOME, Hyprland and Niri
|
||||
Look at the screen and it unlocks, the way a phone does. The lock screen,
|
||||
sudo in a terminal and the admin password prompts of Plasma can take a face
|
||||
instead of a password. A bubble at the top of the screen, above the lock
|
||||
screen too, shows the face being looked for, recognised or refused.
|
||||
sudo in a terminal and the admin password prompts can take a face instead
|
||||
of a password, on KDE Plasma, GNOME, Hyprland and Niri. A bubble at the top
|
||||
of the screen shows the face being looked for, recognised or refused.
|
||||
.
|
||||
This package was called plasma-face-unlock before. It takes over its faces
|
||||
and settings.
|
||||
.
|
||||
A photo on a phone, a tablet or a glossy print is refused by its
|
||||
reflection and its straight edges. The strict photo check also wants a sign
|
||||
@@ -21,5 +26,5 @@ Description: face unlock for KDE Plasma
|
||||
through.
|
||||
.
|
||||
Everything runs on the machine. Faces are stored as numbers readable only by
|
||||
root, never as pictures. Run "plasma-face-unlock disable" before removing
|
||||
root, never as pictures. Run "face-unlock disable" before removing
|
||||
this package, so that sudo and polkit go back to the password alone.
|
||||
@@ -1,17 +1,17 @@
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: plasma-face-unlock
|
||||
Source: https://github.com/LoonixTools/plasma-face-unlock
|
||||
Upstream-Name: face-unlock
|
||||
Source: https://github.com/LoonixTools/face-unlock
|
||||
|
||||
Files: *
|
||||
Copyright: 2026 Felitendo
|
||||
License: GPL-3+
|
||||
|
||||
Files: usr/share/plasma-face-unlock/models/face_detection_yunet_2023mar.onnx
|
||||
Files: usr/share/face-unlock/models/face_detection_yunet_2023mar.onnx
|
||||
Copyright: 2021-2023 Shiqi Yu, Wei Wu and the YuNet authors
|
||||
License: MIT
|
||||
Comment: https://github.com/opencv/opencv_zoo/tree/main/models/face_detection_yunet
|
||||
|
||||
Files: usr/share/plasma-face-unlock/models/face_recognition_sface_2021dec.onnx
|
||||
Files: usr/share/face-unlock/models/face_recognition_sface_2021dec.onnx
|
||||
Copyright: 2021 Yaoyao Zhong and Weihong Deng
|
||||
License: Apache-2.0
|
||||
Comment: https://github.com/opencv/opencv_zoo/tree/main/models/face_recognition_sface
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[plasma-face-unlock]
|
||||
name=plasma-face-unlock
|
||||
[face-unlock]
|
||||
name=face-unlock
|
||||
baseurl=@BASEURL@/rpm
|
||||
enabled=1
|
||||
gpgcheck=1
|
||||
+22
-22
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>plasma-face-unlock</title>
|
||||
<title>face-unlock</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
@@ -62,52 +62,52 @@
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>plasma-face-unlock</h1>
|
||||
<h1>face-unlock</h1>
|
||||
<p class="lead">
|
||||
Face ID for KDE Plasma: look at the screen and it unlocks. The lock screen,
|
||||
sudo and the admin prompts can take a face instead of a password, and a photo
|
||||
of somebody is not enough.
|
||||
Face ID for Linux: look at the screen and it unlocks. The lock screen, sudo
|
||||
and the admin prompts can take a face instead of a password, on KDE Plasma,
|
||||
GNOME, Hyprland and Niri. A photo of somebody is not enough.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This page is the package repository. Set it up once and every new version
|
||||
arrives with the rest of your system updates. The
|
||||
<a href="https://github.com/LoonixTools/plasma-face-unlock">source and the
|
||||
<a href="https://github.com/LoonixTools/face-unlock">source and the
|
||||
documentation</a> are on GitHub.
|
||||
</p>
|
||||
|
||||
<h2>Debian 13</h2>
|
||||
<pre><code>sudo install -d -m 0755 /etc/apt/keyrings
|
||||
curl -fsSL @BASEURL@/KEY.gpg \
|
||||
| sudo gpg --dearmor -o /etc/apt/keyrings/plasma-face-unlock.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/plasma-face-unlock.gpg] @BASEURL@/deb/trixie ./" \
|
||||
| sudo tee /etc/apt/sources.list.d/plasma-face-unlock.list
|
||||
| sudo gpg --dearmor -o /etc/apt/keyrings/face-unlock.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/face-unlock.gpg] @BASEURL@/deb/trixie ./" \
|
||||
| sudo tee /etc/apt/sources.list.d/face-unlock.list
|
||||
sudo apt update
|
||||
sudo apt install plasma-face-unlock</code></pre>
|
||||
sudo apt install face-unlock</code></pre>
|
||||
|
||||
<h2>Kubuntu 26.04</h2>
|
||||
<h2>Ubuntu 26.04</h2>
|
||||
<pre><code>sudo install -d -m 0755 /etc/apt/keyrings
|
||||
curl -fsSL @BASEURL@/KEY.gpg \
|
||||
| sudo gpg --dearmor -o /etc/apt/keyrings/plasma-face-unlock.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/plasma-face-unlock.gpg] @BASEURL@/deb/resolute ./" \
|
||||
| sudo tee /etc/apt/sources.list.d/plasma-face-unlock.list
|
||||
| sudo gpg --dearmor -o /etc/apt/keyrings/face-unlock.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/face-unlock.gpg] @BASEURL@/deb/resolute ./" \
|
||||
| sudo tee /etc/apt/sources.list.d/face-unlock.list
|
||||
sudo apt update
|
||||
sudo apt install plasma-face-unlock</code></pre>
|
||||
sudo apt install face-unlock</code></pre>
|
||||
|
||||
<h2>Fedora 44 (KDE Plasma)</h2>
|
||||
<pre><code>sudo curl -fsSL -o /etc/yum.repos.d/plasma-face-unlock.repo \
|
||||
@BASEURL@/plasma-face-unlock.repo
|
||||
sudo dnf install plasma-face-unlock</code></pre>
|
||||
<h2>Fedora 44</h2>
|
||||
<pre><code>sudo curl -fsSL -o /etc/yum.repos.d/face-unlock.repo \
|
||||
@BASEURL@/face-unlock.repo
|
||||
sudo dnf install face-unlock</code></pre>
|
||||
|
||||
<h2>Arch, CachyOS, EndeavourOS, Manjaro</h2>
|
||||
<pre><code>paru -S plasma-face-unlock</code></pre>
|
||||
<pre><code>paru -S face-unlock</code></pre>
|
||||
|
||||
<h2>Then, once</h2>
|
||||
<pre><code>plasma-face-unlock</code></pre>
|
||||
<pre><code>face-unlock</code></pre>
|
||||
<p>
|
||||
Press 1. It sets up your face (look at the camera, move your head in a
|
||||
circle) and turns face unlock on. sudo and the admin prompts are off until
|
||||
you switch them on under Settings. Run <code>plasma-face-unlock disable</code>
|
||||
you switch them on under Settings. Run <code>face-unlock disable</code>
|
||||
before removing the package: it takes face unlock back out of sudo and
|
||||
polkit.
|
||||
</p>
|
||||
|
||||
@@ -19,7 +19,7 @@ pages="$(cd -- "$1" && pwd)"
|
||||
incoming="$(cd -- "$2" && pwd)"
|
||||
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
base_url="${PFU_REPO_URL:-https://loonixtools.github.io/plasma-face-unlock}"
|
||||
base_url="${FU_REPO_URL:-https://loonixtools.github.io/face-unlock}"
|
||||
|
||||
keyid="$(gpg --list-secret-keys --with-colons | awk -F: '/^sec:/ { print $5; exit }')"
|
||||
[[ -n $keyid ]] || { echo "$0: no secret key in the keyring" >&2; exit 1; }
|
||||
@@ -40,8 +40,8 @@ for suite in trixie:deb13 resolute:ubuntu26.04; do
|
||||
dpkg-scanpackages --multiversion . > Packages
|
||||
gzip -9kf Packages
|
||||
apt-ftparchive \
|
||||
-o APT::FTPArchive::Release::Origin=plasma-face-unlock \
|
||||
-o APT::FTPArchive::Release::Label=plasma-face-unlock \
|
||||
-o APT::FTPArchive::Release::Origin=face-unlock \
|
||||
-o APT::FTPArchive::Release::Label=face-unlock \
|
||||
-o APT::FTPArchive::Release::Suite="$codename" \
|
||||
-o APT::FTPArchive::Release::Codename="$codename" \
|
||||
-o APT::FTPArchive::Release::Architectures=amd64 \
|
||||
@@ -65,8 +65,8 @@ done
|
||||
gpg --armor --export "$keyid" > "$pages/KEY.gpg"
|
||||
|
||||
sed "s|@BASEURL@|$base_url|g" "$here/packaging/pages/index.html" > "$pages/index.html"
|
||||
sed "s|@BASEURL@|$base_url|g" "$here/packaging/pages/plasma-face-unlock.repo" \
|
||||
> "$pages/plasma-face-unlock.repo"
|
||||
sed "s|@BASEURL@|$base_url|g" "$here/packaging/pages/face-unlock.repo" \
|
||||
> "$pages/face-unlock.repo"
|
||||
|
||||
# Pages would otherwise hand the whole directory to Jekyll, which drops every
|
||||
# file whose name starts with an underscore and can rewrite the rest.
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# Built with `packaging/build-rpm.sh`, which passes the version in rather than
|
||||
# editing this file: the Makefile is where the version is written down.
|
||||
%global upstream_version %{?_version}%{!?_version:1.0.1}
|
||||
%global upstream_version %{?_version}%{!?_version:2.0.0}
|
||||
|
||||
Name: plasma-face-unlock
|
||||
Name: face-unlock
|
||||
Version: %{upstream_version}
|
||||
Release: 1%{?dist}
|
||||
Summary: Face unlock for KDE Plasma
|
||||
Summary: Face unlock for Plasma, GNOME, Hyprland and Niri
|
||||
|
||||
# The program is GPL; the two networks it ships are MIT (YuNet) and
|
||||
# Apache-2.0 (SFace).
|
||||
License: GPL-3.0-or-later AND MIT AND Apache-2.0
|
||||
URL: https://github.com/LoonixTools/plasma-face-unlock
|
||||
URL: https://github.com/LoonixTools/face-unlock
|
||||
Source0: %{name}-%{version}.tar.gz
|
||||
Source1: https://github.com/opencv/opencv_zoo/raw/main/models/face_detection_yunet/face_detection_yunet_2023mar.onnx
|
||||
Source2: https://github.com/opencv/opencv_zoo/raw/main/models/face_recognition_sface/face_recognition_sface_2021dec.onnx
|
||||
@@ -45,13 +45,18 @@ Requires: polkit
|
||||
Requires: systemd
|
||||
Requires: qt6-qtdeclarative
|
||||
Recommends: /usr/bin/gettext
|
||||
Recommends: kscreenlocker
|
||||
# The old name.
|
||||
Obsoletes: plasma-face-unlock < 2
|
||||
Provides: plasma-face-unlock = %{version}-%{release}
|
||||
|
||||
%description
|
||||
Look at the screen and it unlocks, the way a phone does. The lock screen, sudo
|
||||
in a terminal and the admin password prompts of Plasma can take a face instead
|
||||
of a password. A bubble at the top of the screen, above the lock screen too,
|
||||
shows the face being looked for, recognised or refused.
|
||||
in a terminal and the admin password prompts can take a face instead of a
|
||||
password, on KDE Plasma, GNOME, Hyprland and Niri. A bubble at the top of the
|
||||
screen shows the face being looked for, recognised or refused.
|
||||
|
||||
This package was called plasma-face-unlock before. It takes over its faces and
|
||||
settings.
|
||||
|
||||
A photo on a phone, a tablet or a glossy print is refused by its reflection and
|
||||
its straight edges. The strict photo check also wants a sign of life (a blink,
|
||||
@@ -59,7 +64,7 @@ or the nose moving the way a real nose does when the head turns), which stops a
|
||||
printed photo too. It is a convenience, not a security upgrade: a webcam sees a
|
||||
flat picture, and a video of the person can get through.
|
||||
|
||||
Run "plasma-face-unlock disable" before removing this package, so that sudo
|
||||
Run "face-unlock disable" before removing this package, so that sudo
|
||||
and polkit go back to the password alone.
|
||||
|
||||
%prep
|
||||
@@ -78,10 +83,14 @@ make install DESTDIR=%{buildroot} PREFIX=%{_prefix} VERSION=%{upstream_version}
|
||||
%find_lang %{name}
|
||||
|
||||
%preun
|
||||
%systemd_preun plasma-face-unlockd.socket plasma-face-unlockd.service
|
||||
%systemd_preun face-unlockd.socket face-unlockd.service
|
||||
|
||||
%postun
|
||||
%systemd_postun plasma-face-unlockd.socket plasma-face-unlockd.service
|
||||
%systemd_postun face-unlockd.socket face-unlockd.service
|
||||
|
||||
# After the old package is gone: its faces, settings and PAM lines.
|
||||
%posttrans
|
||||
%{_bindir}/face-unlock --root migrate || :
|
||||
|
||||
%files -f %{name}.lang
|
||||
%license LICENSE
|
||||
@@ -89,13 +98,14 @@ make install DESTDIR=%{buildroot} PREFIX=%{_prefix} VERSION=%{upstream_version}
|
||||
%{_bindir}/%{name}
|
||||
%{_prefix}/lib/%{name}/
|
||||
%{_datadir}/%{name}/
|
||||
%{_libdir}/security/pam_plasma_face_unlock.so
|
||||
%{_unitdir}/plasma-face-unlockd.socket
|
||||
%{_unitdir}/plasma-face-unlockd.service
|
||||
%{_userunitdir}/plasma-face-unlock-agent.service
|
||||
%{_datadir}/applications/io.github.loonixtools.plasma-face-unlock-agent.desktop
|
||||
%{_datadir}/polkit-1/actions/io.github.loonixtools.plasma-face-unlock.policy
|
||||
%{_datadir}/icons/hicolor/scalable/apps/plasma-face-unlock.svg
|
||||
%{_libdir}/security/pam_face_unlock.so
|
||||
%{_unitdir}/face-unlockd.socket
|
||||
%{_unitdir}/face-unlockd.service
|
||||
%{_userunitdir}/face-unlock-agent.service
|
||||
%{_datadir}/applications/io.github.loonixtools.face-unlock-agent.desktop
|
||||
%{_datadir}/polkit-1/actions/io.github.loonixtools.face-unlock.policy
|
||||
%{_datadir}/icons/hicolor/scalable/apps/face-unlock.svg
|
||||
%{_datadir}/gnome-shell/extensions/face-unlock@loonixtools.github.io/
|
||||
%{_mandir}/man1/%{name}.1*
|
||||
|
||||
%changelog
|
||||
File diff suppressed because it is too large.
Load diff
+1275
File diff suppressed because it is too large.
Load diff
+14
-14
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Collects every translatable string into po/plasma-face-unlock.pot and brings
|
||||
# Collects every translatable string into po/face-unlock.pot and brings
|
||||
# the translations up to date with it. One catalog serves all four parts:
|
||||
# the shell code (pfu_msg), the agent (i18n in C++ and QML) and the PAM module
|
||||
# the shell code (fu_msg), the agent (i18n in C++ and QML) and the PAM module
|
||||
# (dgettext), so a word is translated once wherever it shows up.
|
||||
#
|
||||
# The settings labels and headings live in an array in menu.sh and reach
|
||||
@@ -18,23 +18,23 @@ trap 'rm -rf -- "$tmp"' EXIT
|
||||
version="$(make -s version)"
|
||||
|
||||
# The labels, as calls xgettext understands, pointing back at menu.sh.
|
||||
awk -F'|' '/^\t"(user|sys|pam)\|/ { sub(/"$/, "", $5); print "pfu_msg \"" $5 "\"" }
|
||||
/^\t"group\|/ { sub(/"$/, "", $2); print "pfu_msg \"" $2 "\"" }' src/lib/menu.sh > "$tmp/settings.sh"
|
||||
awk -F'|' '/^\t"(user|sys|pam)\|/ { sub(/"$/, "", $5); print "fu_msg \"" $5 "\"" }
|
||||
/^\t"group\|/ { sub(/"$/, "", $2); print "fu_msg \"" $2 "\"" }' src/lib/menu.sh > "$tmp/settings.sh"
|
||||
|
||||
common=(--from-code=UTF-8 --add-comments=TRANSLATORS --package-name=plasma-face-unlock --package-version="$version"
|
||||
--msgid-bugs-address=https://github.com/LoonixTools/plasma-face-unlock/issues)
|
||||
common=(--from-code=UTF-8 --add-comments=TRANSLATORS --package-name=face-unlock --package-version="$version"
|
||||
--msgid-bugs-address=https://github.com/LoonixTools/face-unlock/issues)
|
||||
|
||||
xgettext "${common[@]}" -L Shell -k --keyword=pfu_msg --keyword=pfu_msg_into:2 --keyword=pfu_msg_in:2 \
|
||||
-o "$tmp/shell.pot" src/plasma-face-unlock src/lib/*.sh "$tmp/settings.sh"
|
||||
sed -i "s|#: $tmp/settings.sh:[0-9]*|#: src/lib/menu.sh|" "$tmp/shell.pot"
|
||||
xgettext "${common[@]}" -L Shell -k --keyword=fu_msg --keyword=fu_msg_into:2 --keyword=fu_msg_in:2 \
|
||||
-o "$tmp/shell.pot" src/face-unlock src/lib/*.sh "$tmp/settings.sh"
|
||||
sed -i "s|$tmp/settings.sh:[0-9]*|src/lib/menu.sh|g" "$tmp/shell.pot"
|
||||
xgettext "${common[@]}" -L C++ --keyword=i18n --keyword=_ -o "$tmp/native.pot" src/agent/*.cpp src/pam/*.c
|
||||
xgettext "${common[@]}" -L JavaScript --keyword=i18n -o "$tmp/qml.pot" src/agent/qml/*.qml
|
||||
|
||||
msgcat --use-first -o po/plasma-face-unlock.pot "$tmp/shell.pot" "$tmp/native.pot" "$tmp/qml.pot"
|
||||
sed -i -e '1i # Translation template for plasma-face-unlock.\n# Copyright (C) 2026 Felitendo\n# This file is distributed under the same license as plasma-face-unlock.' -e '1,4d' \
|
||||
po/plasma-face-unlock.pot
|
||||
msgcat --use-first -o po/face-unlock.pot "$tmp/shell.pot" "$tmp/native.pot" "$tmp/qml.pot"
|
||||
sed -i -e '1i # Translation template for face-unlock.\n# Copyright (C) 2026 Felitendo\n# This file is distributed under the same license as face-unlock.' -e '1,4d' \
|
||||
po/face-unlock.pot
|
||||
|
||||
for po in po/*.po; do
|
||||
msgmerge --quiet --update --backup=none --no-fuzzy-matching "$po" po/plasma-face-unlock.pot
|
||||
msgmerge --quiet --update --backup=none --no-fuzzy-matching "$po" po/face-unlock.pot
|
||||
done
|
||||
echo "po/plasma-face-unlock.pot: $(grep -c '^msgid' po/plasma-face-unlock.pot) strings"
|
||||
echo "po/face-unlock.pot: $(grep -c '^msgid' po/face-unlock.pot) strings"
|
||||
+1199
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,328 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="ext_session_lock_v1">
|
||||
<copyright>
|
||||
Copyright 2021 Isaac Freund
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the "Software"),
|
||||
to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
</copyright>
|
||||
|
||||
<description summary="secure session locking with arbitrary graphics">
|
||||
This protocol allows for a privileged Wayland client to lock the session
|
||||
and display arbitrary graphics while the session is locked.
|
||||
|
||||
The compositor may choose to restrict this protocol to a special client
|
||||
launched by the compositor itself or expose it to all privileged clients,
|
||||
this is compositor policy.
|
||||
|
||||
The client is responsible for performing authentication and informing the
|
||||
compositor when the session should be unlocked. If the client dies while
|
||||
the session is locked the session remains locked, possibly permanently
|
||||
depending on compositor policy.
|
||||
|
||||
The key words "must", "must not", "required", "shall", "shall not",
|
||||
"should", "should not", "recommended", "may", and "optional" in this
|
||||
document are to be interpreted as described in IETF RFC 2119.
|
||||
|
||||
Warning! The protocol described in this file is currently in the
|
||||
testing phase. Backward compatible changes may be added together with
|
||||
the corresponding interface version bump. Backward incompatible changes
|
||||
can only be done by creating a new major version of the extension.
|
||||
</description>
|
||||
|
||||
<interface name="ext_session_lock_manager_v1" version="1">
|
||||
<description summary="used to lock the session">
|
||||
This interface is used to request that the session be locked.
|
||||
</description>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="destroy the session lock manager object">
|
||||
This informs the compositor that the session lock manager object will
|
||||
no longer be used. Existing objects created through this interface
|
||||
remain valid.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<request name="lock">
|
||||
<description summary="attempt to lock the session">
|
||||
This request creates a session lock and asks the compositor to lock the
|
||||
session. The compositor will send either the ext_session_lock_v1.locked
|
||||
or ext_session_lock_v1.finished event on the created object in
|
||||
response to this request.
|
||||
</description>
|
||||
<arg name="id" type="new_id" interface="ext_session_lock_v1"/>
|
||||
</request>
|
||||
</interface>
|
||||
|
||||
<interface name="ext_session_lock_v1" version="1">
|
||||
<description summary="manage lock state and create lock surfaces">
|
||||
In response to the creation of this object the compositor must send
|
||||
either the locked or finished event.
|
||||
|
||||
The locked event indicates that the session is locked. This means
|
||||
that the compositor must stop rendering and providing input to normal
|
||||
clients. Instead the compositor must blank all outputs with an opaque
|
||||
color such that their normal content is fully hidden.
|
||||
|
||||
The only surfaces that should be rendered while the session is locked
|
||||
are the lock surfaces created through this interface and optionally,
|
||||
at the compositor's discretion, special privileged surfaces such as
|
||||
input methods or portions of desktop shell UIs.
|
||||
|
||||
The locked event must not be sent until a new "locked" frame (either
|
||||
from a session lock surface or the compositor blanking the output) has
|
||||
been presented on all outputs and no security sensitive normal/unlocked
|
||||
content is possibly visible.
|
||||
|
||||
The finished event should be sent immediately on creation of this
|
||||
object if the compositor decides that the locked event will not be sent.
|
||||
|
||||
The compositor may wait for the client to create and render session lock
|
||||
surfaces before sending the locked event to avoid displaying intermediate
|
||||
blank frames. However, it must impose a reasonable time limit if
|
||||
waiting and send the locked event as soon as the hard requirements
|
||||
described above can be met if the time limit expires. Clients should
|
||||
immediately create lock surfaces for all outputs on creation of this
|
||||
object to make this possible.
|
||||
|
||||
This behavior of the locked event is required in order to prevent
|
||||
possible race conditions with clients that wish to suspend the system
|
||||
or similar after locking the session. Without these semantics, clients
|
||||
triggering a suspend after receiving the locked event would race with
|
||||
the first "locked" frame being presented and normal/unlocked frames
|
||||
might be briefly visible as the system is resumed if the suspend
|
||||
operation wins the race.
|
||||
|
||||
If the client dies while the session is locked, the compositor must not
|
||||
unlock the session in response. It is acceptable for the session to be
|
||||
permanently locked if this happens. The compositor may choose to continue
|
||||
to display the lock surfaces the client had mapped before it died or
|
||||
alternatively fall back to a solid color, this is compositor policy.
|
||||
|
||||
Compositors may also allow a secure way to recover the session, the
|
||||
details of this are compositor policy. Compositors may allow a new
|
||||
client to create a ext_session_lock_v1 object and take responsibility
|
||||
for unlocking the session, they may even start a new lock client
|
||||
instance automatically.
|
||||
</description>
|
||||
|
||||
<enum name="error">
|
||||
<entry name="invalid_destroy" value="0"
|
||||
summary="attempted to destroy session lock while locked"/>
|
||||
<entry name="invalid_unlock" value="1"
|
||||
summary="unlock requested but locked event was never sent"/>
|
||||
<entry name="role" value="2"
|
||||
summary="given wl_surface already has a role"/>
|
||||
<entry name="duplicate_output" value="3"
|
||||
summary="given output already has a lock surface"/>
|
||||
<entry name="already_constructed" value="4"
|
||||
summary="given wl_surface has a buffer attached or committed"/>
|
||||
</enum>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="destroy the session lock">
|
||||
This informs the compositor that the lock object will no longer be
|
||||
used. Existing objects created through this interface remain valid.
|
||||
|
||||
After this request is made, lock surfaces created through this object
|
||||
should be destroyed by the client as they will no longer be used by
|
||||
the compositor.
|
||||
|
||||
It is a protocol error to make this request if the locked event was
|
||||
sent, the unlock_and_destroy request must be used instead.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<event name="locked">
|
||||
<description summary="session successfully locked">
|
||||
This client is now responsible for displaying graphics while the
|
||||
session is locked and deciding when to unlock the session.
|
||||
|
||||
The locked event must not be sent until a new "locked" frame has been
|
||||
presented on all outputs and no security sensitive normal/unlocked
|
||||
content is possibly visible.
|
||||
|
||||
If this event is sent, making the destroy request is a protocol error,
|
||||
the lock object must be destroyed using the unlock_and_destroy request.
|
||||
</description>
|
||||
</event>
|
||||
|
||||
<event name="finished">
|
||||
<description summary="the session lock object should be destroyed">
|
||||
The compositor has decided that the session lock should be destroyed
|
||||
as it will no longer be used by the compositor. Exactly when this
|
||||
event is sent is compositor policy, but it must never be sent more
|
||||
than once for a given session lock object.
|
||||
|
||||
This might be sent because there is already another ext_session_lock_v1
|
||||
object held by a client, or the compositor has decided to deny the
|
||||
request to lock the session for some other reason. This might also
|
||||
be sent because the compositor implements some alternative, secure
|
||||
way to authenticate and unlock the session.
|
||||
|
||||
The finished event should be sent immediately on creation of this
|
||||
object if the compositor decides that the locked event will not
|
||||
be sent.
|
||||
|
||||
If the locked event is sent on creation of this object the finished
|
||||
event may still be sent at some later time in this object's
|
||||
lifetime. This is compositor policy.
|
||||
|
||||
Upon receiving this event, the client should make either the destroy
|
||||
request or the unlock_and_destroy request, depending on whether or
|
||||
not the locked event was received on this object.
|
||||
</description>
|
||||
</event>
|
||||
|
||||
<request name="get_lock_surface">
|
||||
<description summary="create a lock surface for a given output">
|
||||
The client is expected to create lock surfaces for all outputs
|
||||
currently present and any new outputs as they are advertised. These
|
||||
won't be displayed by the compositor unless the lock is successful
|
||||
and the locked event is sent.
|
||||
|
||||
Providing a wl_surface which already has a role or already has a buffer
|
||||
attached or committed is a protocol error, as is attaching/committing
|
||||
a buffer before the first ext_session_lock_surface_v1.configure event.
|
||||
|
||||
Attempting to create more than one lock surface for a given output
|
||||
is a duplicate_output protocol error.
|
||||
</description>
|
||||
<arg name="id" type="new_id" interface="ext_session_lock_surface_v1"/>
|
||||
<arg name="surface" type="object" interface="wl_surface"/>
|
||||
<arg name="output" type="object" interface="wl_output"/>
|
||||
</request>
|
||||
|
||||
<request name="unlock_and_destroy" type="destructor">
|
||||
<description summary="unlock the session, destroying the object">
|
||||
This request indicates that the session should be unlocked, for
|
||||
example because the user has entered their password and it has been
|
||||
verified by the client.
|
||||
|
||||
This request also informs the compositor that the lock object will
|
||||
no longer be used and should be destroyed. Existing objects created
|
||||
through this interface remain valid.
|
||||
|
||||
After this request is made, lock surfaces created through this object
|
||||
should be destroyed by the client as they will no longer be used by
|
||||
the compositor.
|
||||
|
||||
It is a protocol error to make this request if the locked event has
|
||||
not been sent. In that case, the lock object must be destroyed using
|
||||
the destroy request.
|
||||
|
||||
Note that a correct client that wishes to exit directly after unlocking
|
||||
the session must use the wl_display.sync request to ensure the server
|
||||
receives and processes the unlock_and_destroy request. Otherwise
|
||||
there is no guarantee that the server has unlocked the session due
|
||||
to the asynchronous nature of the Wayland protocol. For example,
|
||||
the server might terminate the client with a protocol error before
|
||||
it processes the unlock_and_destroy request.
|
||||
</description>
|
||||
</request>
|
||||
</interface>
|
||||
|
||||
<interface name="ext_session_lock_surface_v1" version="1">
|
||||
<description summary="a surface displayed while the session is locked">
|
||||
The client may use lock surfaces to display a screensaver, render a
|
||||
dialog to enter a password and unlock the session, or however else it
|
||||
sees fit.
|
||||
|
||||
On binding this interface the compositor will immediately send the
|
||||
first configure event. After making the ack_configure request in
|
||||
response to this event the client should attach and commit the first
|
||||
buffer. Committing the surface before acking the first configure is a
|
||||
protocol error. Committing the surface with a null buffer at any time
|
||||
is a protocol error.
|
||||
|
||||
The compositor is free to handle keyboard/pointer focus for lock
|
||||
surfaces however it chooses. A reasonable way to do this would be to
|
||||
give the first lock surface created keyboard focus and change keyboard
|
||||
focus if the user clicks on other surfaces.
|
||||
</description>
|
||||
|
||||
<enum name="error">
|
||||
<entry name="commit_before_first_ack" value="0"
|
||||
summary="surface committed before first ack_configure request"/>
|
||||
<entry name="null_buffer" value="1"
|
||||
summary="surface committed with a null buffer"/>
|
||||
<entry name="dimensions_mismatch" value="2"
|
||||
summary="failed to match ack'd width/height"/>
|
||||
<entry name="invalid_serial" value="3"
|
||||
summary="serial provided in ack_configure is invalid"/>
|
||||
</enum>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="destroy the lock surface object">
|
||||
This informs the compositor that the lock surface object will no
|
||||
longer be used.
|
||||
|
||||
It is recommended for a lock client to destroy lock surfaces if
|
||||
their corresponding wl_output global is removed.
|
||||
|
||||
If a lock surface on an active output is destroyed before the
|
||||
ext_session_lock_v1.unlock_and_destroy event is sent, the compositor
|
||||
must fall back to rendering a solid color.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<request name="ack_configure">
|
||||
<description summary="ack a configure event">
|
||||
When a configure event is received, if a client commits the surface
|
||||
in response to the configure event, then the client must make an
|
||||
ack_configure request sometime before the commit request, passing
|
||||
along the serial of the configure event.
|
||||
|
||||
If the client receives multiple configure events before it can
|
||||
respond to one, it only has to ack the last configure event.
|
||||
|
||||
A client is not required to commit immediately after sending an
|
||||
ack_configure request - it may even ack_configure several times
|
||||
before its next surface commit.
|
||||
|
||||
A client may send multiple ack_configure requests before committing,
|
||||
but only the last request sent before a commit indicates which
|
||||
configure event the client really is responding to.
|
||||
|
||||
Sending an ack_configure request consumes the configure event
|
||||
referenced by the given serial, as well as all older configure events
|
||||
sent on this object.
|
||||
|
||||
It is a protocol error to issue multiple ack_configure requests
|
||||
referencing the same configure event or to issue an ack_configure
|
||||
request referencing a configure event older than the last configure
|
||||
event acked for a given lock surface.
|
||||
</description>
|
||||
<arg name="serial" type="uint" summary="serial from the configure event"/>
|
||||
</request>
|
||||
|
||||
<event name="configure">
|
||||
<description summary="the client should resize its surface">
|
||||
This event is sent once on binding the interface and may be sent again
|
||||
at the compositor's discretion, for example if output geometry changes.
|
||||
|
||||
The width and height are in surface-local coordinates and are exact
|
||||
requirements. Failing to match these surface dimensions in the next
|
||||
commit after acking a configure is a protocol error.
|
||||
</description>
|
||||
<arg name="serial" type="uint" summary="serial for use in ack_configure"/>
|
||||
<arg name="width" type="uint"/>
|
||||
<arg name="height" type="uint"/>
|
||||
</event>
|
||||
</interface>
|
||||
</protocol>
|
||||
@@ -0,0 +1,36 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Face Unlock
|
||||
Name[de]=Gesichtsentsperrung
|
||||
Name[fr]=Déverrouillage facial
|
||||
Name[es]=Desbloqueo facial
|
||||
Name[it]=Sblocco con il volto
|
||||
Name[pt_BR]=Desbloqueio facial
|
||||
Name[nl]=Gezichtsontgrendeling
|
||||
Name[pl]=Odblokowywanie twarzą
|
||||
Name[ru]=Разблокировка по лицу
|
||||
Name[uk]=Розблокування обличчям
|
||||
Name[tr]=Yüzle kilit açma
|
||||
Name[zh_CN]=人脸解锁
|
||||
Name[ja]=顔認証
|
||||
Name[ko]=얼굴 잠금 해제
|
||||
Comment=Unlocks the lock screen, sudo and admin prompts with your face
|
||||
Comment[de]=Entsperrt Sperrbildschirm, sudo und Admin-Abfragen mit deinem Gesicht
|
||||
Comment[fr]=Déverrouille l'écran de verrouillage, sudo et les demandes d'administration avec votre visage
|
||||
Comment[es]=Desbloquea la pantalla de bloqueo, sudo y las solicitudes de administrador con tu cara
|
||||
Comment[it]=Sblocca la schermata di blocco, sudo e le richieste di amministratore con il tuo volto
|
||||
Comment[pt_BR]=Desbloqueia a tela de bloqueio, o sudo e os pedidos de administrador com o seu rosto
|
||||
Comment[nl]=Ontgrendelt het vergrendelscherm, sudo en beheerdersvragen met je gezicht
|
||||
Comment[pl]=Odblokowuje twarzą ekran blokady, sudo i okna administratora
|
||||
Comment[ru]=Снимает блокировку экрана, sudo и запросы администратора по вашему лицу
|
||||
Comment[uk]=Розблоковує екран блокування, sudo і запити адміністратора вашим обличчям
|
||||
Comment[tr]=Kilit ekranını, sudo'yu ve yönetici istemlerini yüzünüzle açar
|
||||
Comment[zh_CN]=用你的脸解锁锁屏、sudo 和管理员授权
|
||||
Comment[ja]=顔でロック画面、sudo、管理者の認証を解除します
|
||||
Comment[ko]=얼굴로 잠금 화면, sudo, 관리자 인증 창의 잠금을 해제합니다
|
||||
Exec=@LIBEXECDIR@/face-unlock-agent
|
||||
Icon=face-unlock
|
||||
NoDisplay=true
|
||||
# KWin only lets a program show above the lock screen when its desktop file
|
||||
# asks for it by name. This is that file.
|
||||
X-KDE-Wayland-Interfaces=kde_lockscreen_overlay_v1
|
||||
@@ -1,13 +0,0 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Plasma Face Unlock
|
||||
Name[de]=Plasma-Gesichtsentsperrung
|
||||
Comment=Unlocks the lock screen, sudo and admin prompts with your face
|
||||
Comment[de]=Entsperrt Sperrbildschirm, sudo und Admin-Abfragen mit deinem Gesicht
|
||||
Exec=@LIBEXECDIR@/plasma-face-unlock-agent
|
||||
Icon=plasma-face-unlock
|
||||
NoDisplay=true
|
||||
OnlyShowIn=KDE;
|
||||
# KWin only lets a program show above the lock screen when its desktop file
|
||||
# asks for it by name. This is that file.
|
||||
X-KDE-Wayland-Interfaces=kde_lockscreen_overlay_v1
|
||||
File renamed without changes.
@@ -0,0 +1,926 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// face-unlock's bubble on GNOME.
|
||||
//
|
||||
// GNOME lets no program draw above its windows, and nothing at all above its
|
||||
// lock screen. An extension runs inside GNOME Shell and may. This one draws
|
||||
// the bubble the agent draws everywhere else, with the same shapes and the
|
||||
// same timing: a port of src/agent/qml (Bubble, FaceGlyph, LockGlyph,
|
||||
// Checkmark). What to show comes from the agent over the session bus.
|
||||
|
||||
import Clutter from 'gi://Clutter';
|
||||
import Gio from 'gi://Gio';
|
||||
import GLib from 'gi://GLib';
|
||||
import Pango from 'gi://Pango';
|
||||
import PangoCairo from 'gi://PangoCairo';
|
||||
import St from 'gi://St';
|
||||
|
||||
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
||||
import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
|
||||
|
||||
const BUS_NAME = 'io.github.loonixtools.FaceUnlock';
|
||||
const OBJECT_PATH = '/io/github/loonixtools/FaceUnlock';
|
||||
const BubbleProxy = Gio.DBusProxy.makeProxyWrapper(`
|
||||
<node>
|
||||
<interface name="io.github.loonixtools.FaceUnlock.Bubble">
|
||||
<method name="GetState"><arg type="a{sv}" direction="out"/></method>
|
||||
<signal name="StateChanged"><arg type="a{sv}"/></signal>
|
||||
</interface>
|
||||
</node>`);
|
||||
|
||||
// Theme.qml
|
||||
const Theme = {
|
||||
success: [0x30, 0xd1, 0x58],
|
||||
failure: [0xff, 0x45, 0x3a],
|
||||
panel: [0, 0, 0],
|
||||
textPrimary: [0xff, 0xff, 0xff],
|
||||
textSecondary: [0x94, 0x94, 0x94],
|
||||
textDetail: [0xbd, 0xbd, 0xbd],
|
||||
closedWidth: 80,
|
||||
closedHeight: 24,
|
||||
openWidth: 180,
|
||||
openHeight: 180,
|
||||
openRadius: 48,
|
||||
minimalWidth: 150,
|
||||
minimalHeight: 40,
|
||||
topGap: 6,
|
||||
slideInDuration: 280,
|
||||
expandDelay: 150,
|
||||
growDuration: 460,
|
||||
shrinkDuration: 280,
|
||||
slideOutDelay: 150,
|
||||
slideOutDuration: 240,
|
||||
};
|
||||
const WIDTH = 420;
|
||||
const HEIGHT = 300;
|
||||
|
||||
// Qt's easing curves, as the QML uses them.
|
||||
const Ease = {
|
||||
linear: p => p,
|
||||
outQuad: p => 1 - (1 - p) * (1 - p),
|
||||
inOutQuad: p => p < 0.5 ? 2 * p * p : 1 - (-2 * p + 2) ** 2 / 2,
|
||||
outCubic: p => 1 - (1 - p) ** 3,
|
||||
inCubic: p => p ** 3,
|
||||
inOutSine: p => -(Math.cos(Math.PI * p) - 1) / 2,
|
||||
outBack: s => p => {
|
||||
const q = p - 1;
|
||||
return q * q * ((s + 1) * q + s) + 1;
|
||||
},
|
||||
};
|
||||
const OUT_BACK = Ease.outBack(1.70158);
|
||||
|
||||
function now() {
|
||||
return GLib.get_monotonic_time() / 1000;
|
||||
}
|
||||
|
||||
function smooth(from, to, p) {
|
||||
const x = Math.max(0, Math.min(1, (p - from) / (to - from)));
|
||||
return x * x * (3 - 2 * x);
|
||||
}
|
||||
|
||||
// A value that eases to a new target from wherever it is, like a QML
|
||||
// Behavior. Colours are arrays and ease per channel.
|
||||
class Tween {
|
||||
constructor(value) {
|
||||
this._from = value;
|
||||
this._to = value;
|
||||
this._start = 0;
|
||||
this._duration = 0;
|
||||
this._ease = Ease.linear;
|
||||
}
|
||||
|
||||
set(to, duration, ease, at) {
|
||||
if (this._same(to, this._to))
|
||||
return;
|
||||
this._from = this.get(at);
|
||||
this._to = to;
|
||||
this._start = at;
|
||||
this._duration = duration;
|
||||
this._ease = ease;
|
||||
}
|
||||
|
||||
jump(value) {
|
||||
this._from = this._to = value;
|
||||
this._duration = 0;
|
||||
}
|
||||
|
||||
get(at) {
|
||||
const p = this._duration > 0 ? Math.min(1, (at - this._start) / this._duration) : 1;
|
||||
const e = this._ease(p);
|
||||
if (Array.isArray(this._to))
|
||||
return this._to.map((t, i) => this._from[i] + (t - this._from[i]) * e);
|
||||
return this._from + (this._to - this._from) * e;
|
||||
}
|
||||
|
||||
busy(at) {
|
||||
return this._duration > 0 && at - this._start < this._duration;
|
||||
}
|
||||
|
||||
_same(a, b) {
|
||||
return Array.isArray(a) ? a.every((v, i) => v === b[i]) : a === b;
|
||||
}
|
||||
}
|
||||
|
||||
// A run of steps from 0, like a QML SequentialAnimation of NumberAnimations:
|
||||
// [target, duration, easing]...
|
||||
function sequence(steps, start, at, pace) {
|
||||
if (start === null)
|
||||
return [0, false];
|
||||
let t = at - start;
|
||||
let from = 0;
|
||||
for (const [to, duration, ease] of steps) {
|
||||
const d = duration * pace;
|
||||
if (t < d)
|
||||
return [from + (to - from) * ease(t / d), true];
|
||||
t -= d;
|
||||
from = to;
|
||||
}
|
||||
return [from, false];
|
||||
}
|
||||
|
||||
function rgba(cr, color, alpha = 1) {
|
||||
cr.setSourceRGBA(color[0] / 255, color[1] / 255, color[2] / 255, alpha);
|
||||
}
|
||||
|
||||
// A quadratic curve in cairo, which only knows cubic ones.
|
||||
function quadTo(cr, x0, y0, cx, cy, x, y) {
|
||||
cr.curveTo(x0 + 2 / 3 * (cx - x0), y0 + 2 / 3 * (cy - y0),
|
||||
x + 2 / 3 * (cx - x), y + 2 / 3 * (cy - y), x, y);
|
||||
}
|
||||
|
||||
function polyline(cr, points) {
|
||||
if (points.length < 2)
|
||||
return;
|
||||
cr.moveTo(points[0][0], points[0][1]);
|
||||
for (let i = 1; i < points.length; ++i)
|
||||
cr.lineTo(points[i][0], points[i][1]);
|
||||
}
|
||||
|
||||
function roundedRect(cr, x, y, w, h, r) {
|
||||
r = Math.max(0, Math.min(r, w / 2, h / 2));
|
||||
cr.newSubPath();
|
||||
cr.arc(x + w - r, y + r, r, -Math.PI / 2, 0);
|
||||
cr.arc(x + w - r, y + h - r, r, 0, Math.PI / 2);
|
||||
cr.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI);
|
||||
cr.arc(x + r, y + r, r, Math.PI, 3 * Math.PI / 2);
|
||||
cr.closePath();
|
||||
}
|
||||
|
||||
// Draws what draw() draws into its own layer, at the given opacity and
|
||||
// scaled about the middle of the square it sits in.
|
||||
function layer(cr, opacity, scale, cx, cy, draw) {
|
||||
if (opacity <= 0.001)
|
||||
return;
|
||||
cr.save();
|
||||
cr.pushGroup();
|
||||
cr.translate(cx, cy);
|
||||
cr.scale(scale, scale);
|
||||
cr.translate(-cx, -cy);
|
||||
draw();
|
||||
cr.popGroupToSource();
|
||||
cr.paintWithAlpha(Math.min(1, opacity));
|
||||
cr.restore();
|
||||
}
|
||||
|
||||
// LockGlyph.qml: a padlock that opens.
|
||||
class LockGlyph {
|
||||
constructor() {
|
||||
this._openness = new Tween(0);
|
||||
this.open = false;
|
||||
this.pace = 1;
|
||||
}
|
||||
|
||||
setOpen(open, at) {
|
||||
if (open === this.open)
|
||||
return;
|
||||
this.open = open;
|
||||
if (open) {
|
||||
this._openness.jump(0);
|
||||
this._openness.set(1, 560 * this.pace, Ease.linear, at);
|
||||
} else {
|
||||
this._openness.jump(0);
|
||||
}
|
||||
}
|
||||
|
||||
busy(at) {
|
||||
return this._openness.busy(at);
|
||||
}
|
||||
|
||||
draw(cr, x, y, size, color, at) {
|
||||
const u = size / 100;
|
||||
const openness = this._openness.get(at);
|
||||
const lift = 12 * smooth(0, 0.3, openness);
|
||||
const p = Math.max(0, Math.min(1, (openness - 0.22) / 0.78));
|
||||
const s = 1.4;
|
||||
const swing = 180 * (1 + (s + 1) * (p - 1) ** 3 + s * (p - 1) ** 2);
|
||||
const beat = 1 + 0.14 * Math.sin(Math.PI * smooth(0.55, 1, openness));
|
||||
|
||||
cr.save();
|
||||
cr.translate(x + size / 2, y + size / 2);
|
||||
cr.scale(beat, beat);
|
||||
cr.translate(-size / 2, -size / 2);
|
||||
rgba(cr, color);
|
||||
roundedRect(cr, 14 * u, 46 * u, 72 * u, 50 * u, 12 * u);
|
||||
cr.fill();
|
||||
|
||||
const right = 68.5;
|
||||
const radius = 18.5;
|
||||
const top = 28 - lift;
|
||||
const narrow = Math.cos(swing * Math.PI / 180);
|
||||
const points = [];
|
||||
const add = (px, py) => points.push([(right + (px - right) * narrow) * u, py * u]);
|
||||
add(31.5, 56 - 18 * smooth(0, 0.3, openness));
|
||||
add(31.5, top);
|
||||
for (let i = 1; i < 24; ++i) {
|
||||
const a = Math.PI + Math.PI * i / 24;
|
||||
add(50 + radius * Math.cos(a), top + radius * Math.sin(a));
|
||||
}
|
||||
add(right, top);
|
||||
add(right, 56);
|
||||
cr.setLineWidth(11 * u);
|
||||
cr.setLineCap(1 /* round */);
|
||||
cr.setLineJoin(1 /* round */);
|
||||
polyline(cr, points);
|
||||
cr.stroke();
|
||||
cr.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// FaceGlyph.qml: brackets round a face that looks around, then the rings and
|
||||
// a tick, or a red shake of the head.
|
||||
class FaceGlyph {
|
||||
constructor(lineWidth = null) {
|
||||
this.lineWidth = lineWidth;
|
||||
this.color = Theme.textPrimary;
|
||||
this.pace = 1;
|
||||
this.mode = 'idle';
|
||||
this.onSettled = null;
|
||||
this._lookStart = 0;
|
||||
this._lookFrozen = 0;
|
||||
this._lookAmount = new Tween(0);
|
||||
this._smile = new Tween(1);
|
||||
this._bracket = new Tween(1);
|
||||
this._bracketColor = new Tween(Theme.textPrimary);
|
||||
this._shown = new Tween(1);
|
||||
this._lockOpacity = new Tween(0);
|
||||
this._lockScale = new Tween(0.7);
|
||||
this._lock = new LockGlyph();
|
||||
this._successStart = null;
|
||||
this._shakeStart = null;
|
||||
this._settle = 0;
|
||||
}
|
||||
|
||||
_looking() {
|
||||
return this.mode === 'scanning';
|
||||
}
|
||||
|
||||
_lookAngle(at) {
|
||||
if (!this._looking())
|
||||
return this._lookFrozen;
|
||||
const period = 2000 * this.pace;
|
||||
return 2 * Math.PI * (((at - this._lookStart) % period) / period);
|
||||
}
|
||||
|
||||
_breathe(at) {
|
||||
if (!this._looking())
|
||||
return 0;
|
||||
const half = 700 * this.pace;
|
||||
const p = ((at - this._lookStart) % (2 * half)) / half;
|
||||
return p < 1 ? Ease.inOutSine(p) : 1 - Ease.inOutSine(p - 1);
|
||||
}
|
||||
|
||||
setMode(mode, at) {
|
||||
if (mode === this.mode)
|
||||
return;
|
||||
const wasLooking = this._looking();
|
||||
if (wasLooking)
|
||||
this._lookFrozen = this._lookAngle(at);
|
||||
this.mode = mode;
|
||||
const pace = this.pace;
|
||||
if (this._looking() && !wasLooking)
|
||||
this._lookStart = at;
|
||||
|
||||
this._lookAmount.set(this._looking() ? 1 : 0, 350 * pace, Ease.inOutQuad, at);
|
||||
this._smile.set(mode === 'failure' ? 0 : 1, 220 * pace, Ease.outQuad, at);
|
||||
this._bracket.set(mode === 'tracking' ? 0.9 : 1, 260 * pace, OUT_BACK, at);
|
||||
this._bracketColor.set(this._colorFor(mode), 220 * pace, Ease.linear, at);
|
||||
this._shown.set(mode === 'lockout' ? 0 : 1, 180 * pace, Ease.linear, at);
|
||||
this._lockOpacity.set(mode === 'lockout' ? 1 : 0, 200 * pace, Ease.linear, at);
|
||||
this._lockScale.set(mode === 'lockout' ? 1 : 0.7, 260 * pace, OUT_BACK, at);
|
||||
|
||||
this._successStart = null;
|
||||
if (this._settle) {
|
||||
GLib.source_remove(this._settle);
|
||||
this._settle = 0;
|
||||
}
|
||||
if (mode === 'failure') {
|
||||
this._shakeStart = at;
|
||||
this.onSettled?.(false);
|
||||
} else if (mode === 'success') {
|
||||
this._successStart = at;
|
||||
this._settle = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 850 * pace, () => {
|
||||
this._settle = 0;
|
||||
this.onSettled?.(true);
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_colorFor(mode) {
|
||||
if (mode === 'success')
|
||||
return Theme.success;
|
||||
if (mode === 'failure')
|
||||
return Theme.failure;
|
||||
return this.color;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._settle)
|
||||
GLib.source_remove(this._settle);
|
||||
this._settle = 0;
|
||||
}
|
||||
|
||||
busy(at) {
|
||||
return this._looking() || this._lookAmount.busy(at) || this._smile.busy(at) ||
|
||||
this._bracket.busy(at) || this._bracketColor.busy(at) || this._shown.busy(at) ||
|
||||
this._lockOpacity.busy(at) || this._lockScale.busy(at) ||
|
||||
(this._successStart !== null && at - this._successStart < 900 * this.pace) ||
|
||||
sequence(FaceGlyph.SHAKE, this._shakeStart, at, this.pace)[1];
|
||||
}
|
||||
|
||||
draw(cr, x, y, size, at) {
|
||||
const u = size / 100;
|
||||
const lw = this.lineWidth ?? size * 0.055;
|
||||
const pace = this.pace;
|
||||
const color = this._bracketColor.get(at);
|
||||
|
||||
const lookAmount = this._lookAmount.get(at);
|
||||
const angle = this._lookAngle(at);
|
||||
const yaw = 0.34 * Math.cos(angle) * lookAmount;
|
||||
const pitch = 0.2 * Math.sin(angle) * lookAmount;
|
||||
const fx = (px, py, pz) => (50 + px * Math.cos(yaw) + pz * Math.sin(yaw)) * u;
|
||||
const fy = (px, py, pz) => {
|
||||
const depth = pz * Math.cos(yaw) - px * Math.sin(yaw);
|
||||
return (50 + py * Math.cos(pitch) - depth * Math.sin(pitch)) * u;
|
||||
};
|
||||
|
||||
const t = this._successStart === null ? 0 : Math.min(1, (at - this._successStart) / (850 * pace));
|
||||
const tickStart = this._successStart === null ? null : this._successStart + 470 * pace;
|
||||
const tick = tickStart === null || at < tickStart ? 0 : Ease.outQuad(Math.min(1, (at - tickStart) / (260 * pace)));
|
||||
const [shake] = sequence(FaceGlyph.SHAKE, this._shakeStart, at, pace);
|
||||
|
||||
const faceGone = smooth(0, 0.22, t);
|
||||
const ringsIn = smooth(0.04, 0.26, t);
|
||||
const turn = FaceGlyph.turned(Math.min(1, t / 0.7));
|
||||
const ringSize = 40 * (0.7 + 0.3 * ringsIn);
|
||||
const ringA = FaceGlyph.ring(0, -360 * turn, 90 * turn, ringSize);
|
||||
const ringB = FaceGlyph.ring(70 + 360 * turn, 30 + 360 * turn, -90 * turn, ringSize);
|
||||
const ringBShown = 1 - smooth(0.36, 0.58, t);
|
||||
const ringABack = 1 - 0.6 * FaceGlyph.tilt(ringA);
|
||||
const ringBBack = 1 - 0.6 * FaceGlyph.tilt(ringB);
|
||||
const glow = 1 - smooth(0.42, 0.75, t);
|
||||
const onScreen = p => [(50 + p[0]) * u, (50 + p[1]) * u];
|
||||
|
||||
const bracketScale = this._bracket.get(at) - 0.04 * this._breathe(at);
|
||||
const c = 50 * u;
|
||||
|
||||
cr.save();
|
||||
cr.translate(x + shake * u, y);
|
||||
cr.setLineCap(1);
|
||||
cr.setLineJoin(1);
|
||||
|
||||
// Brackets, giving way to the rings
|
||||
layer(cr, 1 - faceGone, 1 - 0.15 * faceGone, c, c, () => {
|
||||
cr.translate(c, c);
|
||||
cr.scale(bracketScale, bracketScale);
|
||||
cr.translate(-c, -c);
|
||||
rgba(cr, color);
|
||||
cr.setLineWidth(lw);
|
||||
const corner = (sx, sy, lx, ly, qx, qy, cx, cy, ex, ey) => {
|
||||
cr.moveTo(sx * u, sy * u);
|
||||
cr.lineTo(lx * u, ly * u);
|
||||
quadTo(cr, lx * u, ly * u, cx * u, cy * u, qx * u, qy * u);
|
||||
cr.lineTo(ex * u, ey * u);
|
||||
};
|
||||
corner(6, 30, 6, 19, 19, 6, 6, 6, 30, 6);
|
||||
corner(70, 6, 81, 6, 94, 19, 94, 6, 94, 30);
|
||||
corner(94, 70, 94, 81, 81, 94, 94, 94, 70, 94);
|
||||
corner(30, 94, 19, 94, 6, 81, 6, 94, 6, 70);
|
||||
cr.stroke();
|
||||
});
|
||||
|
||||
// The rings: a soft glow under them, their far halves dimmed, the
|
||||
// near halves on top.
|
||||
if (t > 0) {
|
||||
layer(cr, ringsIn, 1, c, c, () => {
|
||||
if (glow > 0) {
|
||||
// MultiEffect's blur, drawn as wider and fainter strokes.
|
||||
for (let i = 0; i < 6; ++i) {
|
||||
cr.setLineWidth(lw * 1.6 + i * lw * 0.9);
|
||||
const a = 0.8 * glow * 0.16;
|
||||
rgba(cr, color, a);
|
||||
polyline(cr, [...ringA.map(onScreen), onScreen(ringA[0])]);
|
||||
cr.stroke();
|
||||
rgba(cr, color, a * ringBShown);
|
||||
polyline(cr, [...ringB.map(onScreen), onScreen(ringB[0])]);
|
||||
cr.stroke();
|
||||
}
|
||||
}
|
||||
cr.setLineWidth(lw);
|
||||
const half = (points, front) => FaceGlyph.half(points, front).map(onScreen);
|
||||
rgba(cr, color, ringABack);
|
||||
polyline(cr, half(ringA, false));
|
||||
cr.stroke();
|
||||
rgba(cr, color, ringBBack * ringBShown);
|
||||
polyline(cr, half(ringB, false));
|
||||
cr.stroke();
|
||||
rgba(cr, color, ringBShown);
|
||||
polyline(cr, half(ringB, true));
|
||||
cr.stroke();
|
||||
rgba(cr, color);
|
||||
polyline(cr, half(ringA, true));
|
||||
cr.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
// The face itself
|
||||
layer(cr, this._shown.get(at) * (1 - faceGone), 1 - 0.2 * faceGone, c, c, () => {
|
||||
rgba(cr, color);
|
||||
cr.setLineWidth(lw);
|
||||
const smile = this._smile.get(at);
|
||||
cr.moveTo(fx(-16, -14, 26), fy(-16, -14, 26));
|
||||
cr.lineTo(fx(-16, -5, 26), fy(-16, -5, 26));
|
||||
cr.moveTo(fx(16, -14, 26), fy(16, -14, 26));
|
||||
cr.lineTo(fx(16, -5, 26), fy(16, -5, 26));
|
||||
cr.moveTo(fx(0, -13, 29), fy(0, -13, 29));
|
||||
cr.lineTo(fx(0, 6, 37), fy(0, 6, 37));
|
||||
quadTo(cr, fx(0, 6, 37), fy(0, 6, 37), fx(0, 11, 35), fy(0, 11, 35), fx(-5, 11, 31), fy(-5, 11, 31));
|
||||
cr.moveTo(fx(-15, 20, 21), fy(-15, 20, 21));
|
||||
quadTo(cr, fx(-15, 20, 21), fy(-15, 20, 21), fx(0, 20 + 11 * smile, 24), fy(0, 20 + 11 * smile, 24),
|
||||
fx(15, 20, 21), fy(15, 20, 21));
|
||||
cr.stroke();
|
||||
});
|
||||
|
||||
// The tick, drawn inside the ring as it comes to rest
|
||||
if (tick > 0.001) {
|
||||
layer(cr, Math.min(1, tick * 8), 0.75, c, c, () => {
|
||||
cr.translate(1.5 * u, 0);
|
||||
rgba(cr, Theme.success);
|
||||
cr.setLineWidth(lw / 0.75);
|
||||
const a = [28 * u, 52 * u];
|
||||
const b = [43 * u, 67 * u];
|
||||
const e = [73 * u, 35 * u];
|
||||
const lerp = (p, q, k) => [p[0] + (q[0] - p[0]) * k, p[1] + (q[1] - p[1]) * k];
|
||||
const split = 0.33;
|
||||
polyline(cr, tick <= split ? [a, lerp(a, b, tick / split)]
|
||||
: [a, b, lerp(b, e, (tick - split) / (1 - split))]);
|
||||
cr.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
// A lock instead of the face, after too many tries
|
||||
const lockSize = size * 0.42;
|
||||
layer(cr, this._lockOpacity.get(at), this._lockScale.get(at), c, c, () => {
|
||||
this._lock.draw(cr, c - lockSize / 2, c - lockSize / 2, lockSize, this.color, at);
|
||||
});
|
||||
|
||||
cr.restore();
|
||||
}
|
||||
|
||||
// How far the rings have turned at p: speeding up from rest, fastest a
|
||||
// quarter of the way in, then settling softly, facing forward.
|
||||
static turned(p) {
|
||||
return p * p * (10 - p * (20 - p * (15 - 4 * p)));
|
||||
}
|
||||
|
||||
// A ring turned in 3D (degrees about x, then y, then z).
|
||||
static ring(ax, ay, az, radius) {
|
||||
const d = Math.PI / 180;
|
||||
const ca = Math.cos(ax * d), sa = Math.sin(ax * d);
|
||||
const cb = Math.cos(ay * d), sb = Math.sin(ay * d);
|
||||
const cg = Math.cos(az * d), sg = Math.sin(az * d);
|
||||
const points = [];
|
||||
for (let i = 0; i < 64; ++i) {
|
||||
const phi = 2 * Math.PI * i / 64;
|
||||
let px = radius * Math.cos(phi);
|
||||
let py = radius * Math.sin(phi) * ca;
|
||||
let pz = radius * Math.sin(phi) * sa;
|
||||
const x1 = px * cb + pz * sb;
|
||||
pz = pz * cb - px * sb;
|
||||
px = x1;
|
||||
const x2 = px * cg - py * sg;
|
||||
py = px * sg + py * cg;
|
||||
px = x2;
|
||||
points.push([px, py, pz]);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
static tilt(points) {
|
||||
let most = 0;
|
||||
for (const p of points)
|
||||
most = Math.max(most, Math.abs(p[2]));
|
||||
return most / Math.max(1, Math.hypot(points[0][0], points[0][1], points[0][2]));
|
||||
}
|
||||
|
||||
// The half of a ring in front of the middle, or behind it, in one piece
|
||||
// and reaching one point into the other half so the two meet.
|
||||
static half(points, front) {
|
||||
const n = points.length;
|
||||
const inFront = i => points[(i + n) % n][2] >= -1e-6;
|
||||
let start = -1;
|
||||
for (let i = 0; i < n; ++i) {
|
||||
if (inFront(i) === front && inFront(i - 1) !== front) {
|
||||
start = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
if (start < 0) {
|
||||
if (inFront(0) === front) {
|
||||
for (let i = 0; i <= n; ++i)
|
||||
out.push(points[i % n]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
out.push(points[(start + n - 1) % n]);
|
||||
let i = start;
|
||||
while (inFront(i) === front && i < start + n) {
|
||||
out.push(points[i % n]);
|
||||
++i;
|
||||
}
|
||||
out.push(points[i % n]);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
FaceGlyph.SHAKE = [
|
||||
[-9, 55, Ease.outQuad], [8, 70, Ease.inOutQuad], [-6, 65, Ease.inOutQuad],
|
||||
[4, 60, Ease.inOutQuad], [-2, 55, Ease.inOutQuad], [0, 50, Ease.outQuad],
|
||||
];
|
||||
|
||||
const PILL_SHAKE = [
|
||||
[-10, 55, Ease.outQuad], [9, 70, Ease.linear], [-6, 65, Ease.linear],
|
||||
[4, 60, Ease.linear], [0, 55, Ease.outQuad],
|
||||
];
|
||||
|
||||
// Bubble.qml: the island that slides down, opens up, shows the face and
|
||||
// closes again.
|
||||
class Bubble {
|
||||
constructor() {
|
||||
this.actor = new St.DrawingArea({reactive: false, visible: false});
|
||||
this.actor.connect('repaint', area => this._repaint(area));
|
||||
this._timeline = new Clutter.Timeline({actor: this.actor, duration: 1000, repeat_count: -1});
|
||||
this._timeline.connect('new-frame', () => this._frame());
|
||||
|
||||
this.phase = 'hidden';
|
||||
this.message = '';
|
||||
this.faceSeen = false;
|
||||
this.style = 'full';
|
||||
this.pace = 1;
|
||||
this._shownPhase = 'idle';
|
||||
this._positioned = false;
|
||||
this._expanded = false;
|
||||
this._opening = false;
|
||||
this._timers = {};
|
||||
|
||||
this._width = new Tween(Theme.closedWidth);
|
||||
this._height = new Tween(Theme.closedHeight);
|
||||
this._y = new Tween(-Theme.closedHeight - 20);
|
||||
this._shadow = new Tween(0);
|
||||
this._fullOpacity = new Tween(0);
|
||||
this._smallOpacity = new Tween(0);
|
||||
this._glyphY = new Tween(40);
|
||||
this._messageOpacity = new Tween(0);
|
||||
this._pulseStart = null;
|
||||
this._settle = new Tween(0);
|
||||
this._pillShakeStart = null;
|
||||
|
||||
this._face = new FaceGlyph();
|
||||
this._smallFace = new FaceGlyph(2.4);
|
||||
this._smallFace.onSettled = ok => {
|
||||
if (!ok)
|
||||
this._pillShakeStart = now();
|
||||
};
|
||||
this._lock = new LockGlyph();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
for (const id of Object.values(this._timers))
|
||||
GLib.source_remove(id);
|
||||
this._timers = {};
|
||||
this._face.destroy();
|
||||
this._smallFace.destroy();
|
||||
this._timeline.stop();
|
||||
this.actor.destroy();
|
||||
}
|
||||
|
||||
setState(state) {
|
||||
const at = now();
|
||||
const wasOpen = this.phase !== 'hidden';
|
||||
this.phase = state.phase ?? 'hidden';
|
||||
this.message = state.message ?? '';
|
||||
this.faceSeen = !!state.faceSeen;
|
||||
this.style = state.style === 'minimal' ? 'minimal' : 'full';
|
||||
const pace = state.pace ?? 1;
|
||||
if (pace !== this.pace) {
|
||||
this.pace = pace;
|
||||
this._face.pace = this._smallFace.pace = this._lock.pace = pace;
|
||||
}
|
||||
|
||||
if (this.phase !== 'hidden')
|
||||
this._shownPhase = this.phase;
|
||||
const glyphMode = this._shownPhase === 'scanning'
|
||||
? this.faceSeen ? 'tracking' : 'scanning'
|
||||
: this._shownPhase;
|
||||
this._face.setMode(glyphMode, at);
|
||||
this._smallFace.setMode(glyphMode === 'lockout' ? 'failure' : glyphMode, at);
|
||||
this._lock.setOpen(this._shownPhase === 'success', at);
|
||||
|
||||
const hasMessage = this.message.length > 0;
|
||||
this._glyphY.set(hasMessage ? 28 : 40, 220 * this.pace, Ease.outCubic, at);
|
||||
this._messageOpacity.set(hasMessage ? 1 : 0, 200 * this.pace, Ease.linear, at);
|
||||
this._updatePulse(at);
|
||||
|
||||
const wantOpen = this.phase !== 'hidden';
|
||||
if (wantOpen !== wasOpen)
|
||||
this._choreograph(wantOpen, at);
|
||||
this._wake();
|
||||
}
|
||||
|
||||
_timer(name, ms, callback) {
|
||||
if (this._timers[name])
|
||||
GLib.source_remove(this._timers[name]);
|
||||
this._timers[name] = GLib.timeout_add(GLib.PRIORITY_DEFAULT, Math.max(0, Math.round(ms)), () => {
|
||||
delete this._timers[name];
|
||||
callback(now());
|
||||
this._wake();
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
}
|
||||
|
||||
_cancel(name) {
|
||||
if (this._timers[name]) {
|
||||
GLib.source_remove(this._timers[name]);
|
||||
delete this._timers[name];
|
||||
}
|
||||
}
|
||||
|
||||
_choreograph(wantOpen, at) {
|
||||
if (wantOpen) {
|
||||
this._opening = true;
|
||||
this._cancel('slideOut');
|
||||
this._cancel('closeDone');
|
||||
this._setPositioned(true, at);
|
||||
if (!this._expanded)
|
||||
this._timer('expand', Theme.expandDelay * this.pace, t => this._setExpanded(true, t));
|
||||
this.actor.show();
|
||||
} else {
|
||||
this._opening = false;
|
||||
this._cancel('expand');
|
||||
this._setExpanded(false, at);
|
||||
this._timer('slideOut', Theme.slideOutDelay * this.pace, t => {
|
||||
this._setPositioned(false, t);
|
||||
this._timer('closeDone', Theme.slideOutDuration * this.pace + 60, () => {
|
||||
this.actor.hide();
|
||||
this._timeline.stop();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_setPositioned(positioned, at) {
|
||||
this._positioned = positioned;
|
||||
this._y.set(positioned ? Theme.topGap : -Theme.closedHeight - 20,
|
||||
(this._opening ? Theme.slideInDuration : Theme.slideOutDuration) * this.pace,
|
||||
this._opening ? Ease.outCubic : Ease.inCubic, at);
|
||||
}
|
||||
|
||||
_setExpanded(expanded, at) {
|
||||
this._expanded = expanded;
|
||||
const minimal = this.style === 'minimal';
|
||||
const w = expanded ? minimal ? Theme.minimalWidth : Theme.openWidth : Theme.closedWidth;
|
||||
const h = expanded ? minimal ? Theme.minimalHeight : Theme.openHeight : Theme.closedHeight;
|
||||
const duration = (this._opening ? Theme.growDuration : Theme.shrinkDuration) * this.pace;
|
||||
// Growing overshoots sideways more than down, so the bottom edge does
|
||||
// not sag.
|
||||
this._width.set(w, duration, this._opening ? Ease.outBack(1.6) : Ease.outCubic, at);
|
||||
this._height.set(h, duration, this._opening ? Ease.outBack(0.9) : Ease.outCubic, at);
|
||||
this._shadow.set(expanded ? 0.35 : 0, (this._opening ? 200 : 120) * this.pace, Ease.linear, at);
|
||||
this._fullOpacity.set(expanded ? 1 : 0, (this._opening ? 260 : 120) * this.pace, Ease.linear, at);
|
||||
this._smallOpacity.set(expanded ? 1 : 0, (this._opening ? 200 : 120) * this.pace, Ease.linear, at);
|
||||
this._updatePulse(at);
|
||||
}
|
||||
|
||||
// The breathing while it scans: 1500 ms a breath, settling when it stops.
|
||||
_updatePulse(at) {
|
||||
const running = this.phase === 'scanning' && this._expanded;
|
||||
if (running && this._pulseStart === null) {
|
||||
this._pulseStart = at;
|
||||
} else if (!running && this._pulseStart !== null) {
|
||||
this._settle.jump(this._pulse(at));
|
||||
this._settle.set(0, 200 * this.pace, Ease.outQuad, at);
|
||||
this._pulseStart = null;
|
||||
}
|
||||
}
|
||||
|
||||
_pulse(at) {
|
||||
if (this._pulseStart === null)
|
||||
return this._settle.get(at);
|
||||
const p = this.pace;
|
||||
let t = (at - this._pulseStart) % (1500 * p);
|
||||
if (t < 600 * p)
|
||||
return 0;
|
||||
t -= 600 * p;
|
||||
if (t < 400 * p)
|
||||
return Ease.inOutQuad(t / (400 * p));
|
||||
t -= 400 * p;
|
||||
if (t < 50 * p)
|
||||
return 1;
|
||||
t -= 50 * p;
|
||||
if (t < 400 * p)
|
||||
return 1 - Ease.inOutQuad(t / (400 * p));
|
||||
return 0;
|
||||
}
|
||||
|
||||
_wake() {
|
||||
if (!this.actor.visible)
|
||||
return;
|
||||
if (!this._timeline.is_playing())
|
||||
this._timeline.start();
|
||||
this.actor.queue_repaint();
|
||||
}
|
||||
|
||||
_frame() {
|
||||
const at = now();
|
||||
this.actor.queue_repaint();
|
||||
const busy = this.phase !== 'hidden' || Object.keys(this._timers).length > 0 ||
|
||||
this._width.busy(at) || this._y.busy(at) || this._face.busy(at) || this._smallFace.busy(at);
|
||||
if (!busy)
|
||||
this._timeline.stop();
|
||||
}
|
||||
|
||||
_repaint(area) {
|
||||
const cr = area.get_context();
|
||||
const scale = St.ThemeContext.get_for_stage(global.stage).scale_factor;
|
||||
const at = now();
|
||||
cr.save();
|
||||
cr.setOperator(0 /* clear */);
|
||||
cr.paint();
|
||||
cr.restore();
|
||||
cr.scale(scale, scale);
|
||||
try {
|
||||
this._draw(cr, at);
|
||||
} catch (e) {
|
||||
logError(e, 'face-unlock bubble');
|
||||
}
|
||||
cr.$dispose();
|
||||
}
|
||||
|
||||
_draw(cr, at) {
|
||||
const minimal = this.style === 'minimal';
|
||||
const w = this._width.get(at);
|
||||
const h = this._height.get(at);
|
||||
const [pillShake] = sequence(PILL_SHAKE, this._pillShakeStart, at, this.pace);
|
||||
const x = (WIDTH - w) / 2 + pillShake;
|
||||
const y = this._y.get(at);
|
||||
const radius = minimal ? h / 2 : Math.min(Theme.openRadius, h / 2);
|
||||
|
||||
// The shadow: MultiEffect's soft one, as rings that fade outwards.
|
||||
const shadow = this._shadow.get(at);
|
||||
if (shadow > 0.001) {
|
||||
for (let i = 8; i >= 1; --i) {
|
||||
const grow = i * 2;
|
||||
rgba(cr, [0, 0, 0], shadow * 0.18 * (1 - i / 9));
|
||||
roundedRect(cr, x - grow, y + 3 - grow, w + 2 * grow, h + 2 * grow, radius + grow);
|
||||
cr.fill();
|
||||
}
|
||||
}
|
||||
|
||||
rgba(cr, Theme.panel);
|
||||
roundedRect(cr, x, y, w, h, radius);
|
||||
cr.fill();
|
||||
|
||||
const pulse = this._pulse(at);
|
||||
if (!minimal) {
|
||||
// The face and a line of text, laid out at the open size and
|
||||
// scaled with the island.
|
||||
const fit = Math.min(w / Theme.openWidth, h / Theme.openHeight);
|
||||
const ox = x + w / 2 - Theme.openWidth / 2;
|
||||
const oy = y + h / 2 - Theme.openHeight / 2;
|
||||
const cx = ox + Theme.openWidth / 2;
|
||||
const cy = oy + Theme.openHeight / 2;
|
||||
layer(cr, this._fullOpacity.get(at), fit, cx, cy, () => {
|
||||
layer(cr, 1 - 0.35 * pulse, 1 - 0.03 * pulse, cx, cy, () => {
|
||||
this._face.draw(cr, ox + 40, oy + this._glyphY.get(at), 100, at);
|
||||
this._drawMessage(cr, ox, oy, at);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
layer(cr, this._smallOpacity.get(at), 1, x + w / 2, y + h / 2, () => {
|
||||
const failed = this._shownPhase === 'failure' || this._shownPhase === 'lockout';
|
||||
this._lock.draw(cr, x + 16, y + (h - 18) / 2, 18, failed ? Theme.failure : Theme.textPrimary, at);
|
||||
const fx = x + w - 14 - 24;
|
||||
const fy = y + (h - 24) / 2;
|
||||
layer(cr, 1 - 0.35 * pulse, 1 - 0.03 * pulse, fx + 12, fy + 12, () => {
|
||||
this._smallFace.draw(cr, fx, fy, 24, at);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_drawMessage(cr, ox, oy, at) {
|
||||
const opacity = this._messageOpacity.get(at);
|
||||
if (opacity <= 0.001 || !this.message)
|
||||
return;
|
||||
const layout = PangoCairo.create_layout(cr);
|
||||
const font = Pango.FontDescription.from_string(Bubble._fontName());
|
||||
font.set_absolute_size(13 * Pango.SCALE);
|
||||
font.set_weight(Pango.Weight.MEDIUM);
|
||||
layout.set_font_description(font);
|
||||
layout.set_width((Theme.openWidth - 32) * Pango.SCALE);
|
||||
layout.set_ellipsize(Pango.EllipsizeMode.END);
|
||||
layout.set_alignment(Pango.Alignment.CENTER);
|
||||
layout.set_text(this.message, -1);
|
||||
const [, logical] = layout.get_pixel_extents();
|
||||
const failed = this._shownPhase === 'failure' || this._shownPhase === 'lockout';
|
||||
rgba(cr, failed ? Theme.textDetail : Theme.textSecondary, opacity);
|
||||
cr.moveTo(ox + 16, oy + Theme.openHeight - 20 - logical.height);
|
||||
PangoCairo.show_layout(cr, layout);
|
||||
}
|
||||
|
||||
static _fontName() {
|
||||
if (!Bubble._font) {
|
||||
const name = new Gio.Settings({schema_id: 'org.gnome.desktop.interface'}).get_string('font-name');
|
||||
Bubble._font = name.replace(/\s+[\d.]+$/, '');
|
||||
}
|
||||
return Bubble._font;
|
||||
}
|
||||
}
|
||||
|
||||
export default class FaceUnlockExtension extends Extension {
|
||||
enable() {
|
||||
this._bubble = new Bubble();
|
||||
const ui = Main.layoutManager.uiGroup;
|
||||
ui.add_child(this._bubble.actor);
|
||||
// Above everything, the lock screen and its dialogs included.
|
||||
this._raise = ui.connect('child-added', () => ui.set_child_above_sibling(this._bubble.actor, null));
|
||||
this._monitors = Main.layoutManager.connect('monitors-changed', () => this._place());
|
||||
this._scale = St.ThemeContext.get_for_stage(global.stage).connect('notify::scale-factor', () => this._place());
|
||||
this._place();
|
||||
|
||||
this._watch = Gio.bus_watch_name(Gio.BusType.SESSION, BUS_NAME, Gio.BusNameWatcherFlags.NONE,
|
||||
() => this._connect(), () => this._disconnect());
|
||||
}
|
||||
|
||||
disable() {
|
||||
Gio.bus_unwatch_name(this._watch);
|
||||
this._disconnect();
|
||||
Main.layoutManager.uiGroup.disconnect(this._raise);
|
||||
Main.layoutManager.disconnect(this._monitors);
|
||||
St.ThemeContext.get_for_stage(global.stage).disconnect(this._scale);
|
||||
this._bubble.destroy();
|
||||
this._bubble = null;
|
||||
}
|
||||
|
||||
_place() {
|
||||
const monitor = Main.layoutManager.primaryMonitor;
|
||||
if (!monitor)
|
||||
return;
|
||||
const scale = St.ThemeContext.get_for_stage(global.stage).scale_factor;
|
||||
this._bubble.actor.set_size(WIDTH * scale, HEIGHT * scale);
|
||||
this._bubble.actor.set_position(monitor.x + Math.round((monitor.width - WIDTH * scale) / 2), monitor.y);
|
||||
Main.layoutManager.uiGroup.set_child_above_sibling(this._bubble.actor, null);
|
||||
}
|
||||
|
||||
_connect() {
|
||||
this._disconnect();
|
||||
const unpack = state => {
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(state))
|
||||
out[key] = value instanceof GLib.Variant ? value.recursiveUnpack() : value;
|
||||
return out;
|
||||
};
|
||||
this._proxy = new BubbleProxy(Gio.DBus.session, BUS_NAME, OBJECT_PATH, (proxy, error) => {
|
||||
if (error) {
|
||||
logError(error, 'face-unlock');
|
||||
return;
|
||||
}
|
||||
this._signal = proxy.connectSignal('StateChanged', (p, sender, [state]) => {
|
||||
this._place();
|
||||
this._bubble?.setState(unpack(state));
|
||||
});
|
||||
proxy.GetStateRemote(([state], getError) => {
|
||||
if (!getError)
|
||||
this._bubble?.setState(unpack(state));
|
||||
});
|
||||
}, null, Gio.DBusProxyFlags.DO_NOT_AUTO_START);
|
||||
}
|
||||
|
||||
_disconnect() {
|
||||
if (this._proxy && this._signal)
|
||||
this._proxy.disconnectSignal(this._signal);
|
||||
this._proxy = null;
|
||||
this._signal = null;
|
||||
this._bubble?.setState({phase: 'hidden'});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"uuid": "face-unlock@loonixtools.github.io",
|
||||
"name": "Face Unlock",
|
||||
"description": "The bubble of face-unlock: shows what the camera is doing, on the lock screen too.",
|
||||
"shell-version": ["45", "46", "47", "48", "49", "50"],
|
||||
"session-modes": ["user", "unlock-dialog"],
|
||||
"url": "https://github.com/LoonixTools/face-unlock"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policyconfig PUBLIC
|
||||
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
||||
<policyconfig>
|
||||
<vendor>LoonixTools</vendor>
|
||||
<vendor_url>https://github.com/LoonixTools/face-unlock</vendor_url>
|
||||
<icon_name>face-unlock</icon_name>
|
||||
|
||||
<!-- A face is a way in, so adding, changing or deleting one takes the
|
||||
password, the way a phone asks for its code before it sets up a face.
|
||||
The daemon refuses to let a face answer this particular question. -->
|
||||
<action id="io.github.loonixtools.face-unlock.manage">
|
||||
<description>Change the faces that can unlock your account</description>
|
||||
<description xml:lang="de">Gesichter ändern, die dein Konto entsperren können</description>
|
||||
<description xml:lang="fr">Modifier les visages qui peuvent déverrouiller votre compte</description>
|
||||
<description xml:lang="es">Cambiar las caras que pueden desbloquear tu cuenta</description>
|
||||
<description xml:lang="it">Modificare i volti che possono sbloccare il tuo account</description>
|
||||
<description xml:lang="pt_BR">Alterar os rostos que podem desbloquear sua conta</description>
|
||||
<description xml:lang="nl">De gezichten wijzigen die je account kunnen ontgrendelen</description>
|
||||
<description xml:lang="pl">Zmień twarze, które mogą odblokować twoje konto</description>
|
||||
<description xml:lang="ru">Изменить лица, которые могут разблокировать вашу учётную запись</description>
|
||||
<description xml:lang="uk">Змінити обличчя, які можуть розблокувати ваш обліковий запис</description>
|
||||
<description xml:lang="tr">Hesabınızın kilidini açabilen yüzleri değiştir</description>
|
||||
<description xml:lang="zh_CN">更改可以解锁你账户的人脸</description>
|
||||
<description xml:lang="ja">アカウントのロックを解除できる顔を変更</description>
|
||||
<description xml:lang="ko">계정 잠금을 해제할 수 있는 얼굴 변경</description>
|
||||
<message>Authentication is required to change the faces that can unlock your account.</message>
|
||||
<message xml:lang="de">Zum Ändern der Gesichter, die dein Konto entsperren können, ist eine Legitimierung erforderlich.</message>
|
||||
<message xml:lang="fr">Une authentification est nécessaire pour modifier les visages qui peuvent déverrouiller votre compte.</message>
|
||||
<message xml:lang="es">Se necesita autenticación para cambiar las caras que pueden desbloquear tu cuenta.</message>
|
||||
<message xml:lang="it">È richiesta l'autenticazione per modificare i volti che possono sbloccare il tuo account.</message>
|
||||
<message xml:lang="pt_BR">É necessária autenticação para alterar os rostos que podem desbloquear sua conta.</message>
|
||||
<message xml:lang="nl">Authenticatie is vereist om de gezichten te wijzigen die je account kunnen ontgrendelen.</message>
|
||||
<message xml:lang="pl">Wymagane jest uwierzytelnienie, aby zmienić twarze, które mogą odblokować twoje konto.</message>
|
||||
<message xml:lang="ru">Для изменения лиц, которые могут разблокировать вашу учётную запись, требуется аутентификация.</message>
|
||||
<message xml:lang="uk">Для зміни облич, які можуть розблокувати ваш обліковий запис, потрібна автентифікація.</message>
|
||||
<message xml:lang="tr">Hesabınızın kilidini açabilen yüzleri değiştirmek için kimlik doğrulaması gerekiyor.</message>
|
||||
<message xml:lang="zh_CN">更改可以解锁你账户的人脸需要身份验证。</message>
|
||||
<message xml:lang="ja">アカウントのロックを解除できる顔を変更するには認証が必要です。</message>
|
||||
<message xml:lang="ko">계정 잠금을 해제할 수 있는 얼굴을 변경하려면 인증이 필요합니다.</message>
|
||||
<defaults>
|
||||
<allow_any>no</allow_any>
|
||||
<allow_inactive>no</allow_inactive>
|
||||
<allow_active>auth_self_keep</allow_active>
|
||||
</defaults>
|
||||
</action>
|
||||
</policyconfig>
|
||||
@@ -1,24 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policyconfig PUBLIC
|
||||
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
||||
<policyconfig>
|
||||
<vendor>LoonixTools</vendor>
|
||||
<vendor_url>https://github.com/LoonixTools/plasma-face-unlock</vendor_url>
|
||||
<icon_name>plasma-face-unlock</icon_name>
|
||||
|
||||
<!-- A face is a way in, so adding, changing or deleting one takes the
|
||||
password, the way a phone asks for its code before it sets up a face.
|
||||
The daemon refuses to let a face answer this particular question. -->
|
||||
<action id="io.github.loonixtools.plasma-face-unlock.manage">
|
||||
<description>Change the faces that can unlock your account</description>
|
||||
<description xml:lang="de">Gesichter ändern, die dein Konto entsperren können</description>
|
||||
<message>Authentication is required to change the faces that can unlock your account.</message>
|
||||
<message xml:lang="de">Zum Ändern der Gesichter, die dein Konto entsperren können, ist eine Legitimierung erforderlich.</message>
|
||||
<defaults>
|
||||
<allow_any>no</allow_any>
|
||||
<allow_inactive>no</allow_inactive>
|
||||
<allow_active>auth_self_keep</allow_active>
|
||||
</defaults>
|
||||
</action>
|
||||
</policyconfig>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Face unlock (lock screen and bubble)
|
||||
Documentation=man:face-unlock(1)
|
||||
PartOf=graphical-session.target
|
||||
After=graphical-session.target
|
||||
# Wayland only: the bubble is a layer-shell surface, and the lock screen is
|
||||
# watched through the compositor.
|
||||
ConditionEnvironment=WAYLAND_DISPLAY
|
||||
|
||||
[Service]
|
||||
ExecStart=@LIBEXECDIR@/face-unlock-agent
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
Slice=session.slice
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
@@ -1,14 +1,14 @@
|
||||
[Unit]
|
||||
Description=Face unlock for KDE Plasma
|
||||
Documentation=man:plasma-face-unlock(1)
|
||||
Requires=plasma-face-unlockd.socket
|
||||
After=plasma-face-unlockd.socket
|
||||
Description=Face unlock
|
||||
Documentation=man:face-unlock(1)
|
||||
Requires=face-unlockd.socket
|
||||
After=face-unlockd.socket
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Started by the socket, and gone again after a minute with nothing to do.
|
||||
ExecStart=@LIBEXECDIR@/plasma-face-unlockd
|
||||
StateDirectory=plasma-face-unlock
|
||||
ExecStart=@LIBEXECDIR@/face-unlockd
|
||||
StateDirectory=face-unlock
|
||||
StateDirectoryMode=0700
|
||||
UMask=0077
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
[Unit]
|
||||
Description=Face unlock for KDE Plasma (socket)
|
||||
Documentation=man:plasma-face-unlock(1)
|
||||
Description=Face unlock (socket)
|
||||
Documentation=man:face-unlock(1)
|
||||
|
||||
[Socket]
|
||||
# Everybody may connect. Who may ask for what is decided per request, by the
|
||||
# daemon, from the credentials the kernel reports for the other end.
|
||||
ListenStream=/run/plasma-face-unlock/socket
|
||||
ListenStream=/run/face-unlock/socket
|
||||
SocketMode=0666
|
||||
DirectoryMode=0755
|
||||
RemoveOnStop=yes
|
||||
@@ -1,17 +0,0 @@
|
||||
[Unit]
|
||||
Description=Face unlock for KDE Plasma (lock screen and bubble)
|
||||
Documentation=man:plasma-face-unlock(1)
|
||||
PartOf=graphical-session.target
|
||||
After=graphical-session.target
|
||||
# Plasma on Wayland. The bubble is a layer-shell surface, and there is no such
|
||||
# thing on X11.
|
||||
ConditionEnvironment=WAYLAND_DISPLAY
|
||||
|
||||
[Service]
|
||||
ExecStart=@LIBEXECDIR@/plasma-face-unlock-agent
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
Slice=session.slice
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
@@ -26,7 +26,7 @@ AgentSocket::AgentSocket(QObject *parent)
|
||||
continue;
|
||||
}
|
||||
auto buffer = std::make_shared<QByteArray>();
|
||||
connect(socket, &QLocalSocket::readyRead, this, [this, socket, buffer] {
|
||||
connect(socket, &QLocalSocket::readyRead, this, [this, socket, buffer, cred] {
|
||||
*buffer += socket->readAll();
|
||||
if (buffer->size() > 64 * 1024) {
|
||||
socket->abort();
|
||||
@@ -36,8 +36,12 @@ AgentSocket::AgentSocket(QObject *parent)
|
||||
while ((nl = buffer->indexOf('\n')) >= 0) {
|
||||
const QJsonObject o = QJsonDocument::fromJson(buffer->left(nl)).object();
|
||||
buffer->remove(0, nl + 1);
|
||||
if (o.value(u"event").toString() == u"scan") {
|
||||
const QString what = o.value(u"event").toString();
|
||||
if (what == u"scan") {
|
||||
Q_EMIT scanEvent(o);
|
||||
} else if (what == u"lock" && cred.uid == ::getuid()) {
|
||||
m_waiting.append(socket);
|
||||
Q_EMIT lockRequested();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -48,7 +52,45 @@ AgentSocket::AgentSocket(QObject *parent)
|
||||
|
||||
QString AgentSocket::path()
|
||||
{
|
||||
return QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation) + QStringLiteral("/plasma-face-unlock/agent.socket");
|
||||
return QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation) + QStringLiteral("/face-unlock/agent.socket");
|
||||
}
|
||||
|
||||
bool AgentSocket::running()
|
||||
{
|
||||
QLocalSocket probe;
|
||||
probe.connectToServer(path());
|
||||
return probe.waitForConnected(500);
|
||||
}
|
||||
|
||||
bool AgentSocket::requestLock()
|
||||
{
|
||||
QLocalSocket socket;
|
||||
socket.connectToServer(path());
|
||||
if (!socket.waitForConnected(500)) {
|
||||
return false;
|
||||
}
|
||||
socket.write(QJsonDocument(QJsonObject{{QStringLiteral("event"), QStringLiteral("lock")}}).toJson(QJsonDocument::Compact) + '\n');
|
||||
if (!socket.waitForBytesWritten(500)) {
|
||||
return false;
|
||||
}
|
||||
// The answer comes once the compositor has the lock, so that an idle
|
||||
// daemon that locks before sleep does not suspend an unlocked screen.
|
||||
QByteArray answer;
|
||||
while (!answer.contains('\n') && socket.waitForReadyRead(5000)) {
|
||||
answer += socket.readAll();
|
||||
}
|
||||
return QJsonDocument::fromJson(answer.left(answer.indexOf('\n'))).object().value(u"event").toString() == u"locked";
|
||||
}
|
||||
|
||||
void AgentSocket::confirmLock()
|
||||
{
|
||||
for (const QPointer<QLocalSocket> &socket : std::as_const(m_waiting)) {
|
||||
if (socket) {
|
||||
socket->write(QJsonDocument(QJsonObject{{QStringLiteral("event"), QStringLiteral("locked")}}).toJson(QJsonDocument::Compact) + '\n');
|
||||
socket->flush();
|
||||
}
|
||||
}
|
||||
m_waiting.clear();
|
||||
}
|
||||
|
||||
bool AgentSocket::listen()
|
||||
|
||||
+16
-2
@@ -2,18 +2,21 @@
|
||||
//
|
||||
// Where the daemon tells this session about scans it did not ask for: sudo in
|
||||
// a terminal, an admin prompt, a test from the menu. The daemon connects to
|
||||
// $XDG_RUNTIME_DIR/plasma-face-unlock/agent.socket when such a scan starts,
|
||||
// $XDG_RUNTIME_DIR/face-unlock/agent.socket when such a scan starts,
|
||||
// so neither side has to keep a connection open (and the daemon can exit when
|
||||
// it is idle).
|
||||
//
|
||||
// Only root and this user may talk here, and all they can do is make the
|
||||
// bubble move.
|
||||
// bubble move, or (this user) ask for face-unlock's own lock screen.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QList>
|
||||
#include <QLocalServer>
|
||||
#include <QLocalSocket>
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
|
||||
class AgentSocket : public QObject
|
||||
{
|
||||
@@ -22,11 +25,22 @@ public:
|
||||
explicit AgentSocket(QObject *parent = nullptr);
|
||||
bool listen();
|
||||
|
||||
// Whether another agent already listens here. Hyprland without a systemd
|
||||
// session starts the agent from its own config, and that must not make
|
||||
// two of them.
|
||||
static bool running();
|
||||
// Ask the running agent to lock the screen, and wait until it is. False
|
||||
// when there is no agent or the lock did not come.
|
||||
static bool requestLock();
|
||||
// Tell everybody waiting in requestLock() that the screen is locked.
|
||||
void confirmLock();
|
||||
static QString path();
|
||||
|
||||
Q_SIGNALS:
|
||||
void scanEvent(const QJsonObject &event);
|
||||
void lockRequested();
|
||||
|
||||
private:
|
||||
QLocalServer m_server;
|
||||
QList<QPointer<QLocalSocket>> m_waiting;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "bubbleservice.h"
|
||||
|
||||
#include "bubblecontroller.h"
|
||||
|
||||
#include <QDBusConnection>
|
||||
#include <QDBusError>
|
||||
|
||||
BubbleService::BubbleService(BubbleController *bubble, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_bubble(bubble)
|
||||
{
|
||||
const auto changed = [this] {
|
||||
Q_EMIT StateChanged(GetState());
|
||||
};
|
||||
connect(bubble, &BubbleController::phaseChanged, this, changed);
|
||||
connect(bubble, &BubbleController::messageChanged, this, changed);
|
||||
connect(bubble, &BubbleController::faceSeenChanged, this, changed);
|
||||
connect(bubble, &BubbleController::styleChanged, this, changed);
|
||||
connect(bubble, &BubbleController::paceChanged, this, changed);
|
||||
|
||||
QDBusConnection bus = QDBusConnection::sessionBus();
|
||||
if (!bus.registerService(QStringLiteral("io.github.loonixtools.FaceUnlock"))
|
||||
|| !bus.registerObject(QStringLiteral("/io/github/loonixtools/FaceUnlock"), this, QDBusConnection::ExportScriptableContents)) {
|
||||
qWarning("cannot offer the bubble on the session bus: %s", qPrintable(bus.lastError().message()));
|
||||
}
|
||||
}
|
||||
|
||||
QVariantMap BubbleService::GetState() const
|
||||
{
|
||||
return {
|
||||
{QStringLiteral("phase"), m_bubble->phase()},
|
||||
{QStringLiteral("message"), m_bubble->message()},
|
||||
{QStringLiteral("faceSeen"), m_bubble->faceSeen()},
|
||||
{QStringLiteral("style"), m_bubble->style()},
|
||||
{QStringLiteral("pace"), m_bubble->pace()},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// The bubble's state on the session bus, for GNOME. GNOME lets no program
|
||||
// draw above its windows or its lock screen; only a GNOME Shell extension
|
||||
// may. face-unlock's extension (res/gnome-shell) draws the bubble from what
|
||||
// this says: io.github.loonixtools.FaceUnlock, /io/github/loonixtools/FaceUnlock.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QVariantMap>
|
||||
|
||||
class BubbleController;
|
||||
|
||||
class BubbleService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_CLASSINFO("D-Bus Interface", "io.github.loonixtools.FaceUnlock.Bubble")
|
||||
public:
|
||||
explicit BubbleService(BubbleController *bubble, QObject *parent = nullptr);
|
||||
|
||||
// phase, message, faceSeen, style, pace: what BubbleController has.
|
||||
Q_SCRIPTABLE QVariantMap GetState() const;
|
||||
|
||||
Q_SIGNALS:
|
||||
Q_SCRIPTABLE void StateChanged(const QVariantMap &state);
|
||||
|
||||
private:
|
||||
BubbleController *m_bubble;
|
||||
};
|
||||
@@ -74,13 +74,13 @@ void BubbleWindow::create()
|
||||
// faint box with sharp corners around the bubble. An on-screen
|
||||
// display only fades, which a clear window does not show.
|
||||
layer->setScope(QStringLiteral("on-screen-display"));
|
||||
#ifdef PFU_LAYERSHELL_HAS_SCREEN
|
||||
#ifdef FU_LAYERSHELL_HAS_SCREEN
|
||||
layer->setScreen(QGuiApplication::primaryScreen());
|
||||
#endif
|
||||
}
|
||||
|
||||
m_view->setInitialProperties({{QStringLiteral("bubble"), QVariant::fromValue(m_controller)}});
|
||||
m_view->loadFromModule(QStringLiteral("PlasmaFaceUnlock"), QStringLiteral("Bubble"));
|
||||
m_view->loadFromModule(QStringLiteral("FaceUnlock"), QStringLiteral("Bubble"));
|
||||
if (m_view->status() == QQuickView::Error) {
|
||||
for (const QQmlError &e : m_view->errors()) {
|
||||
qWarning("%s", qPrintable(e.toString()));
|
||||
@@ -101,7 +101,8 @@ void BubbleWindow::allowOverLockscreen()
|
||||
return;
|
||||
}
|
||||
if (!m_overlay->isActive()) {
|
||||
if (!warned) {
|
||||
// Only KWin has the protocol. Anywhere else there is nothing to fix.
|
||||
if (!warned && qEnvironmentVariable("XDG_CURRENT_DESKTOP").split(u':').contains(u"KDE")) {
|
||||
warned = true;
|
||||
qWarning("KWin does not let this program show above the lock screen; is its desktop file installed?");
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
// file lists the interface, which the one installed with this does. The
|
||||
// request has to be made for every new surface role, before it is mapped, so
|
||||
// it is repeated each time the window is shown again.
|
||||
//
|
||||
// Other compositors have no such thing: on Hyprland and Niri the lock screen
|
||||
// covers the bubble, which shows the tick once it is gone. GNOME has no
|
||||
// layer-shell at all; there face-unlock's GNOME Shell extension draws the
|
||||
// bubble (BubbleService).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@ DaemonRequest::~DaemonRequest()
|
||||
|
||||
QString DaemonRequest::socketPath()
|
||||
{
|
||||
const QByteArray env = qgetenv("PFU_SOCKET");
|
||||
return env.isEmpty() ? QStringLiteral(PFU_SOCKET) : QString::fromLocal8Bit(env);
|
||||
const QByteArray env = qgetenv("FU_SOCKET");
|
||||
return env.isEmpty() ? QStringLiteral(FU_SOCKET) : QString::fromLocal8Bit(env);
|
||||
}
|
||||
|
||||
void DaemonRequest::send(const QJsonObject &message)
|
||||
|
||||
@@ -191,6 +191,11 @@ void EnrollController::onFinished(const QJsonObject &result)
|
||||
}
|
||||
if (reason == u"denied") {
|
||||
m_error = i18n("A face can only be added with your password.");
|
||||
// Hyprland, Niri and the like bring no polkit agent, so no password window shows.
|
||||
const QStringList desktops = qEnvironmentVariable("XDG_CURRENT_DESKTOP").split(u':');
|
||||
if (!desktops.contains(u"KDE") && !desktops.contains(u"GNOME")) {
|
||||
m_error += u' ' + i18n("No password window? Start a polkit agent, for example hyprpolkitagent.");
|
||||
}
|
||||
} else if (reason == u"camera") {
|
||||
m_error = i18n("The camera could not be used: %1", result.value(u"message").toString());
|
||||
} else if (reason == u"models") {
|
||||
|
||||
+144
-2
@@ -2,10 +2,15 @@
|
||||
|
||||
#include "inputwatcher.h"
|
||||
|
||||
#include <QDBusConnection>
|
||||
#include <QDBusConnectionInterface>
|
||||
#include <QDBusMessage>
|
||||
#include <QDBusPendingCallWatcher>
|
||||
#include <QGuiApplication>
|
||||
#include <QWaylandClientExtensionTemplate>
|
||||
#include <QtGui/qguiapplication_platform.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <functional>
|
||||
|
||||
#include "qwayland-ext-idle-notify-v1.h"
|
||||
@@ -54,6 +59,111 @@ private:
|
||||
bool m_fired = false;
|
||||
};
|
||||
|
||||
namespace
|
||||
{
|
||||
const QString MutterService = QStringLiteral("org.gnome.Mutter.IdleMonitor");
|
||||
const QString MutterPath = QStringLiteral("/org/gnome/Mutter/IdleMonitor/Core");
|
||||
} // namespace
|
||||
|
||||
// GNOME's way: a watch that fires once nothing was touched for the calm,
|
||||
// then one that fires at the next input. Each fires once and is gone.
|
||||
class MutterIdle : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
MutterIdle(std::function<void()> input, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_input(std::move(input))
|
||||
{
|
||||
QDBusConnection::sessionBus().connect(MutterService, MutterPath, MutterService, QStringLiteral("WatchFired"), this, SLOT(onFired(uint)));
|
||||
}
|
||||
~MutterIdle() override
|
||||
{
|
||||
stop();
|
||||
}
|
||||
|
||||
void watch(int calmMs)
|
||||
{
|
||||
stop();
|
||||
const int generation = m_generation;
|
||||
if (calmMs <= 0) {
|
||||
addWatch(QStringLiteral("AddUserActiveWatch"), {}, generation, &m_active);
|
||||
return;
|
||||
}
|
||||
// Already calm for that long (the scan itself took a while): the
|
||||
// idle watch would wait for the next calm, so go straight on.
|
||||
call(QStringLiteral("GetIdletime"), {}, [this, generation, calmMs](const QDBusMessage &reply) {
|
||||
if (generation != m_generation) {
|
||||
return;
|
||||
}
|
||||
if (reply.arguments().value(0).toULongLong() >= quint64(calmMs)) {
|
||||
addWatch(QStringLiteral("AddUserActiveWatch"), {}, generation, &m_active);
|
||||
} else {
|
||||
addWatch(QStringLiteral("AddIdleWatch"), {QVariant::fromValue(quint64(calmMs))}, generation, &m_idle);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void stop()
|
||||
{
|
||||
++m_generation;
|
||||
remove(m_idle);
|
||||
remove(m_active);
|
||||
m_idle = m_active = 0;
|
||||
}
|
||||
|
||||
private Q_SLOTS:
|
||||
void onFired(uint id)
|
||||
{
|
||||
if (id != 0 && id == m_idle) {
|
||||
remove(m_idle);
|
||||
m_idle = 0;
|
||||
addWatch(QStringLiteral("AddUserActiveWatch"), {}, m_generation, &m_active);
|
||||
} else if (id != 0 && id == m_active) {
|
||||
m_active = 0;
|
||||
m_input();
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
template<typename Done>
|
||||
void call(const QString &method, const QVariantList &args, Done done)
|
||||
{
|
||||
QDBusMessage msg = QDBusMessage::createMethodCall(MutterService, MutterPath, MutterService, method);
|
||||
msg.setArguments(args);
|
||||
auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::sessionBus().asyncCall(msg), this);
|
||||
connect(watcher, &QDBusPendingCallWatcher::finished, this, [watcher, done] {
|
||||
watcher->deleteLater();
|
||||
done(watcher->reply());
|
||||
});
|
||||
}
|
||||
|
||||
void addWatch(const QString &method, const QVariantList &args, int generation, uint *slot)
|
||||
{
|
||||
call(method, args, [this, generation, slot](const QDBusMessage &reply) {
|
||||
const uint id = reply.arguments().value(0).toUInt();
|
||||
if (generation != m_generation) {
|
||||
// Stopped in the meantime.
|
||||
remove(id);
|
||||
return;
|
||||
}
|
||||
*slot = id;
|
||||
});
|
||||
}
|
||||
|
||||
void remove(uint id)
|
||||
{
|
||||
if (id != 0) {
|
||||
call(QStringLiteral("RemoveWatch"), {QVariant::fromValue(id)}, [](const QDBusMessage &) { });
|
||||
}
|
||||
}
|
||||
|
||||
std::function<void()> m_input;
|
||||
int m_generation = 0;
|
||||
uint m_idle = 0;
|
||||
uint m_active = 0;
|
||||
};
|
||||
|
||||
InputWatcher::InputWatcher(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_notifier(std::make_unique<IdleNotifier>())
|
||||
@@ -64,12 +174,29 @@ InputWatcher::~InputWatcher() = default;
|
||||
|
||||
void InputWatcher::watch(int calmMs)
|
||||
{
|
||||
m_notification.reset();
|
||||
stop();
|
||||
m_watching = true;
|
||||
m_calmMs = calmMs;
|
||||
m_calm.start();
|
||||
auto *wayland = qGuiApp->nativeInterface<QNativeInterface::QWaylandApplication>();
|
||||
if (!m_notifier->isActive() || !wayland || !wayland->seat()) {
|
||||
qWarning("no ext_idle_notifier_v1, so no telling when somebody comes back");
|
||||
if (!m_mutter && QDBusConnection::sessionBus().interface()->isServiceRegistered(MutterService)) {
|
||||
m_mutter = new MutterIdle(
|
||||
[this] {
|
||||
QMetaObject::invokeMethod(this, &InputWatcher::input, Qt::QueuedConnection);
|
||||
},
|
||||
this);
|
||||
}
|
||||
if (m_mutter) {
|
||||
m_mutter->watch(calmMs);
|
||||
} else {
|
||||
qWarning("no ext_idle_notifier_v1 and no Mutter IdleMonitor, so no telling when somebody comes back");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Hyprland never says resumed for a timeout of 0. A tenth of a second of
|
||||
// calm first changes nothing anywhere.
|
||||
calmMs = std::max(calmMs, 100);
|
||||
// Version 1 has only the notification that inhibitors hold back.
|
||||
::ext_idle_notification_v1 *object = m_notifier->QWaylandClientExtension::version() >= 2
|
||||
? m_notifier->get_input_idle_notification(uint32_t(calmMs), wayland->seat())
|
||||
@@ -81,7 +208,22 @@ void InputWatcher::watch(int calmMs)
|
||||
});
|
||||
}
|
||||
|
||||
void InputWatcher::noteInput()
|
||||
{
|
||||
if (m_watching && m_calm.elapsed() >= m_calmMs) {
|
||||
m_watching = false;
|
||||
QMetaObject::invokeMethod(this, &InputWatcher::input, Qt::QueuedConnection);
|
||||
}
|
||||
m_calm.restart();
|
||||
}
|
||||
|
||||
void InputWatcher::stop()
|
||||
{
|
||||
m_watching = false;
|
||||
m_notification.reset();
|
||||
if (m_mutter) {
|
||||
m_mutter->stop();
|
||||
}
|
||||
}
|
||||
|
||||
#include "inputwatcher.moc"
|
||||
@@ -5,16 +5,19 @@
|
||||
// From ext_idle_notifier_v1, like KIdleTime, but with the input notification
|
||||
// of version 2. KIdleTime's own honours idle inhibitors: with a video playing
|
||||
// or an app keeping the screen on, no key reached it and the lock screen never
|
||||
// scanned.
|
||||
// scanned. GNOME has no ext_idle_notifier_v1; there it is Mutter's own
|
||||
// IdleMonitor on the session bus, which knows nothing of inhibitors either.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QElapsedTimer>
|
||||
#include <QObject>
|
||||
|
||||
#include <memory>
|
||||
|
||||
class IdleNotifier;
|
||||
class IdleNotification;
|
||||
class MutterIdle;
|
||||
|
||||
class InputWatcher : public QObject
|
||||
{
|
||||
@@ -27,6 +30,9 @@ public:
|
||||
// calmMs. 0: the next input. Replaces what was watched before.
|
||||
void watch(int calmMs);
|
||||
void stop();
|
||||
// A key or the pointer on face-unlock's own lock screen, which sees them
|
||||
// itself. Counts like input the compositor reports.
|
||||
void noteInput();
|
||||
|
||||
Q_SIGNALS:
|
||||
void input();
|
||||
@@ -34,4 +40,8 @@ Q_SIGNALS:
|
||||
private:
|
||||
std::unique_ptr<IdleNotifier> m_notifier;
|
||||
std::unique_ptr<IdleNotification> m_notification;
|
||||
MutterIdle *m_mutter = nullptr;
|
||||
bool m_watching = false;
|
||||
int m_calmMs = 0;
|
||||
QElapsedTimer m_calm;
|
||||
};
|
||||
@@ -4,14 +4,11 @@
|
||||
|
||||
#include "bubblecontroller.h"
|
||||
#include "daemonclient.h"
|
||||
#include "lockscreencontroller.h"
|
||||
#include "userconfig.h"
|
||||
|
||||
#include <QDBusConnection>
|
||||
#include <QDBusMessage>
|
||||
#include <QDBusPendingCallWatcher>
|
||||
#include <QDBusPendingReply>
|
||||
#include <QJsonObject>
|
||||
#include <QProcess>
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -27,24 +24,32 @@ constexpr int CalmBeforeRetryMs = 2000;
|
||||
constexpr int ScanAfterWakeMs = 1000;
|
||||
} // namespace
|
||||
|
||||
LockController::LockController(BubbleController *bubble, UserConfig *config, QObject *parent)
|
||||
LockController::LockController(BubbleController *bubble, UserConfig *config, SessionLock *lock, LockScreenController *screen, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_bubble(bubble)
|
||||
, m_config(config)
|
||||
, m_screen(screen)
|
||||
{
|
||||
if (lock) {
|
||||
m_lock.setOwnLock(lock);
|
||||
}
|
||||
if (screen) {
|
||||
connect(screen, &LockScreenController::input, &m_input, &InputWatcher::noteInput);
|
||||
}
|
||||
m_armTimer.setSingleShot(true);
|
||||
connect(&m_armTimer, &QTimer::timeout, this, [this] {
|
||||
arm(0);
|
||||
});
|
||||
connect(&m_input, &InputWatcher::input, this, &LockController::onResume);
|
||||
|
||||
QDBusConnection session = QDBusConnection::sessionBus();
|
||||
session.connect(QStringLiteral("org.freedesktop.ScreenSaver"),
|
||||
QStringLiteral("/ScreenSaver"),
|
||||
QStringLiteral("org.freedesktop.ScreenSaver"),
|
||||
QStringLiteral("ActiveChanged"),
|
||||
this,
|
||||
SLOT(onActiveChanged(bool)));
|
||||
connect(&m_lock, &LockWatcher::lockedChanged, this, &LockController::onLockedChanged);
|
||||
// A lock screen that is only starting cannot be opened yet: the scan
|
||||
// that was due when it locked follows once it can.
|
||||
connect(&m_lock, &LockWatcher::unlockable, this, [this] {
|
||||
if (m_lockScanDue) {
|
||||
m_lockScanDue = false;
|
||||
startScan(QStringLiteral("lock"));
|
||||
}
|
||||
});
|
||||
|
||||
QDBusConnection::systemBus().connect(QStringLiteral("org.freedesktop.login1"),
|
||||
QStringLiteral("/org/freedesktop/login1"),
|
||||
@@ -52,29 +57,16 @@ LockController::LockController(BubbleController *bubble, UserConfig *config, QOb
|
||||
QStringLiteral("PrepareForSleep"),
|
||||
this,
|
||||
SLOT(onPrepareForSleep(bool)));
|
||||
|
||||
// Started while the screen is already locked (the agent restarted).
|
||||
const QDBusMessage get = QDBusMessage::createMethodCall(QStringLiteral("org.freedesktop.ScreenSaver"),
|
||||
QStringLiteral("/ScreenSaver"),
|
||||
QStringLiteral("org.freedesktop.ScreenSaver"),
|
||||
QStringLiteral("GetActive"));
|
||||
auto *watcher = new QDBusPendingCallWatcher(session.asyncCall(get), this);
|
||||
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher] {
|
||||
watcher->deleteLater();
|
||||
const QDBusPendingReply<bool> reply = *watcher;
|
||||
if (reply.isValid() && reply.value()) {
|
||||
onActiveChanged(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void LockController::onActiveChanged(bool active)
|
||||
void LockController::onLockedChanged(bool locked)
|
||||
{
|
||||
if (active == m_locked) {
|
||||
if (locked == m_locked) {
|
||||
return;
|
||||
}
|
||||
if (!active) {
|
||||
if (!locked) {
|
||||
m_locked = false;
|
||||
m_lockScanDue = false;
|
||||
m_armTimer.stop();
|
||||
m_input.stop();
|
||||
if (m_scan) {
|
||||
@@ -93,7 +85,11 @@ void LockController::onActiveChanged(bool active)
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m_config->lockScreen()) {
|
||||
const bool face = m_config->enabled() && m_config->lockScreen();
|
||||
if (m_screen) {
|
||||
m_screen->setFaceUnlock(face);
|
||||
}
|
||||
if (!face) {
|
||||
return;
|
||||
}
|
||||
m_locked = true;
|
||||
@@ -103,6 +99,7 @@ void LockController::onActiveChanged(bool active)
|
||||
|
||||
if (m_config->scanOnLock()) {
|
||||
QTimer::singleShot(400, this, [this] {
|
||||
m_lockScanDue = m_locked && !m_lock.canUnlock();
|
||||
startScan(QStringLiteral("lock"));
|
||||
});
|
||||
} else {
|
||||
@@ -155,7 +152,9 @@ void LockController::warmUp()
|
||||
|
||||
void LockController::startScan(const QString &why)
|
||||
{
|
||||
if (!m_locked || m_scan || m_stopped) {
|
||||
// A lock screen this cannot open (gtklock, waylock, a shell's own) asks
|
||||
// for the face itself, through PAM, when Enter is pressed.
|
||||
if (!m_locked || m_scan || m_stopped || !m_lock.canUnlock()) {
|
||||
return;
|
||||
}
|
||||
qInfo("scanning (%s)", qPrintable(why));
|
||||
@@ -189,7 +188,7 @@ void LockController::onScanFinished(const QJsonObject &result)
|
||||
// moment to go, and the bubble stays above the desktop, so the rings
|
||||
// and the tick play on over it.
|
||||
m_bubble->succeeded();
|
||||
unlock();
|
||||
m_lock.unlock();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -207,30 +206,3 @@ void LockController::onScanFinished(const QJsonObject &result)
|
||||
}
|
||||
arm(CalmBeforeRetryMs);
|
||||
}
|
||||
|
||||
void LockController::unlock()
|
||||
{
|
||||
if (!m_locked) {
|
||||
return;
|
||||
}
|
||||
// "auto" is the caller's own session, or for a program outside any
|
||||
// session (this one runs as a user service) the session on the display.
|
||||
const QDBusMessage call = QDBusMessage::createMethodCall(QStringLiteral("org.freedesktop.login1"),
|
||||
QStringLiteral("/org/freedesktop/login1/session/auto"),
|
||||
QStringLiteral("org.freedesktop.login1.Session"),
|
||||
QStringLiteral("Unlock"));
|
||||
auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::systemBus().asyncCall(call), this);
|
||||
connect(watcher, &QDBusPendingCallWatcher::finished, this, [watcher] {
|
||||
watcher->deleteLater();
|
||||
const QDBusPendingReply<> reply = *watcher;
|
||||
if (reply.isError()) {
|
||||
qWarning("logind would not unlock: %s; trying loginctl", qPrintable(reply.error().message()));
|
||||
const QString id = qEnvironmentVariable("XDG_SESSION_ID");
|
||||
QStringList args{QStringLiteral("unlock-session")};
|
||||
if (!id.isEmpty()) {
|
||||
args << id;
|
||||
}
|
||||
QProcess::startDetached(QStringLiteral("loginctl"), args);
|
||||
}
|
||||
});
|
||||
}
|
||||
+15
-11
@@ -4,22 +4,20 @@
|
||||
//
|
||||
// When the screen locks, this waits for somebody to come back: a key, the
|
||||
// mouse, the lid opening, the machine waking from sleep. Then it scans, and
|
||||
// when the face matches it asks logind to unlock the session, which is the
|
||||
// same request `loginctl unlock-session` makes and which Plasma's screen
|
||||
// locker has always honoured.
|
||||
// when the face matches it unlocks the session the way that desktop's lock
|
||||
// screen wants it (see LockWatcher).
|
||||
//
|
||||
// Why not a PAM module in the lock screen, like fingerprints? Plasma runs its
|
||||
// fingerprint stack in parallel with the password, but only starts it once per
|
||||
// lock, gives up for good the first time it fails, and labels it "scan your
|
||||
// fingerprint". Doing it from here means scanning again every time somebody
|
||||
// sits back down, no wrong label, and a bubble that knows what is going on.
|
||||
// It does not lower the bar either: any program running as this user can
|
||||
// already unlock this user's session through logind. Face data and the
|
||||
// decision stay with the daemon, which runs as root.
|
||||
// fingerprint". GNOME's, hyprlock's and swaylock's only ask once the password
|
||||
// is typed. Doing it from here means scanning again every time somebody sits
|
||||
// back down, no wrong label, and a bubble that knows what is going on.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "inputwatcher.h"
|
||||
#include "lockwatcher.h"
|
||||
|
||||
#include <QElapsedTimer>
|
||||
#include <QObject>
|
||||
@@ -28,13 +26,16 @@
|
||||
|
||||
class BubbleController;
|
||||
class DaemonRequest;
|
||||
class LockScreenController;
|
||||
class SessionLock;
|
||||
class UserConfig;
|
||||
|
||||
class LockController : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
LockController(BubbleController *bubble, UserConfig *config, QObject *parent = nullptr);
|
||||
// lock and screen are face-unlock's own lock screen, where there is one.
|
||||
LockController(BubbleController *bubble, UserConfig *config, SessionLock *lock, LockScreenController *screen, QObject *parent = nullptr);
|
||||
|
||||
bool locked() const
|
||||
{
|
||||
@@ -42,7 +43,7 @@ public:
|
||||
}
|
||||
|
||||
private Q_SLOTS:
|
||||
void onActiveChanged(bool active);
|
||||
void onLockedChanged(bool locked);
|
||||
void onPrepareForSleep(bool sleeping);
|
||||
|
||||
private:
|
||||
@@ -51,15 +52,18 @@ private:
|
||||
void onResume();
|
||||
void startScan(const QString &why);
|
||||
void onScanFinished(const QJsonObject &result);
|
||||
void unlock();
|
||||
void warmUp();
|
||||
|
||||
BubbleController *m_bubble;
|
||||
UserConfig *m_config;
|
||||
LockScreenController *m_screen;
|
||||
bool m_locked = false;
|
||||
bool m_stopped = false;
|
||||
// Scan right after locking, once the lock screen can be opened.
|
||||
bool m_lockScanDue = false;
|
||||
QElapsedTimer m_lockedFor;
|
||||
QPointer<DaemonRequest> m_scan;
|
||||
QTimer m_armTimer;
|
||||
InputWatcher m_input;
|
||||
LockWatcher m_lock;
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "lockers.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QString>
|
||||
|
||||
#include <algorithm>
|
||||
#include <csignal>
|
||||
#include <dirent.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
bool isLocker(const QByteArray &name)
|
||||
{
|
||||
return name == "hyprlock" || name == "swaylock" || name == "gtklock";
|
||||
}
|
||||
|
||||
QByteArray readFile(const QString &path)
|
||||
{
|
||||
QFile f(path);
|
||||
return f.open(QIODevice::ReadOnly) ? f.readAll() : QByteArray();
|
||||
}
|
||||
|
||||
pid_t parentOf(pid_t pid)
|
||||
{
|
||||
// The fields after the name in brackets, which can hold anything.
|
||||
const QByteArray stat = readFile(QStringLiteral("/proc/%1/stat").arg(pid));
|
||||
const qsizetype close = stat.lastIndexOf(')');
|
||||
return close < 0 ? 0 : pid_t(stat.mid(close + 2).split(' ').value(1).toInt());
|
||||
}
|
||||
|
||||
// The lock screens of this user on this display. Another session of the same
|
||||
// user has a display of its own, and its lock screen is left alone. A child
|
||||
// of a lock screen (swaylock and gtklock check the password in one) is left
|
||||
// out: killed by the signal before its parent unlocks, it would take the
|
||||
// parent down with it, and the screen would stay locked.
|
||||
QList<pid_t> findAll()
|
||||
{
|
||||
QList<pid_t> found;
|
||||
DIR *proc = ::opendir("/proc");
|
||||
if (!proc) {
|
||||
return found;
|
||||
}
|
||||
QByteArray display = qgetenv("WAYLAND_DISPLAY");
|
||||
if (display.isEmpty()) {
|
||||
display = "wayland-0";
|
||||
}
|
||||
const uid_t me = ::getuid();
|
||||
while (dirent *entry = ::readdir(proc)) {
|
||||
const pid_t pid = pid_t(atoi(entry->d_name));
|
||||
struct stat st;
|
||||
if (pid <= 0 || ::fstatat(::dirfd(proc), entry->d_name, &st, 0) != 0 || st.st_uid != me) {
|
||||
continue;
|
||||
}
|
||||
const QString dir = QStringLiteral("/proc/%1/").arg(pid);
|
||||
if (!isLocker(readFile(dir + QStringLiteral("comm")).trimmed())) {
|
||||
continue;
|
||||
}
|
||||
bool sameDisplay = true;
|
||||
for (const QByteArray &var : readFile(dir + QStringLiteral("environ")).split('\0')) {
|
||||
if (var.startsWith("WAYLAND_DISPLAY=")) {
|
||||
sameDisplay = var.mid(16) == display;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (sameDisplay) {
|
||||
found.append(pid);
|
||||
}
|
||||
}
|
||||
::closedir(proc);
|
||||
QList<pid_t> top;
|
||||
for (const pid_t pid : std::as_const(found)) {
|
||||
if (!found.contains(parentOf(pid))) {
|
||||
top.append(pid);
|
||||
}
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
// Whether pid catches sig, from the mask in /proc/<pid>/status.
|
||||
bool catches(pid_t pid, int sig)
|
||||
{
|
||||
for (const QByteArray &line : readFile(QStringLiteral("/proc/%1/status").arg(pid)).split('\n')) {
|
||||
if (line.startsWith("SigCgt:")) {
|
||||
bool ok = false;
|
||||
const qulonglong mask = line.mid(7).trimmed().toULongLong(&ok, 16);
|
||||
return ok && (mask >> (sig - 1)) & 1;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
QList<pid_t> Lockers::find(const QByteArray &name)
|
||||
{
|
||||
QList<pid_t> found;
|
||||
for (const pid_t pid : findAll()) {
|
||||
if (name.isEmpty() || readFile(QStringLiteral("/proc/%1/comm").arg(pid)).trimmed() == name) {
|
||||
found.append(pid);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
bool Lockers::ready(int sig)
|
||||
{
|
||||
const QList<pid_t> lockers = find();
|
||||
return std::any_of(lockers.cbegin(), lockers.cend(), [sig](pid_t pid) {
|
||||
return catches(pid, sig);
|
||||
});
|
||||
}
|
||||
|
||||
int Lockers::signal(int sig, const QByteArray &name)
|
||||
{
|
||||
int waiting = 0;
|
||||
for (const pid_t pid : find(name)) {
|
||||
if (catches(pid, sig)) {
|
||||
::kill(pid, sig);
|
||||
} else {
|
||||
++waiting;
|
||||
}
|
||||
}
|
||||
return waiting;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// The lock screens that are programs of their own and take signals from
|
||||
// outside: hyprlock, swaylock and gtklock open on SIGUSR1, hyprlock reads
|
||||
// its labels again on SIGUSR2.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QList>
|
||||
|
||||
#include <sys/types.h>
|
||||
|
||||
namespace Lockers
|
||||
{
|
||||
// The ones of this user on this display, or only those called name.
|
||||
QList<pid_t> find(const QByteArray &name = {});
|
||||
// Sends sig to each that is ready for it, and says how many were not. A
|
||||
// lock screen only handles its signals once the screen is locked: before
|
||||
// that, the signal kills it, and the compositor keeps the screen locked with
|
||||
// nobody to open it.
|
||||
int signal(int sig, const QByteArray &name = {});
|
||||
// Whether one of them handles sig: gtklock only opens on SIGUSR1 since 2025,
|
||||
// and none does before it has locked.
|
||||
bool ready(int sig);
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "lockpreview.h"
|
||||
|
||||
#include <KLocalizedString>
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QElapsedTimer>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QHash>
|
||||
#include <QProcess>
|
||||
#include <QRegularExpression>
|
||||
#include <QPointF>
|
||||
#include <QStandardPaths>
|
||||
#include <QTime>
|
||||
#include <QVariantMap>
|
||||
|
||||
#include <algorithm>
|
||||
#include <pwd.h>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
QString readFile(const QString &path)
|
||||
{
|
||||
QFile f(path);
|
||||
return f.open(QIODevice::ReadOnly | QIODevice::Text) ? QString::fromUtf8(f.readAll()) : QString();
|
||||
}
|
||||
|
||||
QString expandHome(const QString &path)
|
||||
{
|
||||
return path == u"~" ? QDir::homePath() : path.startsWith(u"~/") ? QDir::homePath() + path.mid(1) : path;
|
||||
}
|
||||
|
||||
QString configHome()
|
||||
{
|
||||
return QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation);
|
||||
}
|
||||
|
||||
// The first file of name under the user's and then the system's config
|
||||
// directories, as hyprlock and swaylock look for theirs.
|
||||
QString findConfig(const QString &name)
|
||||
{
|
||||
QStringList dirs{configHome()};
|
||||
dirs += QStandardPaths::standardLocations(QStandardPaths::GenericConfigLocation);
|
||||
for (const QString &dir : std::as_const(dirs)) {
|
||||
if (QFileInfo::exists(dir + u'/' + name)) {
|
||||
return dir + u'/' + name;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
bool truthy(const QString &value)
|
||||
{
|
||||
return value == u"1" || value.compare(u"true", Qt::CaseInsensitive) == 0 || value.compare(u"yes", Qt::CaseInsensitive) == 0
|
||||
|| value.compare(u"on", Qt::CaseInsensitive) == 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hyprlang, as far as a lock screen's config needs it
|
||||
|
||||
struct Section {
|
||||
QString name;
|
||||
QHash<QString, QString> values;
|
||||
};
|
||||
|
||||
// Sections, $variables, source = (with ~ and wildcards, relative to the
|
||||
// file), and # comments, where ## stands for a #.
|
||||
class Hyprlang
|
||||
{
|
||||
public:
|
||||
void parse(const QString &path, int depth = 0);
|
||||
|
||||
QList<Section> sections;
|
||||
|
||||
private:
|
||||
QString expand(const QString &value) const;
|
||||
|
||||
QHash<QString, QString> m_vars;
|
||||
QList<qsizetype> m_open;
|
||||
};
|
||||
|
||||
QString stripComment(const QString &line)
|
||||
{
|
||||
QString out;
|
||||
for (qsizetype i = 0; i < line.size(); ++i) {
|
||||
if (line.at(i) == u'#') {
|
||||
if (i + 1 < line.size() && line.at(i + 1) == u'#') {
|
||||
out += u'#';
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
out += line.at(i);
|
||||
}
|
||||
return out.trimmed();
|
||||
}
|
||||
|
||||
void Hyprlang::parse(const QString &path, int depth)
|
||||
{
|
||||
if (depth > 8) {
|
||||
return;
|
||||
}
|
||||
const QString dir = QFileInfo(path).absolutePath();
|
||||
const QStringList lines = readFile(path).split(u'\n');
|
||||
for (const QString &raw : lines) {
|
||||
const QString line = stripComment(raw);
|
||||
if (line.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (line == u"}") {
|
||||
if (!m_open.isEmpty()) {
|
||||
m_open.removeLast();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (line.endsWith(u'{')) {
|
||||
sections.append({line.chopped(1).trimmed(), {}});
|
||||
m_open.append(sections.size() - 1);
|
||||
continue;
|
||||
}
|
||||
const qsizetype eq = line.indexOf(u'=');
|
||||
if (eq < 0) {
|
||||
continue;
|
||||
}
|
||||
const QString key = line.left(eq).trimmed();
|
||||
const QString value = expand(line.mid(eq + 1).trimmed());
|
||||
if (key.startsWith(u'$')) {
|
||||
m_vars.insert(key.mid(1), value);
|
||||
} else if (key == u"source" && m_open.isEmpty()) {
|
||||
const QFileInfo pattern(QDir(dir).absoluteFilePath(expandHome(value)));
|
||||
const QStringList matches = QDir(pattern.absolutePath()).entryList({pattern.fileName()}, QDir::Files, QDir::Name);
|
||||
for (const QString &match : matches) {
|
||||
parse(pattern.absolutePath() + u'/' + match, depth + 1);
|
||||
}
|
||||
} else if (!m_open.isEmpty()) {
|
||||
sections[m_open.last()].values.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QString Hyprlang::expand(const QString &value) const
|
||||
{
|
||||
if (!value.contains(u'$') || m_vars.isEmpty()) {
|
||||
return value;
|
||||
}
|
||||
// Longest first, so $font does not eat the start of $fontsize.
|
||||
QStringList names = m_vars.keys();
|
||||
std::sort(names.begin(), names.end(), [](const QString &a, const QString &b) {
|
||||
return a.size() > b.size();
|
||||
});
|
||||
QString out = value;
|
||||
for (const QString &name : std::as_const(names)) {
|
||||
out.replace(u'$' + name, m_vars.value(name));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Values
|
||||
|
||||
// "rgba(a, b) rgba(c) 45deg": the colours and the angle.
|
||||
QVariantMap gradient(const QString &value)
|
||||
{
|
||||
QVariantList colors;
|
||||
double angle = 0;
|
||||
QString token;
|
||||
int depth = 0;
|
||||
const auto flush = [&] {
|
||||
const QString t = token.trimmed();
|
||||
token.clear();
|
||||
if (t.endsWith(u"deg")) {
|
||||
angle = t.chopped(3).toDouble();
|
||||
} else if (const QColor c = LockPreview::color(t); c.isValid()) {
|
||||
colors.append(c);
|
||||
}
|
||||
};
|
||||
for (const QChar ch : value) {
|
||||
if (ch == u'(') {
|
||||
++depth;
|
||||
} else if (ch == u')') {
|
||||
--depth;
|
||||
}
|
||||
if (ch.isSpace() && depth == 0) {
|
||||
flush();
|
||||
} else {
|
||||
token += ch;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return {{QStringLiteral("colors"), colors}, {QStringLiteral("angle"), angle}};
|
||||
}
|
||||
|
||||
// "x, y", each in pixels or percent of the screen.
|
||||
QPointF layout(const QString &value, const QSizeF &screen)
|
||||
{
|
||||
const QStringList parts = value.split(u',');
|
||||
const auto one = [](const QString &part, qreal whole) {
|
||||
const QString s = part.trimmed();
|
||||
return s.endsWith(u'%') ? s.chopped(1).toDouble() / 100.0 * whole : s.toDouble();
|
||||
};
|
||||
return {one(parts.value(0), screen.width()), one(parts.value(1), screen.height())};
|
||||
}
|
||||
|
||||
// A Pango font description ("Noto Sans Light 12") as family, weight and
|
||||
// slant.
|
||||
void font(const QString &description, QVariantMap &into)
|
||||
{
|
||||
static const QHash<QString, int> weights{
|
||||
{QStringLiteral("thin"), 100}, {QStringLiteral("ultra-light"), 200}, {QStringLiteral("extra-light"), 200},
|
||||
{QStringLiteral("ultralight"), 200}, {QStringLiteral("extralight"), 200}, {QStringLiteral("light"), 300},
|
||||
{QStringLiteral("semi-light"), 350}, {QStringLiteral("book"), 380}, {QStringLiteral("regular"), 400},
|
||||
{QStringLiteral("normal"), 400}, {QStringLiteral("medium"), 500}, {QStringLiteral("semi-bold"), 600},
|
||||
{QStringLiteral("semibold"), 600}, {QStringLiteral("demi-bold"), 600}, {QStringLiteral("demibold"), 600},
|
||||
{QStringLiteral("bold"), 700}, {QStringLiteral("ultra-bold"), 800}, {QStringLiteral("extra-bold"), 800},
|
||||
{QStringLiteral("extrabold"), 800}, {QStringLiteral("heavy"), 900}, {QStringLiteral("black"), 900},
|
||||
};
|
||||
QStringList words = description.section(u',', 0, 0).split(u' ', Qt::SkipEmptyParts);
|
||||
int weight = 400;
|
||||
bool italic = false;
|
||||
while (words.size() > 1) {
|
||||
const QString word = words.last().toLower();
|
||||
bool number = false;
|
||||
word.toDouble(&number);
|
||||
if (number) {
|
||||
words.removeLast();
|
||||
} else if (word == u"italic" || word == u"oblique") {
|
||||
italic = true;
|
||||
words.removeLast();
|
||||
} else if (weights.contains(word)) {
|
||||
weight = weights.value(word);
|
||||
words.removeLast();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
into.insert(QStringLiteral("family"), words.isEmpty() ? QStringLiteral("Sans") : words.join(u' '));
|
||||
into.insert(QStringLiteral("weight"), weight);
|
||||
into.insert(QStringLiteral("italic"), italic);
|
||||
}
|
||||
|
||||
// Pango markup as Qt's StyledText: span becomes font, b and i, the rest of
|
||||
// Pango's tags go and their text stays.
|
||||
QString styled(const QString &markup)
|
||||
{
|
||||
static const QRegularExpression tag(QStringLiteral("<(/?)([a-zA-Z]+)([^>]*)>"));
|
||||
static const QRegularExpression attribute(QStringLiteral("([a-z_]+)\\s*=\\s*[\"']([^\"']*)[\"']"));
|
||||
QString out;
|
||||
QStringList closers;
|
||||
qsizetype last = 0;
|
||||
auto it = tag.globalMatch(markup);
|
||||
while (it.hasNext()) {
|
||||
const auto m = it.next();
|
||||
out += markup.mid(last, m.capturedStart() - last);
|
||||
last = m.capturedEnd();
|
||||
const bool closing = !m.captured(1).isEmpty();
|
||||
const QString name = m.captured(2).toLower();
|
||||
if (name == u"span") {
|
||||
if (closing) {
|
||||
out += closers.isEmpty() ? QString() : closers.takeLast();
|
||||
continue;
|
||||
}
|
||||
QString open;
|
||||
QString close;
|
||||
QString fontAttrs;
|
||||
auto attrs = attribute.globalMatch(m.captured(3));
|
||||
while (attrs.hasNext()) {
|
||||
const auto a = attrs.next();
|
||||
const QString key = a.captured(1);
|
||||
QString value = a.captured(2);
|
||||
if (key == u"foreground" || key == u"fgcolor" || key == u"color") {
|
||||
// Pango's #RRGGBBAA is Qt's #AARRGGBB.
|
||||
if (value.startsWith(u'#') && value.size() == 9) {
|
||||
value = u'#' + value.mid(7, 2) + value.mid(1, 6);
|
||||
}
|
||||
fontAttrs += QStringLiteral(" color=\"%1\"").arg(value);
|
||||
} else if (key == u"font_family" || key == u"face") {
|
||||
fontAttrs += QStringLiteral(" face=\"%1\"").arg(value);
|
||||
} else if ((key == u"weight" || key == u"font_weight") && (value.contains(u"bold") || value.contains(u"heavy") || value.toInt() >= 600)) {
|
||||
open += QStringLiteral("<b>");
|
||||
close.prepend(QStringLiteral("</b>"));
|
||||
} else if ((key == u"style" || key == u"font_style") && value == u"italic") {
|
||||
open += QStringLiteral("<i>");
|
||||
close.prepend(QStringLiteral("</i>"));
|
||||
}
|
||||
}
|
||||
if (!fontAttrs.isEmpty()) {
|
||||
open.prepend(QStringLiteral("<font%1>").arg(fontAttrs));
|
||||
close += QStringLiteral("</font>");
|
||||
}
|
||||
out += open;
|
||||
closers.append(close);
|
||||
} else if (name == u"b" || name == u"i" || name == u"u") {
|
||||
out += m.captured(0);
|
||||
}
|
||||
}
|
||||
out += markup.mid(last);
|
||||
out.replace(u'\n', QStringLiteral("<br>"));
|
||||
return out;
|
||||
}
|
||||
|
||||
// What a command prints, or nothing when it takes too long.
|
||||
QString run(const QString &command, QElapsedTimer &budget)
|
||||
{
|
||||
// A config full of slow commands (weather, music) must not keep the
|
||||
// window from opening.
|
||||
if (budget.elapsed() > 2000) {
|
||||
return {};
|
||||
}
|
||||
QProcess process;
|
||||
process.start(QStringLiteral("/bin/sh"), {QStringLiteral("-c"), command});
|
||||
if (!process.waitForFinished(700)) {
|
||||
process.kill();
|
||||
process.waitForFinished(100);
|
||||
return {};
|
||||
}
|
||||
return QString::fromUtf8(process.readAllStandardOutput());
|
||||
}
|
||||
|
||||
// A label's text as hyprlock would show it right after locking.
|
||||
QString labelText(QString text, bool trim, QElapsedTimer &budget)
|
||||
{
|
||||
const passwd *pw = getpwuid(getuid());
|
||||
text.replace(QStringLiteral("$DESC"), pw ? QString::fromLocal8Bit(pw->pw_gecos) : QString());
|
||||
text.replace(QStringLiteral("$USER"), pw ? QString::fromLocal8Bit(pw->pw_name) : QString());
|
||||
text.replace(QStringLiteral("<br/>"), QStringLiteral("\n"));
|
||||
const QTime now = QTime::currentTime();
|
||||
text.replace(QStringLiteral("$TIME12"), now.toString(QStringLiteral("hh:mm AP")));
|
||||
text.replace(QStringLiteral("$TIME"), now.toString(QStringLiteral("HH:mm")));
|
||||
static const QRegularExpression layoutVar(QStringLiteral("\\$LAYOUT(\\[([^\\]]*)\\])?"));
|
||||
auto layouts = layoutVar.globalMatch(text);
|
||||
while (layouts.hasNext()) {
|
||||
const auto m = layouts.next();
|
||||
const QString first = m.captured(2).section(u',', 0, 0).trimmed();
|
||||
text.replace(m.captured(0), first.isEmpty() ? QStringLiteral("en") : first);
|
||||
}
|
||||
// What only a lock screen that runs knows: nothing has failed yet.
|
||||
static const QRegularExpression runtime(QStringLiteral("\\$(ATTEMPTS(\\[[^\\]]*\\])?|PAMFAIL|FPRINTFAIL|FPRINTPROMPT|FAIL)"));
|
||||
text.remove(runtime);
|
||||
text.replace(QStringLiteral("$PAMPROMPT"), QStringLiteral("Password:"));
|
||||
|
||||
if (text.startsWith(u"cmd[") && text.contains(u']')) {
|
||||
const QString command = text.mid(text.indexOf(u']') + 1);
|
||||
// face-unlock's own line, as it reads during a scan.
|
||||
text = command.contains(u"face-unlock/lock-text") ? i18n("Looking for your face…").toHtmlEscaped() : run(command, budget);
|
||||
}
|
||||
return trim ? text.trimmed() : text;
|
||||
}
|
||||
|
||||
void place(const Section &s, const QString &position, const QSizeF &screen, QVariantMap &into)
|
||||
{
|
||||
const QPointF pos = layout(s.values.value(QStringLiteral("position"), position), screen);
|
||||
into.insert(QStringLiteral("x"), pos.x());
|
||||
into.insert(QStringLiteral("y"), pos.y());
|
||||
into.insert(QStringLiteral("halign"), s.values.value(QStringLiteral("halign"), QStringLiteral("center")));
|
||||
into.insert(QStringLiteral("valign"), s.values.value(QStringLiteral("valign"), QStringLiteral("center")));
|
||||
into.insert(QStringLiteral("rotate"), s.values.value(QStringLiteral("rotate"), QStringLiteral("0")).toDouble());
|
||||
}
|
||||
|
||||
QColor colorOr(const QString &value, const QColor &fallback)
|
||||
{
|
||||
const QColor c = LockPreview::color(value);
|
||||
return c.isValid() ? c : fallback;
|
||||
}
|
||||
|
||||
// swaylock's colours: RRGGBB or RRGGBBAA, without a #.
|
||||
QColor hexColor(const QString &value, const QColor &fallback)
|
||||
{
|
||||
QString v = value.trimmed();
|
||||
if (v.startsWith(u'#')) {
|
||||
v = v.mid(1);
|
||||
}
|
||||
bool ok = false;
|
||||
const uint n = v.toUInt(&ok, 16);
|
||||
if (!ok || (v.size() != 6 && v.size() != 8)) {
|
||||
return fallback;
|
||||
}
|
||||
return v.size() == 6 ? QColor((n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff) : QColor((n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff);
|
||||
}
|
||||
|
||||
QVariantMap wallpaperOnly(const QUrl &wallpaper)
|
||||
{
|
||||
return {{QStringLiteral("type"), QStringLiteral("background")},
|
||||
{QStringLiteral("color"), QColor(0x11, 0x11, 0x11)},
|
||||
{QStringLiteral("source"), wallpaper},
|
||||
{QStringLiteral("fill"), QStringLiteral("fill")},
|
||||
{QStringLiteral("blur"), 0.0},
|
||||
{QStringLiteral("dim"), 0.0}};
|
||||
}
|
||||
} // namespace
|
||||
|
||||
QColor LockPreview::color(const QString &value)
|
||||
{
|
||||
const QString v = value.trimmed();
|
||||
static const QRegularExpression call(QStringLiteral("^(rgba?)\\((.*)\\)$"));
|
||||
const auto m = call.match(v);
|
||||
if (m.hasMatch()) {
|
||||
const QString inner = m.captured(2).trimmed();
|
||||
const QStringList parts = inner.split(u',');
|
||||
if (parts.size() >= 3) {
|
||||
QColor c(parts.at(0).trimmed().toInt(), parts.at(1).trimmed().toInt(), parts.at(2).trimmed().toInt());
|
||||
if (parts.size() >= 4) {
|
||||
c.setAlphaF(std::clamp(parts.at(3).trimmed().toDouble(), 0.0, 1.0));
|
||||
}
|
||||
return c;
|
||||
}
|
||||
return hexColor(inner, {});
|
||||
}
|
||||
bool ok = false;
|
||||
const qulonglong n = v.startsWith(u"0x", Qt::CaseInsensitive) ? v.mid(2).toULongLong(&ok, 16) : v.toULongLong(&ok, 10);
|
||||
if (!ok) {
|
||||
return {};
|
||||
}
|
||||
return QColor(int((n >> 16) & 0xff), int((n >> 8) & 0xff), int(n & 0xff), int((n >> 24) & 0xff));
|
||||
}
|
||||
|
||||
QString LockPreview::locker()
|
||||
{
|
||||
static const QStringList names{QStringLiteral("hyprlock"), QStringLiteral("swaylock"), QStringLiteral("gtklock"), QStringLiteral("waylock")};
|
||||
// Where a lock screen is started from: the idle daemons first, then the
|
||||
// keys. The first one named there, outside comments.
|
||||
static const QStringList places{QStringLiteral("hypr/hypridle.conf"), QStringLiteral("swayidle/config"), QStringLiteral("niri/config.kdl"),
|
||||
QStringLiteral("hypr/hyprland.lua"), QStringLiteral("hypr/hyprland.conf")};
|
||||
for (const QString &place : places) {
|
||||
for (const QString &line : readFile(configHome() + u'/' + place).split(u'\n')) {
|
||||
const QString code = line.trimmed();
|
||||
if (code.startsWith(u'#') || code.startsWith(u"//") || code.startsWith(u"--")) {
|
||||
continue;
|
||||
}
|
||||
qsizetype first = -1;
|
||||
QString found;
|
||||
for (const QString &name : names) {
|
||||
const qsizetype at = code.indexOf(name);
|
||||
if (at >= 0 && (first < 0 || at < first)) {
|
||||
first = at;
|
||||
found = name;
|
||||
}
|
||||
}
|
||||
if (!found.isEmpty()) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Started some other way (a script of its own): one that is set up.
|
||||
if (!QStandardPaths::findExecutable(QStringLiteral("hyprlock")).isEmpty() && !findConfig(QStringLiteral("hypr/hyprlock.conf")).isEmpty()) {
|
||||
return QStringLiteral("hyprlock");
|
||||
}
|
||||
for (const QString &name : names) {
|
||||
if (!QStandardPaths::findExecutable(name).isEmpty()) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
QVariantList LockPreview::hyprlock(const QString &configPath, const QSizeF &screen, const QString &output, const QUrl &wallpaper)
|
||||
{
|
||||
Hyprlang config;
|
||||
config.parse(configPath);
|
||||
|
||||
bool trim = true;
|
||||
for (const Section &s : std::as_const(config.sections)) {
|
||||
if (s.name == u"general" && s.values.contains(QStringLiteral("text_trim"))) {
|
||||
trim = truthy(s.values.value(QStringLiteral("text_trim")));
|
||||
}
|
||||
}
|
||||
|
||||
QElapsedTimer budget;
|
||||
budget.start();
|
||||
QList<QPair<int, QVariantMap>> placed;
|
||||
bool ourLine = false;
|
||||
bool background = false;
|
||||
for (const Section &s : std::as_const(config.sections)) {
|
||||
const auto value = [&s](const char *key, const char *fallback) {
|
||||
return s.values.value(QString::fromLatin1(key), QString::fromLatin1(fallback));
|
||||
};
|
||||
const QString monitor = value("monitor", "");
|
||||
if (!monitor.isEmpty() && monitor != output && !monitor.startsWith(u"desc:")) {
|
||||
continue;
|
||||
}
|
||||
QVariantMap w;
|
||||
int z = value("zindex", "0").toInt();
|
||||
if (s.name == u"background") {
|
||||
background = true;
|
||||
z = value("zindex", "-1").toInt();
|
||||
const QString path = value("path", "");
|
||||
const int passes = value("blur_passes", "0").toInt();
|
||||
w = wallpaperOnly(path == u"screenshot" ? wallpaper : path.isEmpty() ? QUrl() : QUrl::fromLocalFile(expandHome(path)));
|
||||
w.insert(QStringLiteral("color"), colorOr(value("color", ""), QColor(0x11, 0x11, 0x11)));
|
||||
w.insert(QStringLiteral("blur"), passes > 0 ? std::min(1.0, passes * value("blur_size", "8").toDouble() / 24.0) : 0.0);
|
||||
// hyprlock's blur also darkens, by its brightness.
|
||||
w.insert(QStringLiteral("dim"), passes > 0 ? std::clamp(1.0 - value("brightness", "0.8172").toDouble(), 0.0, 1.0) : 0.0);
|
||||
} else if (s.name == u"label") {
|
||||
const QString text = value("text", "Sample Text");
|
||||
ourLine = ourLine || text.contains(u"face-unlock/lock-text");
|
||||
w.insert(QStringLiteral("type"), QStringLiteral("label"));
|
||||
w.insert(QStringLiteral("text"), styled(labelText(text, trim, budget)));
|
||||
font(value("font_family", "Sans"), w);
|
||||
w.insert(QStringLiteral("size"), value("font_size", "16").toDouble());
|
||||
w.insert(QStringLiteral("color"), colorOr(value("color", ""), Qt::white));
|
||||
w.insert(QStringLiteral("align"), value("text_align", "center"));
|
||||
w.insert(QStringLiteral("shadow"), value("shadow_passes", "0").toInt() > 0);
|
||||
w.insert(QStringLiteral("shadowSize"), value("shadow_size", "3").toDouble());
|
||||
w.insert(QStringLiteral("shadowColor"), colorOr(value("shadow_color", ""), Qt::black));
|
||||
place(s, QStringLiteral("0,0"), screen, w);
|
||||
} else if (s.name == u"input-field") {
|
||||
const QPointF size = layout(value("size", "400,90"), screen);
|
||||
w.insert(QStringLiteral("type"), QStringLiteral("input"));
|
||||
w.insert(QStringLiteral("width"), size.x());
|
||||
w.insert(QStringLiteral("height"), size.y());
|
||||
w.insert(QStringLiteral("thickness"), value("outline_thickness", "4").toDouble());
|
||||
w.insert(QStringLiteral("outer"), gradient(value("outer_color", "0xFF111111")));
|
||||
w.insert(QStringLiteral("inner"), colorOr(value("inner_color", ""), QColor(0xdd, 0xdd, 0xdd)));
|
||||
w.insert(QStringLiteral("fontColor"), colorOr(value("font_color", ""), Qt::black));
|
||||
font(value("font_family", "Sans"), w);
|
||||
w.insert(QStringLiteral("placeholder"), styled(labelText(value("placeholder_text", "<i>Input Password</i>"), trim, budget)));
|
||||
w.insert(QStringLiteral("rounding"), value("rounding", "-1").toDouble());
|
||||
place(s, QStringLiteral("0,0"), screen, w);
|
||||
} else if (s.name == u"shape") {
|
||||
const QPointF size = layout(value("size", "100,100"), screen);
|
||||
w.insert(QStringLiteral("type"), QStringLiteral("shape"));
|
||||
w.insert(QStringLiteral("width"), size.x());
|
||||
w.insert(QStringLiteral("height"), size.y());
|
||||
w.insert(QStringLiteral("color"), colorOr(value("color", ""), QColor(0x11, 0x11, 0x11)));
|
||||
w.insert(QStringLiteral("rounding"), value("rounding", "0").toDouble());
|
||||
w.insert(QStringLiteral("borderSize"), value("border_size", "0").toDouble());
|
||||
w.insert(QStringLiteral("border"), gradient(value("border_color", "0xFF00CFE6")));
|
||||
place(s, QStringLiteral("0,0"), screen, w);
|
||||
} else if (s.name == u"image") {
|
||||
const QString path = value("path", "");
|
||||
if (path.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
w.insert(QStringLiteral("type"), QStringLiteral("image"));
|
||||
w.insert(QStringLiteral("source"), QUrl::fromLocalFile(expandHome(path)));
|
||||
w.insert(QStringLiteral("size"), value("size", "150").toDouble());
|
||||
w.insert(QStringLiteral("rounding"), value("rounding", "-1").toDouble());
|
||||
w.insert(QStringLiteral("borderSize"), value("border_size", "4").toDouble());
|
||||
w.insert(QStringLiteral("border"), gradient(value("border_color", "0xFFDDDDDD")));
|
||||
place(s, QStringLiteral("0,0"), screen, w);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
placed.append({z, w});
|
||||
}
|
||||
|
||||
if (!background) {
|
||||
QVariantMap w = wallpaperOnly({});
|
||||
placed.append({-1, w});
|
||||
}
|
||||
// face-unlock's line, as the menu puts it in (fu_hyprlock_text_on),
|
||||
// for those who have not picked it yet.
|
||||
if (!ourLine) {
|
||||
QVariantMap w{{QStringLiteral("type"), QStringLiteral("label")},
|
||||
{QStringLiteral("text"), i18n("Looking for your face…").toHtmlEscaped()},
|
||||
{QStringLiteral("size"), 20.0},
|
||||
{QStringLiteral("color"), QColor(255, 255, 255, 242)},
|
||||
{QStringLiteral("align"), QStringLiteral("center")},
|
||||
{QStringLiteral("shadow"), true},
|
||||
{QStringLiteral("shadowSize"), 3.0},
|
||||
{QStringLiteral("shadowColor"), QColor(Qt::black)},
|
||||
{QStringLiteral("x"), 0.0},
|
||||
{QStringLiteral("y"), -48.0},
|
||||
{QStringLiteral("halign"), QStringLiteral("center")},
|
||||
{QStringLiteral("valign"), QStringLiteral("top")},
|
||||
{QStringLiteral("rotate"), 0.0}};
|
||||
font(QStringLiteral("Sans"), w);
|
||||
placed.append({10, w});
|
||||
}
|
||||
|
||||
std::stable_sort(placed.begin(), placed.end(), [](const auto &a, const auto &b) {
|
||||
return a.first < b.first;
|
||||
});
|
||||
QVariantList widgets;
|
||||
for (const auto &p : std::as_const(placed)) {
|
||||
widgets.append(p.second);
|
||||
}
|
||||
return widgets;
|
||||
}
|
||||
|
||||
QVariantList LockPreview::swaylock(const QString &configPath, const QUrl &wallpaper)
|
||||
{
|
||||
// One option per line, as on the command line without the dashes.
|
||||
QHash<QString, QString> o;
|
||||
for (const QString &raw : readFile(configPath).split(u'\n')) {
|
||||
const QString line = raw.trimmed();
|
||||
if (line.isEmpty() || line.startsWith(u'#')) {
|
||||
continue;
|
||||
}
|
||||
o.insert(line.section(u'=', 0, 0).trimmed(), line.contains(u'=') ? line.section(u'=', 1).trimmed() : QString());
|
||||
}
|
||||
|
||||
// An image may name its output first: [[<output>]:]<path>.
|
||||
QString image = o.value(QStringLiteral("image"));
|
||||
if (image.contains(u':') && !image.startsWith(u'/') && !image.startsWith(u'~')) {
|
||||
image = image.section(u':', 1);
|
||||
}
|
||||
QVariantMap bg = wallpaperOnly(o.contains(QStringLiteral("screenshots")) ? wallpaper
|
||||
: image.isEmpty() ? QUrl()
|
||||
: QUrl::fromLocalFile(expandHome(image)));
|
||||
bg.insert(QStringLiteral("color"), hexColor(o.value(QStringLiteral("color")), Qt::white));
|
||||
bg.insert(QStringLiteral("fill"), o.value(QStringLiteral("scaling"), QStringLiteral("fill")));
|
||||
// swaylock-effects: effect-blur=<radius>x<times>.
|
||||
const QString blur = o.value(QStringLiteral("effect-blur"));
|
||||
if (!blur.isEmpty()) {
|
||||
bg.insert(QStringLiteral("blur"), std::min(1.0, blur.section(u'x', 0, 0).toDouble() * blur.section(u'x', 1, 1).toDouble() / 24.0));
|
||||
}
|
||||
QVariantList widgets{bg};
|
||||
|
||||
// The ring shows only while typing, unless it is asked to stay.
|
||||
const bool clock = o.contains(QStringLiteral("clock"));
|
||||
if (o.contains(QStringLiteral("indicator")) || o.contains(QStringLiteral("indicator-idle-visible")) || clock) {
|
||||
const QDateTime now = QDateTime::currentDateTime();
|
||||
widgets.append(QVariantMap{
|
||||
{QStringLiteral("type"), QStringLiteral("ring")},
|
||||
{QStringLiteral("radius"), o.value(QStringLiteral("indicator-radius"), QStringLiteral("50")).toDouble()},
|
||||
{QStringLiteral("thickness"), o.value(QStringLiteral("indicator-thickness"), QStringLiteral("10")).toDouble()},
|
||||
{QStringLiteral("inside"), hexColor(o.value(QStringLiteral("inside-color")), QColor(0, 0, 0, 0xc0))},
|
||||
{QStringLiteral("ring"), hexColor(o.value(QStringLiteral("ring-color")), QColor(0x33, 0x7d, 0x00))},
|
||||
{QStringLiteral("textColor"), hexColor(o.value(QStringLiteral("text-color")), QColor(0xe5, 0xa4, 0x45))},
|
||||
{QStringLiteral("text"), clock ? now.toString(QStringLiteral("HH:mm")) + u'\n' + now.toString(QStringLiteral("yyyy-MM-dd")) : QString()},
|
||||
});
|
||||
}
|
||||
return widgets;
|
||||
}
|
||||
|
||||
QVariantList LockPreview::widgets(const QString &locker, const QSizeF &screen, const QString &output, const QUrl &wallpaper)
|
||||
{
|
||||
if (locker == u"hyprlock") {
|
||||
const QString path = findConfig(QStringLiteral("hypr/hyprlock.conf"));
|
||||
if (!path.isEmpty()) {
|
||||
return hyprlock(path, screen, output, wallpaper);
|
||||
}
|
||||
} else if (locker == u"swaylock") {
|
||||
QString path = findConfig(QStringLiteral("swaylock/config"));
|
||||
if (path.isEmpty() && QFileInfo::exists(QDir::homePath() + QStringLiteral("/.swaylock/config"))) {
|
||||
path = QDir::homePath() + QStringLiteral("/.swaylock/config");
|
||||
}
|
||||
return swaylock(path, wallpaper);
|
||||
}
|
||||
// Something this does not know how to draw: the desktop's picture.
|
||||
return {wallpaperOnly(wallpaper)};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// The user's own lock screen, rebuilt for its picture in the window that asks
|
||||
// which lock screen to use (LockChoice.qml): what hyprlock draws from its
|
||||
// config, with face-unlock's line at the top, or swaylock's background and
|
||||
// ring. A screenshot would take locking the screen. LockPreview.qml draws
|
||||
// what this returns.
|
||||
//
|
||||
// hyprlock places a widget from the corner or middle its halign and valign
|
||||
// name, with y upwards, and takes font sizes as points. Colours and sizes
|
||||
// here are hyprlang's: rgba(r, g, b, a), rgba(RRGGBBAA), 0xAARRGGBB, and
|
||||
// "x, y" in pixels or percent of the screen.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QColor>
|
||||
#include <QSizeF>
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
#include <QVariantList>
|
||||
|
||||
namespace LockPreview
|
||||
{
|
||||
// The lock screen the user starts: the one their idle daemon or key runs,
|
||||
// else one that is installed. hyprlock, swaylock, another name, or empty.
|
||||
QString locker();
|
||||
|
||||
// That lock screen's widgets, bottom first, as maps for LockPreview.qml.
|
||||
// output names the screen, for widgets meant for one monitor; wallpaper is
|
||||
// the desktop's picture, for a background that is a screenshot.
|
||||
QVariantList widgets(const QString &locker, const QSizeF &screen, const QString &output, const QUrl &wallpaper);
|
||||
|
||||
// The pieces, apart for the tests.
|
||||
QVariantList hyprlock(const QString &configPath, const QSizeF &screen, const QString &output, const QUrl &wallpaper);
|
||||
QVariantList swaylock(const QString &configPath, const QUrl &wallpaper);
|
||||
QColor color(const QString &value);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "lockscreencontroller.h"
|
||||
|
||||
#include <KLocalizedString>
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QFile>
|
||||
#include <QPointer>
|
||||
|
||||
#include <security/pam_appl.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <pwd.h>
|
||||
#include <thread>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
struct Conversation {
|
||||
QByteArray password;
|
||||
};
|
||||
|
||||
// Answers every hidden question with the password and every other one with
|
||||
// nothing, as a lock screen that only asks for the password has to.
|
||||
int converse(int count, const pam_message **messages, pam_response **responses, void *data)
|
||||
{
|
||||
auto *conversation = static_cast<Conversation *>(data);
|
||||
auto *answers = static_cast<pam_response *>(calloc(size_t(count), sizeof(pam_response)));
|
||||
if (!answers) {
|
||||
return PAM_BUF_ERR;
|
||||
}
|
||||
for (int i = 0; i < count; ++i) {
|
||||
if (messages[i]->msg_style == PAM_PROMPT_ECHO_OFF) {
|
||||
answers[i].resp = strdup(conversation->password.constData());
|
||||
} else if (messages[i]->msg_style == PAM_PROMPT_ECHO_ON) {
|
||||
answers[i].resp = strdup("");
|
||||
}
|
||||
}
|
||||
*responses = answers;
|
||||
return PAM_SUCCESS;
|
||||
}
|
||||
|
||||
QByteArray serviceName()
|
||||
{
|
||||
static const char *const services[] = {"face-unlock-lock", "password-auth", "common-auth", "login"};
|
||||
static const char *const dirs[] = {"/etc/pam.d/", "/usr/lib/pam.d/", "/usr/share/pam.d/"};
|
||||
for (const char *service : services) {
|
||||
for (const char *dir : dirs) {
|
||||
if (QFile::exists(QString::fromLatin1(dir) + QString::fromLatin1(service))) {
|
||||
return service;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "login";
|
||||
}
|
||||
|
||||
bool checkPassword(const QByteArray &user, const QByteArray &password)
|
||||
{
|
||||
Conversation conversation{password};
|
||||
const pam_conv conv{converse, &conversation};
|
||||
pam_handle_t *pamh = nullptr;
|
||||
// For development only, like the daemon's --socket: a directory of PAM
|
||||
// files of its own, so a test never counts as a wrong password for the
|
||||
// real account.
|
||||
const QByteArray confdir = qgetenv("FU_PAM_CONFDIR");
|
||||
int rc = confdir.isEmpty() ? pam_start(serviceName().constData(), user.constData(), &conv, &pamh)
|
||||
: pam_start_confdir("face-unlock-lock", user.constData(), &conv, confdir.constData(), &pamh);
|
||||
if (rc == PAM_SUCCESS) {
|
||||
rc = pam_authenticate(pamh, 0);
|
||||
if (rc == PAM_SUCCESS) {
|
||||
pam_setcred(pamh, PAM_REFRESH_CRED);
|
||||
}
|
||||
}
|
||||
pam_end(pamh, rc);
|
||||
std::memset(conversation.password.data(), 0, size_t(conversation.password.size()));
|
||||
return rc == PAM_SUCCESS;
|
||||
}
|
||||
|
||||
passwd *me()
|
||||
{
|
||||
return getpwuid(getuid());
|
||||
}
|
||||
} // namespace
|
||||
|
||||
LockScreenController::LockScreenController(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
}
|
||||
|
||||
LockScreenController::~LockScreenController()
|
||||
{
|
||||
clearPassword();
|
||||
}
|
||||
|
||||
void LockScreenController::setPassword(const QString &password)
|
||||
{
|
||||
if (m_password == password) {
|
||||
return;
|
||||
}
|
||||
m_password = password;
|
||||
Q_EMIT passwordChanged();
|
||||
if (!m_message.isEmpty()) {
|
||||
m_message.clear();
|
||||
Q_EMIT messageChanged();
|
||||
}
|
||||
}
|
||||
|
||||
void LockScreenController::setFaceUnlock(bool on)
|
||||
{
|
||||
if (m_faceUnlock != on) {
|
||||
m_faceUnlock = on;
|
||||
Q_EMIT faceUnlockChanged();
|
||||
}
|
||||
}
|
||||
|
||||
void LockScreenController::setBlur(bool blur)
|
||||
{
|
||||
if (m_blur != blur) {
|
||||
m_blur = blur;
|
||||
Q_EMIT blurChanged();
|
||||
}
|
||||
}
|
||||
|
||||
QString LockScreenController::userName() const
|
||||
{
|
||||
const passwd *pw = me();
|
||||
if (!pw) {
|
||||
return {};
|
||||
}
|
||||
// The full name from the comment field, else the login name.
|
||||
const QString full = QString::fromLocal8Bit(pw->pw_gecos).section(u',', 0, 0).trimmed();
|
||||
return full.isEmpty() ? QString::fromLocal8Bit(pw->pw_name) : full;
|
||||
}
|
||||
|
||||
void LockScreenController::submit()
|
||||
{
|
||||
if (m_busy || m_password.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
const passwd *pw = me();
|
||||
if (!pw) {
|
||||
return;
|
||||
}
|
||||
m_busy = true;
|
||||
Q_EMIT busyChanged();
|
||||
|
||||
const QByteArray user(pw->pw_name);
|
||||
QByteArray password = m_password.toUtf8();
|
||||
QPointer<LockScreenController> self(this);
|
||||
std::thread([self, user, password]() mutable {
|
||||
const bool ok = checkPassword(user, password);
|
||||
std::memset(password.data(), 0, size_t(password.size()));
|
||||
QMetaObject::invokeMethod(qApp, [self, ok] {
|
||||
if (self) {
|
||||
self->finish(ok);
|
||||
}
|
||||
});
|
||||
}).detach();
|
||||
}
|
||||
|
||||
void LockScreenController::finish(bool ok)
|
||||
{
|
||||
m_busy = false;
|
||||
Q_EMIT busyChanged();
|
||||
clearPassword();
|
||||
Q_EMIT passwordChanged();
|
||||
if (ok) {
|
||||
Q_EMIT authenticated();
|
||||
return;
|
||||
}
|
||||
m_message = i18n("Wrong password");
|
||||
Q_EMIT messageChanged();
|
||||
Q_EMIT rejected();
|
||||
}
|
||||
|
||||
void LockScreenController::activity()
|
||||
{
|
||||
Q_EMIT input();
|
||||
}
|
||||
|
||||
void LockScreenController::clearPassword()
|
||||
{
|
||||
// What is still in memory of it, as far as a QString lets that happen.
|
||||
m_password.fill(u' ');
|
||||
m_password.clear();
|
||||
}
|
||||
|
||||
void LockScreenController::reset()
|
||||
{
|
||||
clearPassword();
|
||||
Q_EMIT passwordChanged();
|
||||
if (!m_message.isEmpty()) {
|
||||
m_message.clear();
|
||||
Q_EMIT messageChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// The own lock screen's side of things (see SessionLock): the password,
|
||||
// whether it is being checked, what to say under it.
|
||||
//
|
||||
// The password is checked with PAM in a thread of its own, the way swaylock
|
||||
// and hyprlock do it. The service is face-unlock-lock when an administrator
|
||||
// has written one, else the distribution's plain password stack:
|
||||
// password-auth (Fedora), common-auth (Debian, Ubuntu) or login. Not the face
|
||||
// module: the face has its own way in here, the agent.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
class LockScreenController : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(QString password READ password WRITE setPassword NOTIFY passwordChanged)
|
||||
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
|
||||
// After a wrong password, until the next key.
|
||||
Q_PROPERTY(QString message READ message NOTIFY messageChanged)
|
||||
// Whether the face is looked for, which changes what the screen says.
|
||||
Q_PROPERTY(bool faceUnlock READ faceUnlock NOTIFY faceUnlockChanged)
|
||||
Q_PROPERTY(QString userName READ userName CONSTANT)
|
||||
// Whether to blur the picture behind it.
|
||||
Q_PROPERTY(bool blur READ blur NOTIFY blurChanged)
|
||||
public:
|
||||
explicit LockScreenController(QObject *parent = nullptr);
|
||||
~LockScreenController() override;
|
||||
|
||||
QString password() const
|
||||
{
|
||||
return m_password;
|
||||
}
|
||||
void setPassword(const QString &password);
|
||||
bool busy() const
|
||||
{
|
||||
return m_busy;
|
||||
}
|
||||
QString message() const
|
||||
{
|
||||
return m_message;
|
||||
}
|
||||
bool faceUnlock() const
|
||||
{
|
||||
return m_faceUnlock;
|
||||
}
|
||||
void setFaceUnlock(bool on);
|
||||
QString userName() const;
|
||||
bool blur() const
|
||||
{
|
||||
return m_blur;
|
||||
}
|
||||
void setBlur(bool blur);
|
||||
|
||||
// Check the password typed so far.
|
||||
Q_INVOKABLE void submit();
|
||||
// A key or the pointer on the lock screen.
|
||||
Q_INVOKABLE void activity();
|
||||
|
||||
// A fresh lock: no password, nothing said.
|
||||
void reset();
|
||||
|
||||
Q_SIGNALS:
|
||||
void passwordChanged();
|
||||
void busyChanged();
|
||||
void messageChanged();
|
||||
void faceUnlockChanged();
|
||||
void blurChanged();
|
||||
void authenticated();
|
||||
// A wrong password, for the field to shake.
|
||||
void rejected();
|
||||
void input();
|
||||
|
||||
private:
|
||||
void finish(bool ok);
|
||||
void clearPassword();
|
||||
|
||||
QString m_password;
|
||||
QString m_message;
|
||||
bool m_busy = false;
|
||||
bool m_faceUnlock = true;
|
||||
bool m_blur = false;
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "locktext.h"
|
||||
|
||||
#include "bubblecontroller.h"
|
||||
#include "lockers.h"
|
||||
#include "userconfig.h"
|
||||
|
||||
#include <KLocalizedString>
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QSaveFile>
|
||||
#include <QStandardPaths>
|
||||
#include <QTimer>
|
||||
|
||||
#include <csignal>
|
||||
|
||||
LockText::LockText(BubbleController *bubble, UserConfig *config, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_bubble(bubble)
|
||||
, m_config(config)
|
||||
{
|
||||
connect(m_bubble, &BubbleController::phaseChanged, this, &LockText::update);
|
||||
connect(m_bubble, &BubbleController::messageChanged, this, &LockText::update);
|
||||
connect(m_config, &UserConfig::changed, this, &LockText::update);
|
||||
// Nothing left over from an agent before this one.
|
||||
write({});
|
||||
}
|
||||
|
||||
LockText::~LockText()
|
||||
{
|
||||
QFile::remove(path());
|
||||
}
|
||||
|
||||
QString LockText::path()
|
||||
{
|
||||
return QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation) + QStringLiteral("/face-unlock/lock-text");
|
||||
}
|
||||
|
||||
QString LockText::text(const QString &phase, const QString &message)
|
||||
{
|
||||
if (phase == u"scanning") {
|
||||
return message.isEmpty() ? i18n("Looking for your face…") : message;
|
||||
}
|
||||
if (phase == u"success") {
|
||||
return i18n("Face recognized");
|
||||
}
|
||||
// failure and lockout say why; hidden says nothing.
|
||||
return phase == u"hidden" ? QString() : message;
|
||||
}
|
||||
|
||||
void LockText::update()
|
||||
{
|
||||
const bool on = m_config->lockScreenStyle() == u"yours";
|
||||
const QString now = on ? text(m_bubble->phase(), m_bubble->message()) : QString();
|
||||
if (now != m_text) {
|
||||
write(now);
|
||||
// Left alone by those who did not pick it: their hyprlock has no
|
||||
// label to read it.
|
||||
m_tries = 0;
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
void LockText::refresh()
|
||||
{
|
||||
// A hyprlock that is only starting reads the file once it is up, but
|
||||
// may have read it just before this changed: asked again then.
|
||||
if (Lockers::signal(SIGUSR2, "hyprlock") > 0 && ++m_tries < 20) {
|
||||
QTimer::singleShot(250, this, &LockText::refresh);
|
||||
}
|
||||
}
|
||||
|
||||
void LockText::write(const QString &text)
|
||||
{
|
||||
m_text = text;
|
||||
QDir().mkpath(QFileInfo(path()).absolutePath());
|
||||
QSaveFile file(path());
|
||||
// hyprlock reads labels as Pango markup.
|
||||
if (file.open(QIODevice::WriteOnly)) {
|
||||
file.write(text.toHtmlEscaped().toUtf8());
|
||||
file.commit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// The bubble as a line of text, for a lock screen that covers it: hyprlock.
|
||||
// When the user keeps their own lock screen (LockScreenStyle=yours), the
|
||||
// menu puts a label into hyprlock's config that shows the file at path()
|
||||
// (see src/lib/system.sh). This writes what the bubble shows into it, and
|
||||
// SIGUSR2 makes hyprlock read it again.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
class BubbleController;
|
||||
class UserConfig;
|
||||
|
||||
class LockText : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
LockText(BubbleController *bubble, UserConfig *config, QObject *parent = nullptr);
|
||||
~LockText() override;
|
||||
|
||||
static QString path();
|
||||
// The line for a phase and message of the bubble.
|
||||
static QString text(const QString &phase, const QString &message);
|
||||
|
||||
private:
|
||||
void update();
|
||||
void refresh();
|
||||
void write(const QString &text);
|
||||
|
||||
BubbleController *m_bubble;
|
||||
UserConfig *m_config;
|
||||
QString m_text;
|
||||
int m_tries = 0;
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "lockwatcher.h"
|
||||
|
||||
#include "lockers.h"
|
||||
#include "sessionlock.h"
|
||||
#include "wayland.h"
|
||||
|
||||
#include <QDBusConnection>
|
||||
#include <QDBusMessage>
|
||||
#include <QDBusObjectPath>
|
||||
#include <QDBusPendingCallWatcher>
|
||||
#include <QDBusPendingReply>
|
||||
#include <QDBusVariant>
|
||||
#include <QProcess>
|
||||
|
||||
#include <csignal>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
const QString Login1 = QStringLiteral("org.freedesktop.login1");
|
||||
const QString SessionInterface = QStringLiteral("org.freedesktop.login1.Session");
|
||||
const QString Properties = QStringLiteral("org.freedesktop.DBus.Properties");
|
||||
|
||||
constexpr int PollMs = 1000;
|
||||
|
||||
} // namespace
|
||||
|
||||
LockWatcher::LockWatcher(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
QDBusConnection session = QDBusConnection::sessionBus();
|
||||
session.connect(QStringLiteral("org.freedesktop.ScreenSaver"),
|
||||
QStringLiteral("/ScreenSaver"),
|
||||
QStringLiteral("org.freedesktop.ScreenSaver"),
|
||||
QStringLiteral("ActiveChanged"),
|
||||
this,
|
||||
SLOT(onScreenSaver(bool)));
|
||||
|
||||
// Started while the screen is already locked (the agent restarted).
|
||||
QDBusMessage get = QDBusMessage::createMethodCall(QStringLiteral("org.freedesktop.ScreenSaver"),
|
||||
QStringLiteral("/ScreenSaver"),
|
||||
QStringLiteral("org.freedesktop.ScreenSaver"),
|
||||
QStringLiteral("GetActive"));
|
||||
get.setAutoStartService(false);
|
||||
auto *watcher = new QDBusPendingCallWatcher(session.asyncCall(get), this);
|
||||
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher] {
|
||||
watcher->deleteLater();
|
||||
const QDBusPendingReply<bool> reply = *watcher;
|
||||
if (reply.isValid() && reply.value()) {
|
||||
onScreenSaver(true);
|
||||
}
|
||||
});
|
||||
|
||||
findSession();
|
||||
|
||||
m_ownLockers = Wayland::hasGlobal("ext_session_lock_manager_v1");
|
||||
if (m_ownLockers) {
|
||||
connect(&m_poll, &QTimer::timeout, this, &LockWatcher::pollLockers);
|
||||
m_poll.start(PollMs);
|
||||
// Not from here: nobody is connected yet to hear about a lock screen
|
||||
// that is already up.
|
||||
QTimer::singleShot(0, this, &LockWatcher::pollLockers);
|
||||
}
|
||||
}
|
||||
|
||||
void LockWatcher::onScreenSaver(bool active)
|
||||
{
|
||||
m_screenSaver = active;
|
||||
update();
|
||||
}
|
||||
|
||||
void LockWatcher::findSession()
|
||||
{
|
||||
// Niri and some others hand their session on to user services. Without
|
||||
// it, "auto" is the session on the display.
|
||||
const QString id = qEnvironmentVariable("XDG_SESSION_ID");
|
||||
if (!id.isEmpty()) {
|
||||
QDBusMessage call = QDBusMessage::createMethodCall(Login1,
|
||||
QStringLiteral("/org/freedesktop/login1"),
|
||||
QStringLiteral("org.freedesktop.login1.Manager"),
|
||||
QStringLiteral("GetSession"));
|
||||
call << id;
|
||||
auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::systemBus().asyncCall(call), this);
|
||||
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher] {
|
||||
watcher->deleteLater();
|
||||
const QDBusPendingReply<QDBusObjectPath> reply = *watcher;
|
||||
watchSession(reply.isValid() ? reply.value().path() : QStringLiteral("/org/freedesktop/login1/session/auto"));
|
||||
});
|
||||
return;
|
||||
}
|
||||
QDBusMessage call = QDBusMessage::createMethodCall(Login1, QStringLiteral("/org/freedesktop/login1/session/auto"), Properties, QStringLiteral("Get"));
|
||||
call << SessionInterface << QStringLiteral("Id");
|
||||
auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::systemBus().asyncCall(call), this);
|
||||
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher] {
|
||||
watcher->deleteLater();
|
||||
const QDBusPendingReply<QDBusVariant> reply = *watcher;
|
||||
if (!reply.isValid()) {
|
||||
qWarning("logind knows no session for this agent: %s", qPrintable(reply.error().message()));
|
||||
return;
|
||||
}
|
||||
// The signals come from the session's real path, not from "auto".
|
||||
QDBusMessage call = QDBusMessage::createMethodCall(Login1,
|
||||
QStringLiteral("/org/freedesktop/login1"),
|
||||
QStringLiteral("org.freedesktop.login1.Manager"),
|
||||
QStringLiteral("GetSession"));
|
||||
call << reply.value().variant().toString();
|
||||
auto *next = new QDBusPendingCallWatcher(QDBusConnection::systemBus().asyncCall(call), this);
|
||||
connect(next, &QDBusPendingCallWatcher::finished, this, [this, next] {
|
||||
next->deleteLater();
|
||||
const QDBusPendingReply<QDBusObjectPath> path = *next;
|
||||
if (path.isValid()) {
|
||||
watchSession(path.value().path());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void LockWatcher::watchSession(const QString &path)
|
||||
{
|
||||
m_session = path;
|
||||
QDBusConnection::systemBus().connect(Login1,
|
||||
path,
|
||||
Properties,
|
||||
QStringLiteral("PropertiesChanged"),
|
||||
this,
|
||||
SLOT(onSessionProperties(QString, QVariantMap, QStringList)));
|
||||
readLockedHint();
|
||||
}
|
||||
|
||||
void LockWatcher::readLockedHint()
|
||||
{
|
||||
QDBusMessage call = QDBusMessage::createMethodCall(Login1, m_session, Properties, QStringLiteral("Get"));
|
||||
call << SessionInterface << QStringLiteral("LockedHint");
|
||||
auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::systemBus().asyncCall(call), this);
|
||||
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher] {
|
||||
watcher->deleteLater();
|
||||
const QDBusPendingReply<QDBusVariant> reply = *watcher;
|
||||
if (reply.isValid()) {
|
||||
m_lockedHint = reply.value().variant().toBool();
|
||||
update();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void LockWatcher::onSessionProperties(const QString &interface, const QVariantMap &changed, const QStringList &invalidated)
|
||||
{
|
||||
if (interface != SessionInterface) {
|
||||
return;
|
||||
}
|
||||
if (changed.contains(QStringLiteral("LockedHint"))) {
|
||||
m_lockedHint = changed.value(QStringLiteral("LockedHint")).toBool();
|
||||
update();
|
||||
} else if (invalidated.contains(QStringLiteral("LockedHint"))) {
|
||||
readLockedHint();
|
||||
}
|
||||
}
|
||||
|
||||
void LockWatcher::pollLockers()
|
||||
{
|
||||
m_lockerRunning = !Lockers::find().isEmpty();
|
||||
const bool ready = Lockers::ready(SIGUSR1);
|
||||
const bool became = ready && !m_lockerReady;
|
||||
m_lockerReady = ready;
|
||||
update();
|
||||
if (became) {
|
||||
Q_EMIT unlockable();
|
||||
}
|
||||
}
|
||||
|
||||
void LockWatcher::setOwnLock(SessionLock *lock)
|
||||
{
|
||||
m_own = lock;
|
||||
connect(lock, &SessionLock::lockedChanged, this, &LockWatcher::update);
|
||||
}
|
||||
|
||||
bool LockWatcher::canUnlock() const
|
||||
{
|
||||
return (m_own && m_own->isLocked()) || !m_ownLockers || m_lockerReady;
|
||||
}
|
||||
|
||||
void LockWatcher::update()
|
||||
{
|
||||
const bool locked = m_screenSaver || m_lockedHint || m_lockerRunning || (m_own && m_own->isLocked());
|
||||
if (locked != m_locked) {
|
||||
m_locked = locked;
|
||||
Q_EMIT lockedChanged(locked);
|
||||
}
|
||||
}
|
||||
|
||||
void LockWatcher::unlock()
|
||||
{
|
||||
if (m_own && m_own->isLocked()) {
|
||||
m_own->unlock();
|
||||
return;
|
||||
}
|
||||
// Looked up again rather than taken from the last poll: a second is
|
||||
// long enough for a pid to belong to something else.
|
||||
Lockers::signal(SIGUSR1);
|
||||
|
||||
const QDBusMessage call = QDBusMessage::createMethodCall(Login1,
|
||||
m_session.isEmpty() ? QStringLiteral("/org/freedesktop/login1/session/auto") : m_session,
|
||||
SessionInterface,
|
||||
QStringLiteral("Unlock"));
|
||||
auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::systemBus().asyncCall(call), this);
|
||||
connect(watcher, &QDBusPendingCallWatcher::finished, this, [watcher] {
|
||||
watcher->deleteLater();
|
||||
const QDBusPendingReply<> reply = *watcher;
|
||||
if (reply.isError()) {
|
||||
qWarning("logind would not unlock: %s; trying loginctl", qPrintable(reply.error().message()));
|
||||
const QString id = qEnvironmentVariable("XDG_SESSION_ID");
|
||||
QStringList args{QStringLiteral("unlock-session")};
|
||||
if (!id.isEmpty()) {
|
||||
args << id;
|
||||
}
|
||||
QProcess::startDetached(QStringLiteral("loginctl"), args);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Whether the screen is locked, and how to unlock it, on each desktop.
|
||||
//
|
||||
// Plasma org.freedesktop.ScreenSaver and logind's LockedHint. Unlocked
|
||||
// through logind.
|
||||
// GNOME logind's LockedHint. Unlocked through logind.
|
||||
// Niri logind's LockedHint, which niri sets for any lock screen.
|
||||
// Hyprland sets no LockedHint, so the lock screen is looked for among this
|
||||
// user's processes (hyprlock, swaylock, gtklock) once a second.
|
||||
// Only on compositors where the lock screen is a program of its
|
||||
// own (ext-session-lock).
|
||||
//
|
||||
// hyprlock, swaylock and gtklock do not listen to logind. They unlock on
|
||||
// SIGUSR1.
|
||||
// face-unlock's own lock screen (SessionLock) is simply told to. Any other
|
||||
// lock screen of that kind only opens itself: it gets the face through the
|
||||
// PAM module in its own stack (see src/lib/pam.sh), when Enter is pressed.
|
||||
//
|
||||
// None of this lowers the bar: any program running as this user can already
|
||||
// ask logind to unlock the session, or send its own lock screen a signal. The
|
||||
// face data and the decision stay with the daemon, which runs as root.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QTimer>
|
||||
#include <QVariantMap>
|
||||
|
||||
#include <sys/types.h>
|
||||
|
||||
class SessionLock;
|
||||
|
||||
class LockWatcher : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit LockWatcher(QObject *parent = nullptr);
|
||||
|
||||
bool locked() const
|
||||
{
|
||||
return m_locked;
|
||||
}
|
||||
// Whether unlock() can open the lock screen that is up: always where
|
||||
// logind unlocks (Plasma, GNOME), elsewhere only hyprlock, swaylock and
|
||||
// gtklock, once they take SIGUSR1.
|
||||
bool canUnlock() const;
|
||||
void unlock();
|
||||
void setOwnLock(SessionLock *lock);
|
||||
|
||||
Q_SIGNALS:
|
||||
void lockedChanged(bool locked);
|
||||
// canUnlock() came true: the lock screen that is up now takes SIGUSR1.
|
||||
void unlockable();
|
||||
|
||||
private Q_SLOTS:
|
||||
void onScreenSaver(bool active);
|
||||
void onSessionProperties(const QString &interface, const QVariantMap &changed, const QStringList &invalidated);
|
||||
|
||||
private:
|
||||
void findSession();
|
||||
void watchSession(const QString &path);
|
||||
void readLockedHint();
|
||||
void pollLockers();
|
||||
void update();
|
||||
|
||||
bool m_screenSaver = false;
|
||||
bool m_lockedHint = false;
|
||||
bool m_lockerRunning = false;
|
||||
bool m_lockerReady = false;
|
||||
bool m_locked = false;
|
||||
// The compositor's lock screen is a program of its own.
|
||||
bool m_ownLockers = false;
|
||||
SessionLock *m_own = nullptr;
|
||||
QString m_session;
|
||||
QTimer m_poll;
|
||||
};
|
||||
+257
-10
@@ -1,19 +1,34 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// plasma-face-unlock-agent: the part in the user's session.
|
||||
// face-unlock-agent: the part in the user's session.
|
||||
//
|
||||
// (no arguments) stay in the background: unlock the lock screen by face,
|
||||
// and show the bubble for every scan
|
||||
// --enroll open the window that sets up a face
|
||||
// --lock lock the screen: with face-unlock's own lock screen
|
||||
// where the lock screen is a program of its own (Hyprland,
|
||||
// Niri), else through logind with the desktop's
|
||||
// --demo play the bubble's animations once, for trying out a
|
||||
// style (--bubble-style minimal) and for screenshots
|
||||
// --choose-lock-screen
|
||||
// open the window that asks which lock screen to use
|
||||
// where it is a program of its own, and print the answer
|
||||
// --has-own-lock for the menu: exits 0 when this build has face-unlock's
|
||||
// own lock screen (see SessionLock::built)
|
||||
|
||||
#include "agentsocket.h"
|
||||
#include "bubblecontroller.h"
|
||||
#include "bubbleservice.h"
|
||||
#include "bubblewindow.h"
|
||||
#include "enrollcontroller.h"
|
||||
#include "lockcontroller.h"
|
||||
#include "lockpreview.h"
|
||||
#include "lockscreencontroller.h"
|
||||
#include "locktext.h"
|
||||
#include "sessionlock.h"
|
||||
#include "userconfig.h"
|
||||
#include "wallpaper.h"
|
||||
#include "wayland.h"
|
||||
|
||||
#include "buildconfig.h"
|
||||
|
||||
@@ -21,11 +36,20 @@
|
||||
#include <KLocalizedString>
|
||||
|
||||
#include <QCommandLineParser>
|
||||
#include <QDBusConnection>
|
||||
#include <QDBusMessage>
|
||||
#include <QFile>
|
||||
#include <QGuiApplication>
|
||||
#include <QQmlApplicationEngine>
|
||||
#include <QQmlEngine>
|
||||
#include <QScreen>
|
||||
#include <QTimer>
|
||||
#include <QWindow>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace
|
||||
@@ -39,7 +63,7 @@ int runEnroll(QGuiApplication &app, const QString &name)
|
||||
QQmlApplicationEngine engine;
|
||||
KLocalization::setupLocalizedContext(&engine);
|
||||
engine.setInitialProperties({{QStringLiteral("controller"), QVariant::fromValue(&controller)}});
|
||||
engine.loadFromModule(QStringLiteral("PlasmaFaceUnlock"), QStringLiteral("Enroll"));
|
||||
engine.loadFromModule(QStringLiteral("FaceUnlock"), QStringLiteral("Enroll"));
|
||||
if (engine.rootObjects().isEmpty()) {
|
||||
return 2;
|
||||
}
|
||||
@@ -53,6 +77,142 @@ int runEnroll(QGuiApplication &app, const QString &name)
|
||||
return controller.state() == u"done" ? 0 : code;
|
||||
}
|
||||
|
||||
// The window of --choose-lock-screen. Prints "own" or "yours" for the menu,
|
||||
// which carries it out.
|
||||
int runChooseLockScreen(QGuiApplication &app)
|
||||
{
|
||||
app.setQuitOnLastWindowClosed(true);
|
||||
UserConfig config;
|
||||
|
||||
// Both choices show this screen as it would look: face-unlock's lock
|
||||
// screen as it is, with a scan going on, and the user's own rebuilt.
|
||||
const QScreen *screen = QGuiApplication::primaryScreen();
|
||||
const QString output = screen ? screen->name() : QString();
|
||||
const QSize size = screen ? screen->size() : QSize(1920, 1080);
|
||||
const QUrl desktop = Wallpaper::pick(Wallpaper::forLock({}), output);
|
||||
const QUrl ownWallpaper = config.lockWallpaper().isEmpty() ? desktop : Wallpaper::pick(Wallpaper::forLock(config.lockWallpaper()), output);
|
||||
const QString locker = LockPreview::locker();
|
||||
|
||||
LockScreenController lock;
|
||||
lock.setBlur(config.lockBlur());
|
||||
BubbleController bubble(&config);
|
||||
bubble.scanStarted();
|
||||
// A scan that goes on: the bubble closes by itself after a while.
|
||||
QTimer rescan;
|
||||
QObject::connect(&rescan, &QTimer::timeout, &bubble, &BubbleController::scanStarted);
|
||||
rescan.start(20000);
|
||||
|
||||
QQmlApplicationEngine engine;
|
||||
KLocalization::setupLocalizedContext(&engine);
|
||||
engine.setInitialProperties({{QStringLiteral("current"), config.lockScreenStyle()},
|
||||
{QStringLiteral("locker"), locker},
|
||||
{QStringLiteral("widgets"), LockPreview::widgets(locker, size, output, desktop)},
|
||||
{QStringLiteral("screenSize"), size},
|
||||
{QStringLiteral("ownWallpaper"), ownWallpaper},
|
||||
{QStringLiteral("lock"), QVariant::fromValue(&lock)},
|
||||
{QStringLiteral("bubble"), QVariant::fromValue(&bubble)}});
|
||||
engine.loadFromModule(QStringLiteral("FaceUnlock"), QStringLiteral("LockChoice"));
|
||||
auto *window = engine.rootObjects().isEmpty() ? nullptr : qobject_cast<QWindow *>(engine.rootObjects().constFirst());
|
||||
if (!window) {
|
||||
return 2;
|
||||
}
|
||||
// Shown only now, at the size its texts need (see LockChoice.qml).
|
||||
window->show();
|
||||
app.exec();
|
||||
const QString answer = window->property("answer").toString();
|
||||
if (answer.isEmpty()) {
|
||||
return 1;
|
||||
}
|
||||
std::printf("%s\n", qPrintable(answer));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Where --lock tells the command that ran it that the screen is locked, when
|
||||
// it forked to stay behind as the lock screen. -1: it did not.
|
||||
int readyFd = -1;
|
||||
|
||||
void signalReady(bool ok)
|
||||
{
|
||||
if (readyFd >= 0) {
|
||||
if (ok) {
|
||||
[[maybe_unused]] const ssize_t n = write(readyFd, "1", 1);
|
||||
}
|
||||
close(readyFd);
|
||||
readyFd = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Whether an agent listens on its socket. Asked before Qt is up, to decide
|
||||
// whether to fork.
|
||||
bool agentListening()
|
||||
{
|
||||
const char *runtime = getenv("XDG_RUNTIME_DIR");
|
||||
if (!runtime) {
|
||||
return false;
|
||||
}
|
||||
sockaddr_un addr{};
|
||||
addr.sun_family = AF_UNIX;
|
||||
const int len = snprintf(addr.sun_path, sizeof(addr.sun_path), "%s/face-unlock/agent.socket", runtime);
|
||||
if (len <= 0 || size_t(len) >= sizeof(addr.sun_path)) {
|
||||
return false;
|
||||
}
|
||||
const int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
|
||||
const bool ok = fd >= 0 && connect(fd, reinterpret_cast<sockaddr *>(&addr), sizeof(addr)) == 0;
|
||||
if (fd >= 0) {
|
||||
close(fd);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
// With no agent running, --lock stays behind as the lock screen until it is
|
||||
// unlocked. The command that asked for it returns as soon as the screen is
|
||||
// locked, the way swaylock -f does: an idle daemon locking before sleep
|
||||
// waits for that, and not a moment longer.
|
||||
void forkForLock(int argc, char **argv)
|
||||
{
|
||||
bool lock = false;
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
lock = lock || std::strcmp(argv[i], "--lock") == 0;
|
||||
}
|
||||
int fds[2];
|
||||
if (!lock || agentListening() || pipe(fds) != 0) {
|
||||
return;
|
||||
}
|
||||
const pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
close(fds[0]);
|
||||
close(fds[1]);
|
||||
return;
|
||||
}
|
||||
if (pid > 0) {
|
||||
close(fds[1]);
|
||||
char c = 0;
|
||||
const bool locked = read(fds[0], &c, 1) == 1;
|
||||
_exit(locked ? 0 : 1);
|
||||
}
|
||||
close(fds[0]);
|
||||
setsid();
|
||||
readyFd = fds[1];
|
||||
}
|
||||
|
||||
// Plasma and GNOME lock with their own lock screen when logind asks them to,
|
||||
// as `loginctl lock-session` does.
|
||||
int lockThroughLogind()
|
||||
{
|
||||
const QString id = qEnvironmentVariable("XDG_SESSION_ID");
|
||||
QDBusMessage call = QDBusMessage::createMethodCall(QStringLiteral("org.freedesktop.login1"),
|
||||
QStringLiteral("/org/freedesktop/login1"),
|
||||
QStringLiteral("org.freedesktop.login1.Manager"),
|
||||
QStringLiteral("LockSession"));
|
||||
call << (id.isEmpty() ? QStringLiteral("auto") : id);
|
||||
const QDBusMessage reply = QDBusConnection::systemBus().call(call);
|
||||
if (reply.type() == QDBusMessage::ErrorMessage) {
|
||||
qWarning("logind would not lock: %s", qPrintable(reply.errorMessage()));
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The whole life of a bubble, twice: a face that is recognised after a blink,
|
||||
// then one that is not.
|
||||
void scheduleDemo(BubbleController *bubble)
|
||||
@@ -75,24 +235,34 @@ void scheduleDemo(BubbleController *bubble)
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
if (argc == 2 && std::strcmp(argv[1], "--has-own-lock") == 0) {
|
||||
return SessionLock::built ? 0 : 1;
|
||||
}
|
||||
forkForLock(argc, argv);
|
||||
QGuiApplication app(argc, argv);
|
||||
app.setApplicationName(QStringLiteral("plasma-face-unlock-agent"));
|
||||
app.setApplicationVersion(QStringLiteral(PFU_VERSION));
|
||||
app.setDesktopFileName(QStringLiteral("io.github.loonixtools.plasma-face-unlock-agent"));
|
||||
KLocalizedString::setApplicationDomain(PFU_NAME);
|
||||
app.setApplicationName(QStringLiteral("face-unlock-agent"));
|
||||
app.setApplicationVersion(QStringLiteral(FU_VERSION));
|
||||
app.setDesktopFileName(QStringLiteral("io.github.loonixtools.face-unlock-agent"));
|
||||
KLocalizedString::setApplicationDomain(FU_NAME);
|
||||
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription(i18n("Face unlock for KDE Plasma"));
|
||||
parser.setApplicationDescription(i18n("Face unlock for the lock screen, sudo and admin prompts"));
|
||||
parser.addHelpOption();
|
||||
parser.addVersionOption();
|
||||
const QCommandLineOption enrollOpt(QStringLiteral("enroll"), i18n("Set up a face."));
|
||||
const QCommandLineOption nameOpt(QStringLiteral("name"), i18n("What to call the new face."), QStringLiteral("name"));
|
||||
const QCommandLineOption lockOpt(QStringLiteral("lock"), i18n("Lock the screen."));
|
||||
const QCommandLineOption demoOpt(QStringLiteral("demo"), i18n("Play the bubble's animations once."));
|
||||
// Not "--style": QGuiApplication takes that one for itself.
|
||||
const QCommandLineOption styleOpt(QStringLiteral("bubble-style"), i18n("Bubble style for the demo: full or minimal."), QStringLiteral("style"));
|
||||
parser.addOptions({enrollOpt, nameOpt, demoOpt, styleOpt});
|
||||
const QCommandLineOption chooseOpt(QStringLiteral("choose-lock-screen"), i18n("Ask which lock screen to use, and print the answer."));
|
||||
parser.addOptions({enrollOpt, nameOpt, lockOpt, demoOpt, styleOpt, chooseOpt});
|
||||
parser.process(app);
|
||||
|
||||
if (parser.isSet(chooseOpt)) {
|
||||
return runChooseLockScreen(app);
|
||||
}
|
||||
|
||||
if (parser.isSet(enrollOpt)) {
|
||||
QString name = parser.value(nameOpt);
|
||||
if (name.isEmpty()) {
|
||||
@@ -101,6 +271,13 @@ int main(int argc, char **argv)
|
||||
return runEnroll(app, name);
|
||||
}
|
||||
|
||||
const bool lockNow = parser.isSet(lockOpt);
|
||||
if (lockNow && !SessionLock::available()) {
|
||||
const int rc = lockThroughLogind();
|
||||
signalReady(rc == 0);
|
||||
return rc;
|
||||
}
|
||||
|
||||
app.setQuitOnLastWindowClosed(false);
|
||||
QQmlEngine engine;
|
||||
KLocalization::setupLocalizedContext(&engine);
|
||||
@@ -110,17 +287,87 @@ int main(int argc, char **argv)
|
||||
config.overrideStyle(parser.value(styleOpt));
|
||||
}
|
||||
BubbleController bubble(&config);
|
||||
BubbleWindow window(&engine, &bubble);
|
||||
// The bubble floats above everything as a layer-shell surface. GNOME has
|
||||
// none, and a normal window cannot stay on top or pick its place: there
|
||||
// face-unlock's GNOME Shell extension draws it, from what this tells it.
|
||||
std::unique_ptr<BubbleWindow> window;
|
||||
if (Wayland::hasGlobal("zwlr_layer_shell_v1")) {
|
||||
window = std::make_unique<BubbleWindow>(&engine, &bubble);
|
||||
}
|
||||
|
||||
if (parser.isSet(demoOpt)) {
|
||||
if (!window) {
|
||||
qWarning("this desktop has no layer-shell; on GNOME the extension draws the bubble");
|
||||
return 1;
|
||||
}
|
||||
scheduleDemo(&bubble);
|
||||
return app.exec();
|
||||
}
|
||||
|
||||
AgentSocket socket;
|
||||
if (AgentSocket::running()) {
|
||||
if (lockNow) {
|
||||
return AgentSocket::requestLock() ? 0 : 1;
|
||||
}
|
||||
qInfo("an agent is already running in this session");
|
||||
return 0;
|
||||
}
|
||||
socket.listen();
|
||||
QObject::connect(&socket, &AgentSocket::scanEvent, &bubble, &BubbleController::daemonEvent);
|
||||
std::unique_ptr<BubbleService> service;
|
||||
if (!window) {
|
||||
service = std::make_unique<BubbleService>(&bubble);
|
||||
}
|
||||
|
||||
LockController lock(&bubble, &config);
|
||||
LockScreenController screen;
|
||||
std::unique_ptr<SessionLock> sessionLock;
|
||||
if (SessionLock::available()) {
|
||||
sessionLock = std::make_unique<SessionLock>(&engine, &bubble, &screen, &config);
|
||||
SessionLock *lock = sessionLock.get();
|
||||
QObject::connect(&socket, &AgentSocket::lockRequested, lock, [lock, &socket] {
|
||||
lock->lock();
|
||||
if (lock->isConfirmed()) {
|
||||
socket.confirmLock();
|
||||
}
|
||||
});
|
||||
QObject::connect(lock, &SessionLock::confirmed, &socket, &AgentSocket::confirmLock);
|
||||
}
|
||||
LockController lock(&bubble, &config, sessionLock.get(), &screen);
|
||||
// hyprlock covers the bubble: it can show it as a line of text instead.
|
||||
std::unique_ptr<LockText> text;
|
||||
if (sessionLock && !lockNow) {
|
||||
text = std::make_unique<LockText>(&bubble, &config);
|
||||
}
|
||||
|
||||
if (lockNow) {
|
||||
// No agent was running, so this one is only here for the lock. It
|
||||
// goes once the lock is gone and the bubble has finished.
|
||||
QObject::connect(sessionLock.get(), &SessionLock::lockedChanged, &app, [&bubble](bool locked) {
|
||||
if (locked) {
|
||||
return;
|
||||
}
|
||||
if (!bubble.shown()) {
|
||||
QCoreApplication::quit();
|
||||
}
|
||||
QObject::connect(&bubble, &BubbleController::shownChanged, qApp, [&bubble] {
|
||||
if (!bubble.shown()) {
|
||||
QCoreApplication::quit();
|
||||
}
|
||||
});
|
||||
QTimer::singleShot(5000, qApp, &QCoreApplication::quit);
|
||||
});
|
||||
QObject::connect(sessionLock.get(), &SessionLock::confirmed, &app, [] {
|
||||
signalReady(true);
|
||||
});
|
||||
sessionLock->lock();
|
||||
if (!sessionLock->isLocked()) {
|
||||
signalReady(false);
|
||||
return 1;
|
||||
}
|
||||
} else if (sessionLock && QFile::exists(SessionLock::markerPath())) {
|
||||
// The last agent went away with the screen locked, and the compositor
|
||||
// kept it locked. Take the lock back, so it can be opened again.
|
||||
sessionLock->lock();
|
||||
}
|
||||
return app.exec();
|
||||
}
|
||||
@@ -6,17 +6,22 @@
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Window
|
||||
import PlasmaFaceUnlock
|
||||
import FaceUnlock
|
||||
|
||||
Window {
|
||||
id: window
|
||||
|
||||
required property var controller
|
||||
|
||||
// One fixed size: the layout needs no more room, and tiling compositors
|
||||
// (Hyprland, Niri) float a window that cannot be resized instead of
|
||||
// stretching it over half the screen.
|
||||
width: 520
|
||||
height: 680
|
||||
minimumWidth: 460
|
||||
minimumHeight: 620
|
||||
minimumWidth: width
|
||||
maximumWidth: width
|
||||
minimumHeight: height
|
||||
maximumHeight: height
|
||||
visible: true
|
||||
color: Theme.panel
|
||||
title: i18n("Set up Face Unlock")
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Where the lock screen is a program of the user's (Hyprland, Niri): lock
|
||||
// with face-unlock's own, with the bubble, or keep that program, which then
|
||||
// shows a line of text (hyprlock). Each choice shows the user's screen as it
|
||||
// would look: face-unlock's lock screen live, and their own rebuilt from its
|
||||
// config (see LockPreview). The pick goes back to the menu, which carries
|
||||
// it out.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import QtQuick.Window
|
||||
import FaceUnlock
|
||||
|
||||
Window {
|
||||
id: window
|
||||
|
||||
// "own", "yours", or empty when there is none yet.
|
||||
required property string current
|
||||
// The user's lock screen (see LockPreview::locker) and what it shows.
|
||||
required property string locker
|
||||
required property var widgets
|
||||
// The screen the pictures show, and the parts of face-unlock's own.
|
||||
required property size screenSize
|
||||
required property url ownWallpaper
|
||||
required property var lock
|
||||
required property var bubble
|
||||
// What was picked, empty when the window was closed.
|
||||
property string answer: ""
|
||||
property string selected: current
|
||||
|
||||
function pick() {
|
||||
if (selected.length > 0) {
|
||||
answer = selected
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
// One fixed size, so tiling compositors float it (see Enroll). The
|
||||
// height follows the texts, whose length depends on the language. It
|
||||
// is taken from them rather than from the Row and Column, which only lay
|
||||
// out once the window shows, and the agent shows it after that.
|
||||
readonly property int fitHeight: Math.ceil(36 + heading.height + 10 + intro.height + 28 + Math.max(own.needed, yours.needed) + 94)
|
||||
width: 800
|
||||
height: fitHeight
|
||||
minimumWidth: 800
|
||||
maximumWidth: 800
|
||||
minimumHeight: fitHeight
|
||||
maximumHeight: fitHeight
|
||||
visible: false
|
||||
color: Theme.panel
|
||||
title: i18n("Choose your lock screen")
|
||||
|
||||
Component {
|
||||
id: ownScene
|
||||
LockScreen {
|
||||
lock: window.lock
|
||||
bubble: window.bubble
|
||||
primary: true
|
||||
wallpaper: window.ownWallpaper
|
||||
interactive: false
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: yoursScene
|
||||
LockPreview {
|
||||
widgets: window.widgets
|
||||
}
|
||||
}
|
||||
|
||||
// One of the two: the screen, a radio button with the name, what it means.
|
||||
component Choice: Rectangle {
|
||||
id: card
|
||||
|
||||
required property string style
|
||||
required property Component scene
|
||||
required property string name
|
||||
required property string about
|
||||
readonly property bool chosen: window.selected === style
|
||||
// The height its text needs; both take the larger one.
|
||||
readonly property real needed: body.y + body.height + 20
|
||||
|
||||
width: 364
|
||||
radius: 18
|
||||
color: area.containsMouse && !chosen ? Qt.lighter(Theme.surface, 1.25) : Theme.surface
|
||||
border.width: 2
|
||||
border.color: chosen ? Theme.accent : "transparent"
|
||||
Behavior on border.color { ColorAnimation { duration: 150 } }
|
||||
Behavior on color { ColorAnimation { duration: 120 } }
|
||||
|
||||
// The whole screen, made small: filling the picture, cut at the
|
||||
// sides or at the top and bottom.
|
||||
Item {
|
||||
id: picture
|
||||
x: 14
|
||||
y: 14
|
||||
width: parent.width - 28
|
||||
height: Math.round(width * 10 / 16)
|
||||
clip: true
|
||||
layer.enabled: true
|
||||
layer.effect: MultiEffect {
|
||||
maskEnabled: true
|
||||
maskSource: mask
|
||||
}
|
||||
|
||||
Loader {
|
||||
sourceComponent: card.scene
|
||||
width: window.screenSize.width
|
||||
height: window.screenSize.height
|
||||
transformOrigin: Item.TopLeft
|
||||
scale: Math.max(picture.width / width, picture.height / height)
|
||||
x: (picture.width - width * scale) / 2
|
||||
y: (picture.height - height * scale) / 2
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
id: mask
|
||||
width: picture.width
|
||||
height: picture.height
|
||||
radius: 10
|
||||
visible: false
|
||||
layer.enabled: true
|
||||
}
|
||||
|
||||
// A radio button: a ring, filled when picked.
|
||||
Rectangle {
|
||||
id: radio
|
||||
x: 18
|
||||
y: title.y + 3
|
||||
width: 18
|
||||
height: 18
|
||||
radius: 9
|
||||
color: "transparent"
|
||||
border.width: 2
|
||||
border.color: card.chosen ? Theme.accent : Theme.textSecondary
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: 8
|
||||
height: 8
|
||||
radius: 4
|
||||
color: Theme.accent
|
||||
visible: card.chosen
|
||||
}
|
||||
}
|
||||
Text {
|
||||
id: title
|
||||
anchors.top: picture.bottom
|
||||
anchors.topMargin: 16
|
||||
x: radio.x + radio.width + 10
|
||||
width: parent.width - x - 18
|
||||
wrapMode: Text.WordWrap
|
||||
color: Theme.textPrimary
|
||||
font.pixelSize: 16
|
||||
font.weight: Font.DemiBold
|
||||
text: card.name
|
||||
}
|
||||
Text {
|
||||
id: body
|
||||
anchors.top: title.bottom
|
||||
anchors.topMargin: 8
|
||||
x: 18
|
||||
width: parent.width - 36
|
||||
wrapMode: Text.WordWrap
|
||||
color: Theme.textDetail
|
||||
font.pixelSize: 13
|
||||
lineHeight: 1.15
|
||||
text: card.about
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: area
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: window.selected = card.style
|
||||
onDoubleClicked: {
|
||||
window.selected = card.style
|
||||
window.pick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: head
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 36
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: parent.width - 96
|
||||
spacing: 10
|
||||
|
||||
Text {
|
||||
id: heading
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
color: Theme.textPrimary
|
||||
font.pixelSize: 26
|
||||
font.weight: Font.Bold
|
||||
text: i18n("Your lock screen")
|
||||
}
|
||||
Text {
|
||||
id: intro
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
color: Theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
font.weight: Font.Medium
|
||||
lineHeight: 1.15
|
||||
text: i18n("Your desktop leaves the lock screen to a program of your choice. Pick how face unlock shows up there. You can change it later under Settings.")
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: cards
|
||||
anchors.top: head.bottom
|
||||
anchors.topMargin: 28
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: 24
|
||||
focus: true
|
||||
|
||||
Keys.onLeftPressed: window.selected = "own"
|
||||
Keys.onRightPressed: window.selected = "yours"
|
||||
Keys.onReturnPressed: window.pick()
|
||||
Keys.onEnterPressed: window.pick()
|
||||
Keys.onEscapePressed: window.close()
|
||||
|
||||
Choice {
|
||||
id: own
|
||||
height: Math.max(own.needed, yours.needed)
|
||||
style: "own"
|
||||
scene: ownScene
|
||||
name: i18n("face-unlock's lock screen")
|
||||
about: i18n("The bubble at the top, the time and your wallpaper. Your shortcut and idle lock then run face-unlock lock.")
|
||||
}
|
||||
Choice {
|
||||
id: yours
|
||||
height: own.height
|
||||
style: "yours"
|
||||
scene: yoursScene
|
||||
name: i18n("Keep your lock screen")
|
||||
about: window.locker === "hyprlock"
|
||||
? i18n("It stays as it is. hyprlock shows a line of text at the top instead of the bubble. Enter on the empty password field scans.")
|
||||
: i18n("It stays as it is, without the bubble. Enter on the empty password field scans.")
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 32
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: 12
|
||||
|
||||
PillButton {
|
||||
primary: false
|
||||
text: i18n("Cancel")
|
||||
onClicked: window.close()
|
||||
}
|
||||
PillButton {
|
||||
enabled: window.selected.length > 0
|
||||
text: i18n("Continue")
|
||||
onClicked: window.pick()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// The user's own lock screen, drawn from what LockPreview (C++) read out of
|
||||
// its config, at the size of the screen. Whoever shows it scales it down.
|
||||
// Placement as in hyprlock: from the side or middle halign and valign name,
|
||||
// x to the right and y upwards. One Repeater per kind of widget, stacked in
|
||||
// the order hyprlock draws them.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var widgets
|
||||
|
||||
readonly property var numbered: widgets.map((w, i) => Object.assign({order: i}, w))
|
||||
function only(type) {
|
||||
return numbered.filter(w => w.type === type)
|
||||
}
|
||||
function alignX(halign, size, offset) {
|
||||
return halign === "center" ? (width - size) / 2 + offset
|
||||
: halign === "right" ? width - size + offset
|
||||
: offset
|
||||
}
|
||||
function alignY(valign, size, offset) {
|
||||
const up = valign === "center" ? (height - size) / 2 + offset
|
||||
: valign === "top" ? height - size + offset
|
||||
: offset
|
||||
return height - up - size
|
||||
}
|
||||
|
||||
clip: true
|
||||
|
||||
Repeater {
|
||||
model: root.only("background")
|
||||
|
||||
Item {
|
||||
id: back
|
||||
required property var modelData
|
||||
readonly property var w: modelData
|
||||
z: w.order
|
||||
width: root.width
|
||||
height: root.height
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: back.w.color
|
||||
}
|
||||
Image {
|
||||
// Past the edges when blurred: the blur would pull in the
|
||||
// nothing beyond them and darken the rim.
|
||||
anchors.fill: parent
|
||||
anchors.margins: back.w.blur > 0 ? -Math.round(64 * back.w.blur) : 0
|
||||
visible: back.w.fill !== "solid_color"
|
||||
source: back.w.source
|
||||
asynchronous: true
|
||||
sourceSize: Qt.size(root.width, root.height)
|
||||
fillMode: back.w.fill === "fit" ? Image.PreserveAspectFit
|
||||
: back.w.fill === "stretch" ? Image.Stretch
|
||||
: back.w.fill === "center" ? Image.Pad
|
||||
: back.w.fill === "tile" ? Image.Tile
|
||||
: Image.PreserveAspectCrop
|
||||
layer.enabled: back.w.blur > 0
|
||||
layer.effect: MultiEffect {
|
||||
blurEnabled: true
|
||||
blur: 1
|
||||
blurMax: Math.round(64 * back.w.blur)
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: "black"
|
||||
opacity: back.w.dim
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.only("label")
|
||||
|
||||
Text {
|
||||
id: line
|
||||
required property var modelData
|
||||
readonly property var w: modelData
|
||||
z: w.order
|
||||
x: root.alignX(w.halign, width, w.x)
|
||||
y: root.alignY(w.valign, height, w.y)
|
||||
rotation: w.rotate
|
||||
text: w.text
|
||||
textFormat: Text.StyledText
|
||||
color: w.color
|
||||
font.family: w.family
|
||||
font.weight: w.weight
|
||||
font.italic: w.italic
|
||||
font.pointSize: w.size
|
||||
horizontalAlignment: w.align === "left" ? Text.AlignLeft : w.align === "right" ? Text.AlignRight : Text.AlignHCenter
|
||||
layer.enabled: w.shadow
|
||||
layer.effect: MultiEffect {
|
||||
shadowEnabled: true
|
||||
shadowColor: line.w.shadowColor
|
||||
shadowBlur: Math.min(1, line.w.shadowSize / 8)
|
||||
shadowHorizontalOffset: 0
|
||||
shadowVerticalOffset: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.only("input")
|
||||
|
||||
// The outline is the outer colour around the inner one. A gradient
|
||||
// outline needs an opaque inside, which leaves it showing only at
|
||||
// the edge.
|
||||
Rectangle {
|
||||
id: field
|
||||
required property var modelData
|
||||
readonly property var w: modelData
|
||||
readonly property var outer: w.outer.colors
|
||||
readonly property bool blend: outer.length > 1 && w.inner.a >= 0.99
|
||||
z: w.order
|
||||
x: root.alignX(w.halign, width, w.x)
|
||||
y: root.alignY(w.valign, height, w.y)
|
||||
rotation: w.rotate
|
||||
width: w.width
|
||||
height: w.height
|
||||
radius: w.rounding < 0 ? height / 2 : Math.min(w.rounding, height / 2)
|
||||
color: blend ? "transparent" : w.inner
|
||||
border.width: blend ? 0 : w.thickness
|
||||
border.color: outer.length > 0 ? outer[0] : "transparent"
|
||||
gradient: blend ? outline : null
|
||||
|
||||
Gradient {
|
||||
id: outline
|
||||
orientation: Math.abs(Math.cos(field.w.outer.angle * Math.PI / 180)) >= Math.abs(Math.sin(field.w.outer.angle * Math.PI / 180))
|
||||
? Gradient.Horizontal : Gradient.Vertical
|
||||
GradientStop { position: 0; color: field.outer.length > 0 ? field.outer[0] : "transparent" }
|
||||
GradientStop { position: 1; color: field.outer.length > 0 ? field.outer[field.outer.length - 1] : "transparent" }
|
||||
}
|
||||
Rectangle {
|
||||
visible: field.blend
|
||||
anchors.fill: parent
|
||||
anchors.margins: field.w.thickness
|
||||
radius: Math.max(0, field.radius - field.w.thickness)
|
||||
color: field.w.inner
|
||||
}
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: field.w.placeholder
|
||||
textFormat: Text.StyledText
|
||||
color: field.w.fontColor
|
||||
font.family: field.w.family
|
||||
font.weight: field.w.weight
|
||||
font.italic: field.w.italic
|
||||
font.pointSize: Math.max(1, field.height / 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.only("shape")
|
||||
|
||||
Rectangle {
|
||||
required property var modelData
|
||||
readonly property var w: modelData
|
||||
z: w.order
|
||||
x: root.alignX(w.halign, width, w.x)
|
||||
y: root.alignY(w.valign, height, w.y)
|
||||
rotation: w.rotate
|
||||
width: w.width
|
||||
height: w.height
|
||||
radius: w.rounding < 0 ? Math.min(width, height) / 2 : Math.min(w.rounding, Math.min(width, height) / 2)
|
||||
color: w.color
|
||||
border.width: w.borderSize
|
||||
border.color: w.border.colors.length > 0 ? w.border.colors[0] : "transparent"
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.only("image")
|
||||
|
||||
Item {
|
||||
id: frame
|
||||
required property var modelData
|
||||
readonly property var w: modelData
|
||||
readonly property real round: w.rounding < 0 ? width / 2 : Math.min(w.rounding, width / 2)
|
||||
z: w.order
|
||||
x: root.alignX(w.halign, width, w.x)
|
||||
y: root.alignY(w.valign, height, w.y)
|
||||
rotation: w.rotate
|
||||
width: w.size + 2 * w.borderSize
|
||||
height: width
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: frame.round
|
||||
color: frame.w.border.colors.length > 0 ? frame.w.border.colors[0] : "transparent"
|
||||
}
|
||||
Image {
|
||||
id: photo
|
||||
anchors.fill: parent
|
||||
anchors.margins: frame.w.borderSize
|
||||
source: frame.w.source
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
layer.enabled: true
|
||||
layer.effect: MultiEffect {
|
||||
maskEnabled: true
|
||||
maskSource: photoMask
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
id: photoMask
|
||||
width: photo.width
|
||||
height: photo.height
|
||||
radius: Math.max(0, frame.round - frame.w.borderSize)
|
||||
visible: false
|
||||
layer.enabled: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.only("ring")
|
||||
|
||||
// swaylock's indicator, in the middle.
|
||||
Rectangle {
|
||||
id: circle
|
||||
required property var modelData
|
||||
readonly property var w: modelData
|
||||
z: w.order
|
||||
anchors.centerIn: parent
|
||||
width: 2 * w.radius + 2 * w.thickness
|
||||
height: width
|
||||
radius: width / 2
|
||||
color: w.inside
|
||||
border.width: w.thickness
|
||||
border.color: w.ring
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: circle.w.text
|
||||
color: circle.w.textColor
|
||||
font.pixelSize: circle.w.radius / 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// face-unlock's own lock screen, one per screen (see SessionLock): the time,
|
||||
// the password, and on the main screen the bubble at the top, the same one
|
||||
// that shows over the desktop. Behind it the desktop's picture, or the one
|
||||
// the user set (see Wallpaper).
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var lock
|
||||
required property var bubble
|
||||
required property bool primary
|
||||
required property url wallpaper
|
||||
// Off for the picture in the window that asks which lock screen to use:
|
||||
// nothing to type into, no keys taken.
|
||||
property bool interactive: true
|
||||
|
||||
property date now: new Date()
|
||||
Timer {
|
||||
interval: 1000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: root.now = new Date()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0; color: "#11161D" }
|
||||
GradientStop { position: 0.6; color: "#000000" }
|
||||
}
|
||||
}
|
||||
|
||||
// Loaded aside, so a big picture does not hold up the lock, and faded in.
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
opacity: picture.status === Image.Ready ? 1 : 0
|
||||
visible: opacity > 0
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: 200 }
|
||||
}
|
||||
|
||||
Image {
|
||||
id: picture
|
||||
// Past the edges when blurred: the blur would pull in the nothing
|
||||
// beyond them and darken the rim.
|
||||
anchors.fill: parent
|
||||
anchors.margins: root.lock.blur ? -64 : 0
|
||||
source: root.wallpaper
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
sourceSize: Qt.size(root.width, root.height)
|
||||
asynchronous: true
|
||||
cache: false
|
||||
layer.enabled: root.lock.blur
|
||||
layer.effect: MultiEffect {
|
||||
blurEnabled: true
|
||||
blur: 1
|
||||
blurMax: 64
|
||||
}
|
||||
}
|
||||
// Dimmed a little, so the time and the password read on any picture.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: "#000000"
|
||||
opacity: 0.35
|
||||
}
|
||||
}
|
||||
|
||||
// Any touch counts as somebody being back, and a click anywhere puts the
|
||||
// keys into the password.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: root.interactive
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.AllButtons
|
||||
onPositionChanged: root.lock.activity()
|
||||
onPressed: {
|
||||
root.lock.activity()
|
||||
field.forceActiveFocus()
|
||||
}
|
||||
}
|
||||
|
||||
// Below the open bubble, which hangs from the top edge.
|
||||
Column {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
y: Math.max(Theme.topGap + Theme.openHeight + 24, Math.round(parent.height * 0.2))
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
color: Theme.textPrimary
|
||||
font.pixelSize: Math.max(56, Math.min(120, Math.round(root.height * 0.11)))
|
||||
font.weight: Font.Light
|
||||
text: Qt.formatTime(root.now, Qt.locale().timeFormat(Locale.ShortFormat))
|
||||
}
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
color: Theme.textDetail
|
||||
font.pixelSize: 18
|
||||
font.weight: Font.Medium
|
||||
text: Qt.formatDate(root.now, Qt.locale().dateFormat(Locale.LongFormat))
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
y: Math.round(parent.height * 0.62)
|
||||
spacing: 12
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
color: Theme.textPrimary
|
||||
font.pixelSize: 17
|
||||
font.weight: Font.DemiBold
|
||||
text: root.lock.userName
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: pill
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: 280
|
||||
height: 44
|
||||
radius: height / 2
|
||||
color: Theme.surfaceRaised
|
||||
opacity: root.lock.busy ? 0.6 : 1
|
||||
border.width: field.activeFocus ? 1 : 0
|
||||
border.color: Qt.rgba(1, 1, 1, 0.18)
|
||||
|
||||
property real shake: 0
|
||||
transform: Translate { x: pill.shake }
|
||||
SequentialAnimation {
|
||||
id: shakeAnimation
|
||||
NumberAnimation { target: pill; property: "shake"; to: -10; duration: 55; easing.type: Easing.OutQuad }
|
||||
NumberAnimation { target: pill; property: "shake"; to: 9; duration: 70 }
|
||||
NumberAnimation { target: pill; property: "shake"; to: -6; duration: 65 }
|
||||
NumberAnimation { target: pill; property: "shake"; to: 4; duration: 60 }
|
||||
NumberAnimation { target: pill; property: "shake"; to: 0; duration: 55; easing.type: Easing.OutQuad }
|
||||
}
|
||||
|
||||
TextInput {
|
||||
id: field
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 20
|
||||
anchors.rightMargin: 20
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
horizontalAlignment: TextInput.AlignHCenter
|
||||
echoMode: TextInput.Password
|
||||
passwordCharacter: "●"
|
||||
color: Theme.textPrimary
|
||||
selectionColor: Theme.accent
|
||||
font.pixelSize: 15
|
||||
clip: true
|
||||
focus: root.interactive
|
||||
enabled: root.interactive
|
||||
// The dots show how far it is; a blinking bar in the middle
|
||||
// of the empty field only cuts the word in it in two.
|
||||
cursorDelegate: Item {}
|
||||
readOnly: root.lock.busy
|
||||
text: root.lock.password
|
||||
onTextEdited: root.lock.password = text
|
||||
onAccepted: root.lock.submit()
|
||||
Keys.onPressed: event => {
|
||||
root.lock.activity()
|
||||
event.accepted = false
|
||||
}
|
||||
Component.onCompleted: {
|
||||
if (root.interactive) {
|
||||
forceActiveFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
color: Theme.textSecondary
|
||||
font.pixelSize: 15
|
||||
text: i18n("Password")
|
||||
visible: field.text.length === 0
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: 360
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
color: root.lock.message.length > 0 ? Theme.failure : Theme.textDetail
|
||||
font.pixelSize: 13
|
||||
font.weight: Font.Medium
|
||||
text: root.lock.message.length > 0 ? root.lock.message
|
||||
: root.lock.faceUnlock ? i18n("Look at the camera or type your password")
|
||||
: i18n("Type your password")
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root.lock
|
||||
function onRejected() {
|
||||
shakeAnimation.restart()
|
||||
}
|
||||
}
|
||||
|
||||
// The bubble, where it shows over the desktop too.
|
||||
Bubble {
|
||||
visible: root.primary
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
y: 0
|
||||
bubble: root.bubble
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "sessionlock.h"
|
||||
|
||||
#include "bubblecontroller.h"
|
||||
#include "lockscreencontroller.h"
|
||||
#include "userconfig.h"
|
||||
#include "wallpaper.h"
|
||||
#include "wayland.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QGuiApplication>
|
||||
#include <QQuickView>
|
||||
#include <QScreen>
|
||||
#include <QStandardPaths>
|
||||
#include <QUrl>
|
||||
#include <QtWaylandClient/private/qwaylanddisplay_p.h>
|
||||
#include <QtWaylandClient/private/qwaylandintegration_p.h>
|
||||
#include <QtWaylandClient/private/qwaylandscreen_p.h>
|
||||
#include <QtWaylandClient/private/qwaylandshellintegration_p.h>
|
||||
#include <QtWaylandClient/private/qwaylandshellsurface_p.h>
|
||||
#include <QtWaylandClient/private/qwaylandsurface_p.h>
|
||||
#include <QtWaylandClient/private/qwaylandwindow_p.h>
|
||||
|
||||
#include "qwayland-ext-session-lock-v1.h"
|
||||
|
||||
// The manager, and through it the role every lock window gets.
|
||||
class LockIntegration : public QtWaylandClient::QWaylandShellIntegrationTemplate<LockIntegration>, public QtWayland::ext_session_lock_manager_v1
|
||||
{
|
||||
public:
|
||||
LockIntegration()
|
||||
: QWaylandShellIntegrationTemplate<LockIntegration>(1)
|
||||
{
|
||||
}
|
||||
~LockIntegration() override
|
||||
{
|
||||
if (object()) {
|
||||
destroy();
|
||||
}
|
||||
}
|
||||
QtWaylandClient::QWaylandShellSurface *createShellSurface(QtWaylandClient::QWaylandWindow *window) override;
|
||||
|
||||
// The lock the surfaces belong to.
|
||||
::ext_session_lock_v1 *current = nullptr;
|
||||
};
|
||||
|
||||
// One screen's lock surface. Like LayerShellQt's layer surface: the size
|
||||
// comes from the compositor, and nothing is drawn before it has sent one.
|
||||
class LockSurface : public QtWaylandClient::QWaylandShellSurface, public QtWayland::ext_session_lock_surface_v1
|
||||
{
|
||||
public:
|
||||
LockSurface(::ext_session_lock_v1 *lock, QtWaylandClient::QWaylandWindow *window)
|
||||
: QWaylandShellSurface(window)
|
||||
{
|
||||
init(ext_session_lock_v1_get_lock_surface(lock, window->waylandSurface()->object(), window->waylandScreen()->output()));
|
||||
}
|
||||
~LockSurface() override
|
||||
{
|
||||
destroy();
|
||||
}
|
||||
bool isExposed() const override
|
||||
{
|
||||
return m_configured;
|
||||
}
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0)
|
||||
// Qt commits a new role at once, as xdg-shell wants. A lock surface must
|
||||
// not be committed before it has acknowledged its first size. Older Qt
|
||||
// cannot be stopped from that (see SessionLock::built).
|
||||
bool commitSurfaceRole() const override
|
||||
{
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
void applyConfigure() override
|
||||
{
|
||||
window()->resizeFromApplyConfigure(m_size);
|
||||
}
|
||||
|
||||
protected:
|
||||
void ext_session_lock_surface_v1_configure(uint32_t serial, uint32_t width, uint32_t height) override
|
||||
{
|
||||
ack_configure(serial);
|
||||
m_size = QSize(int(width), int(height));
|
||||
if (!m_configured) {
|
||||
m_configured = true;
|
||||
applyConfigure();
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0)
|
||||
window()->updateExposure();
|
||||
#else
|
||||
window()->sendRecursiveExposeEvent();
|
||||
#endif
|
||||
} else {
|
||||
window()->applyConfigureWhenPossible();
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
QSize m_size;
|
||||
bool m_configured = false;
|
||||
};
|
||||
|
||||
QtWaylandClient::QWaylandShellSurface *LockIntegration::createShellSurface(QtWaylandClient::QWaylandWindow *window)
|
||||
{
|
||||
return new LockSurface(current, window);
|
||||
}
|
||||
|
||||
class Lock : public QtWayland::ext_session_lock_v1
|
||||
{
|
||||
public:
|
||||
Lock(::ext_session_lock_v1 *object, SessionLock *owner)
|
||||
: QtWayland::ext_session_lock_v1(object)
|
||||
, m_owner(owner)
|
||||
{
|
||||
}
|
||||
|
||||
protected:
|
||||
// Queued: not from inside the Wayland event, which the answer to it might
|
||||
// destroy.
|
||||
void ext_session_lock_v1_locked() override
|
||||
{
|
||||
QMetaObject::invokeMethod(m_owner, &SessionLock::onLocked, Qt::QueuedConnection);
|
||||
}
|
||||
void ext_session_lock_v1_finished() override
|
||||
{
|
||||
QMetaObject::invokeMethod(m_owner, &SessionLock::onFinished, Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
private:
|
||||
SessionLock *m_owner;
|
||||
};
|
||||
|
||||
SessionLock::SessionLock(QQmlEngine *engine, BubbleController *bubble, LockScreenController *screen, UserConfig *config, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_engine(engine)
|
||||
, m_bubble(bubble)
|
||||
, m_screen(screen)
|
||||
, m_config(config)
|
||||
{
|
||||
connect(qGuiApp, &QGuiApplication::screenAdded, this, [this](QScreen *s) {
|
||||
if (m_lock) {
|
||||
addScreen(s);
|
||||
}
|
||||
});
|
||||
connect(qGuiApp, &QGuiApplication::screenRemoved, this, &SessionLock::removeScreen);
|
||||
connect(m_screen, &LockScreenController::authenticated, this, &SessionLock::unlock);
|
||||
}
|
||||
|
||||
SessionLock::~SessionLock()
|
||||
{
|
||||
// Leaving with the lock held keeps the session locked, which is the
|
||||
// point. The windows go, the lock stays.
|
||||
qDeleteAll(m_views);
|
||||
delete m_lock;
|
||||
delete m_integration;
|
||||
}
|
||||
|
||||
bool SessionLock::available()
|
||||
{
|
||||
return built && Wayland::hasGlobal("ext_session_lock_manager_v1");
|
||||
}
|
||||
|
||||
QString SessionLock::markerPath()
|
||||
{
|
||||
return QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation) + QStringLiteral("/face-unlock/locked");
|
||||
}
|
||||
|
||||
void SessionLock::lock()
|
||||
{
|
||||
if (m_lock) {
|
||||
return;
|
||||
}
|
||||
auto *qpa = QtWaylandClient::QWaylandIntegration::instance();
|
||||
if (!m_integration && qpa) {
|
||||
m_integration = new LockIntegration;
|
||||
if (!m_integration->initialize(qpa->display())) {
|
||||
delete m_integration;
|
||||
m_integration = nullptr;
|
||||
}
|
||||
}
|
||||
if (!m_integration) {
|
||||
qWarning("the compositor has no ext_session_lock_manager_v1, so this cannot lock the screen");
|
||||
return;
|
||||
}
|
||||
|
||||
m_confirmed = false;
|
||||
m_unlockWanted = false;
|
||||
m_lock = new Lock(m_integration->lock(), this);
|
||||
m_integration->current = m_lock->object();
|
||||
m_screen->reset();
|
||||
m_pictures = Wallpaper::forLock(m_config->lockWallpaper());
|
||||
m_screen->setBlur(m_config->lockBlur());
|
||||
for (QScreen *s : QGuiApplication::screens()) {
|
||||
addScreen(s);
|
||||
}
|
||||
|
||||
QDir().mkpath(QFileInfo(markerPath()).absolutePath());
|
||||
QFile marker(markerPath());
|
||||
if (!marker.open(QIODevice::WriteOnly)) {
|
||||
qWarning("cannot write %s", qPrintable(markerPath()));
|
||||
}
|
||||
Q_EMIT lockedChanged(true);
|
||||
}
|
||||
|
||||
void SessionLock::addScreen(QScreen *screen)
|
||||
{
|
||||
if (m_views.contains(screen) || !dynamic_cast<QtWaylandClient::QWaylandScreen *>(screen->handle())) {
|
||||
return;
|
||||
}
|
||||
auto *view = new QQuickView(m_engine, nullptr);
|
||||
view->setColor(Qt::black);
|
||||
view->setScreen(screen);
|
||||
view->setResizeMode(QQuickView::SizeRootObjectToView);
|
||||
view->resize(screen->size());
|
||||
view->setInitialProperties({{QStringLiteral("lock"), QVariant::fromValue(m_screen)},
|
||||
{QStringLiteral("bubble"), QVariant::fromValue(m_bubble)},
|
||||
{QStringLiteral("wallpaper"), Wallpaper::pick(m_pictures, screen->name())},
|
||||
{QStringLiteral("primary"), screen == QGuiApplication::primaryScreen()}});
|
||||
view->loadFromModule(QStringLiteral("FaceUnlock"), QStringLiteral("LockScreen"));
|
||||
for (const QQmlError &e : view->errors()) {
|
||||
qWarning("%s", qPrintable(e.toString()));
|
||||
}
|
||||
view->create();
|
||||
if (auto *wayland = dynamic_cast<QtWaylandClient::QWaylandWindow *>(view->handle())) {
|
||||
wayland->setShellIntegration(m_integration);
|
||||
}
|
||||
view->show();
|
||||
view->requestActivate();
|
||||
m_views.insert(screen, view);
|
||||
}
|
||||
|
||||
void SessionLock::removeScreen(QScreen *screen)
|
||||
{
|
||||
if (auto view = m_views.take(screen)) {
|
||||
delete view;
|
||||
}
|
||||
}
|
||||
|
||||
void SessionLock::onLocked()
|
||||
{
|
||||
if (!m_lock) {
|
||||
return;
|
||||
}
|
||||
m_confirmed = true;
|
||||
Q_EMIT confirmed();
|
||||
if (m_unlockWanted) {
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
|
||||
void SessionLock::onFinished()
|
||||
{
|
||||
if (!m_lock) {
|
||||
return;
|
||||
}
|
||||
// Refused (another lock screen is up) or ended by the compositor.
|
||||
if (m_confirmed) {
|
||||
m_lock->unlock_and_destroy();
|
||||
} else {
|
||||
qWarning("the compositor did not let this lock the screen; is another lock screen running?");
|
||||
m_lock->destroy();
|
||||
}
|
||||
release();
|
||||
}
|
||||
|
||||
void SessionLock::unlock()
|
||||
{
|
||||
if (!m_lock) {
|
||||
return;
|
||||
}
|
||||
// Unlocking before the compositor has confirmed the lock is a protocol
|
||||
// error. It follows as soon as it has.
|
||||
if (!m_confirmed) {
|
||||
m_unlockWanted = true;
|
||||
return;
|
||||
}
|
||||
m_lock->unlock_and_destroy();
|
||||
release();
|
||||
}
|
||||
|
||||
void SessionLock::release()
|
||||
{
|
||||
qDeleteAll(m_views);
|
||||
m_views.clear();
|
||||
delete m_lock;
|
||||
m_lock = nullptr;
|
||||
m_confirmed = false;
|
||||
m_integration->current = nullptr;
|
||||
QFile::remove(markerPath());
|
||||
// Out to the compositor now: an agent started only for this lock quits
|
||||
// straight after.
|
||||
Wayland::sync();
|
||||
m_screen->reset();
|
||||
Q_EMIT lockedChanged(false);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// face-unlock's own lock screen, for whoever wants it on a compositor whose
|
||||
// lock screen is a program of its own (ext-session-lock: Hyprland, Niri,
|
||||
// Sway and others): `face-unlock lock`.
|
||||
//
|
||||
// Any other lock screen there covers the bubble, and only opens itself (it
|
||||
// gets the face through PAM, see src/lib/pam.sh). This one is the lock
|
||||
// screen, so the bubble shows on it and a face opens it straight away. The
|
||||
// picture behind it is the user's choice (LockWallpaper).
|
||||
//
|
||||
// Qt knows no ext-session-lock, so each screen's window gets the lock surface
|
||||
// role through Qt's shell integration, the way LayerShellQt gives windows the
|
||||
// layer-shell role. If this program goes away while locked, the compositor
|
||||
// keeps the session locked: that is the protocol's promise.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QHash>
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QUrl>
|
||||
#include <QtGlobal>
|
||||
|
||||
class BubbleController;
|
||||
class LockScreenController;
|
||||
class UserConfig;
|
||||
class QQmlEngine;
|
||||
class QQuickView;
|
||||
class QScreen;
|
||||
class LockIntegration;
|
||||
class Lock;
|
||||
|
||||
class SessionLock : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
SessionLock(QQmlEngine *engine, BubbleController *bubble, LockScreenController *screen, UserConfig *config, QObject *parent = nullptr);
|
||||
~SessionLock() override;
|
||||
|
||||
// Whether this build has it. Qt before 6.10 commits a window's surface
|
||||
// before a lock surface may be committed, which the protocol forbids.
|
||||
static constexpr bool built = QT_VERSION >= QT_VERSION_CHECK(6, 10, 0);
|
||||
|
||||
// Whether it can lock here: built, and the compositor offers
|
||||
// ext-session-lock.
|
||||
static bool available();
|
||||
|
||||
// From the lock request until the lock is gone again.
|
||||
bool isLocked() const
|
||||
{
|
||||
return m_lock != nullptr;
|
||||
}
|
||||
// The compositor has confirmed the lock: nothing unlocked is on screen.
|
||||
bool isConfirmed() const
|
||||
{
|
||||
return m_confirmed;
|
||||
}
|
||||
|
||||
// A file that says the screen is locked, so that an agent started again
|
||||
// after a crash locks again (see main.cpp).
|
||||
static QString markerPath();
|
||||
|
||||
public Q_SLOTS:
|
||||
void lock();
|
||||
void unlock();
|
||||
|
||||
Q_SIGNALS:
|
||||
void lockedChanged(bool locked);
|
||||
void confirmed();
|
||||
|
||||
private:
|
||||
friend class Lock;
|
||||
void onLocked();
|
||||
void onFinished();
|
||||
void addScreen(QScreen *screen);
|
||||
void removeScreen(QScreen *screen);
|
||||
void release();
|
||||
|
||||
QQmlEngine *m_engine;
|
||||
BubbleController *m_bubble;
|
||||
LockScreenController *m_screen;
|
||||
UserConfig *m_config;
|
||||
LockIntegration *m_integration = nullptr;
|
||||
Lock *m_lock = nullptr;
|
||||
bool m_confirmed = false;
|
||||
bool m_unlockWanted = false;
|
||||
QHash<QScreen *, QPointer<QQuickView>> m_views;
|
||||
// The pictures behind it, by output (see Wallpaper).
|
||||
QHash<QString, QUrl> m_pictures;
|
||||
};
|
||||
@@ -23,12 +23,16 @@ UserConfig::UserConfig(QObject *parent)
|
||||
|
||||
QString UserConfig::path()
|
||||
{
|
||||
return QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation) + QStringLiteral("/plasma-face-unlock/config");
|
||||
return QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation) + QStringLiteral("/face-unlock/config");
|
||||
}
|
||||
|
||||
void UserConfig::load()
|
||||
{
|
||||
const KeyValueFile kv = KeyValueFile::load(path());
|
||||
m_enabled = kv.boolean(QStringLiteral("Enabled"), false);
|
||||
m_lockWallpaper = kv.value(QStringLiteral("LockWallpaper"));
|
||||
m_lockBlur = kv.boolean(QStringLiteral("LockBlur"), false);
|
||||
m_lockScreenStyle = kv.value(QStringLiteral("LockScreenStyle"));
|
||||
m_lockScreen = kv.boolean(QStringLiteral("LockScreen"), true);
|
||||
m_scanOnWake = kv.boolean(QStringLiteral("ScanOnWake"), true);
|
||||
m_scanOnLock = kv.boolean(QStringLiteral("ScanOnLock"), false);
|
||||
|
||||
+29
-1
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// ~/.config/plasma-face-unlock/config: what this user wants from the agent.
|
||||
// ~/.config/face-unlock/config: what this user wants from the agent.
|
||||
// Written by the menu, read here, and read again whenever it changes.
|
||||
|
||||
#pragma once
|
||||
@@ -17,6 +17,30 @@ class UserConfig : public QObject
|
||||
public:
|
||||
explicit UserConfig(QObject *parent = nullptr);
|
||||
|
||||
// Face unlock turned on at all. The agent's service only runs when it is,
|
||||
// but the own lock screen (--lock) runs either way.
|
||||
bool enabled() const
|
||||
{
|
||||
return m_enabled;
|
||||
}
|
||||
// The picture behind the own lock screen (see Wallpaper): empty for the
|
||||
// desktop's, "none", a file, or a folder to take one from at random.
|
||||
QString lockWallpaper() const
|
||||
{
|
||||
return m_lockWallpaper;
|
||||
}
|
||||
// Where the lock screen is a program of the user's (Hyprland, Niri):
|
||||
// "own" to lock with face-unlock's, "yours" to keep that program, which
|
||||
// then shows the bubble as a line of text (see LockText). Empty until
|
||||
// the user picked one.
|
||||
QString lockScreenStyle() const
|
||||
{
|
||||
return m_lockScreenStyle;
|
||||
}
|
||||
bool lockBlur() const
|
||||
{
|
||||
return m_lockBlur;
|
||||
}
|
||||
// Unlock the lock screen by face.
|
||||
bool lockScreen() const
|
||||
{
|
||||
@@ -72,6 +96,10 @@ private:
|
||||
void load();
|
||||
|
||||
QFileSystemWatcher m_watcher;
|
||||
bool m_enabled = false;
|
||||
QString m_lockWallpaper;
|
||||
QString m_lockScreenStyle;
|
||||
bool m_lockBlur = false;
|
||||
bool m_lockScreen = true;
|
||||
bool m_scanOnWake = true;
|
||||
bool m_scanOnLock = false;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "wallpaper.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QProcess>
|
||||
#include <QRandomGenerator>
|
||||
#include <QRegularExpression>
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
// A picture, or one at random from a folder.
|
||||
QUrl picture(const QString &setting)
|
||||
{
|
||||
QString path = setting;
|
||||
if (path.startsWith(u"~/")) {
|
||||
path = QDir::homePath() + path.mid(1);
|
||||
}
|
||||
const QFileInfo info(path);
|
||||
if (setting.isEmpty() || !info.exists()) {
|
||||
return {};
|
||||
}
|
||||
if (info.isFile()) {
|
||||
return QUrl::fromLocalFile(info.absoluteFilePath());
|
||||
}
|
||||
const QStringList pictures = QDir(path).entryList({QStringLiteral("*.jpg"), QStringLiteral("*.jpeg"), QStringLiteral("*.png"), QStringLiteral("*.webp")},
|
||||
QDir::Files | QDir::Readable);
|
||||
if (pictures.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
return QUrl::fromLocalFile(QDir(path).absoluteFilePath(pictures.at(QRandomGenerator::global()->bounded(int(pictures.size())))));
|
||||
}
|
||||
|
||||
// What a command prints, or nothing when it is not there or hangs.
|
||||
QString run(const QString &program, const QStringList &args)
|
||||
{
|
||||
QProcess process;
|
||||
process.start(program, args);
|
||||
if (!process.waitForFinished(1000) || process.exitStatus() != QProcess::NormalExit || process.exitCode() != 0) {
|
||||
process.kill();
|
||||
process.waitForFinished(100);
|
||||
return {};
|
||||
}
|
||||
return QString::fromLocal8Bit(process.readAllStandardOutput());
|
||||
}
|
||||
|
||||
// The pictures the running wallpaper programs of this user show.
|
||||
QHash<QString, QString> desktop()
|
||||
{
|
||||
QHash<QString, QString> found;
|
||||
const uint me = getuid();
|
||||
const QStringList pids = QDir(QStringLiteral("/proc")).entryList({QStringLiteral("[0-9]*")}, QDir::Dirs);
|
||||
for (const QString &pid : pids) {
|
||||
const QString dir = QStringLiteral("/proc/") + pid;
|
||||
if (QFileInfo(dir).ownerId() != me) {
|
||||
continue;
|
||||
}
|
||||
QFile comm(dir + QStringLiteral("/comm"));
|
||||
if (!comm.open(QIODevice::ReadOnly)) {
|
||||
continue;
|
||||
}
|
||||
const QByteArray name = comm.readAll().trimmed();
|
||||
if (name == "swaybg") {
|
||||
QFile cmdline(dir + QStringLiteral("/cmdline"));
|
||||
if (cmdline.open(QIODevice::ReadOnly)) {
|
||||
QStringList args;
|
||||
for (const QByteArray &arg : cmdline.readAll().split('\0')) {
|
||||
args << QString::fromLocal8Bit(arg);
|
||||
}
|
||||
// Relative to where it was started.
|
||||
const QDir cwd(QFileInfo(dir + QStringLiteral("/cwd")).symLinkTarget());
|
||||
QHash<QString, QString> shown = Wallpaper::fromSwaybg(args.mid(1));
|
||||
for (QString &path : shown) {
|
||||
path = cwd.absoluteFilePath(path);
|
||||
}
|
||||
found.insert(shown);
|
||||
}
|
||||
} else if (name == "awww-daemon" || name == "swww-daemon") {
|
||||
found.insert(Wallpaper::fromAwww(run(QString::fromLatin1(name.left(4)), {QStringLiteral("query")})));
|
||||
} else if (name == "hyprpaper") {
|
||||
found.insert(Wallpaper::fromList(run(QStringLiteral("hyprctl"), {QStringLiteral("hyprpaper"), QStringLiteral("listactive")})));
|
||||
} else if (name == "wpaperd") {
|
||||
found.insert(Wallpaper::fromList(run(QStringLiteral("wpaperctl"), {QStringLiteral("all-wallpapers")})));
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
QHash<QString, QString> Wallpaper::fromSwaybg(const QStringList &args)
|
||||
{
|
||||
QHash<QString, QString> found;
|
||||
QString output;
|
||||
for (int i = 0; i < args.size(); ++i) {
|
||||
const QString &arg = args.at(i);
|
||||
const bool hasNext = i + 1 < args.size();
|
||||
if ((arg == u"-o" || arg == u"--output") && hasNext) {
|
||||
output = args.at(++i);
|
||||
} else if (arg.startsWith(u"--output=")) {
|
||||
output = arg.mid(9);
|
||||
} else if ((arg == u"-i" || arg == u"--image") && hasNext) {
|
||||
found.insert(output == u"*" ? QString() : output, args.at(++i));
|
||||
} else if (arg.startsWith(u"--image=")) {
|
||||
found.insert(output == u"*" ? QString() : output, arg.mid(8));
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
QHash<QString, QString> Wallpaper::fromAwww(const QString &text)
|
||||
{
|
||||
// "eDP-1: 1920x1080, scale: 1, currently displaying: image: /a/b.jpg",
|
||||
// with a namespace in front on awww. A plain colour is no picture.
|
||||
static const QRegularExpression line(QStringLiteral("([^\\s:]+): \\d+x\\d+,.*currently displaying: image: (.+)$"), QRegularExpression::MultilineOption);
|
||||
QHash<QString, QString> found;
|
||||
auto it = line.globalMatch(text);
|
||||
while (it.hasNext()) {
|
||||
const auto match = it.next();
|
||||
found.insert(match.captured(1), match.captured(2).trimmed());
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
QHash<QString, QString> Wallpaper::fromList(const QString &text)
|
||||
{
|
||||
// "eDP-1: /a/b.jpg", on older hyprpaper "eDP-1 = /a/b.jpg".
|
||||
static const QRegularExpression line(QStringLiteral("^([^\\s:=]+)(?:: | = )(/.+)$"), QRegularExpression::MultilineOption);
|
||||
QHash<QString, QString> found;
|
||||
auto it = line.globalMatch(text);
|
||||
while (it.hasNext()) {
|
||||
const auto match = it.next();
|
||||
found.insert(match.captured(1), match.captured(2).trimmed());
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
QHash<QString, QUrl> Wallpaper::forLock(const QString &setting)
|
||||
{
|
||||
QHash<QString, QUrl> pictures;
|
||||
if (setting.compare(u"none", Qt::CaseInsensitive) == 0) {
|
||||
return pictures;
|
||||
}
|
||||
if (!setting.isEmpty()) {
|
||||
const QUrl url = picture(setting);
|
||||
if (!url.isEmpty()) {
|
||||
pictures.insert(QString(), url);
|
||||
}
|
||||
return pictures;
|
||||
}
|
||||
const QHash<QString, QString> shown = desktop();
|
||||
for (auto it = shown.cbegin(); it != shown.cend(); ++it) {
|
||||
const QUrl url = picture(it.value());
|
||||
if (!url.isEmpty()) {
|
||||
pictures.insert(it.key(), url);
|
||||
}
|
||||
}
|
||||
return pictures;
|
||||
}
|
||||
|
||||
QUrl Wallpaper::pick(const QHash<QString, QUrl> &pictures, const QString &output)
|
||||
{
|
||||
if (pictures.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
if (pictures.contains(output)) {
|
||||
return pictures.value(output);
|
||||
}
|
||||
if (pictures.contains(QString())) {
|
||||
return pictures.value(QString());
|
||||
}
|
||||
// Named differently than here: some picture of the desktop is still
|
||||
// better than none.
|
||||
QStringList outputs = pictures.keys();
|
||||
outputs.sort();
|
||||
return pictures.value(outputs.first());
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// The picture behind face-unlock's own lock screen. By default the one on
|
||||
// the desktop. Hyprland, niri and the like leave the wallpaper to a program
|
||||
// of its own, so it comes from whichever of the common ones runs: swaybg,
|
||||
// awww (once swww), hyprpaper or wpaperd, each for every output.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QHash>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
|
||||
namespace Wallpaper
|
||||
{
|
||||
// The pictures for a lock by output name, "" for every other output.
|
||||
// setting is LockWallpaper: empty for the desktop's, "none", a picture, or a
|
||||
// folder to take one from at random.
|
||||
QHash<QString, QUrl> forLock(const QString &setting);
|
||||
// The one for this output.
|
||||
QUrl pick(const QHash<QString, QUrl> &pictures, const QString &output);
|
||||
|
||||
// What the programs tell, by output name. Apart for the tests.
|
||||
QHash<QString, QString> fromSwaybg(const QStringList &args);
|
||||
// `awww query` and `swww query`.
|
||||
QHash<QString, QString> fromAwww(const QString &text);
|
||||
// `hyprctl hyprpaper listactive` and `wpaperctl all-wallpapers`.
|
||||
QHash<QString, QString> fromList(const QString &text);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "wayland.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QGuiApplication>
|
||||
#include <QSet>
|
||||
|
||||
#include <wayland-client.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
void onGlobal(void *data, wl_registry *, uint32_t, const char *interface, uint32_t)
|
||||
{
|
||||
static_cast<QSet<QByteArray> *>(data)->insert(QByteArray(interface));
|
||||
}
|
||||
|
||||
void onGlobalRemove(void *, wl_registry *, uint32_t)
|
||||
{
|
||||
}
|
||||
|
||||
const wl_registry_listener listener = {onGlobal, onGlobalRemove};
|
||||
|
||||
wl_display *display()
|
||||
{
|
||||
auto *app = qGuiApp ? qGuiApp->nativeInterface<QNativeInterface::QWaylandApplication>() : nullptr;
|
||||
return app ? app->display() : nullptr;
|
||||
}
|
||||
|
||||
const QSet<QByteArray> &globals()
|
||||
{
|
||||
static QSet<QByteArray> names;
|
||||
static bool asked = false;
|
||||
if (asked) {
|
||||
return names;
|
||||
}
|
||||
asked = true;
|
||||
|
||||
wl_display *d = display();
|
||||
if (!d) {
|
||||
return names;
|
||||
}
|
||||
wl_event_queue *queue = wl_display_create_queue(d);
|
||||
auto *wrapped = static_cast<wl_display *>(wl_proxy_create_wrapper(d));
|
||||
wl_proxy_set_queue(reinterpret_cast<wl_proxy *>(wrapped), queue);
|
||||
wl_registry *registry = wl_display_get_registry(wrapped);
|
||||
wl_registry_add_listener(registry, &listener, &names);
|
||||
wl_display_roundtrip_queue(d, queue);
|
||||
wl_registry_destroy(registry);
|
||||
wl_proxy_wrapper_destroy(wrapped);
|
||||
wl_event_queue_destroy(queue);
|
||||
return names;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool Wayland::hasGlobal(const char *interface)
|
||||
{
|
||||
return globals().contains(QByteArray(interface));
|
||||
}
|
||||
|
||||
void Wayland::sync()
|
||||
{
|
||||
// On a queue of its own, like above: Qt reads the default one.
|
||||
if (wl_display *d = display()) {
|
||||
wl_event_queue *queue = wl_display_create_queue(d);
|
||||
wl_display_roundtrip_queue(d, queue);
|
||||
wl_event_queue_destroy(queue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// What the compositor offers. Not every desktop has everything: GNOME has no
|
||||
// layer-shell (so no bubble) and no ext-idle-notify, and only compositors
|
||||
// whose lock screen is a program of its own have ext-session-lock.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace Wayland
|
||||
{
|
||||
// Whether the compositor announces this interface, for example
|
||||
// "zwlr_layer_shell_v1". Asked once, on an event queue of its own, so Qt's
|
||||
// own handling of the connection is not disturbed.
|
||||
bool hasGlobal(const char *interface);
|
||||
|
||||
// Wait until the compositor has handled everything sent so far.
|
||||
void sync();
|
||||
} // namespace Wayland
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// The system settings, /etc/plasma-face-unlock/config.
|
||||
// The system settings, /etc/face-unlock/config.
|
||||
//
|
||||
// These are the ones that decide how hard it is to get in: which camera, how
|
||||
// strict the match is, whether a photo is checked for. They belong to root
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// The face data.
|
||||
//
|
||||
// One file per user under /var/lib/plasma-face-unlock/users, named after the
|
||||
// One file per user under /var/lib/face-unlock/users, named after the
|
||||
// numeric user id so a rename cannot hand one person's faces to another. Only
|
||||
// root can read or write the directory. There are no pictures in it: every
|
||||
// sample is the 128 numbers the recognizer made of one frame, and the frame
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// plasma-face-unlock-ctl: the shell code's way to the daemon.
|
||||
// face-unlock-ctl: the shell code's way to the daemon.
|
||||
//
|
||||
// bash has no Unix sockets, so this sends one request and prints every
|
||||
// message that comes back as one line of tab separated key=value pairs:
|
||||
@@ -68,7 +68,7 @@ void printObject(const QJsonObject &o, const QString &event)
|
||||
int usage()
|
||||
{
|
||||
std::fprintf(stderr,
|
||||
"usage: plasma-face-unlock-ctl [--socket PATH] COMMAND\n"
|
||||
"usage: face-unlock-ctl [--socket PATH] COMMAND\n"
|
||||
" hello | status | cameras | list | watch | unlocked\n"
|
||||
" verify [USER] [PURPOSE] | test\n"
|
||||
" remove ID | rename ID NAME | enable ID | disable ID | clear\n");
|
||||
@@ -81,12 +81,12 @@ int main(int argc, char **argv)
|
||||
QCoreApplication app(argc, argv);
|
||||
QStringList args = app.arguments().mid(1);
|
||||
|
||||
QString socketPath = QStringLiteral(PFU_SOCKET);
|
||||
QString socketPath = QStringLiteral(FU_SOCKET);
|
||||
if (args.size() >= 2 && args.first() == u"--socket") {
|
||||
socketPath = args.at(1);
|
||||
args = args.mid(2);
|
||||
}
|
||||
if (const QByteArray env = qgetenv("PFU_SOCKET"); !env.isEmpty()) {
|
||||
if (const QByteArray env = qgetenv("FU_SOCKET"); !env.isEmpty()) {
|
||||
socketPath = QString::fromLocal8Bit(env);
|
||||
}
|
||||
if (args.isEmpty()) {
|
||||
|
||||
@@ -25,7 +25,7 @@ AgentLink::AgentLink(uid_t uid, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_uid(uid)
|
||||
{
|
||||
const QString dir = QStringLiteral("/run/user/%1/plasma-face-unlock").arg(uid);
|
||||
const QString dir = QStringLiteral("/run/user/%1/face-unlock").arg(uid);
|
||||
const QString path = dir + QStringLiteral("/agent.socket");
|
||||
if (!ownedBy(dir, uid, false) || !ownedBy(path, uid, true)) {
|
||||
// No agent in that session, or not one of this user's making.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// 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
|
||||
// The agent listens on /run/user/UID/face-unlock/agent.socket. That is
|
||||
// a place the user controls, so nothing is taken for granted: the socket has
|
||||
// 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
|
||||
|
||||
+8
-8
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// plasma-face-unlockd: the part that runs as root.
|
||||
// face-unlockd: the part that runs as root.
|
||||
//
|
||||
// It owns the camera and the face data, and it is the only thing that does.
|
||||
// Everything else (the lock screen agent, sudo, the setup window, the menu)
|
||||
@@ -66,21 +66,21 @@ int main(int argc, char **argv)
|
||||
umask(077);
|
||||
|
||||
QCoreApplication app(argc, argv);
|
||||
app.setApplicationName(QStringLiteral("plasma-face-unlockd"));
|
||||
app.setApplicationVersion(QStringLiteral(PFU_VERSION));
|
||||
app.setApplicationName(QStringLiteral("face-unlockd"));
|
||||
app.setApplicationVersion(QStringLiteral(FU_VERSION));
|
||||
qSetMessagePattern(QStringLiteral("%{if-warning}warning: %{endif}%{if-critical}error: %{endif}%{message}"));
|
||||
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription(QStringLiteral("Face unlock daemon for KDE Plasma"));
|
||||
parser.setApplicationDescription(QStringLiteral("Face unlock daemon"));
|
||||
parser.addHelpOption();
|
||||
parser.addVersionOption();
|
||||
const QCommandLineOption socketOpt(QStringLiteral("socket"), QStringLiteral("Listen here instead of the system socket."), QStringLiteral("path"),
|
||||
QStringLiteral(PFU_SOCKET));
|
||||
QStringLiteral(FU_SOCKET));
|
||||
const QCommandLineOption stateOpt(QStringLiteral("state-dir"), QStringLiteral("Where face data is kept."), QStringLiteral("dir"),
|
||||
QStringLiteral(PFU_STATEDIR));
|
||||
const QCommandLineOption configOpt(QStringLiteral("config"), QStringLiteral("The settings file."), QStringLiteral("file"), QStringLiteral(PFU_CONFIG));
|
||||
QStringLiteral(FU_STATEDIR));
|
||||
const QCommandLineOption configOpt(QStringLiteral("config"), QStringLiteral("The settings file."), QStringLiteral("file"), QStringLiteral(FU_CONFIG));
|
||||
const QCommandLineOption modelOpt(QStringLiteral("models"), QStringLiteral("Where the networks are."), QStringLiteral("dir"),
|
||||
QStringLiteral(PFU_MODELDIR));
|
||||
QStringLiteral(FU_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);
|
||||
|
||||
+4
-4
@@ -4,9 +4,9 @@
|
||||
//
|
||||
// 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.
|
||||
// goes to the polkit agent of the person's own session (the desktop's usual
|
||||
// password dialog), which is why the daemon never sees the password.
|
||||
// Hyprland and Niri have none of their own: one has to be running there.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
namespace Polkit
|
||||
{
|
||||
inline constexpr char ManageAction[] = "io.github.loonixtools.plasma-face-unlock.manage";
|
||||
inline constexpr char ManageAction[] = "io.github.loonixtools.face-unlock.manage";
|
||||
|
||||
// Calls done(true) when the process may go ahead. Interactive: this can take
|
||||
// as long as it takes somebody to type a password.
|
||||
|
||||
+12
-3
@@ -282,7 +282,7 @@ void Server::onRequest(Client *client, const QJsonObject &request)
|
||||
client->role = cmd;
|
||||
|
||||
if (cmd == u"hello") {
|
||||
reply(client, {{QStringLiteral("event"), QStringLiteral("result")}, {QStringLiteral("ok"), true}, {QStringLiteral("version"), QStringLiteral(PFU_VERSION)}});
|
||||
reply(client, {{QStringLiteral("event"), QStringLiteral("result")}, {QStringLiteral("ok"), true}, {QStringLiteral("version"), QStringLiteral(FU_VERSION)}});
|
||||
} else if (cmd == u"status") {
|
||||
handleStatus(client);
|
||||
} else if (cmd == u"cameras") {
|
||||
@@ -344,7 +344,7 @@ void Server::handleStatus(Client *client)
|
||||
reply(client,
|
||||
{{QStringLiteral("event"), QStringLiteral("result")},
|
||||
{QStringLiteral("ok"), true},
|
||||
{QStringLiteral("version"), QStringLiteral(PFU_VERSION)},
|
||||
{QStringLiteral("version"), QStringLiteral(FU_VERSION)},
|
||||
{QStringLiteral("user"), System::nameOf(uid)},
|
||||
{QStringLiteral("faces"), enabled},
|
||||
{QStringLiteral("identities"), int(faces.size())},
|
||||
@@ -468,7 +468,7 @@ void Server::handleVerify(Client *client, const QJsonObject &request)
|
||||
}
|
||||
|
||||
QString purpose = request.value(u"purpose").toString();
|
||||
static const QStringList purposes = {QStringLiteral("unlock"), QStringLiteral("sudo"), QStringLiteral("polkit"), QStringLiteral("test")};
|
||||
static const QStringList purposes = {QStringLiteral("unlock"), QStringLiteral("sudo"), QStringLiteral("polkit"), QStringLiteral("lockscreen"), QStringLiteral("test")};
|
||||
if (!purposes.contains(purpose)) {
|
||||
purpose = QStringLiteral("other");
|
||||
}
|
||||
@@ -579,6 +579,15 @@ void Server::onJobDone()
|
||||
state.lastUnlock = now;
|
||||
state.lastPurpose = scan->purpose();
|
||||
|
||||
// A lock screen that asks for the face through PAM only asks
|
||||
// again after a password, so Enter on the empty field is how a
|
||||
// scan starts there, and pam_faillock counts it as a wrong
|
||||
// password. The face was right: taken back, as a correct
|
||||
// password would.
|
||||
if (geteuid() == 0 && scan->purpose() == u"lockscreen") {
|
||||
System::resetFailedLogins(scan->uid());
|
||||
}
|
||||
|
||||
// Learn from a confident match, the way Face ID keeps up with a
|
||||
// beard growing in. Only well clear of the threshold, so the
|
||||
// samples cannot creep towards somebody else one borderline
|
||||
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# face-unlock: face unlock for Plasma, GNOME, Hyprland and Niri
|
||||
#
|
||||
# Look at the screen and it unlocks, the way a phone does. The lock screen,
|
||||
# sudo in a terminal and the admin password prompts can all take a face
|
||||
# instead of a password, with a check that it is a face and not a photo of one.
|
||||
#
|
||||
# This is the front end: the menu and the commands. The camera, the face data
|
||||
# and the decision are the daemon's (face-unlockd, running as root),
|
||||
# the lock screen and the bubble at the top of the screen are the agent's
|
||||
# (face-unlock-agent, in the session), and sudo and polkit reach the
|
||||
# daemon through a PAM module. See the man page for how the pieces fit.
|
||||
#
|
||||
# Copyright (C) 2026 Felitendo
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
FU_SELF="$(readlink -f "${BASH_SOURCE[0]}")"
|
||||
|
||||
# Run as root (through sudo from the menu), only what was installed counts.
|
||||
# An environment that points the libraries somewhere else is ignored then.
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
unset FU_LIBDIR FU_LIBEXECDIR FU_LOCALEDIR FU_PAMDIR FU_CTL FU_AGENT FU_PAM_MODULE FU_SYSCONFIG \
|
||||
FU_PAM_ETC_DIR FU_PAM_VENDOR_DIRS FU_OLD_SYSDIR FU_OLD_STATEDIR FU_STATEDIR FU_SYSTEMD_ETC
|
||||
fi
|
||||
|
||||
FU_LIBDIR="${FU_LIBDIR:-@LIBDIR@}"
|
||||
|
||||
for _mod in common config daemon pam system migrate menu; do
|
||||
# shellcheck source=/dev/null
|
||||
if ! source "$FU_LIBDIR/$_mod.sh"; then
|
||||
printf 'face-unlock: cannot load %s/%s.sh\n' "$FU_LIBDIR" "$_mod" >&2
|
||||
exit 14
|
||||
fi
|
||||
done
|
||||
unset _mod
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# The daemon is started by its socket. Enabling the socket is the one thing
|
||||
# that needs root once per machine.
|
||||
fu_ensure_service() {
|
||||
fu_status_load && return 0
|
||||
|
||||
if ! fu_socket_enabled; then
|
||||
fu_say " $(fu_msg "Face unlock's service has to be switched on once for this computer. That needs your password.")"
|
||||
fu_root socket-enable || return 1
|
||||
sleep 0.3
|
||||
fi
|
||||
fu_status_load && return 0
|
||||
|
||||
fu_bad "$(fu_reason_text unreachable)"
|
||||
fu_note "$(fu_msg "Check it with: systemctl status %s" "$FU_UNIT_SOCKET")"
|
||||
return 1
|
||||
}
|
||||
|
||||
fu_do_setup() {
|
||||
local name="${1:-}" rc
|
||||
|
||||
fu_ensure_service || return 1
|
||||
|
||||
if [[ -z ${WAYLAND_DISPLAY:-} && -z ${DISPLAY:-} ]]; then
|
||||
fu_bad "$(fu_msg "Setting up a face needs the camera picture on screen. Run this inside your desktop session.")"
|
||||
return 1
|
||||
fi
|
||||
|
||||
fu_say " $(fu_msg "The setup window is open. Follow it there.")"
|
||||
if [[ -n $name ]]; then
|
||||
"$FU_AGENT" --enroll --name "$name" > /dev/null 2>&1
|
||||
else
|
||||
"$FU_AGENT" --enroll > /dev/null 2>&1
|
||||
fi
|
||||
rc=$?
|
||||
|
||||
if (( rc == 0 )); then
|
||||
fu_ok "$(fu_msg "The face is set up.")"
|
||||
fu_config_load
|
||||
if [[ $CFG_ENABLED != yes ]]; then
|
||||
fu_note "$(fu_msg "Face unlock is still off. Turn it on with [1] or \`%s enable\`." "$FU_NAME")"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
fu_note "$(fu_msg "No face was added.")"
|
||||
return 1
|
||||
}
|
||||
|
||||
fu_do_enable() {
|
||||
fu_ensure_service || return 1
|
||||
|
||||
fu_faces_load
|
||||
if (( ${#FU_FACE_IDS[@]} == 0 )); then
|
||||
fu_say " $(fu_msg "First, set up your face.")"
|
||||
fu_do_setup || return 1
|
||||
fi
|
||||
|
||||
fu_config_set Enabled yes || { fu_bad "$(fu_msg "Could not save the setting.")"; return 1; }
|
||||
fu_config_load
|
||||
|
||||
if fu_agent_available; then
|
||||
fu_agent_enable || fu_bad "$(fu_msg "Could not start the lock screen agent.")"
|
||||
fu_agent_autostarts || fu_agent_hint
|
||||
else
|
||||
fu_note "$(fu_msg "No systemd user session, so the lock screen agent was not started.")"
|
||||
fi
|
||||
|
||||
# What was on the last time face unlock was on.
|
||||
[[ $CFG_SUDO == yes ]] && ! fu_pam_enabled sudo && fu_root pam-enable sudo
|
||||
[[ $CFG_POLKIT == yes ]] && ! fu_pam_enabled polkit-1 && fu_root pam-enable polkit-1
|
||||
# The lock screens of Hyprland, Niri and the like only open themselves:
|
||||
# the face goes into their password check. On unless turned off.
|
||||
if fu_pam_lockers_here && [[ $CFG_LOCKERS == yes ]] && ! fu_pam_lockers_enabled; then
|
||||
fu_root pam-enable lockscreens
|
||||
fi
|
||||
|
||||
case "$FU_DESKTOP" in
|
||||
gnome)
|
||||
fu_gnome_extension_enable
|
||||
case $? in
|
||||
1) fu_bad "$(fu_msg "Could not switch on the GNOME extension that shows the bubble.")" ;;
|
||||
2) fu_note "$(fu_msg "Log out and back in once: then GNOME shows the bubble too.")" ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
# Where the lock screen is a program of its own: face-unlock's, with the
|
||||
# bubble, or that program with a line of text. Asked once. Without
|
||||
# face-unlock's own (older Qt) there is nothing to ask.
|
||||
if [[ $FU_DESKTOP != plasma && $FU_DESKTOP != gnome && -z $(fu_config_get LockScreenStyle) ]]; then
|
||||
if ! fu_own_lock_here; then
|
||||
fu_lock_style_set yours
|
||||
elif [[ -n ${WAYLAND_DISPLAY:-} ]]; then
|
||||
fu_say " $(fu_msg "Pick your lock screen in the window.")"
|
||||
fu_lock_choose || fu_note "$(fu_msg "No lock screen picked. You can do it later under Settings.")"
|
||||
fi
|
||||
fi
|
||||
if fu_pam_lockers_here && fu_pam_lockers_enabled && [[ $(fu_config_get LockScreenStyle) != own ]]; then
|
||||
fu_note "$(fu_msg "The lock screen (%s) takes your face too. If it does not scan when you come back, press Enter on the empty password field." "$(fu_pam_lockers_list)")"
|
||||
fi
|
||||
|
||||
fu_ok "$(fu_msg "Face unlock is on. Lock the screen and look at it to try.")"
|
||||
if [[ $CFG_SUDO != yes && $CFG_POLKIT != yes ]]; then
|
||||
fu_note "$(fu_msg "It can do sudo and admin prompts too. See Settings.")"
|
||||
fi
|
||||
}
|
||||
|
||||
fu_do_disable() {
|
||||
fu_config_set Enabled no || { fu_bad "$(fu_msg "Could not save the setting.")"; return 1; }
|
||||
fu_agent_available && fu_agent_disable
|
||||
[[ $FU_DESKTOP == gnome ]] && fu_gnome_extension_disable
|
||||
|
||||
local service
|
||||
for service in "${FU_PAM_SERVICES[@]}" "${FU_PAM_LOCKERS[@]}"; do
|
||||
if fu_pam_enabled "$service"; then
|
||||
fu_root pam-disable "$service" || true
|
||||
fi
|
||||
done
|
||||
|
||||
fu_ok "$(fu_msg "Face unlock is off. Your faces are kept; delete them under Faces.")"
|
||||
}
|
||||
|
||||
fu_do_status() {
|
||||
fu_head " $FU_PRETTY"
|
||||
fu_ui_status
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
fu_do_faces() {
|
||||
local i state
|
||||
fu_status_load || { fu_bad "$(fu_reason_text unreachable)"; return 1; }
|
||||
fu_faces_load
|
||||
if (( ${#FU_FACE_IDS[@]} == 0 )); then
|
||||
fu_note "$(fu_msg "No face is set up yet.")"
|
||||
return 0
|
||||
fi
|
||||
for i in "${!FU_FACE_IDS[@]}"; do
|
||||
if [[ ${FU_FACE_ON[i]} == true ]]; then state="$(fu_msg "on")"; else state="$(fu_msg "off")"; fi
|
||||
printf ' %s %-24s %-4s %s\n' "${FU_FACE_IDS[i]}" "${FU_FACE_NAMES[i]}" "$state" \
|
||||
"$(fu_msg "%s samples, %s learned" "${FU_FACE_SAMPLES[i]}" "${FU_FACE_LEARNED[i]}")"
|
||||
done
|
||||
}
|
||||
|
||||
fu_do_remove() {
|
||||
local id="${1:-}" line
|
||||
[[ -n $id ]] || { fu_bad "$(fu_msg "Which face? See \`%s faces\` for the ids." "$FU_NAME")"; return 1; }
|
||||
line="$(fu_ctl remove "$id" | tail -n1)"
|
||||
_fu_fields "$line"
|
||||
if [[ ${FU_F[ok]:-} == true ]]; then
|
||||
fu_ok "$(fu_msg "Deleted.")"
|
||||
else
|
||||
fu_bad "$(fu_reason_text "${FU_F[reason]:-unreachable}")"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
fu_do_help() {
|
||||
cat <<- EOF
|
||||
$FU_PRETTY $FU_VERSION
|
||||
|
||||
$(fu_msg "Usage: face-unlock [command]")
|
||||
|
||||
$(fu_msg "Commands:")
|
||||
enable $(fu_msg "Turn face unlock on")
|
||||
disable $(fu_msg "Turn it off (faces are kept)")
|
||||
setup [NAME] $(fu_msg "Add a face")
|
||||
faces $(fu_msg "List the faces")
|
||||
remove ID $(fu_msg "Delete a face")
|
||||
test $(fu_msg "Look at the camera and see what it sees")
|
||||
lock $(fu_msg "Lock the screen")
|
||||
status $(fu_msg "Show what is on")
|
||||
-h, --help $(fu_msg "Show this help")
|
||||
-V, --version $(fu_msg "Show the version")
|
||||
|
||||
$(fu_msg "Without a command an interactive menu is shown.")
|
||||
EOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
main() {
|
||||
local cmd="${1:-}"
|
||||
[[ $# -gt 0 ]] && shift
|
||||
|
||||
case "$cmd" in
|
||||
--root) fu_root_verb "$@"; return ;;
|
||||
esac
|
||||
|
||||
# Face data and settings are per user; root has neither a lock screen
|
||||
# nor a face of its own to set up.
|
||||
if [[ $EUID -eq 0 && -z ${FU_ALLOW_ROOT:-} ]]; then
|
||||
fu_bad "$(fu_msg "Run this as your own user, not as root. It asks for your password when it needs to.")"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
case "$cmd" in
|
||||
''|enable|disable|setup|add|enroll|faces|list|remove|delete|test|try) fu_migrate ;;
|
||||
esac
|
||||
|
||||
case "$cmd" in
|
||||
enable) fu_do_enable ;;
|
||||
disable) fu_do_disable ;;
|
||||
setup|add|enroll) fu_do_setup "$@" ;;
|
||||
faces|list) fu_do_faces ;;
|
||||
remove|delete) fu_do_remove "$@" ;;
|
||||
test|try) fu_ensure_service && fu_test ;;
|
||||
status) fu_do_status ;;
|
||||
lock) exec "$FU_AGENT" --lock ;;
|
||||
|
||||
-h|--help|help) fu_do_help ;;
|
||||
-V|--version) printf '%s %s\n' "$FU_NAME" "$FU_VERSION" ;;
|
||||
|
||||
'') fu_ui_menu ;;
|
||||
*)
|
||||
fu_bad "$(fu_msg "Unknown command: %s" "$cmd")"
|
||||
printf '\n'
|
||||
fu_do_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+83
-70
@@ -4,42 +4,53 @@
|
||||
#
|
||||
# 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
|
||||
# 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"
|
||||
FU_VERSION="@VERSION@"
|
||||
FU_NAME="face-unlock"
|
||||
FU_PRETTY="Face Unlock"
|
||||
|
||||
PFU_LIBDIR="${PFU_LIBDIR:-@LIBDIR@}"
|
||||
PFU_LIBEXECDIR="${PFU_LIBEXECDIR:-@LIBEXECDIR@}"
|
||||
PFU_LOCALEDIR="${PFU_LOCALEDIR:-@LOCALEDIR@}"
|
||||
PFU_PAMDIR="${PFU_PAMDIR:-@PAMDIR@}"
|
||||
FU_LIBDIR="${FU_LIBDIR:-@LIBDIR@}"
|
||||
FU_LIBEXECDIR="${FU_LIBEXECDIR:-@LIBEXECDIR@}"
|
||||
FU_LOCALEDIR="${FU_LOCALEDIR:-@LOCALEDIR@}"
|
||||
FU_PAMDIR="${FU_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}"
|
||||
FU_CTL="${FU_CTL:-$FU_LIBEXECDIR/face-unlock-ctl}"
|
||||
FU_AGENT="${FU_AGENT:-$FU_LIBEXECDIR/face-unlock-agent}"
|
||||
FU_PAM_MODULE="${FU_PAM_MODULE:-$FU_PAMDIR/pam_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}"
|
||||
FU_XDG_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}"
|
||||
FU_CONFDIR="${FU_CONFDIR:-${FU_XDG_CONFIG}/${FU_NAME}}"
|
||||
FU_CONFIG="${FU_CONFIG:-${FU_CONFDIR}/config}"
|
||||
|
||||
# The system settings. Only root writes them; see system.sh.
|
||||
PFU_SYSCONFIG="${PFU_SYSCONFIG:-/etc/${PFU_NAME}/config}"
|
||||
FU_SYSCONFIG="${FU_SYSCONFIG:-/etc/${FU_NAME}/config}"
|
||||
|
||||
PFU_UNIT_SOCKET="plasma-face-unlockd.socket"
|
||||
PFU_UNIT_AGENT="plasma-face-unlock-agent.service"
|
||||
FU_UNIT_SOCKET="face-unlockd.socket"
|
||||
FU_UNIT_AGENT="face-unlock-agent.service"
|
||||
|
||||
# The desktop this runs in: plasma, gnome, hyprland, niri or other. All but
|
||||
# the first two have a lock screen that is a program of its own, and there
|
||||
# face-unlock's own one (`face-unlock lock`) is on offer.
|
||||
case ":${XDG_CURRENT_DESKTOP:-}:" in
|
||||
*:KDE:*) FU_DESKTOP=plasma ;;
|
||||
*:GNOME:*) FU_DESKTOP=gnome ;;
|
||||
*:Hyprland:*) FU_DESKTOP=hyprland ;;
|
||||
*:niri:*) FU_DESKTOP=niri ;;
|
||||
*) FU_DESKTOP=other ;;
|
||||
esac
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Translations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
export TEXTDOMAIN="plasma-face-unlock"
|
||||
export TEXTDOMAINDIR="${PFU_LOCALEDIR}"
|
||||
export TEXTDOMAIN="face-unlock"
|
||||
export TEXTDOMAINDIR="${FU_LOCALEDIR}"
|
||||
|
||||
pfu_ui_locale() {
|
||||
local l="${PFU_UI_LOCALE:-}"
|
||||
fu_ui_locale() {
|
||||
local l="${FU_UI_LOCALE:-}"
|
||||
|
||||
if [[ -z $l ]]; then
|
||||
l="${LC_ALL:-}"
|
||||
@@ -63,53 +74,53 @@ pfu_ui_locale() {
|
||||
|
||||
# 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=()
|
||||
declare -A FU_MSG_CACHE=()
|
||||
|
||||
PFU_MSG_RESULT=''
|
||||
FU_MSG_RESULT=''
|
||||
|
||||
# pfu_msg_into <locale> <msgid>
|
||||
# Plain lookup with the result in PFU_MSG_RESULT and no printf formatting, for
|
||||
# fu_msg_into <locale> <msgid>
|
||||
# Plain lookup with the result in FU_MSG_RESULT and no printf formatting, for
|
||||
# callers that would otherwise pay a fork per label per frame.
|
||||
pfu_msg_into() {
|
||||
fu_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]}"
|
||||
if [[ -n ${FU_MSG_CACHE[$cachekey]+set} ]]; then
|
||||
FU_MSG_RESULT="${FU_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"
|
||||
FU_MSG_RESULT="$(LC_ALL="$locale" LANGUAGE="${locale%%.*}" gettext -- "$msgid" 2>/dev/null)"
|
||||
[[ -n $FU_MSG_RESULT ]] || FU_MSG_RESULT="$msgid"
|
||||
FU_MSG_CACHE[$cachekey]="$FU_MSG_RESULT"
|
||||
return 0
|
||||
}
|
||||
|
||||
# pfu_msg_in <locale> <msgid> [printf args...]
|
||||
pfu_msg_in() {
|
||||
# fu_msg_in <locale> <msgid> [printf args...]
|
||||
fu_msg_in() {
|
||||
local locale="$1" msgid="$2"
|
||||
shift 2
|
||||
|
||||
pfu_msg_into "$locale" "$msgid"
|
||||
fu_msg_into "$locale" "$msgid"
|
||||
|
||||
# With no arguments the message is plain text, not a format string. Feeding
|
||||
# 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"
|
||||
printf '%s' "$FU_MSG_RESULT"
|
||||
return
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2059 # the format string is the translated message
|
||||
printf -- "$PFU_MSG_RESULT" "$@"
|
||||
printf -- "$FU_MSG_RESULT" "$@"
|
||||
}
|
||||
|
||||
PFU_LOCALE_CACHED=''
|
||||
FU_LOCALE_CACHED=''
|
||||
|
||||
# pfu_msg <msgid> [printf args...]
|
||||
pfu_msg() {
|
||||
[[ -n $PFU_LOCALE_CACHED ]] || PFU_LOCALE_CACHED="$(pfu_ui_locale)"
|
||||
pfu_msg_in "$PFU_LOCALE_CACHED" "$@"
|
||||
# fu_msg <msgid> [printf args...]
|
||||
fu_msg() {
|
||||
[[ -n $FU_LOCALE_CACHED ]] || FU_LOCALE_CACHED="$(fu_ui_locale)"
|
||||
fu_msg_in "$FU_LOCALE_CACHED" "$@"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -119,45 +130,47 @@ pfu_msg() {
|
||||
# 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
|
||||
FU_INTERACTIVE=''
|
||||
[[ -t 1 ]] && FU_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'
|
||||
if [[ -n $FU_INTERACTIVE && -z ${NO_COLOR:-} ]]; then
|
||||
FU_C_RESET=$'\033[0m'
|
||||
FU_C_BOLD=$'\033[1m'
|
||||
FU_C_DIM=$'\033[2m'
|
||||
FU_C_BLUE=$'\033[38;2;52;153;255m'
|
||||
FU_C_GREEN=$'\033[32m'
|
||||
FU_C_YELLOW=$'\033[33m'
|
||||
FU_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=''
|
||||
FU_C_RESET='' FU_C_BOLD='' FU_C_DIM='' FU_C_BLUE=''
|
||||
FU_C_GREEN='' FU_C_YELLOW='' FU_C_RED=''
|
||||
fi
|
||||
|
||||
# What pfu_ok, pfu_bad and pfu_note printed. The menu redraws straight after an
|
||||
# What fu_ok, fu_bad and fu_note printed. The menu redraws straight after an
|
||||
# action, which wipes the screen, so it shows these again under the new frame
|
||||
# rather than holding everything up for a key press.
|
||||
PFU_UI_NOTICES=()
|
||||
FU_UI_NOTICES=()
|
||||
|
||||
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() { PFU_UI_NOTICES+=("$PFU_C_GREEN✔$PFU_C_RESET $*"); printf '%s✔%s %s\n' "$PFU_C_GREEN" "$PFU_C_RESET" "$*"; }
|
||||
pfu_bad() { PFU_UI_NOTICES+=("$PFU_C_RED✘$PFU_C_RESET $*"); printf '%s✘%s %s\n' "$PFU_C_RED" "$PFU_C_RESET" "$*" >&2; }
|
||||
pfu_note() { PFU_UI_NOTICES+=("$PFU_C_DIM•$PFU_C_RESET $*"); printf '%s•%s %s\n' "$PFU_C_DIM" "$PFU_C_RESET" "$*"; }
|
||||
fu_say() { printf '%s\n' "$*"; }
|
||||
fu_head() { printf '\n%s%s%s\n\n' "$FU_C_BOLD$FU_C_BLUE" "$*" "$FU_C_RESET"; }
|
||||
fu_ok() { FU_UI_NOTICES+=("$FU_C_GREEN✔$FU_C_RESET $*"); printf '%s✔%s %s\n' "$FU_C_GREEN" "$FU_C_RESET" "$*"; }
|
||||
fu_bad() { FU_UI_NOTICES+=("$FU_C_RED✘$FU_C_RESET $*"); printf '%s✘%s %s\n' "$FU_C_RED" "$FU_C_RESET" "$*" >&2; }
|
||||
fu_note() { FU_UI_NOTICES+=("$FU_C_DIM•$FU_C_RESET $*"); printf '%s•%s %s\n' "$FU_C_DIM" "$FU_C_RESET" "$*"; }
|
||||
# A line of config to copy, under a note.
|
||||
fu_code() { FU_UI_NOTICES+=(" $*"); printf ' %s\n' "$*"; }
|
||||
|
||||
pfu_have() { command -v "$1" > /dev/null 2>&1; }
|
||||
fu_have() { command -v "$1" > /dev/null 2>&1; }
|
||||
|
||||
# Human-readable "x minutes ago" for a unix timestamp. 0 or empty yields the
|
||||
# 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() {
|
||||
fu_time_ago() {
|
||||
local ts="$1" now delta n
|
||||
|
||||
if [[ ! $ts =~ ^[0-9]+$ || $ts -eq 0 ]]; then
|
||||
pfu_msg "never"
|
||||
fu_msg "never"
|
||||
printf '\n'
|
||||
return
|
||||
fi
|
||||
@@ -167,22 +180,22 @@ pfu_time_ago() {
|
||||
[[ $delta -lt 0 ]] && delta=0
|
||||
|
||||
if [[ $delta -lt 60 ]]; then
|
||||
pfu_msg "just now"
|
||||
fu_msg "just now"
|
||||
elif [[ $delta -lt 120 ]]; then
|
||||
pfu_msg "1 minute ago"
|
||||
fu_msg "1 minute ago"
|
||||
elif [[ $delta -lt 3600 ]]; then
|
||||
n=$(( delta / 60 ))
|
||||
pfu_msg "%d minutes ago" "$n"
|
||||
fu_msg "%d minutes ago" "$n"
|
||||
elif [[ $delta -lt 7200 ]]; then
|
||||
pfu_msg "1 hour ago"
|
||||
fu_msg "1 hour ago"
|
||||
elif [[ $delta -lt 86400 ]]; then
|
||||
n=$(( delta / 3600 ))
|
||||
pfu_msg "%d hours ago" "$n"
|
||||
fu_msg "%d hours ago" "$n"
|
||||
elif [[ $delta -lt 172800 ]]; then
|
||||
pfu_msg "1 day ago"
|
||||
fu_msg "1 day ago"
|
||||
else
|
||||
n=$(( delta / 86400 ))
|
||||
pfu_msg "%d days ago" "$n"
|
||||
fu_msg "%d days ago" "$n"
|
||||
fi
|
||||
printf '\n'
|
||||
}
|
||||
+36
-34
@@ -2,9 +2,9 @@
|
||||
#
|
||||
# Reading and writing the settings files.
|
||||
#
|
||||
# Two of them, in the same format: ~/.config/plasma-face-unlock/config for
|
||||
# Two of them, in the same format: ~/.config/face-unlock/config for
|
||||
# what this user wants from the lock screen and the bubble, and
|
||||
# /etc/plasma-face-unlock/config for what the daemon does (which camera, how
|
||||
# /etc/face-unlock/config for what the daemon does (which camera, how
|
||||
# strict), which only root writes. Neither is meant to be edited by hand:
|
||||
# every option is in the menu.
|
||||
#
|
||||
@@ -12,25 +12,25 @@
|
||||
# comments. The daemon and the agent read them with the same rules (see
|
||||
# src/core/keyvalue.cpp).
|
||||
|
||||
declare -A PFU_KV_CACHE=()
|
||||
declare -A FU_KV_CACHE=()
|
||||
|
||||
# _pfu_kv_lookup <file> <Key> [default]
|
||||
# Result in PFU_KV_VALUE. Assigning rather than printing matters on the
|
||||
# _fu_kv_lookup <file> <Key> [default]
|
||||
# Result in FU_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=''
|
||||
FU_KV_VALUE=''
|
||||
|
||||
_pfu_kv_lookup() {
|
||||
_fu_kv_lookup() {
|
||||
local file="$1" key="$2" default="${3:-}" val='' line content
|
||||
|
||||
PFU_KV_VALUE="$default"
|
||||
FU_KV_VALUE="$default"
|
||||
[[ -r $file ]] || return 0
|
||||
|
||||
if [[ -n ${PFU_KV_CACHE[$file]+set} ]]; then
|
||||
content="${PFU_KV_CACHE[$file]}"
|
||||
if [[ -n ${FU_KV_CACHE[$file]+set} ]]; then
|
||||
content="${FU_KV_CACHE[$file]}"
|
||||
else
|
||||
content="$(< "$file")"
|
||||
PFU_KV_CACHE[$file]="$content"
|
||||
FU_KV_CACHE[$file]="$content"
|
||||
fi
|
||||
|
||||
while IFS= read -r line; do
|
||||
@@ -45,19 +45,19 @@ _pfu_kv_lookup() {
|
||||
val="${val%\"}"
|
||||
val="${val#\"}"
|
||||
|
||||
[[ -n $val ]] && PFU_KV_VALUE="$val"
|
||||
[[ -n $val ]] && FU_KV_VALUE="$val"
|
||||
return 0
|
||||
}
|
||||
|
||||
pfu_is_true() {
|
||||
fu_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() {
|
||||
# fu_kv_set <file> <Key> <Value> <header line>
|
||||
fu_kv_set() {
|
||||
local file="$1" key="$2" value="$3" header="$4" tmp
|
||||
|
||||
if [[ ! -e $file ]]; then
|
||||
@@ -65,7 +65,8 @@ pfu_kv_set() {
|
||||
{
|
||||
printf '# %s\n' "$header"
|
||||
printf '#\n'
|
||||
printf '# Written by `%s`. Nothing here needs editing by hand:\n' "$PFU_NAME"
|
||||
# shellcheck disable=SC2016 # the backticks are text
|
||||
printf '# Written by `%s`. Nothing here needs editing by hand:\n' "$FU_NAME"
|
||||
printf '# every option is in the menu.\n'
|
||||
} > "$file" || return 1
|
||||
fi
|
||||
@@ -91,42 +92,43 @@ pfu_kv_set() {
|
||||
# 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]'
|
||||
unset 'FU_KV_CACHE[$file]'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# This user's settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
pfu_config_get() {
|
||||
_pfu_kv_lookup "$PFU_CONFIG" "$@"
|
||||
printf '%s\n' "$PFU_KV_VALUE"
|
||||
fu_config_get() {
|
||||
_fu_kv_lookup "$FU_CONFIG" "$@"
|
||||
printf '%s\n' "$FU_KV_VALUE"
|
||||
}
|
||||
|
||||
pfu_config_set() {
|
||||
pfu_kv_set "$PFU_CONFIG" "$1" "$2" "$PFU_PRETTY"
|
||||
fu_config_set() {
|
||||
fu_kv_set "$FU_CONFIG" "$1" "$2" "$FU_PRETTY"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The system settings (read by anybody, written by root)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
pfu_sys_get() {
|
||||
_pfu_kv_lookup "$PFU_SYSCONFIG" "$@"
|
||||
printf '%s\n' "$PFU_KV_VALUE"
|
||||
fu_sys_get() {
|
||||
_fu_kv_lookup "$FU_SYSCONFIG" "$@"
|
||||
printf '%s\n' "$FU_KV_VALUE"
|
||||
}
|
||||
|
||||
# pfu_config_load
|
||||
# fu_config_load
|
||||
# Everything the menu shows, resolved once per screen.
|
||||
pfu_config_load() {
|
||||
PFU_KV_CACHE=()
|
||||
fu_config_load() {
|
||||
FU_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
|
||||
_fu_kv_lookup "$FU_CONFIG" Enabled no; CFG_ENABLED=no; fu_is_true "$FU_KV_VALUE" && CFG_ENABLED=yes
|
||||
_fu_kv_lookup "$FU_CONFIG" LockScreen yes; CFG_LOCK=no; fu_is_true "$FU_KV_VALUE" && CFG_LOCK=yes
|
||||
_fu_kv_lookup "$FU_CONFIG" Sudo no; CFG_SUDO=no; fu_is_true "$FU_KV_VALUE" && CFG_SUDO=yes
|
||||
_fu_kv_lookup "$FU_CONFIG" Polkit no; CFG_POLKIT=no; fu_is_true "$FU_KV_VALUE" && CFG_POLKIT=yes
|
||||
_fu_kv_lookup "$FU_CONFIG" LockScreens yes; CFG_LOCKERS=no; fu_is_true "$FU_KV_VALUE" && CFG_LOCKERS=yes
|
||||
|
||||
_pfu_kv_lookup "$PFU_SYSCONFIG" Liveness light; CFG_LIVENESS="$PFU_KV_VALUE"
|
||||
_pfu_kv_lookup "$PFU_SYSCONFIG" Camera auto; CFG_CAMERA="$PFU_KV_VALUE"
|
||||
_fu_kv_lookup "$FU_SYSCONFIG" Liveness light; CFG_LIVENESS="$FU_KV_VALUE"
|
||||
_fu_kv_lookup "$FU_SYSCONFIG" Camera auto; CFG_CAMERA="$FU_KV_VALUE"
|
||||
return 0
|
||||
}
|
||||
+110
-103
@@ -2,184 +2,191 @@
|
||||
#
|
||||
# Asking the daemon.
|
||||
#
|
||||
# Through plasma-face-unlock-ctl, which prints every message as a line of tab
|
||||
# Through face-unlock-ctl, which prints every message as a line of tab
|
||||
# separated key=value pairs (see src/ctl/main.cpp). What comes back is parsed
|
||||
# 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=()
|
||||
# _fu_fields <line>
|
||||
# Splits one line of ctl output into the associative array FU_F.
|
||||
declare -A FU_F=()
|
||||
|
||||
_pfu_fields() {
|
||||
_fu_fields() {
|
||||
local line="$1" field
|
||||
local -a parts
|
||||
PFU_F=()
|
||||
FU_F=()
|
||||
IFS=$'\t' read -r -a parts <<< "$line"
|
||||
for field in "${parts[@]}"; do
|
||||
PFU_F["${field%%=*}"]="${field#*=}"
|
||||
FU_F["${field%%=*}"]="${field#*=}"
|
||||
done
|
||||
}
|
||||
|
||||
pfu_ctl() {
|
||||
"$PFU_CTL" "$@"
|
||||
fu_ctl() {
|
||||
"$FU_CTL" "$@"
|
||||
}
|
||||
|
||||
# pfu_status_load
|
||||
# PFU_ST_REACHABLE is yes when the daemon answered at all. Everything else
|
||||
# fu_status_load
|
||||
# FU_ST_REACHABLE is yes when the daemon answered at all. Everything else
|
||||
# is only filled in then.
|
||||
pfu_status_load() {
|
||||
fu_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
|
||||
FU_ST_REACHABLE=no
|
||||
FU_ST_FACES=0
|
||||
FU_ST_LOCKOUT=0
|
||||
FU_ST_LAST=0
|
||||
FU_ST_PURPOSE=''
|
||||
FU_ST_CAMERA=''
|
||||
FU_ST_CAMERA_NAME=''
|
||||
FU_ST_CAMERA_PRESENT=no
|
||||
FU_ST_MODELS=no
|
||||
|
||||
out="$(pfu_ctl status 2>/dev/null | tail -n1)"
|
||||
_pfu_fields "$out"
|
||||
[[ ${PFU_F[ok]:-} == true ]] || return 1
|
||||
out="$(fu_ctl status 2>/dev/null | tail -n1)"
|
||||
_fu_fields "$out"
|
||||
[[ ${FU_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}"
|
||||
FU_ST_REACHABLE=yes
|
||||
FU_ST_FACES="${FU_F[faces]:-0}"
|
||||
FU_ST_LOCKOUT="${FU_F[lockout]:-0}"
|
||||
FU_ST_LAST="${FU_F[lastUnlock]:-0}"
|
||||
FU_ST_PURPOSE="${FU_F[lastPurpose]:-}"
|
||||
FU_ST_CAMERA="${FU_F[cameraPath]:-}"
|
||||
FU_ST_CAMERA_NAME="${FU_F[cameraName]:-}"
|
||||
FU_ST_CAMERA_PRESENT="${FU_F[cameraPresent]:-false}"
|
||||
FU_ST_MODELS="${FU_F[models]:-false}"
|
||||
return 0
|
||||
}
|
||||
|
||||
# pfu_faces_load
|
||||
# fu_faces_load
|
||||
# The caller's faces, into parallel arrays.
|
||||
pfu_faces_load() {
|
||||
fu_faces_load() {
|
||||
local line
|
||||
PFU_FACE_IDS=() PFU_FACE_NAMES=() PFU_FACE_ON=() PFU_FACE_SAMPLES=() PFU_FACE_LEARNED=() PFU_FACE_CREATED=()
|
||||
FU_FACE_IDS=() FU_FACE_NAMES=() FU_FACE_ON=() FU_FACE_SAMPLES=() FU_FACE_LEARNED=() FU_FACE_CREATED=()
|
||||
|
||||
while IFS= read -r line; do
|
||||
_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)
|
||||
_fu_fields "$line"
|
||||
[[ ${FU_F[event]:-} == item ]] || continue
|
||||
FU_FACE_IDS+=("${FU_F[id]}")
|
||||
FU_FACE_NAMES+=("${FU_F[name]}")
|
||||
FU_FACE_ON+=("${FU_F[enabled]}")
|
||||
FU_FACE_SAMPLES+=("${FU_F[samples]:-0}")
|
||||
FU_FACE_LEARNED+=("${FU_F[adaptive]:-0}")
|
||||
FU_FACE_CREATED+=("${FU_F[created]:-0}")
|
||||
done < <(fu_ctl list 2>/dev/null)
|
||||
}
|
||||
|
||||
# pfu_cameras_load
|
||||
pfu_cameras_load() {
|
||||
# fu_cameras_load
|
||||
fu_cameras_load() {
|
||||
local line
|
||||
PFU_CAM_PATHS=() PFU_CAM_NAMES=() PFU_CAM_IR=()
|
||||
PFU_CAM_AUTO=''
|
||||
FU_CAM_PATHS=() FU_CAM_NAMES=() FU_CAM_IR=()
|
||||
FU_CAM_AUTO=''
|
||||
|
||||
while IFS= read -r line; do
|
||||
_pfu_fields "$line"
|
||||
case "${PFU_F[event]:-}" in
|
||||
_fu_fields "$line"
|
||||
case "${FU_F[event]:-}" in
|
||||
item)
|
||||
PFU_CAM_PATHS+=("${PFU_F[path]}")
|
||||
PFU_CAM_NAMES+=("${PFU_F[name]}")
|
||||
PFU_CAM_IR+=("${PFU_F[infrared]}")
|
||||
FU_CAM_PATHS+=("${FU_F[path]}")
|
||||
FU_CAM_NAMES+=("${FU_F[name]}")
|
||||
FU_CAM_IR+=("${FU_F[infrared]}")
|
||||
;;
|
||||
result)
|
||||
PFU_CAM_AUTO="${PFU_F[auto]:-}"
|
||||
FU_CAM_AUTO="${FU_F[auto]:-}"
|
||||
;;
|
||||
esac
|
||||
done < <(pfu_ctl cameras 2>/dev/null)
|
||||
done < <(fu_ctl cameras 2>/dev/null)
|
||||
}
|
||||
|
||||
# pfu_reason_text <reason>
|
||||
# fu_reason_text <reason>
|
||||
# What a daemon answer means, for people.
|
||||
pfu_reason_text() {
|
||||
fu_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" ;;
|
||||
mismatch) fu_msg "The face did not match." ;;
|
||||
spoof) fu_msg "That looked like a photo or a screen." ;;
|
||||
liveness) fu_msg "The face matched, but did not blink or move." ;;
|
||||
attention) fu_msg "The face was not looking at the screen." ;;
|
||||
quality) fu_msg "The picture was too dark, too blurry or too far away." ;;
|
||||
no-face) fu_msg "No face in view." ;;
|
||||
lockout) fu_msg "Face unlock is paused after too many tries. Unlock once with your password." ;;
|
||||
not-enrolled) fu_msg "No face is set up yet." ;;
|
||||
camera) fu_msg "The camera could not be used." ;;
|
||||
models) fu_msg "The recognition models are missing. Reinstall the package." ;;
|
||||
lid-closed) fu_msg "The lid is closed." ;;
|
||||
busy) fu_msg "The camera is busy with another scan." ;;
|
||||
unreachable) fu_msg "The face unlock service is not running." ;;
|
||||
denied)
|
||||
fu_msg "Not allowed."
|
||||
# Hyprland, Niri and the like bring no polkit agent, so no password window shows.
|
||||
if [[ $FU_DESKTOP != plasma && $FU_DESKTOP != gnome ]]; then
|
||||
printf ' '
|
||||
fu_msg "No password window? Start a polkit agent, for example hyprpolkitagent."
|
||||
fi
|
||||
;;
|
||||
*) fu_msg "Something went wrong (%s)." "$1" ;;
|
||||
esac
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
# pfu_test
|
||||
# fu_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() {
|
||||
fu_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.")"
|
||||
fu_say " $(fu_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
|
||||
_fu_fields "$line"
|
||||
case "${FU_F[event]:-}" in
|
||||
started)
|
||||
started=1
|
||||
;;
|
||||
face)
|
||||
printf '\r\033[K %s\n' "$(pfu_msg "Face found.")"
|
||||
printf '\r\033[K %s\n' "$(fu_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.")" ;;
|
||||
case "${FU_F[hint]}" in
|
||||
blink) printf '\r\033[K %s\n' "$(fu_msg "Recognised. Now blink once, or turn your head a little.")" ;;
|
||||
look) printf '\r\033[K %s\n' "$(fu_msg "Look at the screen.")" ;;
|
||||
closer) printf '\r\033[K %s\n' "$(fu_msg "Move closer to the camera.")" ;;
|
||||
light) printf '\r\033[K %s\n' "$(fu_msg "It is too dark to see your face.")" ;;
|
||||
esac
|
||||
;;
|
||||
frame)
|
||||
[[ -n ${PFU_F[score]:-} ]] && score="${PFU_F[score]}"
|
||||
matched="${PFU_F[matched]:-0}"
|
||||
[[ -n ${FU_F[score]:-} ]] && score="${FU_F[score]}"
|
||||
matched="${FU_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"
|
||||
"$(fu_msg "match")" "$score" "$(fu_msg "turn")" "${FU_F[yaw]:-0}" \
|
||||
"$(fu_msg "eyes")" "${FU_F[eyes]:-0}" \
|
||||
"$(fu_msg "depth")" "${FU_F[depth]:-0}" "$(fu_msg "blink")" "${FU_F[blink]:-0}" \
|
||||
"$(fu_msg "glare")" "${FU_F[glare]:-0}" "$(fu_msg "edge")" "${FU_F[device]:-0}"
|
||||
[[ -n $FU_INTERACTIVE ]] && printf '\r\033[K%s%s%s' "$FU_C_DIM" "$shown" "$FU_C_RESET"
|
||||
;;
|
||||
result)
|
||||
printf '\r\033[K'
|
||||
if [[ ${PFU_F[ok]} == true ]]; then
|
||||
if [[ ${FU_F[ok]} == true ]]; then
|
||||
local how=''
|
||||
case "${PFU_F[liveness]:-}" in
|
||||
blink) how="$(pfu_msg "blink")" ;;
|
||||
depth) how="$(pfu_msg "head turn")" ;;
|
||||
case "${FU_F[liveness]:-}" in
|
||||
blink) how="$(fu_msg "blink")" ;;
|
||||
depth) how="$(fu_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")"
|
||||
fu_ok "$(fu_msg "Recognised as %s (match %s) in %s ms." "${FU_F[name]}" "${FU_F[score]}" "${FU_F[ms]}")"
|
||||
[[ -n $how ]] && fu_say " $(fu_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.")"
|
||||
fu_bad "$(fu_reason_text "${FU_F[reason]}")"
|
||||
[[ -n ${FU_F[message]:-} ]] && fu_say " ${FU_F[message]}"
|
||||
if [[ -n ${FU_F[lockout]:-} ]]; then
|
||||
fu_note "$(fu_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)
|
||||
done < <(fu_ctl test 2>/dev/null)
|
||||
|
||||
printf '\n'
|
||||
pfu_bad "$(pfu_reason_text unreachable)"
|
||||
fu_bad "$(fu_reason_text unreachable)"
|
||||
return 1
|
||||
}
|
||||
+406
-269
@@ -14,39 +14,39 @@
|
||||
# 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=''
|
||||
FU_TERM_SAVED=''
|
||||
|
||||
pfu_ui_term_raw() {
|
||||
pfu_have stty || return 0
|
||||
fu_ui_term_raw() {
|
||||
fu_have stty || return 0
|
||||
[[ -t 0 ]] || return 0
|
||||
[[ -n $PFU_TERM_SAVED ]] && return 0
|
||||
[[ -n $FU_TERM_SAVED ]] && return 0
|
||||
|
||||
PFU_TERM_SAVED="$(stty -g 2>/dev/null)" || { PFU_TERM_SAVED=''; return 0; }
|
||||
FU_TERM_SAVED="$(stty -g 2>/dev/null)" || { FU_TERM_SAVED=''; return 0; }
|
||||
stty -icanon -echo min 1 time 0 2>/dev/null || true
|
||||
}
|
||||
|
||||
pfu_ui_term_restore() {
|
||||
[[ -n $PFU_TERM_SAVED ]] || return 0
|
||||
stty "$PFU_TERM_SAVED" 2>/dev/null || true
|
||||
PFU_TERM_SAVED=''
|
||||
fu_ui_term_restore() {
|
||||
[[ -n $FU_TERM_SAVED ]] || return 0
|
||||
stty "$FU_TERM_SAVED" 2>/dev/null || true
|
||||
FU_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
|
||||
fu_ui_cooked() {
|
||||
fu_ui_term_restore
|
||||
"$@"
|
||||
local rc=$?
|
||||
pfu_ui_term_raw
|
||||
fu_ui_term_raw
|
||||
return $rc
|
||||
}
|
||||
|
||||
# pfu_read_key
|
||||
# fu_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() {
|
||||
fu_read_key() {
|
||||
local k rest
|
||||
|
||||
IFS= read -rsn1 k || return 1
|
||||
@@ -72,26 +72,26 @@ pfu_read_key() {
|
||||
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
|
||||
# fu_ui_read_line <initial>
|
||||
# A minimal line editor built on fu_read_key, with the result in
|
||||
# FU_LINE_RESULT. This exists instead of bash's own `read -r` because mixing
|
||||
# line mode into a single-key interface breaks it: after one cooked-mode read
|
||||
# the following `read -sn1` stops receiving keystrokes entirely.
|
||||
PFU_LINE_RESULT=''
|
||||
FU_LINE_RESULT=''
|
||||
|
||||
pfu_ui_read_line() {
|
||||
fu_ui_read_line() {
|
||||
local buf="${1:-}" key
|
||||
|
||||
PFU_LINE_RESULT=''
|
||||
FU_LINE_RESULT=''
|
||||
printf '%s' "$buf"
|
||||
|
||||
while true; do
|
||||
key="$(pfu_read_key)" || { printf '\n'; return 1; }
|
||||
key="$(fu_read_key)" || { printf '\n'; return 1; }
|
||||
|
||||
case "$key" in
|
||||
enter)
|
||||
printf '\n'
|
||||
PFU_LINE_RESULT="$buf"
|
||||
FU_LINE_RESULT="$buf"
|
||||
return 0
|
||||
;;
|
||||
escape)
|
||||
@@ -118,75 +118,90 @@ pfu_ui_read_line() {
|
||||
done
|
||||
}
|
||||
|
||||
# _pfu_ui_take_notices
|
||||
# _fu_ui_take_notices
|
||||
# The messages the last action left, as lines for under a frame, in
|
||||
# PFU_UI_NOTICE_TEXT. Each is shown once.
|
||||
PFU_UI_NOTICE_TEXT=''
|
||||
# FU_UI_NOTICE_TEXT. Each is shown once.
|
||||
FU_UI_NOTICE_TEXT=''
|
||||
|
||||
_pfu_ui_take_notices() {
|
||||
_fu_ui_take_notices() {
|
||||
local n
|
||||
PFU_UI_NOTICE_TEXT=''
|
||||
(( ${#PFU_UI_NOTICES[@]} )) || return 0
|
||||
PFU_UI_NOTICE_TEXT=$'\n'
|
||||
for n in "${PFU_UI_NOTICES[@]}"; do
|
||||
PFU_UI_NOTICE_TEXT+=" $n"$'\n'
|
||||
FU_UI_NOTICE_TEXT=''
|
||||
(( ${#FU_UI_NOTICES[@]} )) || return 0
|
||||
FU_UI_NOTICE_TEXT=$'\n'
|
||||
for n in "${FU_UI_NOTICES[@]}"; do
|
||||
FU_UI_NOTICE_TEXT+=" $n"$'\n'
|
||||
done
|
||||
PFU_UI_NOTICES=()
|
||||
FU_UI_NOTICES=()
|
||||
}
|
||||
|
||||
# pfu_ui_confirm <question>
|
||||
pfu_ui_confirm() {
|
||||
local key
|
||||
printf '\n %s %s ' "$1" "$(pfu_msg "[y/N]")"
|
||||
key="$(pfu_read_key)" || return 1
|
||||
# fu_ui_confirm <question>
|
||||
fu_ui_confirm() {
|
||||
local key hint yes
|
||||
# TRANSLATORS: the letter after "[" is the key for yes. y works too.
|
||||
hint="$(fu_msg "[y/N]")"
|
||||
yes="${hint:1:1}"
|
||||
printf '\n %s %s ' "$1" "$hint"
|
||||
key="$(fu_read_key)" || return 1
|
||||
printf '%s\n' "$key"
|
||||
[[ $key == [yYjJ] ]]
|
||||
[[ ${key,,} == y || ${key,,} == "${yes,,}" ]]
|
||||
}
|
||||
|
||||
# _pfu_row <label> <value>
|
||||
# _fu_width <text>
|
||||
# The columns the text takes, into FU_WIDTH. ${#s} counts characters, but
|
||||
# Chinese, Japanese and Korean ones take two columns each.
|
||||
FU_WIDTH=0
|
||||
|
||||
_fu_width() {
|
||||
local wide="${1//[^ -〿-ヿ㐀-䶿一-鿿가-豈--⦆]/}"
|
||||
FU_WIDTH=$(( ${#1} + ${#wide} ))
|
||||
}
|
||||
|
||||
# _fu_row <label> <value>
|
||||
# printf's %-28s pads by bytes, so a label containing "ü" comes out one column
|
||||
# short. ${#s} counts characters in a UTF-8 locale, so the padding is computed
|
||||
# here instead.
|
||||
_pfu_row() {
|
||||
# short. The padding is computed here instead.
|
||||
_fu_row() {
|
||||
local label="$1" value="$2" pad
|
||||
pad=$(( 30 - ${#label} ))
|
||||
_fu_width "$label"
|
||||
pad=$(( 30 - FU_WIDTH ))
|
||||
(( pad < 0 )) && pad=0
|
||||
printf ' %s%*s %s\n' "$label" "$pad" '' "$value"
|
||||
}
|
||||
|
||||
_pfu_onoff() {
|
||||
_fu_onoff() {
|
||||
if [[ $1 == yes ]]; then
|
||||
printf '%s%s%s' "$PFU_C_GREEN" "$(pfu_msg "ON")" "$PFU_C_RESET"
|
||||
printf '%s%s%s' "$FU_C_GREEN" "$(fu_msg "ON")" "$FU_C_RESET"
|
||||
else
|
||||
printf '%s%s%s' "$PFU_C_DIM" "$(pfu_msg "OFF")" "$PFU_C_RESET"
|
||||
printf '%s%s%s' "$FU_C_DIM" "$(fu_msg "OFF")" "$FU_C_RESET"
|
||||
fi
|
||||
}
|
||||
|
||||
_pfu_small_onoff() {
|
||||
_fu_small_onoff() {
|
||||
if [[ $1 == yes ]]; then
|
||||
printf '%s%s%s' "$PFU_C_GREEN" "$(pfu_msg "on")" "$PFU_C_RESET"
|
||||
printf '%s%s%s' "$FU_C_GREEN" "$(fu_msg "on")" "$FU_C_RESET"
|
||||
else
|
||||
printf '%s%s%s' "$PFU_C_DIM" "$(pfu_msg "off")" "$PFU_C_RESET"
|
||||
printf '%s%s%s' "$FU_C_DIM" "$(fu_msg "off")" "$FU_C_RESET"
|
||||
fi
|
||||
}
|
||||
|
||||
# pfu_value_label <Key> <value>
|
||||
# fu_value_label <Key> <value>
|
||||
# How a setting's value reads in the menu.
|
||||
pfu_value_label() {
|
||||
fu_value_label() {
|
||||
case "$1:$2" in
|
||||
Liveness:heavy) pfu_msg "strict" ;;
|
||||
Liveness:light) pfu_msg "basic" ;;
|
||||
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" ;;
|
||||
AnimationSpeed:normal) pfu_msg "normal" ;;
|
||||
AnimationSpeed:fast) pfu_msg "fast" ;;
|
||||
AnimationSpeed:slow) pfu_msg "slow" ;;
|
||||
ScanSeconds:*) pfu_msg "%s seconds" "$2" ;;
|
||||
Camera:auto) pfu_msg "automatic" ;;
|
||||
Liveness:heavy) fu_msg "strict" ;;
|
||||
Liveness:light) fu_msg "basic" ;;
|
||||
Liveness:off) fu_msg "off" ;;
|
||||
Strictness:normal) fu_msg "normal" ;;
|
||||
Strictness:strict) fu_msg "strict" ;;
|
||||
Strictness:relaxed) fu_msg "relaxed" ;;
|
||||
BubbleStyle:full) fu_msg "island with the face" ;;
|
||||
BubbleStyle:minimal) fu_msg "small pill with a lock" ;;
|
||||
AnimationSpeed:normal) fu_msg "normal" ;;
|
||||
AnimationSpeed:fast) fu_msg "fast" ;;
|
||||
AnimationSpeed:slow) fu_msg "slow" ;;
|
||||
ScanSeconds:*) fu_msg "%s seconds" "$2" ;;
|
||||
Camera:auto) fu_msg "automatic" ;;
|
||||
LockScreenStyle:own) fu_msg "face-unlock's" ;;
|
||||
LockScreenStyle:*) fu_msg "yours" ;;
|
||||
*) printf '%s' "$2" ;;
|
||||
esac
|
||||
}
|
||||
@@ -195,76 +210,105 @@ pfu_value_label() {
|
||||
# Status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# pfu_ui_status
|
||||
_fu_polkit_warning() {
|
||||
fu_polkit_agent_missing || return 0
|
||||
printf '\n %s%s%s\n' "$FU_C_YELLOW" "$(fu_msg "No polkit agent is running, so no password window can open. Adding a face and the admin prompts need one.")" "$FU_C_RESET"
|
||||
}
|
||||
|
||||
# fu_ui_status
|
||||
# Shared by the `status` subcommand and the menu header.
|
||||
pfu_ui_status() {
|
||||
local names='' i camera
|
||||
fu_ui_status() {
|
||||
local names='' i camera agent=yes
|
||||
|
||||
pfu_config_load
|
||||
pfu_status_load
|
||||
fu_config_load
|
||||
fu_status_load
|
||||
|
||||
_pfu_row "$(pfu_msg "Face unlock")" "$(_pfu_onoff "$CFG_ENABLED")"
|
||||
_fu_row "$(fu_msg "Face unlock")" "$(_fu_onoff "$CFG_ENABLED")"
|
||||
printf '\n'
|
||||
|
||||
if [[ $PFU_ST_REACHABLE != yes ]]; then
|
||||
_pfu_row "$(pfu_msg "Service")" "${PFU_C_YELLOW}$(pfu_msg "not running")${PFU_C_RESET}"
|
||||
if [[ $FU_ST_REACHABLE != yes ]]; then
|
||||
_fu_row "$(fu_msg "Service")" "${FU_C_YELLOW}$(fu_msg "not running")${FU_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"
|
||||
printf '\n %s%s%s\n' "$FU_C_DIM" "$(fu_msg "Turning face unlock on again starts it.")" "$FU_C_RESET"
|
||||
fi
|
||||
_fu_polkit_warning
|
||||
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]}"
|
||||
fu_faces_load
|
||||
for i in "${!FU_FACE_NAMES[@]}"; do
|
||||
[[ ${FU_FACE_ON[i]} == true ]] || continue
|
||||
names="${names:+$names, }${FU_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}"
|
||||
if (( ${#FU_FACE_IDS[@]} == 0 )); then
|
||||
_fu_row "$(fu_msg "Faces")" "${FU_C_YELLOW}$(fu_msg "none set up yet")${FU_C_RESET}"
|
||||
else
|
||||
_pfu_row "$(pfu_msg "Faces")" "${PFU_ST_FACES} ${PFU_C_DIM}(${names:-$(pfu_msg "all turned off")})${PFU_C_RESET}"
|
||||
_fu_row "$(fu_msg "Faces")" "${FU_ST_FACES} ${FU_C_DIM}(${names:-$(fu_msg "all turned off")})${FU_C_RESET}"
|
||||
fi
|
||||
|
||||
if [[ $PFU_ST_CAMERA_PRESENT == true ]]; then
|
||||
camera="${PFU_ST_CAMERA_NAME:-$PFU_ST_CAMERA}"
|
||||
if [[ $FU_ST_CAMERA_PRESENT == true ]]; then
|
||||
camera="${FU_ST_CAMERA_NAME:-$FU_ST_CAMERA}"
|
||||
else
|
||||
camera="${PFU_C_YELLOW}$(pfu_msg "none found")${PFU_C_RESET}"
|
||||
camera="${FU_C_YELLOW}$(fu_msg "none found")${FU_C_RESET}"
|
||||
fi
|
||||
_pfu_row "$(pfu_msg "Camera")" "$camera"
|
||||
_fu_row "$(fu_msg "Camera")" "$camera"
|
||||
|
||||
_pfu_row "$(pfu_msg "Lock screen")" "$(_pfu_small_onoff "$( [[ $CFG_ENABLED == yes && $CFG_LOCK == yes ]] && echo yes || echo no)")"
|
||||
_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}"
|
||||
_fu_row "$(fu_msg "Lock screen")" "$(_fu_small_onoff "$( [[ $CFG_ENABLED == yes && $CFG_LOCK == yes ]] && echo yes || echo no)")"
|
||||
if [[ $CFG_ENABLED == yes && $CFG_LOCK == yes ]] && fu_agent_available && ! fu_agent_running; then
|
||||
agent=no
|
||||
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"
|
||||
_fu_row "$(fu_msg "sudo")" "$(_fu_small_onoff "$(fu_pam_enabled sudo && echo yes || echo no)")"
|
||||
_fu_row "$(fu_msg "Admin prompts")" "$(_fu_small_onoff "$(fu_pam_enabled polkit-1 && echo yes || echo no)")"
|
||||
if fu_pam_lockers_here; then
|
||||
_fu_row "$(fu_msg "Lock screens")" "$(_fu_small_onoff "$(fu_pam_lockers_enabled && echo yes || echo no)") ${FU_C_DIM}($(fu_pam_lockers_list))${FU_C_RESET}"
|
||||
fi
|
||||
_fu_row "$(fu_msg "Photo check")" "$(fu_value_label Liveness "$CFG_LIVENESS")"
|
||||
|
||||
if (( FU_ST_LOCKOUT > 0 )); then
|
||||
_fu_row "$(fu_msg "Paused")" "${FU_C_YELLOW}$(fu_msg "for %d more minutes, or until the password is used" "$(( (FU_ST_LOCKOUT + 59) / 60 ))")${FU_C_RESET}"
|
||||
fi
|
||||
_fu_row "$(fu_msg "Last unlock")" "$(fu_time_ago "$FU_ST_LAST")"
|
||||
|
||||
if [[ $FU_ST_MODELS != true ]]; then
|
||||
printf '\n %s%s%s\n' "$FU_C_RED" "$(fu_msg "The recognition models are missing. Reinstall the package.")" "$FU_C_RESET"
|
||||
fi
|
||||
if [[ $agent == no ]]; then
|
||||
printf '\n %s%s%s\n' "$FU_C_YELLOW" "$(fu_msg "The lock screen agent is not running.")" "$FU_C_RESET"
|
||||
# Printed here already, so not again under the menu.
|
||||
local -a notices=("${FU_UI_NOTICES[@]}")
|
||||
fu_agent_autostarts || fu_agent_hint
|
||||
FU_UI_NOTICES=("${notices[@]}")
|
||||
fi
|
||||
_fu_polkit_warning
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings
|
||||
# ---------------------------------------------------------------------------
|
||||
# Format: scope|Key|type|default|label-msgid|choices|needs
|
||||
# Format: scope|Key|type|default|label-msgid|choices|needs|only
|
||||
# scope user (this user's file), sys (the system file, through sudo),
|
||||
# pam (the service of that name, through sudo), or group for a
|
||||
# heading, with only the label after it
|
||||
# type bool, choice (steps through the choices) or camera
|
||||
# needs a bool setting this one does nothing without; it is dimmed while
|
||||
# that is off
|
||||
PFU_SETTINGS=(
|
||||
# type bool, choice (steps through the choices), camera, path (typed
|
||||
# in; empty for the desktop's picture), or lockstyle (the window
|
||||
# with the two lock screens, see fu_lock_choose)
|
||||
# needs a bool setting this one does nothing without, or Key=value; it is
|
||||
# dimmed while that is not so
|
||||
# only lockers: only where the lock screen is a program of its own with a
|
||||
# PAM file (Hyprland, Niri), see fu_pam_lockers_here; ownlock: only
|
||||
# where face-unlock's own lock screen can be used
|
||||
FU_SETTINGS=(
|
||||
"group|Lock screen"
|
||||
"user|LockScreen|bool|yes|Unlock with your face"
|
||||
"user|ScanOnWake|bool|yes|Scan when you come back||LockScreen"
|
||||
"user|ScanOnLock|bool|no|Scan right after locking||LockScreen"
|
||||
"user|LockScreenStyle|lockstyle|yours|Which lock screen|||ownlock"
|
||||
"user|LockWallpaper|path||Wallpaper||LockScreenStyle=own|ownlock"
|
||||
"user|LockBlur|bool|no|Blur the wallpaper||LockScreenStyle=own|ownlock"
|
||||
"group|Password prompts"
|
||||
"pam|sudo|bool|no|sudo in a terminal"
|
||||
"pam|polkit-1|bool|no|Admin prompts"
|
||||
"pam|lockscreens|bool|yes|Lock screens|||lockers"
|
||||
"group|Recognition"
|
||||
"sys|Liveness|choice|light|Photo check|off,light,heavy"
|
||||
"sys|Strictness|choice|normal|How closely the face has to match|relaxed,normal,strict"
|
||||
@@ -280,70 +324,107 @@ PFU_SETTINGS=(
|
||||
"user|BubbleForPrompts|bool|yes|Also for sudo and admin prompts||Bubble"
|
||||
)
|
||||
|
||||
# pfu_setting_help <Key> <value>
|
||||
# fu_setting_help <Key> <value>
|
||||
# What a setting does, shown under the list for the selected one.
|
||||
pfu_setting_help() {
|
||||
fu_setting_help() {
|
||||
case "$FU_DESKTOP:$1" in
|
||||
gnome:Bubble)
|
||||
fu_msg "On GNOME a small GNOME extension shows the bubble. Turning face unlock on switches it on."
|
||||
return
|
||||
;;
|
||||
hyprland:LockScreen|niri:LockScreen)
|
||||
fu_msg "Scans when you come back to hyprlock, swaylock or face-unlock's own lock screen (\`face-unlock lock\`, with the bubble), and opens them. Other lock screens scan on Enter."
|
||||
return
|
||||
;;
|
||||
esac
|
||||
case "$1:$2" in
|
||||
LockScreen:*) pfu_msg "Unlocks the lock screen when it sees your face. Off: only your password works there." ;;
|
||||
ScanOnWake:*) pfu_msg "Scans when you press a key or move the mouse on the lock screen, and when the computer wakes up." ;;
|
||||
ScanOnLock:*) pfu_msg "Scans as soon as the screen locks. Off by default: if you lock it yourself, it would unlock again right away." ;;
|
||||
sudo:*) pfu_msg "sudo takes your face instead of the password. No match: you type the password as usual." ;;
|
||||
polkit-1:*) pfu_msg "The password windows of Plasma and apps, for example when you install software. No match: you type the password." ;;
|
||||
Liveness:heavy) pfu_msg "You have to blink or turn your head a little. This also stops printed photos." ;;
|
||||
Liveness:off) pfu_msg "No check at all. Only for trying out a camera." ;;
|
||||
Liveness:*) pfu_msg "Stops photos on a phone, a tablet or glossy paper. You do not have to blink. A matte printed photo can get through." ;;
|
||||
Strictness:strict) pfu_msg "Fewer wrong matches, but it may not know you with glasses or in bad light." ;;
|
||||
Strictness:relaxed) pfu_msg "Knows you more easily, but also somebody who looks a lot like you." ;;
|
||||
Strictness:*) pfu_msg "The default. Right for most people." ;;
|
||||
Attention:*) pfu_msg "Your eyes have to be open and on the screen. So it does not unlock while you look away or sleep." ;;
|
||||
Camera:*) pfu_msg "Automatic takes the first normal camera. After a change, set up your face again. An infrared camera needs its light on (linux-enable-ir-emitter)." ;;
|
||||
ScanSeconds:*) pfu_msg "How long the camera looks for your face before it gives up." ;;
|
||||
Adapt:*) pfu_msg "After a sure match it keeps how you look now. So a new haircut or glasses need no new setup." ;;
|
||||
SkipLidClosed:*) pfu_msg "No scan while the laptop is closed, for example at a desk with an external screen." ;;
|
||||
Bubble:*) pfu_msg "The bubble at the top of the screen shows what the camera is doing." ;;
|
||||
BubbleStyle:minimal) pfu_msg "A small pill with a lock that opens." ;;
|
||||
BubbleStyle:*) pfu_msg "An island with a face that looks around, then rings and a tick." ;;
|
||||
AnimationSpeed:*) pfu_msg "How fast the bubble moves." ;;
|
||||
BubbleForPrompts:*) pfu_msg "Shows the bubble when sudo or an admin prompt scans your face, too." ;;
|
||||
LockScreen:*) fu_msg "Unlocks the lock screen when it sees your face. Off: only your password works there." ;;
|
||||
ScanOnWake:*) fu_msg "Scans when you press a key or move the mouse on the lock screen, and when the computer wakes up." ;;
|
||||
ScanOnLock:*) fu_msg "Scans as soon as the screen locks. Off by default: if you lock it yourself, it would unlock again right away." ;;
|
||||
sudo:*) fu_msg "sudo takes your face instead of the password. No match: you type the password as usual." ;;
|
||||
LockScreenStyle:*) fu_msg "face-unlock's lock screen shows the bubble, and you lock with \`face-unlock lock\`. Or keep yours: hyprlock then shows a line of text at the top. Enter opens the choice." ;;
|
||||
LockWallpaper:*) fu_msg "The picture behind face-unlock's own lock screen (\`face-unlock lock\`). Empty: the one on your desktop. Or a file, a folder to take one from at random, or \`none\`. Enter to type it." ;;
|
||||
LockBlur:*) fu_msg "Blurs the picture behind face-unlock's own lock screen." ;;
|
||||
lockscreens:*) fu_msg "The lock screen takes your face in its password check. Press Enter on the empty field to scan. Found here: %s." "$(fu_pam_lockers_list)" ;;
|
||||
polkit-1:*) fu_msg "The password windows of your desktop and apps, for example when you install software. No match: you type the password." ;;
|
||||
Liveness:heavy) fu_msg "You have to blink or turn your head a little. This also stops printed photos." ;;
|
||||
Liveness:off) fu_msg "No check at all. Only for trying out a camera." ;;
|
||||
Liveness:*) fu_msg "Stops photos on a phone, a tablet or glossy paper. You do not have to blink. A matte printed photo can get through." ;;
|
||||
Strictness:strict) fu_msg "Fewer wrong matches, but it may not know you with glasses or in bad light." ;;
|
||||
Strictness:relaxed) fu_msg "Knows you more easily, but also somebody who looks a lot like you." ;;
|
||||
Strictness:*) fu_msg "The default. Right for most people." ;;
|
||||
Attention:*) fu_msg "Your eyes have to be open and on the screen. So it does not unlock while you look away or sleep." ;;
|
||||
Camera:*) fu_msg "Automatic takes the first normal camera. After a change, set up your face again. An infrared camera needs its light on (linux-enable-ir-emitter)." ;;
|
||||
ScanSeconds:*) fu_msg "How long the camera looks for your face before it gives up." ;;
|
||||
Adapt:*) fu_msg "After a sure match it keeps how you look now. So a new haircut or glasses need no new setup." ;;
|
||||
SkipLidClosed:*) fu_msg "No scan while the laptop is closed, for example at a desk with an external screen." ;;
|
||||
Bubble:*) fu_msg "The bubble at the top of the screen shows what the camera is doing." ;;
|
||||
BubbleStyle:minimal) fu_msg "A small pill with a lock that opens." ;;
|
||||
BubbleStyle:*) fu_msg "An island with a face that looks around, then rings and a tick." ;;
|
||||
AnimationSpeed:*) fu_msg "How fast the bubble moves." ;;
|
||||
BubbleForPrompts:*) fu_msg "Shows the bubble when sudo or an admin prompt scans your face, too." ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# _pfu_wrap <width> <text>
|
||||
# Word wrap, into PFU_WRAP_LINES.
|
||||
PFU_WRAP_LINES=()
|
||||
# _fu_wrap <width> <text>
|
||||
# Word wrap, into FU_WRAP_LINES.
|
||||
FU_WRAP_LINES=()
|
||||
|
||||
_pfu_wrap() {
|
||||
local width="$1" word line=''
|
||||
_fu_wrap() {
|
||||
local width="$1" word line='' used=0 c i
|
||||
local -a words
|
||||
PFU_WRAP_LINES=()
|
||||
FU_WRAP_LINES=()
|
||||
read -r -a words <<< "$2"
|
||||
for word in "${words[@]}"; do
|
||||
if [[ -n $line ]] && (( ${#line} + 1 + ${#word} > width )); then
|
||||
PFU_WRAP_LINES+=("$line")
|
||||
line="$word"
|
||||
else
|
||||
line="${line:+$line }$word"
|
||||
_fu_width "$word"
|
||||
if (( used > 0 && used + 1 + FU_WIDTH > width )); then
|
||||
FU_WRAP_LINES+=("$line")
|
||||
line='' used=0
|
||||
fi
|
||||
(( used > 0 )) && line+=' ' used=$(( used + 1 ))
|
||||
if (( used + FU_WIDTH <= width )); then
|
||||
line+="$word" used=$(( used + FU_WIDTH ))
|
||||
continue
|
||||
fi
|
||||
# Longer than a line: Chinese and Japanese have no spaces, so break
|
||||
# between any two characters.
|
||||
for (( i = 0; i < ${#word}; i++ )); do
|
||||
c="${word:i:1}"
|
||||
_fu_width "$c"
|
||||
if (( used + FU_WIDTH > width )); then
|
||||
FU_WRAP_LINES+=("$line")
|
||||
line='' used=0
|
||||
fi
|
||||
line+="$c" used=$(( used + FU_WIDTH ))
|
||||
done
|
||||
done
|
||||
[[ -n $line ]] && PFU_WRAP_LINES+=("$line")
|
||||
[[ -n $line ]] && FU_WRAP_LINES+=("$line")
|
||||
return 0
|
||||
}
|
||||
|
||||
# _pfu_setting_value <scope> <Key> <default>
|
||||
# The current value, in PFU_SETTING_VALUE.
|
||||
PFU_SETTING_VALUE=''
|
||||
# _fu_setting_value <scope> <Key> <default>
|
||||
# The current value, in FU_SETTING_VALUE.
|
||||
FU_SETTING_VALUE=''
|
||||
|
||||
_pfu_setting_value() {
|
||||
_fu_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 ;;
|
||||
user) _fu_kv_lookup "$FU_CONFIG" "$2" "$3"; FU_SETTING_VALUE="$FU_KV_VALUE" ;;
|
||||
sys) _fu_kv_lookup "$FU_SYSCONFIG" "$2" "$3"; FU_SETTING_VALUE="$FU_KV_VALUE" ;;
|
||||
pam)
|
||||
if [[ $2 == lockscreens ]]; then
|
||||
if fu_pam_lockers_enabled; then FU_SETTING_VALUE=yes; else FU_SETTING_VALUE=no; fi
|
||||
elif fu_pam_enabled "$2"; then
|
||||
FU_SETTING_VALUE=yes
|
||||
else
|
||||
FU_SETTING_VALUE=no
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# _pfu_step <current> <step> <choice>...
|
||||
# _fu_step <current> <step> <choice>...
|
||||
# The choice <step> (1 or -1) away from the current one, round at the ends.
|
||||
_pfu_step() {
|
||||
_fu_step() {
|
||||
local current="$1" step="$2" i
|
||||
shift 2
|
||||
local -a all=("$@")
|
||||
@@ -356,98 +437,143 @@ _pfu_step() {
|
||||
printf '%s\n' "${all[0]}"
|
||||
}
|
||||
|
||||
# _pfu_next_choice <current> <a,b,c> <step>
|
||||
_pfu_next_choice() {
|
||||
# _fu_next_choice <current> <a,b,c> <step>
|
||||
_fu_next_choice() {
|
||||
local -a choices
|
||||
IFS=',' read -r -a choices <<< "$2"
|
||||
_pfu_step "$1" "$3" "${choices[@]}"
|
||||
_fu_step "$1" "$3" "${choices[@]}"
|
||||
}
|
||||
|
||||
# _pfu_next_camera <current> <step>
|
||||
_pfu_next_camera() {
|
||||
pfu_cameras_load
|
||||
_pfu_step "$1" "$2" auto "${PFU_CAM_PATHS[@]}"
|
||||
# _fu_next_camera <current> <step>
|
||||
_fu_next_camera() {
|
||||
fu_cameras_load
|
||||
_fu_step "$1" "$2" auto "${FU_CAM_PATHS[@]}"
|
||||
}
|
||||
|
||||
_pfu_camera_label() {
|
||||
_fu_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]}"
|
||||
for i in "${!FU_CAM_PATHS[@]}"; do
|
||||
if [[ ${FU_CAM_PATHS[i]} == "$FU_CAM_AUTO" ]]; then
|
||||
fu_msg "automatic (%s)" "${FU_CAM_NAMES[i]}"
|
||||
return
|
||||
fi
|
||||
done
|
||||
pfu_msg "automatic"
|
||||
fu_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)")"
|
||||
for i in "${!FU_CAM_PATHS[@]}"; do
|
||||
if [[ ${FU_CAM_PATHS[i]} == "$value" ]]; then
|
||||
printf '%s' "${FU_CAM_NAMES[i]}"
|
||||
[[ ${FU_CAM_IR[i]} == true ]] && printf ' %s' "$(fu_msg "(infrared)")"
|
||||
return
|
||||
fi
|
||||
done
|
||||
printf '%s %s' "$value" "$(pfu_msg "(not connected)")"
|
||||
printf '%s %s' "$value" "$(fu_msg "(not connected)")"
|
||||
}
|
||||
|
||||
# _pfu_setting_change <scope> <Key> <type> <current> <choices> <step>
|
||||
_pfu_setting_change() {
|
||||
local scope="$1" key="$2" type="$3" current="$4" choices="$5" step="$6" next
|
||||
# _fu_path_label <path>
|
||||
# A path as it fits in the list: with ~ for home, and cut from the left.
|
||||
# Empty is the desktop's picture.
|
||||
_fu_path_label() {
|
||||
local path="$1"
|
||||
if [[ -z $path ]]; then
|
||||
fu_msg "same as desktop"
|
||||
return
|
||||
fi
|
||||
if [[ ${path,,} == none ]]; then
|
||||
fu_msg "none"
|
||||
return
|
||||
fi
|
||||
[[ $path == "$HOME"/* ]] && path="~${path#"$HOME"}"
|
||||
(( ${#path} > 36 )) && path="…${path: -35}"
|
||||
printf '%s' "$path"
|
||||
}
|
||||
|
||||
# _fu_setting_change <scope> <Key> <type> <current> <choices> <step>
|
||||
_fu_setting_change() {
|
||||
local scope="$1" key="$2" type="$3" current="$4" options="$5" step="$6" next
|
||||
|
||||
case "$type" in
|
||||
bool) if pfu_is_true "$current"; then next=no; else next=yes; fi ;;
|
||||
choice) next="$(_pfu_next_choice "$current" "$choices" "$step")" ;;
|
||||
camera) next="$(_pfu_next_camera "$current" "$step")" ;;
|
||||
bool) if fu_is_true "$current"; then next=no; else next=yes; fi ;;
|
||||
choice) next="$(_fu_next_choice "$current" "$options" "$step")" ;;
|
||||
camera) next="$(_fu_next_camera "$current" "$step")" ;;
|
||||
lockstyle)
|
||||
# The window with the pictures where there is a screen for it,
|
||||
# else the other one of the two.
|
||||
if [[ -n ${WAYLAND_DISPLAY:-} ]]; then
|
||||
fu_ui_cooked fu_lock_choose
|
||||
elif [[ $current == own ]]; then
|
||||
fu_lock_style_set yours
|
||||
else
|
||||
fu_lock_style_set own
|
||||
fi
|
||||
return 0
|
||||
;;
|
||||
path)
|
||||
printf '\n %s ' "$(fu_msg "Picture or folder:")"
|
||||
fu_ui_read_line "$current" || return 0
|
||||
next="$FU_LINE_RESULT"
|
||||
# shellcheck disable=SC2088 # typed by the user, expanded here
|
||||
if [[ -n $next && ${next,,} != none && ! -e ${next/#\~/$HOME} ]]; then
|
||||
fu_bad "$(fu_msg "Not found: %s" "$next")"
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$scope" in
|
||||
user)
|
||||
pfu_config_set "$key" "$next" || pfu_bad "$(pfu_msg "Could not save the setting.")"
|
||||
fu_config_set "$key" "$next" || fu_bad "$(fu_msg "Could not save the setting.")"
|
||||
;;
|
||||
sys)
|
||||
printf '\n'
|
||||
pfu_ui_cooked pfu_root set "$key" "$next" || pfu_bad "$(pfu_msg "Could not save the setting.")"
|
||||
PFU_KV_CACHE=()
|
||||
fu_ui_cooked fu_root set "$key" "$next" || fu_bad "$(fu_msg "Could not save the setting.")"
|
||||
FU_KV_CACHE=()
|
||||
;;
|
||||
pam)
|
||||
printf '\n'
|
||||
if [[ $next == yes ]]; then
|
||||
pfu_ui_cooked pfu_root pam-enable "$key" || pfu_bad "$(pfu_msg "Could not save the setting.")"
|
||||
fu_ui_cooked fu_root pam-enable "$key" || fu_bad "$(fu_msg "Could not save the setting.")"
|
||||
else
|
||||
pfu_ui_cooked pfu_root pam-disable "$key" || pfu_bad "$(pfu_msg "Could not save the setting.")"
|
||||
fu_ui_cooked fu_root pam-disable "$key" || fu_bad "$(fu_msg "Could not save the setting.")"
|
||||
fi
|
||||
# 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" ;;
|
||||
sudo) fu_config_set Sudo "$next" ;;
|
||||
polkit-1) fu_config_set Polkit "$next" ;;
|
||||
lockscreens) fu_config_set LockScreens "$next" ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# pfu_ui_settings
|
||||
# fu_ui_settings
|
||||
# A cursor list in groups, with what the selected setting does under it. The
|
||||
# frame is assembled in memory and written once, and everything constant is
|
||||
# resolved before the loop.
|
||||
pfu_ui_settings() {
|
||||
local -a scopes=() keys=() types=() defaults=() labels=() choices=() needs=() values=() rows=()
|
||||
local spec scope key type default label choice need locale i j frame row pad dirty=1 cursor=0 shown
|
||||
fu_ui_settings() {
|
||||
local -a scopes=() keys=() types=() defaults=() labels=() widths=() choices=() needs=() values=() rows=()
|
||||
local spec scope key type default label choice need only locale i j frame row pad dirty=1 cursor=0 shown
|
||||
local width=0 wrap cols
|
||||
|
||||
locale="$(pfu_ui_locale)"
|
||||
for spec in "${PFU_SETTINGS[@]}"; do
|
||||
IFS='|' read -r scope key type default label choice need <<< "$spec"
|
||||
locale="$(fu_ui_locale)"
|
||||
for spec in "${FU_SETTINGS[@]}"; do
|
||||
IFS='|' read -r scope key type default label choice need only <<< "$spec"
|
||||
[[ $only == lockers ]] && ! fu_pam_lockers_here && continue
|
||||
[[ $only == ownlock ]] && ! fu_own_lock_here && continue
|
||||
# A heading has its label where the key would be.
|
||||
[[ $scope == group ]] && label="$key" key=''
|
||||
scopes+=("$scope"); keys+=("$key"); types+=("$type"); defaults+=("$default")
|
||||
choices+=("$choice"); needs+=("$need")
|
||||
pfu_msg_into "$locale" "$label"
|
||||
labels+=("$PFU_MSG_RESULT")
|
||||
fu_msg_into "$locale" "$label"
|
||||
labels+=("$FU_MSG_RESULT")
|
||||
_fu_width "$FU_MSG_RESULT"
|
||||
widths+=("$FU_WIDTH")
|
||||
if [[ $scope != group ]]; then
|
||||
rows+=($(( ${#keys[@]} - 1 )))
|
||||
(( ${#PFU_MSG_RESULT} > width )) && width=${#PFU_MSG_RESULT}
|
||||
(( FU_WIDTH > width )) && width=$FU_WIDTH
|
||||
fi
|
||||
done
|
||||
local count=${#rows[@]}
|
||||
@@ -461,77 +587,82 @@ pfu_ui_settings() {
|
||||
rule="${rule// /─}"
|
||||
|
||||
local title hint shared l_on l_off
|
||||
pfu_msg_into "$locale" "Settings"; title="$PFU_MSG_RESULT"
|
||||
pfu_msg_into "$locale" "↑↓ select ←→ or Space: change q: back"; hint="$PFU_MSG_RESULT"
|
||||
pfu_msg_into "$locale" "for all users, asks for your password"; shared="$PFU_MSG_RESULT"
|
||||
pfu_msg_into "$locale" "ON"; l_on="$PFU_MSG_RESULT"
|
||||
pfu_msg_into "$locale" "OFF"; l_off="$PFU_MSG_RESULT"
|
||||
fu_msg_into "$locale" "Settings"; title="$FU_MSG_RESULT"
|
||||
fu_msg_into "$locale" "↑↓ select ←→ or Space: change q: back"; hint="$FU_MSG_RESULT"
|
||||
fu_msg_into "$locale" "for all users, asks for your password"; shared="$FU_MSG_RESULT"
|
||||
fu_msg_into "$locale" "ON"; l_on="$FU_MSG_RESULT"
|
||||
fu_msg_into "$locale" "OFF"; l_off="$FU_MSG_RESULT"
|
||||
|
||||
local clearseq
|
||||
clearseq="$(clear 2>/dev/null)" || clearseq=$'\033[H\033[2J'
|
||||
|
||||
pfu_cameras_load
|
||||
fu_cameras_load
|
||||
|
||||
while true; do
|
||||
if (( dirty )); then
|
||||
PFU_KV_CACHE=()
|
||||
FU_KV_CACHE=()
|
||||
declare -A current=()
|
||||
for i in "${rows[@]}"; do
|
||||
_pfu_setting_value "${scopes[i]}" "${keys[i]}" "${defaults[i]}"
|
||||
values[i]="$PFU_SETTING_VALUE"
|
||||
current[${keys[i]}]="$PFU_SETTING_VALUE"
|
||||
_fu_setting_value "${scopes[i]}" "${keys[i]}" "${defaults[i]}"
|
||||
values[i]="$FU_SETTING_VALUE"
|
||||
current[${keys[i]}]="$FU_SETTING_VALUE"
|
||||
done
|
||||
dirty=0
|
||||
fi
|
||||
|
||||
frame="$clearseq"$'\n'"${PFU_C_BOLD}${PFU_C_BLUE} ${title}${PFU_C_RESET}"$'\n'
|
||||
frame="$clearseq"$'\n'"${FU_C_BOLD}${FU_C_BLUE} ${title}${FU_C_RESET}"$'\n'
|
||||
|
||||
local selected=${rows[cursor]} marker before after dim
|
||||
for i in "${!keys[@]}"; do
|
||||
if [[ ${scopes[i]} == group ]]; then
|
||||
frame+=$'\n'" ${PFU_C_BOLD}${labels[i]}${PFU_C_RESET}"
|
||||
frame+=$'\n'" ${FU_C_BOLD}${labels[i]}${FU_C_RESET}"
|
||||
# The next row says whose settings these are.
|
||||
[[ ${scopes[i + 1]} != user ]] && frame+=" ${PFU_C_DIM}${shared}${PFU_C_RESET}"
|
||||
[[ ${scopes[i + 1]} != user ]] && frame+=" ${FU_C_DIM}${shared}${FU_C_RESET}"
|
||||
frame+=$'\n'
|
||||
continue
|
||||
fi
|
||||
case "${types[i]}" in
|
||||
bool)
|
||||
if pfu_is_true "${values[i]}"; then
|
||||
shown="${PFU_C_GREEN}${l_on}${PFU_C_RESET}"
|
||||
if fu_is_true "${values[i]}"; then
|
||||
shown="${FU_C_GREEN}${l_on}${FU_C_RESET}"
|
||||
else
|
||||
shown="${PFU_C_DIM}${l_off}${PFU_C_RESET}"
|
||||
shown="${FU_C_DIM}${l_off}${FU_C_RESET}"
|
||||
fi
|
||||
;;
|
||||
camera) shown="$(_pfu_camera_label "${values[i]}")" ;;
|
||||
*) shown="$(pfu_value_label "${keys[i]}" "${values[i]}")" ;;
|
||||
camera) shown="$(_fu_camera_label "${values[i]}")" ;;
|
||||
path) shown="$(_fu_path_label "${values[i]}")" ;;
|
||||
*) shown="$(fu_value_label "${keys[i]}" "${values[i]}")" ;;
|
||||
esac
|
||||
# Arrows on the selected choice: left and right step through it.
|
||||
before=' ' after=''
|
||||
if (( i == selected )) && [[ ${types[i]} != bool ]]; then
|
||||
before="${PFU_C_BLUE}◂${PFU_C_RESET} " after=" ${PFU_C_BLUE}▸${PFU_C_RESET}"
|
||||
before="${FU_C_BLUE}◂${FU_C_RESET} " after=" ${FU_C_BLUE}▸${FU_C_RESET}"
|
||||
fi
|
||||
dim=''
|
||||
[[ -n ${needs[i]} ]] && ! pfu_is_true "${current[${needs[i]}]}" && dim="$PFU_C_DIM"
|
||||
pad=$(( width + 2 - ${#labels[i]} ))
|
||||
if (( i == selected )); then marker="${PFU_C_BLUE}▸${PFU_C_RESET} "; else marker=' '; fi
|
||||
printf -v row ' %s%s%s%s%*s%s%s%s' "$marker" "$dim" "${labels[i]}" "$PFU_C_RESET" "$pad" '' "$before" "$dim$shown$PFU_C_RESET" "$after"
|
||||
case "${needs[i]}" in
|
||||
'') ;;
|
||||
*=*) [[ ${current[${needs[i]%%=*}]} == "${needs[i]#*=}" ]] || dim="$FU_C_DIM" ;;
|
||||
*) fu_is_true "${current[${needs[i]}]}" || dim="$FU_C_DIM" ;;
|
||||
esac
|
||||
pad=$(( width + 2 - widths[i] ))
|
||||
if (( i == selected )); then marker="${FU_C_BLUE}▸${FU_C_RESET} "; else marker=' '; fi
|
||||
printf -v row ' %s%s%s%s%*s%s%s%s' "$marker" "$dim" "${labels[i]}" "$FU_C_RESET" "$pad" '' "$before" "$dim$shown$FU_C_RESET" "$after"
|
||||
frame+="$row"$'\n'
|
||||
done
|
||||
|
||||
# What the selected setting does, in a box of fixed height so the
|
||||
# screen does not jump while moving through the list.
|
||||
_pfu_wrap "$wrap" "$(pfu_setting_help "${keys[selected]}" "${values[selected]}")"
|
||||
frame+=$'\n'" ${PFU_C_DIM}${rule}${PFU_C_RESET}"$'\n'
|
||||
_fu_wrap "$wrap" "$(fu_setting_help "${keys[selected]}" "${values[selected]}")"
|
||||
frame+=$'\n'" ${FU_C_DIM}${rule}${FU_C_RESET}"$'\n'
|
||||
for j in 0 1 2; do
|
||||
frame+=" ${PFU_WRAP_LINES[j]:-}"$'\n'
|
||||
frame+=" ${FU_WRAP_LINES[j]:-}"$'\n'
|
||||
done
|
||||
frame+=$'\n'" ${PFU_C_DIM}${hint}${PFU_C_RESET}"$'\n'
|
||||
_pfu_ui_take_notices
|
||||
frame+="$PFU_UI_NOTICE_TEXT"
|
||||
frame+=$'\n'" ${FU_C_DIM}${hint}${FU_C_RESET}"$'\n'
|
||||
_fu_ui_take_notices
|
||||
frame+="$FU_UI_NOTICE_TEXT"
|
||||
printf '%s' "$frame"
|
||||
|
||||
key="$(pfu_read_key)" || return 0
|
||||
key="$(fu_read_key)" || return 0
|
||||
|
||||
case "$key" in
|
||||
up|k) cursor=$(( (cursor - 1 + count) % count )) ;;
|
||||
@@ -539,7 +670,7 @@ pfu_ui_settings() {
|
||||
space|enter|right|l|left|h)
|
||||
local step=1
|
||||
[[ $key == left || $key == h ]] && step=-1
|
||||
_pfu_setting_change "${scopes[selected]}" "${keys[selected]}" "${types[selected]}" "${values[selected]}" "${choices[selected]}" "$step"
|
||||
_fu_setting_change "${scopes[selected]}" "${keys[selected]}" "${types[selected]}" "${values[selected]}" "${choices[selected]}" "$step"
|
||||
dirty=1
|
||||
;;
|
||||
q|Q|escape) return 0 ;;
|
||||
@@ -552,70 +683,72 @@ pfu_ui_settings() {
|
||||
# Faces
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
pfu_ui_faces() {
|
||||
fu_ui_faces() {
|
||||
local key frame row pad i cursor=0 count locale shown
|
||||
|
||||
locale="$(pfu_ui_locale)"
|
||||
locale="$(fu_ui_locale)"
|
||||
local title hint empty l_on l_off
|
||||
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"
|
||||
fu_msg_into "$locale" "Faces"; title="$FU_MSG_RESULT"
|
||||
# TRANSLATORS: keep the letters, they are the keys.
|
||||
fu_msg_into "$locale" "Up/Down: select, Space: on/off, r: rename, d: delete, a: add, q: back"; hint="$FU_MSG_RESULT"
|
||||
fu_msg_into "$locale" "No face is set up yet. Press a to add one."; empty="$FU_MSG_RESULT"
|
||||
fu_msg_into "$locale" "on"; l_on="$FU_MSG_RESULT"
|
||||
fu_msg_into "$locale" "off"; l_off="$FU_MSG_RESULT"
|
||||
|
||||
local clearseq
|
||||
clearseq="$(clear 2>/dev/null)" || clearseq=$'\033[H\033[2J'
|
||||
|
||||
while true; do
|
||||
pfu_faces_load
|
||||
count=${#PFU_FACE_IDS[@]}
|
||||
fu_faces_load
|
||||
count=${#FU_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'
|
||||
frame="$clearseq"$'\n'"${FU_C_BOLD}${FU_C_BLUE} ${title}${FU_C_RESET}"$'\n\n'
|
||||
if (( count == 0 )); then
|
||||
frame+=" ${PFU_C_DIM}${empty}${PFU_C_RESET}"$'\n'
|
||||
frame+=" ${FU_C_DIM}${empty}${FU_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]} ))
|
||||
local marker selected="${FU_C_BLUE}▸${FU_C_RESET} " samples
|
||||
for i in "${!FU_FACE_IDS[@]}"; do
|
||||
if [[ ${FU_FACE_ON[i]} == true ]]; then shown="${FU_C_GREEN}${l_on}${FU_C_RESET}"; else shown="${FU_C_DIM}${l_off}${FU_C_RESET}"; fi
|
||||
samples="$(fu_msg "%s samples, %s learned" "${FU_FACE_SAMPLES[i]}" "${FU_FACE_LEARNED[i]}")"
|
||||
_fu_width "${FU_FACE_NAMES[i]}"
|
||||
pad=$(( 30 - FU_WIDTH ))
|
||||
(( 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"
|
||||
printf -v row ' %s%s%*s %s %s%s%s' "$marker" "${FU_FACE_NAMES[i]}" "$pad" '' "$shown" "$FU_C_DIM" "$samples" "$FU_C_RESET"
|
||||
frame+="$row"$'\n'
|
||||
done
|
||||
frame+=$'\n'" ${PFU_C_DIM}${hint}${PFU_C_RESET}"$'\n'
|
||||
_pfu_ui_take_notices
|
||||
frame+="$PFU_UI_NOTICE_TEXT"
|
||||
frame+=$'\n'" ${FU_C_DIM}${hint}${FU_C_RESET}"$'\n'
|
||||
_fu_ui_take_notices
|
||||
frame+="$FU_UI_NOTICE_TEXT"
|
||||
printf '%s' "$frame"
|
||||
|
||||
key="$(pfu_read_key)" || return 0
|
||||
key="$(fu_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
|
||||
if [[ ${FU_FACE_ON[cursor]} == true ]]; then
|
||||
fu_ctl disable "${FU_FACE_IDS[cursor]}" > /dev/null
|
||||
else
|
||||
pfu_ctl enable "${PFU_FACE_IDS[cursor]}" > /dev/null
|
||||
fu_ctl enable "${FU_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
|
||||
printf '\n %s ' "$(fu_msg "New name:")"
|
||||
if fu_ui_read_line "${FU_FACE_NAMES[cursor]}" && [[ -n $FU_LINE_RESULT ]]; then
|
||||
fu_ctl rename "${FU_FACE_IDS[cursor]}" "$FU_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
|
||||
if fu_ui_confirm "$(fu_msg "Delete \"%s\"?" "${FU_FACE_NAMES[cursor]}")"; then
|
||||
fu_ctl remove "${FU_FACE_IDS[cursor]}" > /dev/null
|
||||
fi
|
||||
;;
|
||||
a|A) pfu_ui_cooked pfu_do_setup ;;
|
||||
a|A) fu_ui_cooked fu_do_setup ;;
|
||||
q|Q|escape) return 0 ;;
|
||||
*) ;;
|
||||
esac
|
||||
@@ -626,11 +759,11 @@ pfu_ui_faces() {
|
||||
# The menu
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
pfu_ui_menu() {
|
||||
fu_ui_menu() {
|
||||
local choice keep=0
|
||||
|
||||
pfu_ui_term_raw
|
||||
trap 'pfu_ui_term_restore' EXIT INT TERM
|
||||
fu_ui_term_raw
|
||||
trap 'fu_ui_term_restore' EXIT INT TERM
|
||||
|
||||
while true; do
|
||||
# After a test the menu goes under what the camera saw instead.
|
||||
@@ -639,21 +772,24 @@ pfu_ui_menu() {
|
||||
else
|
||||
clear 2>/dev/null || true
|
||||
fi
|
||||
pfu_head " $PFU_PRETTY"
|
||||
pfu_ui_status
|
||||
fu_head " $FU_PRETTY"
|
||||
fu_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")"
|
||||
_pfu_ui_take_notices
|
||||
printf '%s' "$PFU_UI_NOTICE_TEXT"
|
||||
printf ' [1] %s\n' "$(fu_msg "Turn face unlock on or off")"
|
||||
printf ' [2] %s\n' "$(fu_msg "Add a face")"
|
||||
printf ' [3] %s\n' "$(fu_msg "Faces")"
|
||||
printf ' [4] %s\n' "$(fu_msg "Settings")"
|
||||
printf ' [5] %s\n' "$(fu_msg "Try it")"
|
||||
if fu_polkit_agent_missing; then
|
||||
printf ' %s[p] %s%s\n' "$FU_C_YELLOW" "$(fu_msg "Install a polkit agent")" "$FU_C_RESET"
|
||||
fi
|
||||
printf ' [q] %s\n' "$(fu_msg "Quit")"
|
||||
_fu_ui_take_notices
|
||||
printf '%s' "$FU_UI_NOTICE_TEXT"
|
||||
printf '\n > '
|
||||
|
||||
choice="$(pfu_read_key)" || {
|
||||
printf '\n'; pfu_ui_term_restore; trap - EXIT INT TERM; return 0
|
||||
choice="$(fu_read_key)" || {
|
||||
printf '\n'; fu_ui_term_restore; trap - EXIT INT TERM; return 0
|
||||
}
|
||||
case "$choice" in
|
||||
enter|space|up|down|left|right|escape) choice='' ;;
|
||||
@@ -662,24 +798,25 @@ pfu_ui_menu() {
|
||||
|
||||
case "$choice" in
|
||||
1)
|
||||
pfu_config_load
|
||||
fu_config_load
|
||||
if [[ $CFG_ENABLED == yes ]]; then
|
||||
pfu_ui_cooked pfu_do_disable
|
||||
fu_ui_cooked fu_do_disable
|
||||
else
|
||||
pfu_ui_cooked pfu_do_enable
|
||||
fu_ui_cooked fu_do_enable
|
||||
fi
|
||||
;;
|
||||
2) pfu_ui_cooked pfu_do_setup ;;
|
||||
3) pfu_ui_faces ;;
|
||||
4) pfu_ui_settings ;;
|
||||
2) fu_ui_cooked fu_do_setup ;;
|
||||
3) fu_ui_faces ;;
|
||||
4) fu_ui_settings ;;
|
||||
5)
|
||||
printf '\n'
|
||||
pfu_ui_cooked pfu_test
|
||||
fu_ui_cooked fu_test
|
||||
# Already on screen, with the rest of the test.
|
||||
PFU_UI_NOTICES=()
|
||||
FU_UI_NOTICES=()
|
||||
keep=1
|
||||
;;
|
||||
q|Q) pfu_ui_term_restore; trap - EXIT INT TERM; return 0 ;;
|
||||
p|P) fu_polkit_agent_missing && fu_ui_cooked fu_polkit_agent_fix ;;
|
||||
q|Q) fu_ui_term_restore; trap - EXIT INT TERM; return 0 ;;
|
||||
# Anything else (Enter, arrow keys, stray characters) just
|
||||
# redraws. Escape is deliberately not a quit key, so a mistyped
|
||||
# arrow key cannot close the menu.
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# Moving over from plasma-face-unlock, the old name.
|
||||
#
|
||||
# The packages run `face-unlock --root migrate` when they are installed. The
|
||||
# command runs it too (through sudo) when it finds something left over, for
|
||||
# anybody who built it from source. This user's own settings need no root and
|
||||
# are moved every time the command starts.
|
||||
|
||||
FU_OLD_NAME="plasma-face-unlock"
|
||||
FU_OLD_PAM_MARK="# plasma-face-unlock: the face first, the password if that does not work"
|
||||
FU_OLD_PAM_WRAPPER_MARK="# Written by plasma-face-unlock."
|
||||
|
||||
# Overridable for the tests only, like the PAM directories.
|
||||
FU_OLD_SYSDIR="${FU_OLD_SYSDIR:-/etc/$FU_OLD_NAME}"
|
||||
FU_OLD_STATEDIR="${FU_OLD_STATEDIR:-/var/lib/$FU_OLD_NAME}"
|
||||
FU_STATEDIR="${FU_STATEDIR:-/var/lib/$FU_NAME}"
|
||||
FU_SYSTEMD_ETC="${FU_SYSTEMD_ETC:-/etc/systemd/system}"
|
||||
|
||||
_fu_old_socket_link() {
|
||||
printf '%s/sockets.target.wants/%sd.socket\n' "$FU_SYSTEMD_ETC" "$FU_OLD_NAME"
|
||||
}
|
||||
|
||||
# _fu_pam_has_old <service>
|
||||
_fu_pam_has_old() {
|
||||
local file
|
||||
file="$(fu_pam_etc "$1")"
|
||||
[[ -r $file ]] && grep -q 'pam_plasma_face_unlock\.so' "$file"
|
||||
}
|
||||
|
||||
# _fu_pam_drop_old <service> (root)
|
||||
_fu_pam_drop_old() {
|
||||
local file content
|
||||
file="$(fu_pam_etc "$1")"
|
||||
if head -n 3 "$file" | grep -qF "$FU_OLD_PAM_WRAPPER_MARK"; then
|
||||
rm -f -- "$file"
|
||||
return
|
||||
fi
|
||||
content="$(awk -v mark="$FU_OLD_PAM_MARK" '
|
||||
$0 == mark { next }
|
||||
/pam_plasma_face_unlock\.so/ { next }
|
||||
{ print }
|
||||
' "$file")" || return 1
|
||||
_fu_pam_replace "$file" "$content"
|
||||
}
|
||||
|
||||
# fu_migrate_system (root)
|
||||
# The system settings, the faces, sudo and admin prompts and the daemon's
|
||||
# socket, as plasma-face-unlock left them.
|
||||
fu_migrate_system() {
|
||||
local service link used=0
|
||||
|
||||
if [[ -d $FU_OLD_SYSDIR ]]; then
|
||||
if [[ -f $FU_OLD_SYSDIR/config && ! -e $FU_SYSCONFIG ]]; then
|
||||
install -D -m 0644 "$FU_OLD_SYSDIR/config" "$FU_SYSCONFIG" || return 1
|
||||
fi
|
||||
rm -rf -- "$FU_OLD_SYSDIR"
|
||||
fi
|
||||
|
||||
if [[ -d $FU_OLD_STATEDIR ]]; then
|
||||
# Faces already set up under the new name win.
|
||||
if [[ -d $FU_OLD_STATEDIR/users ]] && ! compgen -G "$FU_STATEDIR/users/*" > /dev/null; then
|
||||
install -d -m 0700 "$FU_STATEDIR" || return 1
|
||||
rm -rf -- "$FU_STATEDIR/users"
|
||||
mv -- "$FU_OLD_STATEDIR/users" "$FU_STATEDIR/users" || return 1
|
||||
used=1
|
||||
fi
|
||||
rm -rf -- "$FU_OLD_STATEDIR"
|
||||
fi
|
||||
|
||||
for service in "${FU_PAM_SERVICES[@]}"; do
|
||||
_fu_pam_has_old "$service" || continue
|
||||
_fu_pam_drop_old "$service" && fu_pam_enable "$service"
|
||||
used=1
|
||||
done
|
||||
|
||||
link="$(_fu_old_socket_link)"
|
||||
if [[ -L $link ]]; then
|
||||
systemctl disable --now "${FU_OLD_NAME}d.socket" > /dev/null 2>&1 || true
|
||||
rm -f -- "$link"
|
||||
used=1
|
||||
fi
|
||||
|
||||
# The old package switches its socket off when it is removed, mostly before
|
||||
# this runs. So its faces and PAM lines count as the sign that it was used.
|
||||
if (( used )); then
|
||||
systemctl enable --now "$FU_UNIT_SOCKET" > /dev/null 2>&1 || true
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# fu_migrate_pending
|
||||
# Whether something needs fu_migrate_system.
|
||||
fu_migrate_pending() {
|
||||
local service
|
||||
[[ -d $FU_OLD_SYSDIR || -d $FU_OLD_STATEDIR || -L $(_fu_old_socket_link) ]] && return 0
|
||||
for service in "${FU_PAM_SERVICES[@]}"; do
|
||||
_fu_pam_has_old "$service" && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# fu_migrate_user
|
||||
# This user's settings and the agent's service.
|
||||
fu_migrate_user() {
|
||||
local old="$FU_XDG_CONFIG/$FU_OLD_NAME" link
|
||||
if [[ -d $old ]]; then
|
||||
if [[ -e $FU_CONFDIR ]]; then
|
||||
rm -rf -- "$old"
|
||||
else
|
||||
mv -- "$old" "$FU_CONFDIR"
|
||||
fi
|
||||
fi
|
||||
link="$FU_XDG_CONFIG/systemd/user/graphical-session.target.wants/$FU_OLD_NAME-agent.service"
|
||||
if [[ -L $link ]]; then
|
||||
systemctl --user disable --now "$FU_OLD_NAME-agent.service" > /dev/null 2>&1 || true
|
||||
rm -f -- "$link"
|
||||
fu_agent_available && fu_agent_enable
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# fu_migrate
|
||||
fu_migrate() {
|
||||
fu_migrate_user
|
||||
fu_migrate_pending || return 0
|
||||
fu_say " $(fu_msg "Moving your faces and settings over from plasma-face-unlock. That needs your password.")"
|
||||
fu_root migrate
|
||||
}
|
||||
+114
-55
@@ -1,16 +1,20 @@
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# Putting face unlock in front of sudo and polkit's admin prompts, and taking
|
||||
# it out again.
|
||||
# Putting face unlock in front of sudo, polkit's admin prompts and the lock
|
||||
# screens of Hyprland and Niri, and taking it out again.
|
||||
#
|
||||
# Two services and nothing else. The lock screen does not go through PAM at all
|
||||
# (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 lock screens of Plasma and GNOME do not go through this at all: the
|
||||
# agent opens them (see src/agent/lockcontroller.h). hyprlock, swaylock and
|
||||
# the others are programs of their own that only open themselves, so the face
|
||||
# goes into their password check, and pressing Enter on the empty field is
|
||||
# how a scan starts. The login screen stays with the password: logging in is
|
||||
# what unlocks the wallet, and a face has no password to hand it.
|
||||
#
|
||||
# The line that goes in:
|
||||
#
|
||||
# -auth sufficient /usr/lib/security/pam_plasma_face_unlock.so
|
||||
# -auth sufficient /usr/lib/security/pam_face_unlock.so
|
||||
#
|
||||
# with "lockscreen" after it for a lock screen (see the PAM module).
|
||||
#
|
||||
# "sufficient": a match lets the person in, anything else falls through to the
|
||||
# lines below as if this one were not there. The dash makes PAM skip it
|
||||
@@ -26,24 +30,27 @@
|
||||
# an update to that file still counts.
|
||||
# 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)
|
||||
FU_PAM_MARK="# face-unlock: the face first, the password if that does not work"
|
||||
FU_PAM_WRAPPER_MARK="# Written by face-unlock."
|
||||
FU_PAM_SERVICES=(sudo polkit-1)
|
||||
# Lock screens with a PAM file of their own. One that uses "login" or
|
||||
# "system-auth" directly is left alone: those also let people log in.
|
||||
FU_PAM_LOCKERS=(hyprlock swaylock gtklock waylock)
|
||||
|
||||
# Overridable for the tests only; the root side never takes them from the
|
||||
# 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}"
|
||||
# environment (see the top of face-unlock).
|
||||
FU_PAM_ETC_DIR="${FU_PAM_ETC_DIR:-/etc/pam.d}"
|
||||
read -r -a FU_PAM_VENDOR_DIRS <<< "${FU_PAM_VENDOR_DIRS:-/usr/lib/pam.d /usr/share/pam.d /lib/pam.d}"
|
||||
|
||||
pfu_pam_etc() {
|
||||
printf '%s/%s\n' "$PFU_PAM_ETC_DIR" "$1"
|
||||
fu_pam_etc() {
|
||||
printf '%s/%s\n' "$FU_PAM_ETC_DIR" "$1"
|
||||
}
|
||||
|
||||
# pfu_pam_vendor <service>
|
||||
# fu_pam_vendor <service>
|
||||
# The distribution's own copy, when it keeps one outside /etc.
|
||||
pfu_pam_vendor() {
|
||||
fu_pam_vendor() {
|
||||
local dir
|
||||
for dir in "${PFU_PAM_VENDOR_DIRS[@]}"; do
|
||||
for dir in "${FU_PAM_VENDOR_DIRS[@]}"; do
|
||||
if [[ -f $dir/$1 ]]; then
|
||||
printf '%s\n' "$dir/$1"
|
||||
return 0
|
||||
@@ -52,24 +59,75 @@ pfu_pam_vendor() {
|
||||
return 1
|
||||
}
|
||||
|
||||
pfu_pam_line() {
|
||||
printf -- '-auth sufficient %s\n' "$PFU_PAM_MODULE"
|
||||
# fu_pam_is_locker <service>
|
||||
fu_pam_is_locker() {
|
||||
local s
|
||||
for s in "${FU_PAM_LOCKERS[@]}"; do
|
||||
[[ $s == "$1" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# pfu_pam_enabled <service>
|
||||
pfu_pam_enabled() {
|
||||
# fu_pam_line <service>
|
||||
fu_pam_line() {
|
||||
if fu_pam_is_locker "$1"; then
|
||||
printf -- '-auth sufficient %s lockscreen\n' "$FU_PAM_MODULE"
|
||||
else
|
||||
printf -- '-auth sufficient %s\n' "$FU_PAM_MODULE"
|
||||
fi
|
||||
}
|
||||
|
||||
# fu_pam_lockers
|
||||
# The lock screens installed here, one per line.
|
||||
fu_pam_lockers() {
|
||||
local s
|
||||
for s in "${FU_PAM_LOCKERS[@]}"; do
|
||||
if [[ -f $(fu_pam_etc "$s") ]] || fu_pam_vendor "$s" > /dev/null; then
|
||||
printf '%s\n' "$s"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# fu_pam_lockers_list
|
||||
# The same, for people: "hyprlock, swaylock".
|
||||
fu_pam_lockers_list() {
|
||||
local list
|
||||
list="$(fu_pam_lockers | paste -sd ',')"
|
||||
printf '%s\n' "${list//,/, }"
|
||||
}
|
||||
|
||||
# fu_pam_lockers_here
|
||||
# Whether this desktop's lock screen is a program of its own (Hyprland,
|
||||
# Niri and the like), and one with a PAM file is installed.
|
||||
fu_pam_lockers_here() {
|
||||
[[ $FU_DESKTOP != plasma && $FU_DESKTOP != gnome && -n $(fu_pam_lockers) ]]
|
||||
}
|
||||
|
||||
# fu_pam_lockers_enabled
|
||||
# Whether every lock screen installed here takes the face, and there is one.
|
||||
fu_pam_lockers_enabled() {
|
||||
local s found=0
|
||||
while IFS= read -r s; do
|
||||
found=1
|
||||
fu_pam_enabled "$s" || return 1
|
||||
done < <(fu_pam_lockers)
|
||||
(( found ))
|
||||
}
|
||||
|
||||
# fu_pam_enabled <service>
|
||||
fu_pam_enabled() {
|
||||
local file
|
||||
file="$(pfu_pam_etc "$1")"
|
||||
[[ -r $file ]] && grep -q 'pam_plasma_face_unlock\.so' "$file"
|
||||
file="$(fu_pam_etc "$1")"
|
||||
[[ -r $file ]] && grep -q 'pam_face_unlock\.so' "$file"
|
||||
}
|
||||
|
||||
# pfu_pam_insert <file>
|
||||
# fu_pam_insert <file> <service>
|
||||
# Prints the file with the line added before its first auth line. Debian and
|
||||
# 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)" '
|
||||
fu_pam_insert() {
|
||||
awk -v mark="$FU_PAM_MARK" -v line="$(fu_pam_line "$2")" '
|
||||
!done && ($0 ~ /^[[:space:]]*-?auth[[:space:]]/ || $0 ~ /^[[:space:]]*@include[[:space:]]+common-auth([[:space:]]|$)/) {
|
||||
print mark
|
||||
print line
|
||||
@@ -85,35 +143,36 @@ pfu_pam_insert() {
|
||||
' "$1"
|
||||
}
|
||||
|
||||
# pfu_pam_remove_lines <file>
|
||||
# fu_pam_remove_lines <file>
|
||||
# Prints the file without our comment and our line.
|
||||
pfu_pam_remove_lines() {
|
||||
awk -v mark="$PFU_PAM_MARK" '
|
||||
fu_pam_remove_lines() {
|
||||
awk -v mark="$FU_PAM_MARK" '
|
||||
$0 == mark { next }
|
||||
/pam_plasma_face_unlock\.so/ { next }
|
||||
/pam_face_unlock\.so/ { next }
|
||||
{ print }
|
||||
' "$1"
|
||||
}
|
||||
|
||||
pfu_pam_wrapper() {
|
||||
# fu_pam_wrapper <vendor file> <service>
|
||||
fu_pam_wrapper() {
|
||||
local vendor="$1"
|
||||
printf '#%%PAM-1.0\n'
|
||||
printf '%s The face first, then everything\n' "$PFU_PAM_WRAPPER_MARK"
|
||||
printf '%s The face first, then everything\n' "$FU_PAM_WRAPPER_MARK"
|
||||
printf '# %s does for this service. Removed again by\n' "$vendor"
|
||||
printf '# "plasma-face-unlock disable" or from its settings.\n\n'
|
||||
pfu_pam_line
|
||||
printf '# "face-unlock disable" or from its settings.\n\n'
|
||||
fu_pam_line "$2"
|
||||
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>
|
||||
# _fu_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() {
|
||||
_fu_pam_replace() {
|
||||
local file="$1" content="$2" tmp
|
||||
tmp="$(mktemp "${file}.pfu.XXXXXX")" || return 1
|
||||
tmp="$(mktemp "${file}.fu.XXXXXX")" || return 1
|
||||
printf '%s\n' "$content" > "$tmp" || { rm -f "$tmp"; return 1; }
|
||||
if [[ -e $file ]]; then
|
||||
chown --reference="$file" "$tmp" 2>/dev/null
|
||||
@@ -124,40 +183,40 @@ _pfu_pam_replace() {
|
||||
mv -f "$tmp" "$file"
|
||||
}
|
||||
|
||||
# pfu_pam_enable <service> (root)
|
||||
pfu_pam_enable() {
|
||||
# fu_pam_enable <service> (root)
|
||||
fu_pam_enable() {
|
||||
local service="$1" file vendor content
|
||||
file="$(pfu_pam_etc "$service")"
|
||||
file="$(fu_pam_etc "$service")"
|
||||
|
||||
[[ -e $PFU_PAM_MODULE ]] || { pfu_bad "$(pfu_msg "The PAM module is not installed at %s." "$PFU_PAM_MODULE")"; return 1; }
|
||||
pfu_pam_enabled "$service" && return 0
|
||||
[[ -e $FU_PAM_MODULE ]] || { fu_bad "$(fu_msg "The PAM module is not installed at %s." "$FU_PAM_MODULE")"; return 1; }
|
||||
fu_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")"
|
||||
content="$(fu_pam_insert "$file" "$service")" || return 1
|
||||
elif vendor="$(fu_pam_vendor "$service")"; then
|
||||
content="$(fu_pam_wrapper "$vendor" "$service")"
|
||||
else
|
||||
pfu_bad "$(pfu_msg "There is no PAM configuration for %s on this system." "$service")"
|
||||
fu_bad "$(fu_msg "There is no PAM configuration for %s on this system." "$service")"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_pfu_pam_replace "$file" "$content"
|
||||
_fu_pam_replace "$file" "$content"
|
||||
}
|
||||
|
||||
# pfu_pam_disable <service> (root)
|
||||
pfu_pam_disable() {
|
||||
# fu_pam_disable <service> (root)
|
||||
fu_pam_disable() {
|
||||
local service="$1" file content
|
||||
file="$(pfu_pam_etc "$service")"
|
||||
file="$(fu_pam_etc "$service")"
|
||||
|
||||
pfu_pam_enabled "$service" || return 0
|
||||
fu_pam_enabled "$service" || return 0
|
||||
|
||||
if head -n 3 "$file" | grep -qF "$PFU_PAM_WRAPPER_MARK"; then
|
||||
if head -n 3 "$file" | grep -qF "$FU_PAM_WRAPPER_MARK"; then
|
||||
# Ours from the first line to the last. Without it the distribution's
|
||||
# own copy is in charge again.
|
||||
rm -f -- "$file"
|
||||
return
|
||||
fi
|
||||
|
||||
content="$(pfu_pam_remove_lines "$file")" || return 1
|
||||
_pfu_pam_replace "$file" "$content"
|
||||
content="$(fu_pam_remove_lines "$file")" || return 1
|
||||
_fu_pam_replace "$file" "$content"
|
||||
}
|
||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user