Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc0ced901a | ||
|
|
68e7717b6a | ||
|
|
cd81b1c57a | ||
|
|
2413cfa00f | ||
|
|
70ea1437ee | ||
|
|
5b46b8f70a | ||
|
|
da532e711f | ||
|
|
c1cf6cd948 | ||
|
|
d8bbefed8a | ||
|
|
658adb7da7 | ||
|
|
5eb9ea7438 | ||
|
|
ac38638dd4 | ||
|
|
a6f6d1fab4 | ||
|
|
fd8449ea31 | ||
|
|
0a48ba9338 | ||
|
|
c94a94aa8b | ||
|
|
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.1.0
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: install
|
||||
attributes:
|
||||
label: How did you install it?
|
||||
options:
|
||||
- AUR (Arch, CachyOS, EndeavourOS, Manjaro)
|
||||
- Fedora package
|
||||
- Debian or Ubuntu package
|
||||
- Built from source
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: system
|
||||
attributes:
|
||||
label: System
|
||||
description: Distribution, desktop and its version, and on Hyprland or Niri the lock screen.
|
||||
placeholder: CachyOS, Hyprland 0.56, hyprlock
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Status and logs
|
||||
description: The output of `face-unlock status`. If a scan goes wrong, also `journalctl -b -u face-unlockd` and `journalctl --user -b -u face-unlock-agent`.
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: more
|
||||
attributes:
|
||||
label: Anything else?
|
||||
description: Screenshots, videos or anything else that could help.
|
||||
@@ -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:
|
||||
@@ -127,9 +127,47 @@ jobs:
|
||||
dist/rpm-signer.asc
|
||||
if-no-files-found: error
|
||||
|
||||
tarball:
|
||||
name: Arch Linux tarball
|
||||
runs-on: ubuntu-latest
|
||||
# Built on Arch itself, because the agent only fits the Qt it was built
|
||||
# against. OpenCV is linked in, so no opencv here.
|
||||
container: archlinux:latest
|
||||
steps:
|
||||
- name: Install the build tools
|
||||
run: |
|
||||
pacman -Syu --noconfirm --needed git base-devel cmake pkgconf curl zstd \
|
||||
qt6-base qt6-declarative qt6-wayland layer-shell-qt ki18n \
|
||||
pam systemd-libs gettext scdoc
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Check the tag against the Makefile
|
||||
run: packaging/check-version.sh "${{ github.ref_name }}"
|
||||
|
||||
- run: packaging/build-opencv.sh
|
||||
|
||||
- run: packaging/build-tarball.sh
|
||||
|
||||
- name: Install it and run it
|
||||
run: |
|
||||
tar -xf dist/*.tar.zst --strip-components=1 -C /
|
||||
for f in /usr/lib/face-unlock/* /usr/lib/security/pam_face_unlock.so; do
|
||||
if ldd "$f" | grep 'not found'; then echo "$f is missing a library"; exit 1; fi
|
||||
done
|
||||
/usr/lib/face-unlock/face-unlockd --version
|
||||
useradd -m tester
|
||||
runuser -u tester -- face-unlock --version
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: tarball
|
||||
path: dist/*.tar.zst
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Release and repositories
|
||||
needs: [deb, rpm]
|
||||
needs: [deb, rpm, tarball]
|
||||
if: startsWith(github.ref, 'refs/tags/v') || inputs.dry_run
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -152,9 +190,9 @@ jobs:
|
||||
gh release create "${{ github.ref_name }}" \
|
||||
--title "$(.github/release-notes.sh --title "${{ github.ref_name }}")" \
|
||||
--notes-file notes.md \
|
||||
incoming/*.deb incoming/*.rpm \
|
||||
incoming/*.deb incoming/*.rpm incoming/*.tar.zst \
|
||||
|| gh release upload "${{ github.ref_name }}" \
|
||||
incoming/*.deb incoming/*.rpm --clobber
|
||||
incoming/*.deb incoming/*.rpm incoming/*.tar.zst --clobber
|
||||
|
||||
- name: Install the repository tools
|
||||
run: |
|
||||
@@ -239,7 +277,7 @@ jobs:
|
||||
- name: Debian 13
|
||||
image: debian:trixie
|
||||
codename: trixie
|
||||
- name: Kubuntu 26.04
|
||||
- name: Ubuntu 26.04
|
||||
image: ubuntu:26.04
|
||||
codename: resolute
|
||||
container: ${{ matrix.image }}
|
||||
@@ -254,13 +292,13 @@ jobs:
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y --no-install-recommends gpg
|
||||
install -d -m 0755 /etc/apt/keyrings
|
||||
gpg --dearmor -o /etc/apt/keyrings/plasma-face-unlock.gpg < pages/KEY.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/plasma-face-unlock.gpg] file://$PWD/pages/deb/${{ matrix.codename }} ./" \
|
||||
> /etc/apt/sources.list.d/plasma-face-unlock.list
|
||||
gpg --dearmor -o /etc/apt/keyrings/face-unlock.gpg < pages/KEY.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/face-unlock.gpg] file://$PWD/pages/deb/${{ matrix.codename }} ./" \
|
||||
> /etc/apt/sources.list.d/face-unlock.list
|
||||
apt-get update
|
||||
apt-get install -y plasma-face-unlock
|
||||
apt-get install -y face-unlock
|
||||
useradd -m tester
|
||||
runuser -u tester -- plasma-face-unlock --version
|
||||
runuser -u tester -- face-unlock --version
|
||||
|
||||
verify-dnf:
|
||||
name: Install from the RPM repository
|
||||
@@ -281,15 +319,15 @@ jobs:
|
||||
run: |
|
||||
rpm --import pages/KEY.gpg
|
||||
rpm --import signer/rpm-signer.asc
|
||||
cat > /etc/yum.repos.d/plasma-face-unlock.repo <<EOF
|
||||
[plasma-face-unlock]
|
||||
name=plasma-face-unlock
|
||||
cat > /etc/yum.repos.d/face-unlock.repo <<EOF
|
||||
[face-unlock]
|
||||
name=face-unlock
|
||||
baseurl=file://$PWD/pages/rpm
|
||||
enabled=1
|
||||
gpgcheck=1
|
||||
repo_gpgcheck=1
|
||||
gpgkey=file://$PWD/pages/KEY.gpg
|
||||
EOF
|
||||
dnf install -y plasma-face-unlock util-linux
|
||||
dnf install -y face-unlock util-linux
|
||||
useradd -m tester
|
||||
runuser -u tester -- plasma-face-unlock --version
|
||||
runuser -u tester -- face-unlock --version
|
||||
+44
-2
@@ -1,6 +1,48 @@
|
||||
# Changelog
|
||||
|
||||
What each release brings, newest first. The [GitHub releases](https://github.com/LoonixTools/plasma-face-unlock/releases) add every commit that went into it.
|
||||
What each release brings, newest first. The [GitHub releases](https://github.com/LoonixTools/face-unlock/releases) add every commit that went into it.
|
||||
|
||||
## v2.1.0
|
||||
|
||||
_2026-09-28_
|
||||
|
||||
Welcome to face-unlock `v2.1.0`! It now gets out of the way during video calls: when another app uses the camera, you type your password right away. sudo and admin prompts take your face from the start.
|
||||
|
||||
<p align="center">
|
||||
<img height="320" alt="The bubble over the lock screen with a crossed-out camera and the words Camera in use" src="https://raw.githubusercontent.com/LoonixTools/face-unlock/70ea1437eee61f10ca1f509faa6534f9b6b6a6f5/res/screenshots/camera-busy.png">
|
||||
<img height="320" alt="The settings in Konsole with the new rows In SSH sessions and Quiet while the camera is in use" src="https://raw.githubusercontent.com/LoonixTools/face-unlock/v2.1.0/res/screenshots/settings.png">
|
||||
</p>
|
||||
|
||||
### Highlights
|
||||
|
||||
- A crossed-out camera when another app uses it
|
||||
- A setting to stay quiet while the camera is in use
|
||||
- sudo and admin prompts are on by default
|
||||
- A setting for sudo over SSH, off by default
|
||||
|
||||
## v2.0.0
|
||||
|
||||
_2026-09-26_
|
||||
|
||||
Welcome to face-unlock `v2.0.0`! plasma-face-unlock has a new name, because it no longer needs Plasma: it now works on GNOME, Hyprland and Niri too.
|
||||
|
||||
<p align="center">
|
||||
<img width="480" alt="The window that asks which lock screen to use on Hyprland: face-unlock's own with the bubble, or your own hyprlock with a line of text at the top" src="https://raw.githubusercontent.com/LoonixTools/face-unlock/v2.0.0/res/screenshots/lock-choice.png">
|
||||
</p>
|
||||
|
||||
### 🚨 Breaking changes
|
||||
|
||||
- plasma-face-unlock is now called face-unlock, and so is the command. Your faces and settings move over on their own.
|
||||
- On Arch, `yay -S face-unlock` replaces the old package.
|
||||
- On Fedora, Debian and Ubuntu the repository moved too. Remove the old `plasma-face-unlock` repository file and add the new one from the [install steps](https://github.com/LoonixTools/face-unlock#install).
|
||||
|
||||
### Highlights
|
||||
|
||||
- Works on GNOME, Hyprland and Niri
|
||||
- Unlock hyprlock, swaylock, gtklock and waylock with your face
|
||||
- A lock screen of its own, with the bubble and your wallpaper
|
||||
- 12 new languages
|
||||
- A warning when no polkit agent runs
|
||||
|
||||
## v1.0.1
|
||||
|
||||
@@ -17,7 +59,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
|
||||
@@ -74,6 +78,12 @@ when you press Enter." The list of commits follows on its own.
|
||||
- To change an old release: edit its entry, commit, then
|
||||
`gh release edit <tag> --title <tag> --notes "$(.github/release-notes.sh <tag>)"`.
|
||||
|
||||
## README
|
||||
|
||||
- New UI (a window, a screen, a menu, a setting, a notification) gets a screenshot in the README,
|
||||
next to the text about it. Like for releases: a real screenshot of it running, in English.
|
||||
- When a screen changes, take its screenshot again. The README never shows an old one.
|
||||
|
||||
## Testing
|
||||
|
||||
Never test against the real setup: enrolling and the PAM files belong to the user's machine.
|
||||
@@ -82,7 +92,10 @@ Never test against the real setup: enrolling and the PAM files belong to the use
|
||||
PAM file editing against copies of real PAM files. No camera, no root.
|
||||
- Without a camera, point the daemon at pictures or a video: `Camera=images:<dir>` or
|
||||
`Camera=file:<video>` in the config passed to `--config`.
|
||||
- A development daemon needs no root: `plasma-face-unlockd --socket $XDG_RUNTIME_DIR/pfu/socket
|
||||
- A development daemon needs no root: `face-unlockd --socket $XDG_RUNTIME_DIR/fu/socket
|
||||
--state-dir DIR --config FILE --models DIR`. It skips polkit when it does not run as root. Point
|
||||
the other parts at it with `PFU_SOCKET`.
|
||||
- `make check` after every change. New strings: `po/update-pot.sh`, then translate them in `po/de.po`.
|
||||
the other parts at it with `FU_SOCKET`.
|
||||
- face-unlock's own lock screen checks the password with PAM. `FU_PAM_CONFDIR=DIR` points it at a
|
||||
`face-unlock-lock` file of its own (pam_permit, pam_deny), so no test counts as a wrong password
|
||||
for the real account.
|
||||
- `make check` after every change. New strings: `po/update-pot.sh`, then translate them in every `po/*.po`.
|
||||
+99
-59
@@ -1,4 +1,4 @@
|
||||
# plasma-face-unlock: the compiled half
|
||||
# face-unlock: the compiled half
|
||||
#
|
||||
# The Makefile is the entry point and calls this. Everything that has to be
|
||||
# compiled lives here: the daemon, the agent, the client the shell code uses,
|
||||
@@ -6,9 +6,9 @@
|
||||
# installed by the Makefile, which is where the paths come from.
|
||||
|
||||
cmake_minimum_required(VERSION 3.22)
|
||||
project(plasma-face-unlock VERSION 1.0.1 LANGUAGES C CXX)
|
||||
project(face-unlock VERSION 2.1.0 LANGUAGES C CXX)
|
||||
|
||||
set(PFU_VERSION "${PROJECT_VERSION}" CACHE STRING "Version reported by every binary")
|
||||
set(FU_VERSION "${PROJECT_VERSION}" CACHE STRING "Version reported by every binary")
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
@@ -21,19 +21,19 @@ endif()
|
||||
|
||||
include(GNUInstallDirs)
|
||||
|
||||
option(PFU_BUILD_AGENT "Build the Plasma agent (bubble, lock screen, set up window)" ON)
|
||||
option(PFU_BUILD_PAM "Build the PAM module" ON)
|
||||
option(PFU_BUILD_TESTS "Build the tests" ON)
|
||||
option(FU_BUILD_AGENT "Build the agent (bubble, lock screen, set up window)" ON)
|
||||
option(FU_BUILD_PAM "Build the PAM module" ON)
|
||||
option(FU_BUILD_TESTS "Build the tests" ON)
|
||||
|
||||
# Where things go at run time. The defaults suit a normal install into /usr;
|
||||
# the Makefile passes its own values so the two never disagree.
|
||||
set(PFU_LIBEXECDIR "${CMAKE_INSTALL_PREFIX}/lib/plasma-face-unlock" CACHE PATH "Helper programs")
|
||||
set(PFU_MODELDIR "${CMAKE_INSTALL_FULL_DATADIR}/plasma-face-unlock/models" CACHE PATH "Neural network models")
|
||||
set(PFU_LOCALEDIR "${CMAKE_INSTALL_FULL_LOCALEDIR}" CACHE PATH "Translations")
|
||||
set(PFU_SOCKET "/run/plasma-face-unlock/socket" CACHE STRING "The daemon's socket")
|
||||
set(PFU_STATEDIR "/var/lib/plasma-face-unlock" CACHE PATH "Face data")
|
||||
set(PFU_CONFIG "/etc/plasma-face-unlock/config" CACHE FILEPATH "System settings")
|
||||
set(PFU_PAMDIR "${CMAKE_INSTALL_PREFIX}/lib/security" CACHE PATH "Where PAM modules live")
|
||||
set(FU_LIBEXECDIR "${CMAKE_INSTALL_PREFIX}/lib/face-unlock" CACHE PATH "Helper programs")
|
||||
set(FU_MODELDIR "${CMAKE_INSTALL_FULL_DATADIR}/face-unlock/models" CACHE PATH "Neural network models")
|
||||
set(FU_LOCALEDIR "${CMAKE_INSTALL_FULL_LOCALEDIR}" CACHE PATH "Translations")
|
||||
set(FU_SOCKET "/run/face-unlock/socket" CACHE STRING "The daemon's socket")
|
||||
set(FU_STATEDIR "/var/lib/face-unlock" CACHE PATH "Face data")
|
||||
set(FU_CONFIG "/etc/face-unlock/config" CACHE FILEPATH "System settings")
|
||||
set(FU_PAMDIR "${CMAKE_INSTALL_PREFIX}/lib/security" CACHE PATH "Where PAM modules live")
|
||||
|
||||
configure_file(src/shared/buildconfig.h.in ${CMAKE_CURRENT_BINARY_DIR}/buildconfig.h @ONLY)
|
||||
include_directories(${CMAKE_CURRENT_BINARY_DIR} src/shared)
|
||||
@@ -47,35 +47,35 @@ add_compile_options(-Wall -Wextra -Wno-unused-parameter)
|
||||
find_package(Qt6 6.5 REQUIRED COMPONENTS Core DBus Network)
|
||||
# OpenCV 5 split the contour and transform helpers into "geometry".
|
||||
find_package(OpenCV REQUIRED COMPONENTS core)
|
||||
set(PFU_OPENCV_MODULES core imgproc imgcodecs videoio objdetect dnn)
|
||||
set(FU_OPENCV_MODULES core imgproc imgcodecs videoio objdetect dnn)
|
||||
if(OpenCV_VERSION_MAJOR GREATER_EQUAL 5)
|
||||
list(APPEND PFU_OPENCV_MODULES geometry)
|
||||
list(APPEND FU_OPENCV_MODULES geometry)
|
||||
endif()
|
||||
find_package(OpenCV REQUIRED COMPONENTS ${PFU_OPENCV_MODULES})
|
||||
find_package(OpenCV REQUIRED COMPONENTS ${FU_OPENCV_MODULES})
|
||||
|
||||
# The settings file reader, shared with the agent (which has no use for
|
||||
# OpenCV).
|
||||
add_library(pfu_common STATIC src/core/keyvalue.cpp)
|
||||
target_include_directories(pfu_common PUBLIC src/core)
|
||||
target_link_libraries(pfu_common PUBLIC Qt6::Core)
|
||||
set_target_properties(pfu_common PROPERTIES POSITION_INDEPENDENT_CODE ON)
|
||||
add_library(fu_common STATIC src/core/keyvalue.cpp)
|
||||
target_include_directories(fu_common PUBLIC src/core)
|
||||
target_link_libraries(fu_common PUBLIC Qt6::Core)
|
||||
set_target_properties(fu_common PROPERTIES POSITION_INDEPENDENT_CODE ON)
|
||||
|
||||
add_library(pfu_core STATIC
|
||||
add_library(fu_core STATIC
|
||||
src/core/settings.cpp
|
||||
src/core/camera.cpp
|
||||
src/core/vision.cpp
|
||||
src/core/liveness.cpp
|
||||
src/core/store.cpp
|
||||
)
|
||||
target_include_directories(pfu_core PUBLIC src/core ${OpenCV_INCLUDE_DIRS})
|
||||
target_link_libraries(pfu_core PUBLIC pfu_common Qt6::Core ${OpenCV_LIBS})
|
||||
set_target_properties(pfu_core PROPERTIES POSITION_INDEPENDENT_CODE ON)
|
||||
target_include_directories(fu_core PUBLIC src/core ${OpenCV_INCLUDE_DIRS})
|
||||
target_link_libraries(fu_core PUBLIC fu_common Qt6::Core ${OpenCV_LIBS})
|
||||
set_target_properties(fu_core PROPERTIES POSITION_INDEPENDENT_CODE ON)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The daemon
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
add_executable(plasma-face-unlockd
|
||||
add_executable(face-unlockd
|
||||
src/daemon/job.h
|
||||
src/daemon/agentlink.cpp
|
||||
src/daemon/main.cpp
|
||||
@@ -87,38 +87,40 @@ add_executable(plasma-face-unlockd
|
||||
src/daemon/userstate.cpp
|
||||
src/daemon/system.cpp
|
||||
)
|
||||
target_link_libraries(plasma-face-unlockd PRIVATE pfu_core Qt6::DBus Qt6::Network)
|
||||
install(TARGETS plasma-face-unlockd DESTINATION ${PFU_LIBEXECDIR})
|
||||
target_link_libraries(face-unlockd PRIVATE fu_core Qt6::DBus Qt6::Network)
|
||||
install(TARGETS face-unlockd DESTINATION ${FU_LIBEXECDIR})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The client the shell code uses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
add_executable(plasma-face-unlock-ctl src/ctl/main.cpp)
|
||||
target_link_libraries(plasma-face-unlock-ctl PRIVATE Qt6::Core Qt6::Network)
|
||||
install(TARGETS plasma-face-unlock-ctl DESTINATION ${PFU_LIBEXECDIR})
|
||||
add_executable(face-unlock-ctl src/ctl/main.cpp)
|
||||
target_link_libraries(face-unlock-ctl PRIVATE Qt6::Core Qt6::Network)
|
||||
install(TARGETS face-unlock-ctl DESTINATION ${FU_LIBEXECDIR})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The PAM module
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if(PFU_BUILD_PAM)
|
||||
find_path(PAM_INCLUDE_DIR security/pam_modules.h REQUIRED)
|
||||
find_library(PAM_LIBRARY pam REQUIRED)
|
||||
# The PAM module, and the agent's own lock screen, which checks the password.
|
||||
find_path(PAM_INCLUDE_DIR security/pam_modules.h REQUIRED)
|
||||
find_library(PAM_LIBRARY pam REQUIRED)
|
||||
|
||||
if(FU_BUILD_PAM)
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(SYSTEMD IMPORTED_TARGET libsystemd)
|
||||
|
||||
add_library(pam_plasma_face_unlock MODULE src/pam/pam_plasma_face_unlock.c)
|
||||
set_target_properties(pam_plasma_face_unlock PROPERTIES PREFIX "" C_VISIBILITY_PRESET hidden)
|
||||
target_include_directories(pam_plasma_face_unlock PRIVATE ${PAM_INCLUDE_DIR})
|
||||
target_link_libraries(pam_plasma_face_unlock PRIVATE ${PAM_LIBRARY})
|
||||
add_library(pam_face_unlock MODULE src/pam/pam_face_unlock.c)
|
||||
set_target_properties(pam_face_unlock PROPERTIES PREFIX "" C_VISIBILITY_PRESET hidden)
|
||||
target_include_directories(pam_face_unlock PRIVATE ${PAM_INCLUDE_DIR})
|
||||
target_link_libraries(pam_face_unlock PRIVATE ${PAM_LIBRARY})
|
||||
if(SYSTEMD_FOUND)
|
||||
target_compile_definitions(pam_plasma_face_unlock PRIVATE HAVE_SYSTEMD=1)
|
||||
target_link_libraries(pam_plasma_face_unlock PRIVATE PkgConfig::SYSTEMD)
|
||||
target_compile_definitions(pam_face_unlock PRIVATE HAVE_SYSTEMD=1)
|
||||
target_link_libraries(pam_face_unlock PRIVATE PkgConfig::SYSTEMD)
|
||||
endif()
|
||||
install(TARGETS pam_plasma_face_unlock DESTINATION ${PFU_PAMDIR})
|
||||
install(TARGETS pam_face_unlock DESTINATION ${FU_PAMDIR})
|
||||
|
||||
if(PFU_BUILD_TESTS)
|
||||
if(FU_BUILD_TESTS)
|
||||
add_executable(pam_harness tests/pam_harness.c)
|
||||
target_include_directories(pam_harness PRIVATE ${PAM_INCLUDE_DIR})
|
||||
target_link_libraries(pam_harness PRIVATE ${PAM_LIBRARY})
|
||||
@@ -129,7 +131,7 @@ endif()
|
||||
# The agent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if(PFU_BUILD_AGENT)
|
||||
if(FU_BUILD_AGENT)
|
||||
find_package(Qt6 6.5 REQUIRED COMPONENTS Gui Quick WaylandClient)
|
||||
# The bubble needs the Wayland surface of its window, which only the
|
||||
# private API hands out. It is the same one Plasma itself uses for this.
|
||||
@@ -142,38 +144,48 @@ if(PFU_BUILD_AGENT)
|
||||
find_package(LayerShellQt REQUIRED)
|
||||
find_package(KF6I18n REQUIRED)
|
||||
|
||||
qt_add_executable(plasma-face-unlock-agent
|
||||
qt_add_executable(face-unlock-agent
|
||||
src/agent/main.cpp
|
||||
src/agent/daemonclient.cpp
|
||||
src/agent/userconfig.cpp
|
||||
src/agent/agentsocket.cpp
|
||||
src/agent/bubblewindow.cpp
|
||||
src/agent/bubblecontroller.cpp
|
||||
src/agent/bubbleservice.cpp
|
||||
src/agent/lockcontroller.cpp
|
||||
src/agent/lockwatcher.cpp
|
||||
src/agent/lockers.cpp
|
||||
src/agent/inputwatcher.cpp
|
||||
src/agent/wayland.cpp
|
||||
src/agent/sessionlock.cpp
|
||||
src/agent/lockscreencontroller.cpp
|
||||
src/agent/wallpaper.cpp
|
||||
src/agent/locktext.cpp
|
||||
src/agent/lockpreview.cpp
|
||||
src/agent/enrollcontroller.cpp
|
||||
)
|
||||
target_include_directories(plasma-face-unlock-agent PRIVATE src/agent)
|
||||
target_include_directories(face-unlock-agent PRIVATE src/agent)
|
||||
if(LayerShellQt_VERSION VERSION_GREATER_EQUAL 6.6)
|
||||
target_compile_definitions(plasma-face-unlock-agent PRIVATE PFU_LAYERSHELL_HAS_SCREEN)
|
||||
target_compile_definitions(face-unlock-agent PRIVATE FU_LAYERSHELL_HAS_SCREEN)
|
||||
endif()
|
||||
|
||||
qt6_generate_wayland_protocol_client_sources(plasma-face-unlock-agent
|
||||
qt6_generate_wayland_protocol_client_sources(face-unlock-agent
|
||||
FILES ${CMAKE_CURRENT_SOURCE_DIR}/protocols/kde-lockscreen-overlay-v1.xml
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/protocols/ext-idle-notify-v1.xml)
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/protocols/ext-idle-notify-v1.xml
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/protocols/ext-session-lock-v1.xml)
|
||||
|
||||
# The QML files sit next to the module's qmldir in the resources, so they
|
||||
# see each other (and the Theme singleton) without importing anything.
|
||||
# Under /qt/qml, which the engine searches, or an installed agent finds no
|
||||
# types (the build directory only works by its PlasmaFaceUnlock/qmldir).
|
||||
set(PFU_QML_FILES Theme FaceGlyph LockGlyph Checkmark Bubble TickRing Enroll PillButton)
|
||||
foreach(f ${PFU_QML_FILES})
|
||||
# types (the build directory only works by its FaceUnlock/qmldir).
|
||||
set(FU_QML_FILES Theme FaceGlyph LockGlyph CameraGlyph Checkmark Bubble TickRing Enroll PillButton LockScreen LockChoice LockPreview)
|
||||
foreach(f ${FU_QML_FILES})
|
||||
set_source_files_properties(src/agent/qml/${f}.qml PROPERTIES QT_RESOURCE_ALIAS ${f}.qml)
|
||||
endforeach()
|
||||
set_source_files_properties(src/agent/qml/Theme.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
|
||||
|
||||
qt_add_qml_module(plasma-face-unlock-agent
|
||||
URI PlasmaFaceUnlock
|
||||
qt_add_qml_module(face-unlock-agent
|
||||
URI FaceUnlock
|
||||
VERSION 1.0
|
||||
RESOURCE_PREFIX /qt/qml
|
||||
SOURCES
|
||||
@@ -183,38 +195,66 @@ if(PFU_BUILD_AGENT)
|
||||
src/agent/qml/Theme.qml
|
||||
src/agent/qml/FaceGlyph.qml
|
||||
src/agent/qml/LockGlyph.qml
|
||||
src/agent/qml/CameraGlyph.qml
|
||||
src/agent/qml/Checkmark.qml
|
||||
src/agent/qml/Bubble.qml
|
||||
src/agent/qml/TickRing.qml
|
||||
src/agent/qml/Enroll.qml
|
||||
src/agent/qml/PillButton.qml
|
||||
src/agent/qml/LockScreen.qml
|
||||
src/agent/qml/LockChoice.qml
|
||||
src/agent/qml/LockPreview.qml
|
||||
)
|
||||
|
||||
target_link_libraries(plasma-face-unlock-agent PRIVATE
|
||||
target_link_libraries(face-unlock-agent PRIVATE
|
||||
Qt6::Gui Qt6::GuiPrivate Qt6::Quick Qt6::DBus Qt6::Network
|
||||
Qt6::WaylandClient Qt6::WaylandClientPrivate
|
||||
LayerShellQt::Interface
|
||||
KF6::I18n KF6::I18nQml
|
||||
pfu_common
|
||||
fu_common
|
||||
${PAM_LIBRARY}
|
||||
)
|
||||
install(TARGETS plasma-face-unlock-agent DESTINATION ${PFU_LIBEXECDIR})
|
||||
target_include_directories(face-unlock-agent PRIVATE ${PAM_INCLUDE_DIR})
|
||||
install(TARGETS face-unlock-agent DESTINATION ${FU_LIBEXECDIR})
|
||||
endif()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if(PFU_BUILD_TESTS)
|
||||
if(FU_BUILD_TESTS)
|
||||
enable_testing()
|
||||
|
||||
add_executable(test_liveness tests/test_liveness.cpp)
|
||||
target_link_libraries(test_liveness PRIVATE pfu_core)
|
||||
target_link_libraries(test_liveness PRIVATE fu_core)
|
||||
add_test(NAME liveness COMMAND test_liveness)
|
||||
|
||||
add_executable(test_store tests/test_store.cpp)
|
||||
target_link_libraries(test_store PRIVATE pfu_core)
|
||||
target_link_libraries(test_store PRIVATE fu_core)
|
||||
add_test(NAME store COMMAND test_store)
|
||||
|
||||
add_executable(test_images tests/test_images.cpp)
|
||||
target_link_libraries(test_images PRIVATE pfu_core)
|
||||
target_link_libraries(test_images PRIVATE fu_core)
|
||||
|
||||
if(FU_BUILD_AGENT)
|
||||
add_executable(test_lockscreen tests/test_lockscreen.cpp src/agent/lockscreencontroller.cpp)
|
||||
target_include_directories(test_lockscreen PRIVATE src/agent ${PAM_INCLUDE_DIR})
|
||||
target_link_libraries(test_lockscreen PRIVATE Qt6::Core KF6::I18n ${PAM_LIBRARY})
|
||||
add_test(NAME lockscreen COMMAND test_lockscreen)
|
||||
|
||||
add_executable(test_wallpaper tests/test_wallpaper.cpp src/agent/wallpaper.cpp)
|
||||
target_include_directories(test_wallpaper PRIVATE src/agent)
|
||||
target_link_libraries(test_wallpaper PRIVATE Qt6::Core)
|
||||
add_test(NAME wallpaper COMMAND test_wallpaper)
|
||||
|
||||
add_executable(test_lockpreview tests/test_lockpreview.cpp src/agent/lockpreview.cpp)
|
||||
target_include_directories(test_lockpreview PRIVATE src/agent)
|
||||
target_link_libraries(test_lockpreview PRIVATE Qt6::Gui KF6::I18n)
|
||||
add_test(NAME lockpreview COMMAND test_lockpreview)
|
||||
|
||||
add_executable(test_lockers tests/test_lockers.cpp src/agent/lockers.cpp)
|
||||
target_include_directories(test_lockers PRIVATE src/agent)
|
||||
target_link_libraries(test_lockers PRIVATE Qt6::Core)
|
||||
add_test(NAME lockers COMMAND test_lockers)
|
||||
endif()
|
||||
endif()
|
||||
@@ -1,4 +1,4 @@
|
||||
# plasma-face-unlock: build and install
|
||||
# face-unlock: build and install
|
||||
#
|
||||
# The shell front end installs as it is, like middleclick-autoscroll. The rest
|
||||
# is compiled by CMake (the daemon, the agent, the PAM module, the client the
|
||||
@@ -9,20 +9,22 @@
|
||||
# Overridable so a packager can pass the version it is actually building
|
||||
# (`make VERSION=$pkgver`). The literal below is the fallback for builds
|
||||
# straight from a checkout, and is what a release tag has to carry.
|
||||
VERSION ?= 1.0.1
|
||||
VERSION ?= 2.1.0
|
||||
|
||||
PREFIX ?= /usr
|
||||
DESTDIR ?=
|
||||
BINDIR ?= $(PREFIX)/bin
|
||||
DATADIR ?= $(PREFIX)/share
|
||||
LIBDIR ?= $(DATADIR)/plasma-face-unlock/lib
|
||||
LIBEXECDIR ?= $(PREFIX)/lib/plasma-face-unlock
|
||||
MODELDIR ?= $(DATADIR)/plasma-face-unlock/models
|
||||
LIBDIR ?= $(DATADIR)/face-unlock/lib
|
||||
LIBEXECDIR ?= $(PREFIX)/lib/face-unlock
|
||||
MODELDIR ?= $(DATADIR)/face-unlock/models
|
||||
LOCALEDIR ?= $(DATADIR)/locale
|
||||
MANDIR ?= $(DATADIR)/man
|
||||
APPDIR ?= $(DATADIR)/applications
|
||||
POLKITDIR ?= $(DATADIR)/polkit-1/actions
|
||||
ICONDIR ?= $(DATADIR)/icons/hicolor/scalable/apps
|
||||
GNOMEEXTDIR ?= $(DATADIR)/gnome-shell/extensions
|
||||
GNOMEEXT := face-unlock@loonixtools.github.io
|
||||
|
||||
# Where systemd looks for units, asked of systemd itself for a normal install.
|
||||
# A build with a prefix of its own keeps them under that prefix.
|
||||
@@ -44,11 +46,14 @@ endif
|
||||
|
||||
BUILDDIR ?= build
|
||||
CMAKE ?= cmake
|
||||
# One compiler per core. A bare --parallel lets make start them all at once,
|
||||
# and a few dozen Qt and OpenCV files at once can use up the memory.
|
||||
JOBS ?= $(shell nproc 2>/dev/null || echo 2)
|
||||
CMAKE_FLAGS ?=
|
||||
|
||||
LINGUAS := de
|
||||
LINGUAS := de es fr it ja ko nl pl pt_BR ru tr uk zh_CN
|
||||
MOFILES := $(patsubst %,po/%.mo,$(LINGUAS))
|
||||
MANPAGE := doc/plasma-face-unlock.1
|
||||
MANPAGE := doc/face-unlock.1
|
||||
|
||||
LIBS := $(wildcard src/lib/*.sh)
|
||||
|
||||
@@ -85,13 +90,13 @@ native:
|
||||
$(CMAKE) -S . -B $(BUILDDIR) \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX=$(PREFIX) \
|
||||
-DPFU_VERSION=$(VERSION) \
|
||||
-DPFU_LIBEXECDIR=$(LIBEXECDIR) \
|
||||
-DPFU_MODELDIR=$(MODELDIR) \
|
||||
-DPFU_LOCALEDIR=$(LOCALEDIR) \
|
||||
-DPFU_PAMDIR=$(PAMDIR) \
|
||||
-DFU_VERSION=$(VERSION) \
|
||||
-DFU_LIBEXECDIR=$(LIBEXECDIR) \
|
||||
-DFU_MODELDIR=$(MODELDIR) \
|
||||
-DFU_LOCALEDIR=$(LOCALEDIR) \
|
||||
-DFU_PAMDIR=$(PAMDIR) \
|
||||
$(CMAKE_FLAGS)
|
||||
$(CMAKE) --build $(BUILDDIR) --parallel
|
||||
$(CMAKE) --build $(BUILDDIR) --parallel $(JOBS)
|
||||
|
||||
models: $(addprefix models/,$(MODELS))
|
||||
|
||||
@@ -108,7 +113,7 @@ else
|
||||
@echo "msgfmt not found, skipping $@"
|
||||
endif
|
||||
|
||||
$(MANPAGE): doc/plasma-face-unlock.1.scd
|
||||
$(MANPAGE): doc/face-unlock.1.scd
|
||||
ifdef SCDOC
|
||||
$(SCDOC) < $< > $@
|
||||
else
|
||||
@@ -116,13 +121,15 @@ else
|
||||
endif
|
||||
|
||||
# Syntax-check every shell file, and run shellcheck when it is available.
|
||||
# SC2034 is off: the files share their variables, and shellcheck looks at one
|
||||
# file at a time.
|
||||
check:
|
||||
@set -e; for f in src/plasma-face-unlock $(LIBS) tests/*.sh; do \
|
||||
@set -e; for f in src/face-unlock $(LIBS) tests/*.sh; do \
|
||||
bash -n "$$f" && echo "ok $$f"; \
|
||||
done
|
||||
@if command -v shellcheck >/dev/null 2>&1; then \
|
||||
shellcheck -x -e SC1090,SC1091 src/plasma-face-unlock $(LIBS) tests/*.sh; \
|
||||
echo "ok shellcheck"; \
|
||||
shellcheck -x -e SC1090,SC1091,SC2034 src/face-unlock $(LIBS) tests/*.sh \
|
||||
&& echo "ok shellcheck"; \
|
||||
else \
|
||||
echo "shellcheck not found, skipped"; \
|
||||
fi
|
||||
@@ -144,7 +151,7 @@ install: build
|
||||
DESTDIR="$(DESTDIR)" $(CMAKE) --install $(BUILDDIR)
|
||||
|
||||
# the command
|
||||
install -Dm755 src/plasma-face-unlock "$(DESTDIR)$(BINDIR)/plasma-face-unlock"
|
||||
install -Dm755 src/face-unlock "$(DESTDIR)$(BINDIR)/face-unlock"
|
||||
install -d "$(DESTDIR)$(LIBDIR)"
|
||||
install -Dm644 -t "$(DESTDIR)$(LIBDIR)" $(LIBS)
|
||||
sed -i -e 's|@VERSION@|$(VERSION)|g' \
|
||||
@@ -152,7 +159,7 @@ install: build
|
||||
-e 's|@LIBEXECDIR@|$(LIBEXECDIR)|g' \
|
||||
-e 's|@LOCALEDIR@|$(LOCALEDIR)|g' \
|
||||
-e 's|@PAMDIR@|$(PAMDIR)|g' \
|
||||
"$(DESTDIR)$(BINDIR)/plasma-face-unlock" \
|
||||
"$(DESTDIR)$(BINDIR)/face-unlock" \
|
||||
"$(DESTDIR)$(LIBDIR)"/*.sh
|
||||
|
||||
# the models
|
||||
@@ -161,50 +168,54 @@ install: build
|
||||
done
|
||||
|
||||
# the daemon's socket and service, the agent's user service
|
||||
install -Dm644 res/systemd/plasma-face-unlockd.socket "$(DESTDIR)$(SYSTEMUNITDIR)/plasma-face-unlockd.socket"
|
||||
install -Dm644 res/systemd/plasma-face-unlockd.service "$(DESTDIR)$(SYSTEMUNITDIR)/plasma-face-unlockd.service"
|
||||
install -Dm644 res/systemd/plasma-face-unlock-agent.service "$(DESTDIR)$(USERUNITDIR)/plasma-face-unlock-agent.service"
|
||||
install -Dm644 res/systemd/face-unlockd.socket "$(DESTDIR)$(SYSTEMUNITDIR)/face-unlockd.socket"
|
||||
install -Dm644 res/systemd/face-unlockd.service "$(DESTDIR)$(SYSTEMUNITDIR)/face-unlockd.service"
|
||||
install -Dm644 res/systemd/face-unlock-agent.service "$(DESTDIR)$(USERUNITDIR)/face-unlock-agent.service"
|
||||
sed -i -e 's|@LIBEXECDIR@|$(LIBEXECDIR)|g' \
|
||||
"$(DESTDIR)$(SYSTEMUNITDIR)/plasma-face-unlockd.service" \
|
||||
"$(DESTDIR)$(USERUNITDIR)/plasma-face-unlock-agent.service"
|
||||
"$(DESTDIR)$(SYSTEMUNITDIR)/face-unlockd.service" \
|
||||
"$(DESTDIR)$(USERUNITDIR)/face-unlock-agent.service"
|
||||
|
||||
# the desktop file KWin looks for before it lets the bubble above the
|
||||
# lock screen, the polkit action, the icon
|
||||
install -Dm644 res/applications/io.github.loonixtools.plasma-face-unlock-agent.desktop \
|
||||
"$(DESTDIR)$(APPDIR)/io.github.loonixtools.plasma-face-unlock-agent.desktop"
|
||||
sed -i -e 's|@LIBEXECDIR@|$(LIBEXECDIR)|g' "$(DESTDIR)$(APPDIR)/io.github.loonixtools.plasma-face-unlock-agent.desktop"
|
||||
install -Dm644 res/polkit/io.github.loonixtools.plasma-face-unlock.policy \
|
||||
"$(DESTDIR)$(POLKITDIR)/io.github.loonixtools.plasma-face-unlock.policy"
|
||||
install -Dm644 res/plasma-face-unlock.svg "$(DESTDIR)$(ICONDIR)/plasma-face-unlock.svg"
|
||||
install -Dm644 res/applications/io.github.loonixtools.face-unlock-agent.desktop \
|
||||
"$(DESTDIR)$(APPDIR)/io.github.loonixtools.face-unlock-agent.desktop"
|
||||
sed -i -e 's|@LIBEXECDIR@|$(LIBEXECDIR)|g' "$(DESTDIR)$(APPDIR)/io.github.loonixtools.face-unlock-agent.desktop"
|
||||
install -Dm644 res/polkit/io.github.loonixtools.face-unlock.policy \
|
||||
"$(DESTDIR)$(POLKITDIR)/io.github.loonixtools.face-unlock.policy"
|
||||
install -Dm644 res/face-unlock.svg "$(DESTDIR)$(ICONDIR)/face-unlock.svg"
|
||||
|
||||
# the GNOME Shell extension that draws the bubble on GNOME
|
||||
install -Dm644 -t "$(DESTDIR)$(GNOMEEXTDIR)/$(GNOMEEXT)" res/gnome-shell/$(GNOMEEXT)/*
|
||||
|
||||
# translations
|
||||
@for l in $(LINGUAS); do \
|
||||
if [ -f "po/$$l.mo" ]; then \
|
||||
install -Dm644 "po/$$l.mo" \
|
||||
"$(DESTDIR)$(LOCALEDIR)/$$l/LC_MESSAGES/plasma-face-unlock.mo"; \
|
||||
"$(DESTDIR)$(LOCALEDIR)/$$l/LC_MESSAGES/face-unlock.mo"; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
# documentation
|
||||
@if [ -f $(MANPAGE) ]; then \
|
||||
install -Dm644 $(MANPAGE) "$(DESTDIR)$(MANDIR)/man1/plasma-face-unlock.1"; \
|
||||
install -Dm644 $(MANPAGE) "$(DESTDIR)$(MANDIR)/man1/face-unlock.1"; \
|
||||
fi
|
||||
install -Dm644 README.md "$(DESTDIR)$(DATADIR)/doc/plasma-face-unlock/README.md"
|
||||
install -Dm644 README.md "$(DESTDIR)$(DATADIR)/doc/face-unlock/README.md"
|
||||
|
||||
uninstall:
|
||||
rm -f "$(DESTDIR)$(BINDIR)/plasma-face-unlock"
|
||||
rm -rf "$(DESTDIR)$(DATADIR)/plasma-face-unlock"
|
||||
rm -f "$(DESTDIR)$(BINDIR)/face-unlock"
|
||||
rm -rf "$(DESTDIR)$(DATADIR)/face-unlock"
|
||||
rm -rf "$(DESTDIR)$(LIBEXECDIR)"
|
||||
rm -f "$(DESTDIR)$(PAMDIR)/pam_plasma_face_unlock.so"
|
||||
rm -f "$(DESTDIR)$(SYSTEMUNITDIR)/plasma-face-unlockd.socket"
|
||||
rm -f "$(DESTDIR)$(SYSTEMUNITDIR)/plasma-face-unlockd.service"
|
||||
rm -f "$(DESTDIR)$(USERUNITDIR)/plasma-face-unlock-agent.service"
|
||||
rm -f "$(DESTDIR)$(APPDIR)/io.github.loonixtools.plasma-face-unlock-agent.desktop"
|
||||
rm -f "$(DESTDIR)$(POLKITDIR)/io.github.loonixtools.plasma-face-unlock.policy"
|
||||
rm -f "$(DESTDIR)$(ICONDIR)/plasma-face-unlock.svg"
|
||||
rm -f "$(DESTDIR)$(MANDIR)/man1/plasma-face-unlock.1"
|
||||
rm -rf "$(DESTDIR)$(DATADIR)/doc/plasma-face-unlock"
|
||||
@for l in $(LINGUAS); do rm -f "$(DESTDIR)$(LOCALEDIR)/$$l/LC_MESSAGES/plasma-face-unlock.mo"; done
|
||||
rm -f "$(DESTDIR)$(PAMDIR)/pam_face_unlock.so"
|
||||
rm -f "$(DESTDIR)$(SYSTEMUNITDIR)/face-unlockd.socket"
|
||||
rm -f "$(DESTDIR)$(SYSTEMUNITDIR)/face-unlockd.service"
|
||||
rm -f "$(DESTDIR)$(USERUNITDIR)/face-unlock-agent.service"
|
||||
rm -f "$(DESTDIR)$(APPDIR)/io.github.loonixtools.face-unlock-agent.desktop"
|
||||
rm -f "$(DESTDIR)$(POLKITDIR)/io.github.loonixtools.face-unlock.policy"
|
||||
rm -f "$(DESTDIR)$(ICONDIR)/face-unlock.svg"
|
||||
rm -rf "$(DESTDIR)$(GNOMEEXTDIR)/$(GNOMEEXT)"
|
||||
rm -f "$(DESTDIR)$(MANDIR)/man1/face-unlock.1"
|
||||
rm -rf "$(DESTDIR)$(DATADIR)/doc/face-unlock"
|
||||
@for l in $(LINGUAS); do rm -f "$(DESTDIR)$(LOCALEDIR)/$$l/LC_MESSAGES/face-unlock.mo"; done
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILDDIR) po/*.mo $(MANPAGE)
|
||||
@@ -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,156 @@
|
||||
</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>
|
||||
|
||||
<details>
|
||||
<summary>All bubble states</summary>
|
||||
|
||||
<p align="center">
|
||||
<img src="res/screenshots/bubble-states.png" alt="The bubble above the Plasma lock screen: looking, recognised, not recognised, and a crossed-out camera while another app uses the camera" width="900">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="res/screenshots/pill.png" alt="The small pill style of the bubble: the same four states" width="900">
|
||||
</p>
|
||||
|
||||
</details>
|
||||
|
||||
## Is it safe?
|
||||
|
||||
It is a convenience, not a security upgrade. Face ID on a phone sees your face in 3D. A webcam only
|
||||
sees a flat picture.
|
||||
It is a convenience upgrade, not extra security. Your webcam only sees a flat picture, so
|
||||
it cannot always differentiate your face from a good fake. But the tool does everything in its power to prevent that:
|
||||
|
||||
- You do not have to blink. A photo on a phone or tablet, or a glossy print, is still refused:
|
||||
it gives itself away by its reflection and its straight edges.
|
||||
- A matte printed photo can get past that. Set the photo check to *strict* in the settings: then
|
||||
the face has to blink or turn a little, and a photo can do neither.
|
||||
- A video of you can still get in.
|
||||
- Five failed tries in a row pause it for 15 minutes, or until you use your password.
|
||||
- Only root can read your face data. Adding or deleting a face always needs your password.
|
||||
- sudo and admin prompts never take a face over SSH.
|
||||
- Photos and videos on a phone, tablet or glossy screen are caught.
|
||||
- A matte printed photo is caught when the photo check is set to *strict*.
|
||||
|
||||
If the computer guards something important, leave sudo and admin prompts off.
|
||||
But a video of you on a big matte screen could get in.
|
||||
|
||||
## How it works
|
||||
After five failed attempts, face unlock pauses for 15 minutes. Your face is kept as
|
||||
numbers, not pictures, and only root can read them. sudo over SSH asks for the
|
||||
password, unless you turn that on.
|
||||
|
||||
## More
|
||||
|
||||
<details>
|
||||
<summary>How it works</summary>
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `plasma-face-unlockd` | Runs as root when needed. It owns the camera and the face data and decides. |
|
||||
| `plasma-face-unlock-agent` | Runs in your session. It watches the lock screen and draws the bubble. |
|
||||
| `pam_plasma_face_unlock.so` | Lets sudo and admin prompts ask the daemon. No match: you type your password as usual. |
|
||||
| `plasma-face-unlock` | The menu. |
|
||||
| `face-unlockd` | The root service. Owns the camera and the face data. |
|
||||
| `face-unlock-agent` | Runs in your session. Watches the lock screen, draws the bubble. |
|
||||
| `pam_face_unlock.so` | Lets sudo and admin prompts ask the service. |
|
||||
| `face-unlock` | The menu. |
|
||||
|
||||
Two small networks from the OpenCV model zoo find the face (YuNet) and turn it into numbers (SFace).
|
||||
They run on the CPU in a few milliseconds. The lock screen is unlocked through logind, the same way
|
||||
`loginctl unlock-session` does it. `man plasma-face-unlock` has all the details.
|
||||
Two small networks from the OpenCV model zoo run on the CPU: YuNet finds the face, SFace turns it
|
||||
into numbers. All details: `man face-unlock`.
|
||||
|
||||
## Build from source
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Build from source</summary>
|
||||
|
||||
```bash
|
||||
make models # downloads the two networks and checks them
|
||||
make models
|
||||
make
|
||||
make test
|
||||
sudo make install
|
||||
```
|
||||
|
||||
You need CMake, a C++20 compiler, Qt 6, LayerShellQt, KI18n, OpenCV 4.5.4 or newer (with DNN),
|
||||
Linux-PAM and libsystemd. `scdoc` and `msgfmt` are optional (man page, translations).
|
||||
[packaging/README.md](packaging/README.md) explains releases.
|
||||
Needs CMake, a C++20 compiler, Qt 6, LayerShellQt, KI18n, OpenCV 4.5.4+ (with DNN), Linux-PAM and
|
||||
libsystemd.
|
||||
|
||||
</details>
|
||||
|
||||
## Credits
|
||||
|
||||
- [Glance](https://github.com/jonnyoo/glance) by Jonathan Zhou (MIT): face unlock for the Mac. The
|
||||
idea, the look of the bubble and the photo check come from there. The code here is new.
|
||||
- [YuNet](https://github.com/opencv/opencv_zoo/tree/main/models/face_detection_yunet) (MIT) and
|
||||
[SFace](https://github.com/opencv/opencv_zoo/tree/main/models/face_recognition_sface) (Apache-2.0)
|
||||
from the OpenCV model zoo.
|
||||
|
||||
## License
|
||||
- [Glance](https://github.com/jonnyoo/glance) by Jonathan Zhou: the idea and the look of the bubble.
|
||||
- [YuNet](https://github.com/opencv/opencv_zoo/tree/main/models/face_detection_yunet) and
|
||||
[SFace](https://github.com/opencv/opencv_zoo/tree/main/models/face_recognition_sface) from the
|
||||
OpenCV model zoo.
|
||||
|
||||
GPL-3.0-or-later.
|
||||
@@ -1,19 +1,20 @@
|
||||
plasma-face-unlock(1)
|
||||
face-unlock(1)
|
||||
|
||||
# NAME
|
||||
|
||||
plasma-face-unlock - face unlock for KDE Plasma
|
||||
face-unlock - face unlock for Plasma, GNOME, Hyprland and Niri
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
*plasma-face-unlock* [_command_]
|
||||
*face-unlock* [_command_]
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
Look at the screen and it unlocks, the way a phone does. The lock screen, sudo
|
||||
in a terminal and the admin password prompts of Plasma can all take a face
|
||||
instead of a password. A photo of somebody on a phone or tablet does not get
|
||||
in, and with the strict photo check a printed one does not either.
|
||||
in a terminal and the admin password prompts can all take a face instead of a
|
||||
password, on KDE Plasma, GNOME, Hyprland and Niri (see *DESKTOPS*). A photo of
|
||||
somebody on a phone or tablet does not get in, and with the strict photo check
|
||||
a printed one does not either.
|
||||
|
||||
A bubble at the top of the screen shows what is going on: it drops down when
|
||||
the camera starts looking, the face in it looks around, and when it recognises
|
||||
@@ -30,8 +31,11 @@ It is a convenience, not a security upgrade. See *HOW SAFE IS THIS*.
|
||||
|
||||
*enable*
|
||||
Turn face unlock on for this user. Sets up a face first if there is none,
|
||||
starts the lock screen agent, and turns sudo and admin prompts back on if
|
||||
they were on before.
|
||||
starts the lock screen agent, and turns on sudo and admin prompts, unless
|
||||
they were turned off under *Settings*. On Hyprland and Niri it also puts
|
||||
the face into the lock screen's password check (*Lock screens*), unless
|
||||
that was turned off. On GNOME it switches on the extension that draws
|
||||
the bubble.
|
||||
|
||||
*disable*
|
||||
Turn it off: the agent stops and sudo and the admin prompts go back to the
|
||||
@@ -56,6 +60,12 @@ It is a convenience, not a security upgrade. See *HOW SAFE IS THIS*.
|
||||
*status*
|
||||
What is on, and when the last unlock was.
|
||||
|
||||
*lock*
|
||||
Lock the screen. On Hyprland, Niri and other compositors whose lock
|
||||
screen is a program of its own, with face-unlock's own lock screen,
|
||||
which shows the bubble (see *DESKTOPS*). Elsewhere with the desktop's
|
||||
lock screen. Returns once the screen is locked.
|
||||
|
||||
*-h*, *--help*
|
||||
Show a summary of the commands.
|
||||
|
||||
@@ -64,23 +74,23 @@ It is a convenience, not a security upgrade. See *HOW SAFE IS THIS*.
|
||||
|
||||
# THE PIECES
|
||||
|
||||
*plasma-face-unlockd*
|
||||
*face-unlockd*
|
||||
The daemon, running as root and started by its socket
|
||||
(_plasma-face-unlockd.socket_). It is the only thing that opens the camera
|
||||
(_face-unlockd.socket_). It is the only thing that opens the camera
|
||||
and the only thing that can read the face data. It exits after a minute
|
||||
with nothing to do.
|
||||
|
||||
*plasma-face-unlock-agent*
|
||||
Runs in the Plasma session as a user service
|
||||
(_plasma-face-unlock-agent.service_). It watches the lock screen, asks the
|
||||
*face-unlock-agent*
|
||||
Runs in the desktop session as a user service
|
||||
(_face-unlock-agent.service_). It watches the lock screen, asks the
|
||||
daemon to scan when somebody comes back, unlocks the session when the face
|
||||
matches, draws the bubble, and is the setup window.
|
||||
|
||||
*pam_plasma_face_unlock.so*
|
||||
*pam_face_unlock.so*
|
||||
The PAM module for sudo and admin prompts. It asks the daemon and turns
|
||||
the answer into a PAM result.
|
||||
|
||||
*plasma-face-unlock-ctl*
|
||||
*face-unlock-ctl*
|
||||
How this command talks to the daemon.
|
||||
|
||||
# RECOGNITION
|
||||
@@ -156,8 +166,8 @@ The other guards:
|
||||
face needs the password (polkit, *auth_self_keep*). A face cannot answer
|
||||
that question even with admin prompts on: the daemon refuses to scan while
|
||||
it is being asked;
|
||||
- sudo and admin prompts are refused over SSH and for anybody without an
|
||||
active session at the machine;
|
||||
- sudo and admin prompts are refused over SSH, unless *In SSH sessions* is
|
||||
on, and always for anybody without an active session at the machine;
|
||||
- the lock screen is unlocked through logind, the same way
|
||||
*loginctl unlock-session* does it. That does not lower the bar: any program
|
||||
running as the user could already do that. For sudo and admin prompts the
|
||||
@@ -171,14 +181,16 @@ prompts off, or face unlock altogether.
|
||||
|
||||
Plasma's lock screen runs a fingerprint stack next to the password, but starts
|
||||
it once per lock, gives up for good after the first failure and labels it for
|
||||
fingerprints. So face unlock does not go through the lock screen's PAM at all.
|
||||
The agent watches for the screen to lock (org.freedesktop.ScreenSaver) and
|
||||
scans when somebody comes back:
|
||||
fingerprints. GNOME's, hyprlock and swaylock only ask their PAM stack once the
|
||||
password is typed. So face unlock does not go through the lock screen's PAM at
|
||||
all. The agent watches for the screen to lock (see *DESKTOPS*) and scans when
|
||||
somebody comes back:
|
||||
|
||||
- on any key or mouse movement after the screen locked, once 1.5 seconds have
|
||||
passed (the key that locked it does not count). Enter on the empty password
|
||||
field is a key like any other. This works while a video or an app keeps the
|
||||
screen on, too (ext_idle_notifier_v1, input notification);
|
||||
screen on, too (ext_idle_notifier_v1 with its input notification, and
|
||||
Mutter's IdleMonitor on GNOME);
|
||||
- when the machine wakes from sleep;
|
||||
- right after locking, if *Scan right after locking* is on. Off by
|
||||
default: whoever locks their screen on purpose is usually still in front of
|
||||
@@ -188,24 +200,101 @@ After a scan that did not get anybody in, the next one waits for the person to
|
||||
be still for two seconds and then touch something again, so typing the password
|
||||
does not start a scan with every key.
|
||||
|
||||
Unlocked through logind, the lock screen cuts off its own password prompt and
|
||||
counts that as a wrong password. After a face unlock the daemon resets the
|
||||
When the face matches, the agent unlocks the session the way its lock screen
|
||||
wants it: through logind, as *loginctl unlock-session* does, on Plasma and
|
||||
GNOME, by itself on its own lock screen, and with SIGUSR1 for hyprlock,
|
||||
swaylock and gtklock after 4.0.0. Any other lock screen opens itself, through
|
||||
PAM (see *DESKTOPS*).
|
||||
|
||||
Unlocked through logind, Plasma's lock screen cuts off its own password prompt
|
||||
and counts that as a wrong password. After a face unlock the daemon resets the
|
||||
failed logins of *pam_faillock*(8), as a correct password does, so face unlocks
|
||||
never lock the account.
|
||||
|
||||
The bubble is a layer-shell surface that KWin keeps above the lock screen
|
||||
(kde_lockscreen_overlay_v1). KWin only allows that for a program whose desktop
|
||||
file asks for it, which is
|
||||
_io.github.loonixtools.plasma-face-unlock-agent.desktop_.
|
||||
On Plasma the bubble is a layer-shell surface that KWin keeps above the lock
|
||||
screen (kde_lockscreen_overlay_v1). KWin only allows that for a program whose
|
||||
desktop file asks for it, which is
|
||||
_io.github.loonixtools.face-unlock-agent.desktop_. How it gets there elsewhere
|
||||
is under *DESKTOPS*.
|
||||
|
||||
# SUDO AND ADMIN PROMPTS
|
||||
# DESKTOPS
|
||||
|
||||
All of them on Wayland. sudo and admin prompts work the same everywhere, and
|
||||
so does the bubble, above the lock screen too.
|
||||
|
||||
*KDE Plasma 6*
|
||||
The lock is seen through org.freedesktop.ScreenSaver and logind, and
|
||||
the bubble shows above the lock screen (kde_lockscreen_overlay_v1).
|
||||
|
||||
*GNOME*
|
||||
The lock is seen through logind, where GNOME sets *LockedHint*. GNOME
|
||||
lets no program draw above its windows, so a GNOME Shell extension
|
||||
(_face-unlock@loonixtools.github.io_) draws the bubble, from what the
|
||||
agent says on the session bus. *enable* switches it on; GNOME loads an
|
||||
extension that was installed after the login at the next one.
|
||||
|
||||
*Hyprland*, *Niri* and other compositors with ext-session-lock
|
||||
The lock screen is a program of the user's choice, and nothing but it
|
||||
can open it. So the face goes into its password check, the way it goes
|
||||
into sudo's: *Lock screens* under *Settings* puts the PAM module in
|
||||
front of the stacks of hyprlock, swaylock, gtklock and waylock, where
|
||||
installed (see *SUDO, ADMIN PROMPTS AND LOCK SCREENS*). Enter on the
|
||||
empty password field starts a scan, and a match lets the lock screen
|
||||
open itself. hyprlock asks PAM the moment it starts; that first time
|
||||
is skipped, so a screen locked on purpose does not open again at once.
|
||||
|
||||
hyprlock, swaylock and gtklock (after 4.0.0) can also be opened from
|
||||
outside: the agent finds them among the user's processes (niri also
|
||||
sets *LockedHint*), scans when somebody comes back, and opens them with
|
||||
SIGUSR1. It only does so once the lock screen handles SIGUSR1: before
|
||||
it has locked, the signal would kill it. Those lock screens cover the
|
||||
bubble. hyprlock shows it as a line of text instead: a label in
|
||||
_~/.config/hypr/hyprlock.conf_ (a *source* line under a face-unlock
|
||||
comment) reads what the agent writes, and SIGUSR2 makes hyprlock read
|
||||
it again. gtklock shows the messages of the PAM module; swaylock and
|
||||
waylock show none.
|
||||
|
||||
*enable* asks once in a window which way to go: face-unlock's lock
|
||||
screen, or the user's own with that line of text. The window shows the
|
||||
screen both ways, the user's own rebuilt from the config of hyprlock or
|
||||
swaylock. *Which lock screen* under *Settings* opens it again.
|
||||
|
||||
For the bubble on the lock screen there is face-unlock's own: *lock*.
|
||||
It shows the time, a password field (PAM service _face-unlock-lock_
|
||||
when an administrator wrote one, else the distribution's password-auth,
|
||||
common-auth or login) and the bubble, and a face opens it straight
|
||||
away. Behind it is the picture on the desktop, as swaybg, awww (swww),
|
||||
hyprpaper or wpaperd show it. *Wallpaper* under *Settings* puts another
|
||||
picture there, one at random from a folder, or _none_; *Blur the
|
||||
wallpaper* blurs it. If the agent dies while this screen is locked, the
|
||||
compositor keeps it locked, and the agent takes the lock back when it
|
||||
starts again. It needs Qt 6.10 or newer: with older Qt (Debian 13) the
|
||||
menu does not offer it, and *lock* asks logind to lock.
|
||||
|
||||
Hyprland only starts the agent's user service under uwsm. Without it,
|
||||
*enable* and *status* show what to add to its config.
|
||||
|
||||
Hyprland and Niri come without a polkit agent. One has to run for admin
|
||||
prompts and for setting up a face, for example hyprpolkitagent. The menu warns
|
||||
when none runs, and *p* installs one: hyprpolkitagent where the distribution
|
||||
has it, else KDE's agent. It starts it too, now and with the session.
|
||||
|
||||
# SUDO, ADMIN PROMPTS AND LOCK SCREENS
|
||||
|
||||
Turned on under *Settings*, one line goes in front of the service's PAM stack:
|
||||
|
||||
```
|
||||
-auth sufficient /usr/lib/security/pam_plasma_face_unlock.so
|
||||
-auth sufficient /usr/lib/security/pam_face_unlock.so
|
||||
```
|
||||
|
||||
For a lock screen (hyprlock, swaylock, gtklock, waylock) it ends in
|
||||
*lockscreen*. The scan then counts as a lock screen's, and a lock screen less
|
||||
than two seconds old gets none. Enter on the empty password field counts as a
|
||||
wrong password for *pam_faillock*(8) before the face is tried; a match takes
|
||||
that back, as a correct password does. A lock screen that checks the password
|
||||
with *login* or *system-auth* directly is left alone: those also let people
|
||||
log in.
|
||||
|
||||
A match lets the person in; anything else falls through to the password as if
|
||||
the line were not there. The dash makes PAM skip it quietly if the module ever
|
||||
goes missing, so sudo keeps working even when the package was removed without
|
||||
@@ -230,31 +319,49 @@ easier. A face set up with one camera should be set up again for another.
|
||||
|
||||
A laptop with its lid shut is not scanned (*Not when the lid is closed*).
|
||||
|
||||
A camera that another app uses, in a video call for example, is not scanned
|
||||
either. The password is asked for at once, and the bubble shows a camera with
|
||||
a line through it. *Quiet while the camera is in use* leaves out the bubble
|
||||
too.
|
||||
|
||||
# FILES
|
||||
|
||||
_~/.config/plasma-face-unlock/config_
|
||||
_~/.config/face-unlock/config_
|
||||
This user's settings: the lock screen, the bubble and its animation
|
||||
speed. Written by the menu.
|
||||
|
||||
_/etc/plasma-face-unlock/config_
|
||||
_/etc/face-unlock/config_
|
||||
The system settings: camera, photo check, strictness, attention, scan
|
||||
length. Written by the menu through sudo.
|
||||
length, SSH sessions. Written by the menu through sudo.
|
||||
|
||||
_/var/lib/plasma-face-unlock/users/<uid>.json_
|
||||
_/var/lib/face-unlock/users/<uid>.json_
|
||||
The face data: numbers, no pictures. Root only.
|
||||
|
||||
_/var/lib/plasma-face-unlock/users/<uid>.state_
|
||||
_/var/lib/face-unlock/users/<uid>.state_
|
||||
Failed scans in a row, the pause they lead to, the last unlock.
|
||||
|
||||
_/usr/share/plasma-face-unlock/models/_
|
||||
_/usr/share/face-unlock/models/_
|
||||
The two networks.
|
||||
|
||||
_/run/plasma-face-unlock/socket_
|
||||
_/run/face-unlock/socket_
|
||||
The daemon's socket.
|
||||
|
||||
_$XDG_RUNTIME_DIR/plasma-face-unlock/agent.socket_
|
||||
_$XDG_RUNTIME_DIR/face-unlock/agent.socket_
|
||||
Where the daemon tells the agent about scans it did not start, for the
|
||||
bubble.
|
||||
bubble, and where *lock* asks it to lock.
|
||||
|
||||
_$XDG_RUNTIME_DIR/face-unlock/locked_
|
||||
There while face-unlock's own lock screen is up.
|
||||
|
||||
_$XDG_RUNTIME_DIR/face-unlock/lock-text_
|
||||
What hyprlock shows at the top when the user keeps their own lock
|
||||
screen.
|
||||
|
||||
_~/.config/face-unlock/hyprlock.conf_
|
||||
The label for that, which _~/.config/hypr/hyprlock.conf_ sources.
|
||||
|
||||
_/usr/share/gnome-shell/extensions/face-unlock@loonixtools.github.io/_
|
||||
The GNOME Shell extension that draws the bubble on GNOME.
|
||||
|
||||
# ENVIRONMENT
|
||||
|
||||
@@ -263,17 +370,18 @@ _$XDG_RUNTIME_DIR/plasma-face-unlock/agent.socket_
|
||||
|
||||
# REQUIREMENTS
|
||||
|
||||
KDE Plasma 6 on Wayland, a camera, OpenCV 4.5.4 or newer with its DNN module,
|
||||
Qt 6, LayerShellQt, KI18n, systemd, polkit and Linux-PAM.
|
||||
KDE Plasma 6, GNOME, Hyprland or Niri on Wayland, a camera, OpenCV 4.5.4 or
|
||||
newer with its DNN module, Qt 6, LayerShellQt, KI18n, systemd, polkit and
|
||||
Linux-PAM.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
*loginctl*(1), *pam*(8), *polkit*(8), *systemctl*(1)
|
||||
*loginctl*(1), *pam*(8), *polkit*(8), *systemctl*(1), *swaylock*(1)
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Felitendo. Source and issue tracker at
|
||||
https://github.com/LoonixTools/plasma-face-unlock
|
||||
https://github.com/LoonixTools/face-unlock
|
||||
|
||||
The liveness model and the look of the bubble follow Glance by Jonathan Zhou
|
||||
(https://github.com/jonnyoo/glance, MIT). The models are YuNet and SFace from
|
||||
+28
-17
@@ -7,34 +7,43 @@ description of the layout, and two descriptions drift.
|
||||
| | |
|
||||
|---|---|
|
||||
| `deb/control`, `deb/copyright` | metadata for the Debian binary package |
|
||||
| `rpm/plasma-face-unlock.spec` | the RPM spec |
|
||||
| `build-deb.sh`, `build-rpm.sh` | build one package into `dist/` |
|
||||
| `rpm/face-unlock.spec` | the RPM spec |
|
||||
| `build-deb.sh`, `build-rpm.sh`, `build-tarball.sh` | build one package into `dist/` |
|
||||
| `build-opencv.sh` | builds the static OpenCV the Arch tarball links in |
|
||||
| `check-version.sh` | refuses a tag that disagrees with the Makefile |
|
||||
| `publish-repos.sh` | regenerates the APT and RPM repositories |
|
||||
| `pages/` | the landing page and the `.repo` file served from GitHub Pages |
|
||||
|
||||
The AUR package lives in [Felitendo/PKGBUILDS](https://github.com/Felitendo/PKGBUILDS/tree/main/plasma-face-unlock).
|
||||
Its CI notices a new GitHub release, updates the checksum and pushes to the AUR.
|
||||
The AUR packages live in [Felitendo/PKGBUILDS](https://github.com/Felitendo/PKGBUILDS):
|
||||
`face-unlock` builds from source, `face-unlock-bin` takes the Arch tarball.
|
||||
Its CI notices a new GitHub release, updates the checksums and pushes to the AUR.
|
||||
|
||||
Unlike the shell-only LoonixTools, this one is compiled. The packages are per
|
||||
architecture (amd64 and x86_64), and they need the Plasma 6 and Qt 6
|
||||
architecture (amd64 and x86_64), and they need the Qt 6 and KDE Frameworks 6
|
||||
development packages to build: Debian 13 (trixie) and current Fedora have
|
||||
them. The Debian package's library dependencies are read off the binaries by
|
||||
`dpkg-shlibdeps`; RPM does the same on its own.
|
||||
|
||||
The program uses Qt's private API, so a package only fits the Qt it was built
|
||||
against. The `.deb` is therefore built twice, in Debian 13 and in Ubuntu 26.04
|
||||
(for Kubuntu), with a suffix on the version (`~deb13`, `~ubuntu26.04`), and each
|
||||
(for Ubuntu and Kubuntu), with a suffix on the version (`~deb13`, `~ubuntu26.04`), and each
|
||||
gets an APT repository of its own: `deb/trixie` and `deb/resolute`. The RPM is
|
||||
built on the current Fedora.
|
||||
|
||||
The Arch tarball (`face-unlock-<version>-arch-x86_64.tar.zst`) is built on Arch,
|
||||
for the same reason. It holds a folder with the `make install` tree. Arch
|
||||
changes the OpenCV soname with every new OpenCV, so OpenCV is linked in
|
||||
statically (`build-opencv.sh`: only the modules the daemon uses, nothing it
|
||||
would load at run time). Qt stays shared, so a new Qt minor version on Arch
|
||||
needs a new release.
|
||||
|
||||
The two networks (YuNet and SFace, from the OpenCV model zoo) are not in the
|
||||
repository. `make models` downloads them and checks them against the
|
||||
checksums in the Makefile. The RPM spec and the PKGBUILD list them as sources
|
||||
of their own, with the same checksums.
|
||||
|
||||
Neither the deb nor the rpm switches anything on at install time. The daemon's
|
||||
socket is enabled by `plasma-face-unlock` the first time somebody turns it on,
|
||||
socket is enabled by `face-unlock` the first time somebody turns it on,
|
||||
the agent is a user service each user enables, and the PAM files are only
|
||||
touched when somebody asks for sudo or admin prompts. Removing a package
|
||||
disables the socket.
|
||||
@@ -44,22 +53,24 @@ disables the socket.
|
||||
```bash
|
||||
packaging/build-deb.sh # in a Debian 13 container, with the -dev packages from release.yml
|
||||
packaging/build-rpm.sh # in a Fedora container, with the -devel packages from the spec
|
||||
packaging/build-opencv.sh && packaging/build-tarball.sh # in an Arch container, with the packages from release.yml
|
||||
```
|
||||
|
||||
Both take the version from `make version` unless one is passed as the first
|
||||
All three take the version from `make version` unless one is passed as the first
|
||||
argument.
|
||||
|
||||
## Making a release
|
||||
|
||||
1. Bump `VERSION` in the Makefile. The compiled programs get it from there
|
||||
too (`-DPFU_VERSION`).
|
||||
too (`-DFU_VERSION`).
|
||||
2. Add the release to `CHANGELOG.md`, in the format CLAUDE.md describes.
|
||||
3. Commit, then `git tag vX.Y.Z && git push --tags`.
|
||||
|
||||
The `release` workflow builds both packages in a Debian and a Fedora container,
|
||||
refuses the tag if it disagrees with the Makefile or has no changelog entry,
|
||||
attaches the packages to a GitHub release with the entry as its notes, and adds
|
||||
them to the APT and RPM repositories on the `gh-pages` branch.
|
||||
The `release` workflow builds the packages in Debian, Ubuntu, Fedora and Arch
|
||||
containers, refuses the tag if it disagrees with the Makefile or has no
|
||||
changelog entry, attaches the packages to a GitHub release with the entry as
|
||||
its notes, and adds the deb and rpm files to the APT and RPM repositories on
|
||||
the `gh-pages` branch.
|
||||
|
||||
## Trying the release path first
|
||||
|
||||
@@ -67,7 +78,7 @@ them to the APT and RPM repositories on the `gh-pages` branch.
|
||||
gh workflow run release.yml -f dry_run=true
|
||||
```
|
||||
|
||||
Builds both packages, builds both repositories with a key generated on the
|
||||
Builds the packages, builds both repositories with a key generated on the
|
||||
spot, checks the signatures, and installs the packages back out of the
|
||||
repositories. Nothing is pushed and no release is made.
|
||||
|
||||
@@ -75,13 +86,13 @@ repositories. Nothing is pushed and no release is made.
|
||||
|
||||
```bash
|
||||
gpg --batch --passphrase '' --quick-generate-key \
|
||||
'plasma-face-unlock repository <felitendoyt@gmail.com>' rsa4096 sign never
|
||||
'face-unlock repository <felitendoyt@gmail.com>' rsa4096 sign never
|
||||
|
||||
gpg --armor --export-secret-keys 'plasma-face-unlock repository' \
|
||||
gpg --armor --export-secret-keys 'face-unlock repository' \
|
||||
| gh secret set GPG_PRIVATE_KEY
|
||||
```
|
||||
|
||||
Without the secret the workflow still builds both packages and attaches them to
|
||||
Without the secret the workflow still builds the packages and attaches them to
|
||||
the release; it says so in the log and leaves the repositories alone.
|
||||
|
||||
## Pointing Pages at it, once, in this order
|
||||
|
||||
+12
-2
@@ -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 )
|
||||
|
||||
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Builds the OpenCV the Arch tarball links in, as static libraries.
|
||||
#
|
||||
# Arch moves to a new OpenCV now and then, each with a new soname, and a
|
||||
# daemon linked against the old one then no longer starts. Linked in
|
||||
# statically, the daemon brings its own. Only the modules face-unlock uses are
|
||||
# built, with OpenCV's own copies of zlib, libjpeg and libpng, so nothing is
|
||||
# left to load at run time. The camera is read through V4L2, which OpenCV
|
||||
# talks to directly.
|
||||
#
|
||||
# packaging/build-opencv.sh [PREFIX]
|
||||
#
|
||||
# Installs into PREFIX (build/opencv-static by default) and does nothing when
|
||||
# PREFIX already holds what this script builds. Needs: cmake, a C++ compiler,
|
||||
# curl.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
prefix="${1:-$here/build/opencv-static}"
|
||||
|
||||
# 4.x, like the deb and the rpm. The static dnn of 5.0.0 does not link
|
||||
# (https://github.com/opencv/opencv/issues/29342).
|
||||
version=4.14.0
|
||||
sha256=ee8fb9b30eb60850431b4656447080e3737b56e45719c92b67f245950609f86e
|
||||
|
||||
# The flags Arch builds its own packages with: hardening, and full RELRO.
|
||||
if [[ -f /etc/makepkg.conf ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
source /etc/makepkg.conf
|
||||
export CFLAGS CXXFLAGS LDFLAGS
|
||||
fi
|
||||
|
||||
# A change to this script or to the flags is a different build.
|
||||
stamp="$({ cat "${BASH_SOURCE[0]}"; echo "${CFLAGS:-} ${CXXFLAGS:-} ${LDFLAGS:-}"; } | sha256sum | cut -d' ' -f1)"
|
||||
if [[ -f $prefix/.stamp && $(<"$prefix/.stamp") == "$stamp" ]]; then
|
||||
echo "$prefix already has this OpenCV"
|
||||
exit 0
|
||||
fi
|
||||
# It is emptied before the install, so it has to be one of ours.
|
||||
if [[ -e $prefix && ! -f $prefix/.stamp ]]; then
|
||||
echo "$0: $prefix exists and was not made by this script" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf -- "$work"' EXIT
|
||||
|
||||
curl -fL --retry 3 -o "$work/opencv.tar.gz" \
|
||||
"https://github.com/opencv/opencv/archive/refs/tags/$version.tar.gz"
|
||||
echo "$sha256 $work/opencv.tar.gz" | sha256sum -c --quiet -
|
||||
tar -xzf "$work/opencv.tar.gz" -C "$work"
|
||||
src="$work/opencv-$version"
|
||||
|
||||
# BUILD_LIST adds what the listed modules need (calib3d, features2d, flann).
|
||||
# The rest is switched off because it would be picked up from the system or
|
||||
# downloaded: codecs, video backends, IPP and the other accelerators.
|
||||
cmake -S "$src" -B "$work/build" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX="$prefix" \
|
||||
-DBUILD_SHARED_LIBS=OFF \
|
||||
-DBUILD_LIST=core,imgproc,imgcodecs,videoio,objdetect,dnn \
|
||||
-DBUILD_TESTS=OFF -DBUILD_PERF_TESTS=OFF -DBUILD_EXAMPLES=OFF \
|
||||
-DBUILD_DOCS=OFF -DBUILD_opencv_apps=OFF -DBUILD_JAVA=OFF \
|
||||
-DBUILD_ZLIB=ON -DBUILD_JPEG=ON -DBUILD_PNG=ON -DBUILD_PROTOBUF=ON \
|
||||
-DWITH_V4L=ON \
|
||||
-DWITH_FFMPEG=OFF -DWITH_GSTREAMER=OFF -DWITH_OBSENSOR=OFF \
|
||||
-DWITH_TIFF=OFF -DWITH_WEBP=OFF -DWITH_AVIF=OFF -DWITH_OPENEXR=OFF \
|
||||
-DWITH_OPENJPEG=OFF -DWITH_JASPER=OFF \
|
||||
-DWITH_IPP=OFF -DWITH_ITT=OFF -DWITH_OPENCL=OFF -DWITH_VA=OFF -DWITH_VA_INTEL=OFF \
|
||||
-DWITH_LAPACK=OFF -DWITH_EIGEN=OFF -DWITH_FLATBUFFERS=OFF -DWITH_QUIRC=OFF -DWITH_ADE=OFF
|
||||
cmake --build "$work/build" --parallel "$(nproc)"
|
||||
|
||||
rm -rf -- "$prefix"
|
||||
cmake --install "$work/build"
|
||||
|
||||
# OpenCV installs the licences of the libraries in it, but not its own.
|
||||
mv "$prefix"/share/licenses/opencv* "$prefix/share/licenses/opencv"
|
||||
install -Dm644 "$src/LICENSE" "$prefix/share/licenses/opencv/LICENSE"
|
||||
|
||||
# A picture with a face, for the check that the models load and find one.
|
||||
install -Dm644 "$src/samples/data/messi5.jpg" "$prefix/share/face-unlock-check/face.jpg"
|
||||
echo "$stamp" > "$prefix/.stamp"
|
||||
@@ -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; }
|
||||
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Builds the tarball for Arch Linux into dist/. The AUR package
|
||||
# face-unlock-bin is made from it.
|
||||
#
|
||||
# Like the deb and the rpm, it holds what `make install` produced, under a
|
||||
# folder named like the tarball. One thing is different: OpenCV is linked in
|
||||
# statically (packaging/build-opencv.sh), so a new OpenCV on Arch does not
|
||||
# break the daemon. Qt stays shared. The agent uses Qt's private API, so the
|
||||
# tarball fits the Qt that Arch had when it was built.
|
||||
#
|
||||
# Needs: make, cmake, a C++ compiler, the packages check.yml installs (without
|
||||
# opencv), msgfmt (gettext), scdoc, curl, zstd, and the static OpenCV:
|
||||
#
|
||||
# packaging/build-opencv.sh && packaging/build-tarball.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
version="${1:-$(make -s -C "$here" version)}"
|
||||
opencv="${OPENCV_PREFIX:-$here/build/opencv-static}"
|
||||
name="face-unlock-$version-arch-$(uname -m)"
|
||||
|
||||
# The flags Arch builds its own packages with: hardening, and full RELRO.
|
||||
if [[ -f /etc/makepkg.conf ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
source /etc/makepkg.conf
|
||||
export CFLAGS CXXFLAGS LDFLAGS
|
||||
fi
|
||||
|
||||
for tool in msgfmt scdoc cmake readelf zstd; do
|
||||
command -v "$tool" > /dev/null || { echo "$0: $tool is not installed" >&2; exit 1; }
|
||||
done
|
||||
opencv_dir="$(dirname "$(find "$opencv" -name OpenCVConfig.cmake -print -quit 2>/dev/null)")"
|
||||
[[ $opencv_dir != . ]] || { echo "$0: no OpenCV in $opencv, run packaging/build-opencv.sh first" >&2; exit 1; }
|
||||
|
||||
root="$(mktemp -d)"
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf -- "$root" "$work"' EXIT
|
||||
dest="$root/$name"
|
||||
|
||||
make -C "$here" models
|
||||
make -C "$here" install \
|
||||
DESTDIR="$dest" \
|
||||
PREFIX=/usr \
|
||||
VERSION="$version" \
|
||||
BUILDDIR="$work/build" \
|
||||
PAMDIR=/usr/lib/security \
|
||||
SYSTEMUNITDIR=/usr/lib/systemd/system \
|
||||
USERUNITDIR=/usr/lib/systemd/user \
|
||||
CMAKE_FLAGS="-DOpenCV_DIR=$opencv_dir"
|
||||
|
||||
# The tests, and the models on a real face: the part a smaller OpenCV could
|
||||
# break without anything else noticing.
|
||||
ctest --test-dir "$work/build" --output-on-failure
|
||||
face="$opencv/share/face-unlock-check/face.jpg"
|
||||
"$work/build/test_images" "$dest/usr/share/face-unlock/models" "$face" "$face" | tee "$work/faces"
|
||||
grep -q 'similarity' "$work/faces" || { echo "$0: the models found no face" >&2; exit 1; }
|
||||
|
||||
if readelf -d "$dest/usr/lib/face-unlock/face-unlockd" | grep -q 'libopencv'; then
|
||||
echo "$0: the daemon still loads a shared OpenCV" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The program is GPL, OpenCV and the libraries in it come with their own
|
||||
# licences, and the copyright file names the models' authors and licences.
|
||||
lic="$dest/usr/share/licenses/face-unlock"
|
||||
install -Dm644 "$here/LICENSE" "$lic/LICENSE"
|
||||
install -Dm644 "$here/packaging/deb/copyright" "$lic/copyright"
|
||||
cp -r "$opencv/share/licenses/opencv" "$lic/opencv"
|
||||
|
||||
mkdir -p "$here/dist"
|
||||
out="$here/dist/$name.tar.zst"
|
||||
tar -C "$root" --owner=0 --group=0 --numeric-owner --sort=name \
|
||||
-I 'zstd -19 -T0' -cf "$out" "$name"
|
||||
|
||||
echo "$out"
|
||||
+13
-8
@@ -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.1.0}
|
||||
|
||||
Name: plasma-face-unlock
|
||||
Name: face-unlock
|
||||
Version: %{upstream_version}
|
||||
Release: 1%{?dist}
|
||||
Summary: Face unlock for KDE Plasma
|
||||
Summary: Face unlock for Plasma, GNOME, Hyprland and Niri
|
||||
|
||||
# The program is GPL; the two networks it ships are MIT (YuNet) and
|
||||
# Apache-2.0 (SFace).
|
||||
License: GPL-3.0-or-later AND MIT AND Apache-2.0
|
||||
URL: https://github.com/LoonixTools/plasma-face-unlock
|
||||
URL: https://github.com/LoonixTools/face-unlock
|
||||
Source0: %{name}-%{version}.tar.gz
|
||||
Source1: https://github.com/opencv/opencv_zoo/raw/main/models/face_detection_yunet/face_detection_yunet_2023mar.onnx
|
||||
Source2: https://github.com/opencv/opencv_zoo/raw/main/models/face_recognition_sface/face_recognition_sface_2021dec.onnx
|
||||
@@ -45,13 +45,18 @@ Requires: polkit
|
||||
Requires: systemd
|
||||
Requires: qt6-qtdeclarative
|
||||
Recommends: /usr/bin/gettext
|
||||
Recommends: kscreenlocker
|
||||
# The old name.
|
||||
Obsoletes: plasma-face-unlock < 2
|
||||
Provides: plasma-face-unlock = %{version}-%{release}
|
||||
|
||||
%description
|
||||
Look at the screen and it unlocks, the way a phone does. The lock screen, sudo
|
||||
in a terminal and the admin password prompts of Plasma can take a face instead
|
||||
of a password. A bubble at the top of the screen, above the lock screen too,
|
||||
shows the face being looked for, recognised or refused.
|
||||
in a terminal and the admin password prompts can take a face instead of a
|
||||
password, on KDE Plasma, GNOME, Hyprland and Niri. A bubble at the top of the
|
||||
screen shows the face being looked for, recognised or refused.
|
||||
|
||||
This package was called plasma-face-unlock before. It takes over its faces and
|
||||
settings.
|
||||
|
||||
A photo on a phone, a tablet or a glossy print is refused by its reflection and
|
||||
its straight edges. The strict photo check also wants a sign of life (a blink,
|
||||
@@ -59,7 +64,7 @@ or the nose moving the way a real nose does when the head turns), which stops a
|
||||
printed photo too. It is a convenience, not a security upgrade: a webcam sees a
|
||||
flat picture, and a video of the person can get through.
|
||||
|
||||
Run "plasma-face-unlock disable" before removing this package, so that sudo
|
||||
Run "face-unlock disable" before removing this package, so that sudo
|
||||
and polkit go back to the password alone.
|
||||
|
||||
%prep
|
||||
@@ -78,10 +83,14 @@ make install DESTDIR=%{buildroot} PREFIX=%{_prefix} VERSION=%{upstream_version}
|
||||
%find_lang %{name}
|
||||
|
||||
%preun
|
||||
%systemd_preun plasma-face-unlockd.socket plasma-face-unlockd.service
|
||||
%systemd_preun face-unlockd.socket face-unlockd.service
|
||||
|
||||
%postun
|
||||
%systemd_postun plasma-face-unlockd.socket plasma-face-unlockd.service
|
||||
%systemd_postun face-unlockd.socket face-unlockd.service
|
||||
|
||||
# After the old package is gone: its faces, settings and PAM lines.
|
||||
%posttrans
|
||||
%{_bindir}/face-unlock --root migrate || :
|
||||
|
||||
%files -f %{name}.lang
|
||||
%license LICENSE
|
||||
@@ -89,13 +98,14 @@ make install DESTDIR=%{buildroot} PREFIX=%{_prefix} VERSION=%{upstream_version}
|
||||
%{_bindir}/%{name}
|
||||
%{_prefix}/lib/%{name}/
|
||||
%{_datadir}/%{name}/
|
||||
%{_libdir}/security/pam_plasma_face_unlock.so
|
||||
%{_unitdir}/plasma-face-unlockd.socket
|
||||
%{_unitdir}/plasma-face-unlockd.service
|
||||
%{_userunitdir}/plasma-face-unlock-agent.service
|
||||
%{_datadir}/applications/io.github.loonixtools.plasma-face-unlock-agent.desktop
|
||||
%{_datadir}/polkit-1/actions/io.github.loonixtools.plasma-face-unlock.policy
|
||||
%{_datadir}/icons/hicolor/scalable/apps/plasma-face-unlock.svg
|
||||
%{_libdir}/security/pam_face_unlock.so
|
||||
%{_unitdir}/face-unlockd.socket
|
||||
%{_unitdir}/face-unlockd.service
|
||||
%{_userunitdir}/face-unlock-agent.service
|
||||
%{_datadir}/applications/io.github.loonixtools.face-unlock-agent.desktop
|
||||
%{_datadir}/polkit-1/actions/io.github.loonixtools.face-unlock.policy
|
||||
%{_datadir}/icons/hicolor/scalable/apps/face-unlock.svg
|
||||
%{_datadir}/gnome-shell/extensions/face-unlock@loonixtools.github.io/
|
||||
%{_mandir}/man1/%{name}.1*
|
||||
|
||||
%changelog
|
||||
File diff suppressed because it is too large.
Load diff
+1313
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"
|
||||
+1233
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.
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"uuid": "face-unlock@loonixtools.github.io",
|
||||
"name": "Face Unlock",
|
||||
"description": "The bubble of face-unlock: shows what the camera is doing, on the lock screen too.",
|
||||
"shell-version": ["45", "46", "47", "48", "49", "50"],
|
||||
"session-modes": ["user", "unlock-dialog"],
|
||||
"url": "https://github.com/LoonixTools/face-unlock"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policyconfig PUBLIC
|
||||
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
||||
<policyconfig>
|
||||
<vendor>LoonixTools</vendor>
|
||||
<vendor_url>https://github.com/LoonixTools/face-unlock</vendor_url>
|
||||
<icon_name>face-unlock</icon_name>
|
||||
|
||||
<!-- A face is a way in, so adding, changing or deleting one takes the
|
||||
password, the way a phone asks for its code before it sets up a face.
|
||||
The daemon refuses to let a face answer this particular question. -->
|
||||
<action id="io.github.loonixtools.face-unlock.manage">
|
||||
<description>Change the faces that can unlock your account</description>
|
||||
<description xml:lang="de">Gesichter ändern, die dein Konto entsperren können</description>
|
||||
<description xml:lang="fr">Modifier les visages qui peuvent déverrouiller votre compte</description>
|
||||
<description xml:lang="es">Cambiar las caras que pueden desbloquear tu cuenta</description>
|
||||
<description xml:lang="it">Modificare i volti che possono sbloccare il tuo account</description>
|
||||
<description xml:lang="pt_BR">Alterar os rostos que podem desbloquear sua conta</description>
|
||||
<description xml:lang="nl">De gezichten wijzigen die je account kunnen ontgrendelen</description>
|
||||
<description xml:lang="pl">Zmień twarze, które mogą odblokować twoje konto</description>
|
||||
<description xml:lang="ru">Изменить лица, которые могут разблокировать вашу учётную запись</description>
|
||||
<description xml:lang="uk">Змінити обличчя, які можуть розблокувати ваш обліковий запис</description>
|
||||
<description xml:lang="tr">Hesabınızın kilidini açabilen yüzleri değiştir</description>
|
||||
<description xml:lang="zh_CN">更改可以解锁你账户的人脸</description>
|
||||
<description xml:lang="ja">アカウントのロックを解除できる顔を変更</description>
|
||||
<description xml:lang="ko">계정 잠금을 해제할 수 있는 얼굴 변경</description>
|
||||
<message>Authentication is required to change the faces that can unlock your account.</message>
|
||||
<message xml:lang="de">Zum Ändern der Gesichter, die dein Konto entsperren können, ist eine Legitimierung erforderlich.</message>
|
||||
<message xml:lang="fr">Une authentification est nécessaire pour modifier les visages qui peuvent déverrouiller votre compte.</message>
|
||||
<message xml:lang="es">Se necesita autenticación para cambiar las caras que pueden desbloquear tu cuenta.</message>
|
||||
<message xml:lang="it">È richiesta l'autenticazione per modificare i volti che possono sbloccare il tuo account.</message>
|
||||
<message xml:lang="pt_BR">É necessária autenticação para alterar os rostos que podem desbloquear sua conta.</message>
|
||||
<message xml:lang="nl">Authenticatie is vereist om de gezichten te wijzigen die je account kunnen ontgrendelen.</message>
|
||||
<message xml:lang="pl">Wymagane jest uwierzytelnienie, aby zmienić twarze, które mogą odblokować twoje konto.</message>
|
||||
<message xml:lang="ru">Для изменения лиц, которые могут разблокировать вашу учётную запись, требуется аутентификация.</message>
|
||||
<message xml:lang="uk">Для зміни облич, які можуть розблокувати ваш обліковий запис, потрібна автентифікація.</message>
|
||||
<message xml:lang="tr">Hesabınızın kilidini açabilen yüzleri değiştirmek için kimlik doğrulaması gerekiyor.</message>
|
||||
<message xml:lang="zh_CN">更改可以解锁你账户的人脸需要身份验证。</message>
|
||||
<message xml:lang="ja">アカウントのロックを解除できる顔を変更するには認証が必要です。</message>
|
||||
<message xml:lang="ko">계정 잠금을 해제할 수 있는 얼굴을 변경하려면 인증이 필요합니다.</message>
|
||||
<defaults>
|
||||
<allow_any>no</allow_any>
|
||||
<allow_inactive>no</allow_inactive>
|
||||
<allow_active>auth_self_keep</allow_active>
|
||||
</defaults>
|
||||
</action>
|
||||
</policyconfig>
|
||||
@@ -1,24 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policyconfig PUBLIC
|
||||
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
||||
<policyconfig>
|
||||
<vendor>LoonixTools</vendor>
|
||||
<vendor_url>https://github.com/LoonixTools/plasma-face-unlock</vendor_url>
|
||||
<icon_name>plasma-face-unlock</icon_name>
|
||||
|
||||
<!-- A face is a way in, so adding, changing or deleting one takes the
|
||||
password, the way a phone asks for its code before it sets up a face.
|
||||
The daemon refuses to let a face answer this particular question. -->
|
||||
<action id="io.github.loonixtools.plasma-face-unlock.manage">
|
||||
<description>Change the faces that can unlock your account</description>
|
||||
<description xml:lang="de">Gesichter ändern, die dein Konto entsperren können</description>
|
||||
<message>Authentication is required to change the faces that can unlock your account.</message>
|
||||
<message xml:lang="de">Zum Ändern der Gesichter, die dein Konto entsperren können, ist eine Legitimierung erforderlich.</message>
|
||||
<defaults>
|
||||
<allow_any>no</allow_any>
|
||||
<allow_inactive>no</allow_inactive>
|
||||
<allow_active>auth_self_keep</allow_active>
|
||||
</defaults>
|
||||
</action>
|
||||
</policyconfig>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 232 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 190 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;
|
||||
};
|
||||
@@ -117,6 +117,18 @@ void BubbleController::succeeded()
|
||||
|
||||
void BubbleController::failed(const QString &reason, qint64 lockout)
|
||||
{
|
||||
if (reason == u"camera-busy") {
|
||||
// A video call, most likely. The daemon starts no scan then, so this
|
||||
// may come with nothing on screen yet.
|
||||
if (!enabled() || m_config->quietWhenCameraBusy()) {
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
setMessage(i18n("Camera in use"));
|
||||
setPhase(QStringLiteral("busy"));
|
||||
m_hide.start(int(FailureHoldMs * pace()));
|
||||
return;
|
||||
}
|
||||
if (m_phase == u"hidden") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ class UserConfig;
|
||||
class BubbleController : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
// hidden, scanning, success, failure, lockout
|
||||
// hidden, scanning, success, failure, lockout, busy (another program has
|
||||
// the camera)
|
||||
Q_PROPERTY(QString phase READ phase NOTIFY phaseChanged)
|
||||
// A short line under the face in the full style: a hint while
|
||||
// scanning, the reason after a failure.
|
||||
|
||||
@@ -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, lockout and busy say why; hidden says nothing.
|
||||
return phase == u"hidden" ? QString() : message;
|
||||
}
|
||||
|
||||
void LockText::update()
|
||||
{
|
||||
const bool on = m_config->lockScreenStyle() == u"yours";
|
||||
const QString now = on ? text(m_bubble->phase(), m_bubble->message()) : QString();
|
||||
if (now != m_text) {
|
||||
write(now);
|
||||
// Left alone by those who did not pick it: their hyprlock has no
|
||||
// label to read it.
|
||||
m_tries = 0;
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
void LockText::refresh()
|
||||
{
|
||||
// A hyprlock that is only starting reads the file once it is up, but
|
||||
// may have read it just before this changed: asked again then.
|
||||
if (Lockers::signal(SIGUSR2, "hyprlock") > 0 && ++m_tries < 20) {
|
||||
QTimer::singleShot(250, this, &LockText::refresh);
|
||||
}
|
||||
}
|
||||
|
||||
void LockText::write(const QString &text)
|
||||
{
|
||||
m_text = text;
|
||||
QDir().mkpath(QFileInfo(path()).absolutePath());
|
||||
QSaveFile file(path());
|
||||
// hyprlock reads labels as Pango markup.
|
||||
if (file.open(QIODevice::WriteOnly)) {
|
||||
file.write(text.toHtmlEscaped().toUtf8());
|
||||
file.commit();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
+260
-12
@@ -1,19 +1,34 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// plasma-face-unlock-agent: the part in the user's session.
|
||||
// face-unlock-agent: the part in the user's session.
|
||||
//
|
||||
// (no arguments) stay in the background: unlock the lock screen by face,
|
||||
// and show the bubble for every scan
|
||||
// --enroll open the window that sets up a face
|
||||
// --lock lock the screen: with face-unlock's own lock screen
|
||||
// where the lock screen is a program of its own (Hyprland,
|
||||
// Niri), else through logind with the desktop's
|
||||
// --demo play the bubble's animations once, for trying out a
|
||||
// style (--bubble-style minimal) and for screenshots
|
||||
// --choose-lock-screen
|
||||
// open the window that asks which lock screen to use
|
||||
// where it is a program of its own, and print the answer
|
||||
// --has-own-lock for the menu: exits 0 when this build has face-unlock's
|
||||
// own lock screen (see SessionLock::built)
|
||||
|
||||
#include "agentsocket.h"
|
||||
#include "bubblecontroller.h"
|
||||
#include "bubbleservice.h"
|
||||
#include "bubblewindow.h"
|
||||
#include "enrollcontroller.h"
|
||||
#include "lockcontroller.h"
|
||||
#include "lockpreview.h"
|
||||
#include "lockscreencontroller.h"
|
||||
#include "locktext.h"
|
||||
#include "sessionlock.h"
|
||||
#include "userconfig.h"
|
||||
#include "wallpaper.h"
|
||||
#include "wayland.h"
|
||||
|
||||
#include "buildconfig.h"
|
||||
|
||||
@@ -21,11 +36,20 @@
|
||||
#include <KLocalizedString>
|
||||
|
||||
#include <QCommandLineParser>
|
||||
#include <QDBusConnection>
|
||||
#include <QDBusMessage>
|
||||
#include <QFile>
|
||||
#include <QGuiApplication>
|
||||
#include <QQmlApplicationEngine>
|
||||
#include <QQmlEngine>
|
||||
#include <QScreen>
|
||||
#include <QTimer>
|
||||
#include <QWindow>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace
|
||||
@@ -39,7 +63,7 @@ int runEnroll(QGuiApplication &app, const QString &name)
|
||||
QQmlApplicationEngine engine;
|
||||
KLocalization::setupLocalizedContext(&engine);
|
||||
engine.setInitialProperties({{QStringLiteral("controller"), QVariant::fromValue(&controller)}});
|
||||
engine.loadFromModule(QStringLiteral("PlasmaFaceUnlock"), QStringLiteral("Enroll"));
|
||||
engine.loadFromModule(QStringLiteral("FaceUnlock"), QStringLiteral("Enroll"));
|
||||
if (engine.rootObjects().isEmpty()) {
|
||||
return 2;
|
||||
}
|
||||
@@ -53,8 +77,144 @@ int runEnroll(QGuiApplication &app, const QString &name)
|
||||
return controller.state() == u"done" ? 0 : code;
|
||||
}
|
||||
|
||||
// The window of --choose-lock-screen. Prints "own" or "yours" for the menu,
|
||||
// which carries it out.
|
||||
int runChooseLockScreen(QGuiApplication &app)
|
||||
{
|
||||
app.setQuitOnLastWindowClosed(true);
|
||||
UserConfig config;
|
||||
|
||||
// Both choices show this screen as it would look: face-unlock's lock
|
||||
// screen as it is, with a scan going on, and the user's own rebuilt.
|
||||
const QScreen *screen = QGuiApplication::primaryScreen();
|
||||
const QString output = screen ? screen->name() : QString();
|
||||
const QSize size = screen ? screen->size() : QSize(1920, 1080);
|
||||
const QUrl desktop = Wallpaper::pick(Wallpaper::forLock({}), output);
|
||||
const QUrl ownWallpaper = config.lockWallpaper().isEmpty() ? desktop : Wallpaper::pick(Wallpaper::forLock(config.lockWallpaper()), output);
|
||||
const QString locker = LockPreview::locker();
|
||||
|
||||
LockScreenController lock;
|
||||
lock.setBlur(config.lockBlur());
|
||||
BubbleController bubble(&config);
|
||||
bubble.scanStarted();
|
||||
// A scan that goes on: the bubble closes by itself after a while.
|
||||
QTimer rescan;
|
||||
QObject::connect(&rescan, &QTimer::timeout, &bubble, &BubbleController::scanStarted);
|
||||
rescan.start(20000);
|
||||
|
||||
QQmlApplicationEngine engine;
|
||||
KLocalization::setupLocalizedContext(&engine);
|
||||
engine.setInitialProperties({{QStringLiteral("current"), config.lockScreenStyle()},
|
||||
{QStringLiteral("locker"), locker},
|
||||
{QStringLiteral("widgets"), LockPreview::widgets(locker, size, output, desktop)},
|
||||
{QStringLiteral("screenSize"), size},
|
||||
{QStringLiteral("ownWallpaper"), ownWallpaper},
|
||||
{QStringLiteral("lock"), QVariant::fromValue(&lock)},
|
||||
{QStringLiteral("bubble"), QVariant::fromValue(&bubble)}});
|
||||
engine.loadFromModule(QStringLiteral("FaceUnlock"), QStringLiteral("LockChoice"));
|
||||
auto *window = engine.rootObjects().isEmpty() ? nullptr : qobject_cast<QWindow *>(engine.rootObjects().constFirst());
|
||||
if (!window) {
|
||||
return 2;
|
||||
}
|
||||
// Shown only now, at the size its texts need (see LockChoice.qml).
|
||||
window->show();
|
||||
app.exec();
|
||||
const QString answer = window->property("answer").toString();
|
||||
if (answer.isEmpty()) {
|
||||
return 1;
|
||||
}
|
||||
std::printf("%s\n", qPrintable(answer));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Where --lock tells the command that ran it that the screen is locked, when
|
||||
// it forked to stay behind as the lock screen. -1: it did not.
|
||||
int readyFd = -1;
|
||||
|
||||
void signalReady(bool ok)
|
||||
{
|
||||
if (readyFd >= 0) {
|
||||
if (ok) {
|
||||
[[maybe_unused]] const ssize_t n = write(readyFd, "1", 1);
|
||||
}
|
||||
close(readyFd);
|
||||
readyFd = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Whether an agent listens on its socket. Asked before Qt is up, to decide
|
||||
// whether to fork.
|
||||
bool agentListening()
|
||||
{
|
||||
const char *runtime = getenv("XDG_RUNTIME_DIR");
|
||||
if (!runtime) {
|
||||
return false;
|
||||
}
|
||||
sockaddr_un addr{};
|
||||
addr.sun_family = AF_UNIX;
|
||||
const int len = snprintf(addr.sun_path, sizeof(addr.sun_path), "%s/face-unlock/agent.socket", runtime);
|
||||
if (len <= 0 || size_t(len) >= sizeof(addr.sun_path)) {
|
||||
return false;
|
||||
}
|
||||
const int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
|
||||
const bool ok = fd >= 0 && connect(fd, reinterpret_cast<sockaddr *>(&addr), sizeof(addr)) == 0;
|
||||
if (fd >= 0) {
|
||||
close(fd);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
// With no agent running, --lock stays behind as the lock screen until it is
|
||||
// unlocked. The command that asked for it returns as soon as the screen is
|
||||
// locked, the way swaylock -f does: an idle daemon locking before sleep
|
||||
// waits for that, and not a moment longer.
|
||||
void forkForLock(int argc, char **argv)
|
||||
{
|
||||
bool lock = false;
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
lock = lock || std::strcmp(argv[i], "--lock") == 0;
|
||||
}
|
||||
int fds[2];
|
||||
if (!lock || agentListening() || pipe(fds) != 0) {
|
||||
return;
|
||||
}
|
||||
const pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
close(fds[0]);
|
||||
close(fds[1]);
|
||||
return;
|
||||
}
|
||||
if (pid > 0) {
|
||||
close(fds[1]);
|
||||
char c = 0;
|
||||
const bool locked = read(fds[0], &c, 1) == 1;
|
||||
_exit(locked ? 0 : 1);
|
||||
}
|
||||
close(fds[0]);
|
||||
setsid();
|
||||
readyFd = fds[1];
|
||||
}
|
||||
|
||||
// Plasma and GNOME lock with their own lock screen when logind asks them to,
|
||||
// as `loginctl lock-session` does.
|
||||
int lockThroughLogind()
|
||||
{
|
||||
const QString id = qEnvironmentVariable("XDG_SESSION_ID");
|
||||
QDBusMessage call = QDBusMessage::createMethodCall(QStringLiteral("org.freedesktop.login1"),
|
||||
QStringLiteral("/org/freedesktop/login1"),
|
||||
QStringLiteral("org.freedesktop.login1.Manager"),
|
||||
QStringLiteral("LockSession"));
|
||||
call << (id.isEmpty() ? QStringLiteral("auto") : id);
|
||||
const QDBusMessage reply = QDBusConnection::systemBus().call(call);
|
||||
if (reply.type() == QDBusMessage::ErrorMessage) {
|
||||
qWarning("logind would not lock: %s", qPrintable(reply.errorMessage()));
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The whole life of a bubble, twice: a face that is recognised after a blink,
|
||||
// then one that is not.
|
||||
// then one that is not. Last, a camera another program has.
|
||||
void scheduleDemo(BubbleController *bubble)
|
||||
{
|
||||
const QList<std::pair<int, std::function<void()>>> steps = {
|
||||
@@ -65,7 +225,8 @@ void scheduleDemo(BubbleController *bubble)
|
||||
{5600, [bubble] { bubble->scanStarted(); }},
|
||||
{6200, [bubble] { bubble->faceFound(); }},
|
||||
{7800, [bubble] { bubble->failed(QStringLiteral("mismatch")); }},
|
||||
{11000, [] { QCoreApplication::quit(); }},
|
||||
{11000, [bubble] { bubble->failed(QStringLiteral("camera-busy")); }},
|
||||
{14000, [] { QCoreApplication::quit(); }},
|
||||
};
|
||||
for (const auto &[at, what] : steps) {
|
||||
QTimer::singleShot(at, bubble, what);
|
||||
@@ -75,24 +236,34 @@ void scheduleDemo(BubbleController *bubble)
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
if (argc == 2 && std::strcmp(argv[1], "--has-own-lock") == 0) {
|
||||
return SessionLock::built ? 0 : 1;
|
||||
}
|
||||
forkForLock(argc, argv);
|
||||
QGuiApplication app(argc, argv);
|
||||
app.setApplicationName(QStringLiteral("plasma-face-unlock-agent"));
|
||||
app.setApplicationVersion(QStringLiteral(PFU_VERSION));
|
||||
app.setDesktopFileName(QStringLiteral("io.github.loonixtools.plasma-face-unlock-agent"));
|
||||
KLocalizedString::setApplicationDomain(PFU_NAME);
|
||||
app.setApplicationName(QStringLiteral("face-unlock-agent"));
|
||||
app.setApplicationVersion(QStringLiteral(FU_VERSION));
|
||||
app.setDesktopFileName(QStringLiteral("io.github.loonixtools.face-unlock-agent"));
|
||||
KLocalizedString::setApplicationDomain(FU_NAME);
|
||||
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription(i18n("Face unlock for KDE Plasma"));
|
||||
parser.setApplicationDescription(i18n("Face unlock for the lock screen, sudo and admin prompts"));
|
||||
parser.addHelpOption();
|
||||
parser.addVersionOption();
|
||||
const QCommandLineOption enrollOpt(QStringLiteral("enroll"), i18n("Set up a face."));
|
||||
const QCommandLineOption nameOpt(QStringLiteral("name"), i18n("What to call the new face."), QStringLiteral("name"));
|
||||
const QCommandLineOption lockOpt(QStringLiteral("lock"), i18n("Lock the screen."));
|
||||
const QCommandLineOption demoOpt(QStringLiteral("demo"), i18n("Play the bubble's animations once."));
|
||||
// Not "--style": QGuiApplication takes that one for itself.
|
||||
const QCommandLineOption styleOpt(QStringLiteral("bubble-style"), i18n("Bubble style for the demo: full or minimal."), QStringLiteral("style"));
|
||||
parser.addOptions({enrollOpt, nameOpt, demoOpt, styleOpt});
|
||||
const QCommandLineOption chooseOpt(QStringLiteral("choose-lock-screen"), i18n("Ask which lock screen to use, and print the answer."));
|
||||
parser.addOptions({enrollOpt, nameOpt, lockOpt, demoOpt, styleOpt, chooseOpt});
|
||||
parser.process(app);
|
||||
|
||||
if (parser.isSet(chooseOpt)) {
|
||||
return runChooseLockScreen(app);
|
||||
}
|
||||
|
||||
if (parser.isSet(enrollOpt)) {
|
||||
QString name = parser.value(nameOpt);
|
||||
if (name.isEmpty()) {
|
||||
@@ -101,6 +272,13 @@ int main(int argc, char **argv)
|
||||
return runEnroll(app, name);
|
||||
}
|
||||
|
||||
const bool lockNow = parser.isSet(lockOpt);
|
||||
if (lockNow && !SessionLock::available()) {
|
||||
const int rc = lockThroughLogind();
|
||||
signalReady(rc == 0);
|
||||
return rc;
|
||||
}
|
||||
|
||||
app.setQuitOnLastWindowClosed(false);
|
||||
QQmlEngine engine;
|
||||
KLocalization::setupLocalizedContext(&engine);
|
||||
@@ -110,17 +288,87 @@ int main(int argc, char **argv)
|
||||
config.overrideStyle(parser.value(styleOpt));
|
||||
}
|
||||
BubbleController bubble(&config);
|
||||
BubbleWindow window(&engine, &bubble);
|
||||
// The bubble floats above everything as a layer-shell surface. GNOME has
|
||||
// none, and a normal window cannot stay on top or pick its place: there
|
||||
// face-unlock's GNOME Shell extension draws it, from what this tells it.
|
||||
std::unique_ptr<BubbleWindow> window;
|
||||
if (Wayland::hasGlobal("zwlr_layer_shell_v1")) {
|
||||
window = std::make_unique<BubbleWindow>(&engine, &bubble);
|
||||
}
|
||||
|
||||
if (parser.isSet(demoOpt)) {
|
||||
if (!window) {
|
||||
qWarning("this desktop has no layer-shell; on GNOME the extension draws the bubble");
|
||||
return 1;
|
||||
}
|
||||
scheduleDemo(&bubble);
|
||||
return app.exec();
|
||||
}
|
||||
|
||||
AgentSocket socket;
|
||||
if (AgentSocket::running()) {
|
||||
if (lockNow) {
|
||||
return AgentSocket::requestLock() ? 0 : 1;
|
||||
}
|
||||
qInfo("an agent is already running in this session");
|
||||
return 0;
|
||||
}
|
||||
socket.listen();
|
||||
QObject::connect(&socket, &AgentSocket::scanEvent, &bubble, &BubbleController::daemonEvent);
|
||||
std::unique_ptr<BubbleService> service;
|
||||
if (!window) {
|
||||
service = std::make_unique<BubbleService>(&bubble);
|
||||
}
|
||||
|
||||
LockController lock(&bubble, &config);
|
||||
LockScreenController screen;
|
||||
std::unique_ptr<SessionLock> sessionLock;
|
||||
if (SessionLock::available()) {
|
||||
sessionLock = std::make_unique<SessionLock>(&engine, &bubble, &screen, &config);
|
||||
SessionLock *lock = sessionLock.get();
|
||||
QObject::connect(&socket, &AgentSocket::lockRequested, lock, [lock, &socket] {
|
||||
lock->lock();
|
||||
if (lock->isConfirmed()) {
|
||||
socket.confirmLock();
|
||||
}
|
||||
});
|
||||
QObject::connect(lock, &SessionLock::confirmed, &socket, &AgentSocket::confirmLock);
|
||||
}
|
||||
LockController lock(&bubble, &config, sessionLock.get(), &screen);
|
||||
// hyprlock covers the bubble: it can show it as a line of text instead.
|
||||
std::unique_ptr<LockText> text;
|
||||
if (sessionLock && !lockNow) {
|
||||
text = std::make_unique<LockText>(&bubble, &config);
|
||||
}
|
||||
|
||||
if (lockNow) {
|
||||
// No agent was running, so this one is only here for the lock. It
|
||||
// goes once the lock is gone and the bubble has finished.
|
||||
QObject::connect(sessionLock.get(), &SessionLock::lockedChanged, &app, [&bubble](bool locked) {
|
||||
if (locked) {
|
||||
return;
|
||||
}
|
||||
if (!bubble.shown()) {
|
||||
QCoreApplication::quit();
|
||||
}
|
||||
QObject::connect(&bubble, &BubbleController::shownChanged, qApp, [&bubble] {
|
||||
if (!bubble.shown()) {
|
||||
QCoreApplication::quit();
|
||||
}
|
||||
});
|
||||
QTimer::singleShot(5000, qApp, &QCoreApplication::quit);
|
||||
});
|
||||
QObject::connect(sessionLock.get(), &SessionLock::confirmed, &app, [] {
|
||||
signalReady(true);
|
||||
});
|
||||
sessionLock->lock();
|
||||
if (!sessionLock->isLocked()) {
|
||||
signalReady(false);
|
||||
return 1;
|
||||
}
|
||||
} else if (sessionLock && QFile::exists(SessionLock::markerPath())) {
|
||||
// The last agent went away with the screen locked, and the compositor
|
||||
// kept it locked. Take the lock back, so it can be opened again.
|
||||
sessionLock->lock();
|
||||
}
|
||||
return app.exec();
|
||||
}
|
||||
@@ -212,7 +212,7 @@ Item {
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
elide: Text.ElideRight
|
||||
text: root.bubble ? root.bubble.message : ""
|
||||
color: root.shownPhase === "failure" || root.shownPhase === "lockout" ? Theme.textDetail : Theme.textSecondary
|
||||
color: root.shownPhase === "failure" || root.shownPhase === "lockout" || root.shownPhase === "busy" ? Theme.textDetail : Theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
font.weight: Font.Medium
|
||||
opacity: full.hasMessage ? 1 : 0
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// A video camera with a line through it: another program has the camera, a
|
||||
// video call most likely. The line strikes through once the camera shows.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property color color: Theme.textPrimary
|
||||
// What the line cuts out of the camera: the island behind it.
|
||||
property color background: Theme.panel
|
||||
property bool shown: false
|
||||
property real pace: 1
|
||||
|
||||
readonly property real u: width / 100
|
||||
|
||||
// How far the line has come, 0 to 1.
|
||||
property real strike: 0
|
||||
onShownChanged: {
|
||||
if (shown) {
|
||||
striking.restart()
|
||||
} else {
|
||||
striking.stop()
|
||||
strike = 0
|
||||
}
|
||||
}
|
||||
SequentialAnimation {
|
||||
id: striking
|
||||
PropertyAction { target: root; property: "strike"; value: 0 }
|
||||
PauseAnimation { duration: 120 * root.pace }
|
||||
NumberAnimation { target: root; property: "strike"; to: 1; duration: 320 * root.pace; easing.type: Easing.OutCubic }
|
||||
}
|
||||
|
||||
readonly property point lineStart: Qt.point(12 * u, 10 * u)
|
||||
readonly property point lineEnd: Qt.point((12 + 76 * strike) * u, (10 + 80 * strike) * u)
|
||||
|
||||
// Body
|
||||
Rectangle {
|
||||
x: 4 * root.u
|
||||
y: 24 * root.u
|
||||
width: 62 * root.u
|
||||
height: 52 * root.u
|
||||
radius: 12 * root.u
|
||||
color: root.color
|
||||
}
|
||||
|
||||
// Lens, its corners rounded by a stroke of the same colour
|
||||
Shape {
|
||||
anchors.fill: parent
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
|
||||
ShapePath {
|
||||
fillColor: root.color
|
||||
strokeColor: root.color
|
||||
strokeWidth: 6 * root.u
|
||||
joinStyle: ShapePath.RoundJoin
|
||||
startX: 72 * root.u; startY: 42 * root.u
|
||||
PathLine { x: 94 * root.u; y: 29 * root.u }
|
||||
PathLine { x: 94 * root.u; y: 71 * root.u }
|
||||
PathLine { x: 72 * root.u; y: 58 * root.u }
|
||||
PathLine { x: 72 * root.u; y: 42 * root.u }
|
||||
}
|
||||
}
|
||||
|
||||
// The line, with a gap round it so it reads on the filled camera
|
||||
Shape {
|
||||
anchors.fill: parent
|
||||
visible: root.strike > 0.01
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
|
||||
ShapePath {
|
||||
strokeColor: root.background
|
||||
strokeWidth: 24 * root.u
|
||||
fillColor: "transparent"
|
||||
capStyle: ShapePath.RoundCap
|
||||
startX: root.lineStart.x; startY: root.lineStart.y
|
||||
PathLine { x: root.lineEnd.x; y: root.lineEnd.y }
|
||||
}
|
||||
ShapePath {
|
||||
strokeColor: root.color
|
||||
strokeWidth: 9 * root.u
|
||||
fillColor: "transparent"
|
||||
capStyle: ShapePath.RoundCap
|
||||
startX: root.lineStart.x; startY: root.lineStart.y
|
||||
PathLine { x: root.lineEnd.x; y: root.lineEnd.y }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
// success the rings (below), then a tick in the one that is left
|
||||
// failure it turns red and shakes its head, as on iOS
|
||||
// lockout a lock instead of a face
|
||||
// busy a camera with a line through it: another program has the
|
||||
// camera
|
||||
//
|
||||
// The rings are Face ID's in the Dynamic Island: the face gives way to two
|
||||
// crossed rings that tumble in 3D with a soft glow, slow down, and land flat
|
||||
@@ -352,8 +354,8 @@ Item {
|
||||
id: face
|
||||
anchors.fill: parent
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
// Gives way to the rings, and to the lock.
|
||||
property real shown: root.mode === "lockout" ? 0 : 1
|
||||
// Gives way to the rings, the lock and the camera.
|
||||
property real shown: root.mode === "lockout" || root.mode === "busy" ? 0 : 1
|
||||
Behavior on shown { NumberAnimation { duration: 180 * root.pace } }
|
||||
opacity: shown * (1 - root.faceGone)
|
||||
scale: 1 - 0.2 * root.faceGone
|
||||
@@ -418,5 +420,18 @@ Item {
|
||||
Behavior on opacity { NumberAnimation { duration: 200 * root.pace } }
|
||||
Behavior on scale { NumberAnimation { duration: 260 * root.pace; easing.type: Easing.OutBack } }
|
||||
}
|
||||
|
||||
CameraGlyph {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width * 0.5
|
||||
height: width
|
||||
pace: root.pace
|
||||
color: root.color
|
||||
shown: root.mode === "busy"
|
||||
opacity: shown ? 1 : 0
|
||||
scale: shown ? 1 : 0.7
|
||||
Behavior on opacity { NumberAnimation { duration: 200 * root.pace } }
|
||||
Behavior on scale { NumberAnimation { duration: 260 * root.pace; easing.type: Easing.OutBack } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,18 +23,23 @@ UserConfig::UserConfig(QObject *parent)
|
||||
|
||||
QString UserConfig::path()
|
||||
{
|
||||
return QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation) + QStringLiteral("/plasma-face-unlock/config");
|
||||
return QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation) + QStringLiteral("/face-unlock/config");
|
||||
}
|
||||
|
||||
void UserConfig::load()
|
||||
{
|
||||
const KeyValueFile kv = KeyValueFile::load(path());
|
||||
m_enabled = kv.boolean(QStringLiteral("Enabled"), false);
|
||||
m_lockWallpaper = kv.value(QStringLiteral("LockWallpaper"));
|
||||
m_lockBlur = kv.boolean(QStringLiteral("LockBlur"), false);
|
||||
m_lockScreenStyle = kv.value(QStringLiteral("LockScreenStyle"));
|
||||
m_lockScreen = kv.boolean(QStringLiteral("LockScreen"), true);
|
||||
m_scanOnWake = kv.boolean(QStringLiteral("ScanOnWake"), true);
|
||||
m_scanOnLock = kv.boolean(QStringLiteral("ScanOnLock"), false);
|
||||
m_bubble = kv.boolean(QStringLiteral("Bubble"), true);
|
||||
m_bubbleStyle = kv.value(QStringLiteral("BubbleStyle"), QStringLiteral("full")) == u"minimal" ? QStringLiteral("minimal") : QStringLiteral("full");
|
||||
m_bubbleForPrompts = kv.boolean(QStringLiteral("BubbleForPrompts"), true);
|
||||
m_quietWhenCameraBusy = kv.boolean(QStringLiteral("QuietWhenCameraBusy"), false);
|
||||
const QString speed = kv.value(QStringLiteral("AnimationSpeed"), QStringLiteral("normal"));
|
||||
// The durations in the code are brisk, like on a phone. On a big screen
|
||||
// they read better a little longer, so normal stretches them.
|
||||
|
||||
+36
-1
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// ~/.config/plasma-face-unlock/config: what this user wants from the agent.
|
||||
// ~/.config/face-unlock/config: what this user wants from the agent.
|
||||
// Written by the menu, read here, and read again whenever it changes.
|
||||
|
||||
#pragma once
|
||||
@@ -17,6 +17,30 @@ class UserConfig : public QObject
|
||||
public:
|
||||
explicit UserConfig(QObject *parent = nullptr);
|
||||
|
||||
// Face unlock turned on at all. The agent's service only runs when it is,
|
||||
// but the own lock screen (--lock) runs either way.
|
||||
bool enabled() const
|
||||
{
|
||||
return m_enabled;
|
||||
}
|
||||
// The picture behind the own lock screen (see Wallpaper): empty for the
|
||||
// desktop's, "none", a file, or a folder to take one from at random.
|
||||
QString lockWallpaper() const
|
||||
{
|
||||
return m_lockWallpaper;
|
||||
}
|
||||
// Where the lock screen is a program of the user's (Hyprland, Niri):
|
||||
// "own" to lock with face-unlock's, "yours" to keep that program, which
|
||||
// then shows the bubble as a line of text (see LockText). Empty until
|
||||
// the user picked one.
|
||||
QString lockScreenStyle() const
|
||||
{
|
||||
return m_lockScreenStyle;
|
||||
}
|
||||
bool lockBlur() const
|
||||
{
|
||||
return m_lockBlur;
|
||||
}
|
||||
// Unlock the lock screen by face.
|
||||
bool lockScreen() const
|
||||
{
|
||||
@@ -55,6 +79,12 @@ public:
|
||||
{
|
||||
return m_bubbleForPrompts;
|
||||
}
|
||||
// No bubble at all while another program has the camera (a video call).
|
||||
// Off: the bubble shows a camera with a line through it.
|
||||
bool quietWhenCameraBusy() const
|
||||
{
|
||||
return m_quietWhenCameraBusy;
|
||||
}
|
||||
// How long the bubble's animations take, as a multiple of the durations
|
||||
// in the code: the animation speed setting (fast 1, normal 1.3,
|
||||
// slow 2).
|
||||
@@ -72,12 +102,17 @@ private:
|
||||
void load();
|
||||
|
||||
QFileSystemWatcher m_watcher;
|
||||
bool m_enabled = false;
|
||||
QString m_lockWallpaper;
|
||||
QString m_lockScreenStyle;
|
||||
bool m_lockBlur = false;
|
||||
bool m_lockScreen = true;
|
||||
bool m_scanOnWake = true;
|
||||
bool m_scanOnLock = false;
|
||||
bool m_bubble = true;
|
||||
QString m_bubbleStyle = QStringLiteral("full");
|
||||
bool m_bubbleForPrompts = true;
|
||||
bool m_quietWhenCameraBusy = false;
|
||||
qreal m_pace = 1.3;
|
||||
QString m_styleOverride;
|
||||
};
|
||||
@@ -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
|
||||
@@ -15,6 +15,7 @@
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <thread>
|
||||
|
||||
using namespace std::chrono;
|
||||
@@ -105,6 +106,24 @@ bool probe(const QString &path, CameraInfo *info)
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
// A program that streams holds the camera's buffers. Asking for none is how
|
||||
// V4L2 lets a program check without taking them: the driver says EBUSY when
|
||||
// they belong to somebody else.
|
||||
bool busy(const QString &path)
|
||||
{
|
||||
const int fd = ::open(QFile::encodeName(path).constData(), O_RDWR | O_NONBLOCK | O_CLOEXEC);
|
||||
if (fd < 0) {
|
||||
return errno == EBUSY;
|
||||
}
|
||||
v4l2_requestbuffers req{};
|
||||
req.count = 0;
|
||||
req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
req.memory = V4L2_MEMORY_MMAP;
|
||||
const bool result = ::ioctl(fd, VIDIOC_REQBUFS, &req) != 0 && errno == EBUSY;
|
||||
::close(fd);
|
||||
return result;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Camera::Camera() = default;
|
||||
@@ -142,6 +161,15 @@ QString Camera::autoPath()
|
||||
return cameras.isEmpty() ? QString() : cameras.first().path;
|
||||
}
|
||||
|
||||
bool Camera::inUse(const QString &spec)
|
||||
{
|
||||
if (spec.startsWith(u"file:") || spec.startsWith(u"images:")) {
|
||||
return false;
|
||||
}
|
||||
const QString path = spec.isEmpty() || spec == u"auto" ? autoPath() : spec;
|
||||
return !path.isEmpty() && busy(path);
|
||||
}
|
||||
|
||||
bool Camera::open(const QString &spec, QString *error)
|
||||
{
|
||||
close();
|
||||
@@ -178,6 +206,10 @@ bool Camera::open(const QString &spec, QString *error)
|
||||
*error = QStringLiteral("%1 is not a camera").arg(path);
|
||||
return false;
|
||||
}
|
||||
if (busy(path)) {
|
||||
*error = QStringLiteral("%1 is in use by another program").arg(path);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!m_capture.open(QFile::encodeName(path).toStdString(), cv::CAP_V4L2) || !m_capture.isOpened()) {
|
||||
*error = QStringLiteral("cannot open %1 (in use by another program?)").arg(path);
|
||||
|
||||
@@ -55,6 +55,10 @@ public:
|
||||
// What "auto" means on this machine: the first colour camera, else the
|
||||
// first camera at all.
|
||||
static QString autoPath();
|
||||
// Whether another program streams from the camera right now, a video
|
||||
// call for example. Asked without starting it, so the light stays off.
|
||||
// Never true for a video file or pictures.
|
||||
static bool inUse(const QString &spec);
|
||||
|
||||
private:
|
||||
bool openImages(const QString &dir, QString *error);
|
||||
|
||||
@@ -50,6 +50,7 @@ Settings Settings::load(const QString &path)
|
||||
s.maxFailures = kv.integer(QStringLiteral("MaxFailures"), s.maxFailures, 1, 20);
|
||||
s.lockoutMinutes = kv.integer(QStringLiteral("LockoutMinutes"), s.lockoutMinutes, 1, 24 * 60);
|
||||
s.skipLidClosed = kv.boolean(QStringLiteral("SkipLidClosed"), s.skipLidClosed);
|
||||
s.sshSessions = kv.boolean(QStringLiteral("SshSessions"), s.sshSessions);
|
||||
s.adapt = kv.boolean(QStringLiteral("Adapt"), s.adapt);
|
||||
return s;
|
||||
}
|
||||
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// The system settings, /etc/plasma-face-unlock/config.
|
||||
// The system settings, /etc/face-unlock/config.
|
||||
//
|
||||
// These are the ones that decide how hard it is to get in: which camera, how
|
||||
// strict the match is, whether a photo is checked for. They belong to root
|
||||
@@ -44,6 +44,10 @@ struct Settings {
|
||||
int lockoutMinutes = 15;
|
||||
// A laptop with the lid shut has its camera looking at the keyboard.
|
||||
bool skipLidClosed = true;
|
||||
// sudo and admin prompts in an SSH session. Off by default: whoever sits
|
||||
// in front of the camera need not be whoever types. On, that person still
|
||||
// has to be at the machine (see the PAM module).
|
||||
bool sshSessions = false;
|
||||
// Take in a little of each confident unlock, so a new haircut or a pair
|
||||
// of glasses does not need a new setup. Face ID does the same.
|
||||
bool adapt = true;
|
||||
|
||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user