glanced 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. glanced-0.3.0/LICENSE +21 -0
  2. glanced-0.3.0/NOTICE +15 -0
  3. glanced-0.3.0/PKG-INFO +317 -0
  4. glanced-0.3.0/README.md +296 -0
  5. glanced-0.3.0/pyproject.toml +37 -0
  6. glanced-0.3.0/setup.cfg +4 -0
  7. glanced-0.3.0/src/glanced/__init__.py +8 -0
  8. glanced-0.3.0/src/glanced/align.py +55 -0
  9. glanced-0.3.0/src/glanced/attention.py +360 -0
  10. glanced-0.3.0/src/glanced/camera.py +122 -0
  11. glanced-0.3.0/src/glanced/cli.py +676 -0
  12. glanced-0.3.0/src/glanced/daemon.py +409 -0
  13. glanced-0.3.0/src/glanced/embed.py +61 -0
  14. glanced-0.3.0/src/glanced/enroll.py +339 -0
  15. glanced-0.3.0/src/glanced/gui/__init__.py +40 -0
  16. glanced-0.3.0/src/glanced/gui/enroll_window.py +547 -0
  17. glanced-0.3.0/src/glanced/gui/passphrase.py +146 -0
  18. glanced-0.3.0/src/glanced/ipc.py +103 -0
  19. glanced-0.3.0/src/glanced/landmarker.py +95 -0
  20. glanced-0.3.0/src/glanced/liveness/__init__.py +66 -0
  21. glanced-0.3.0/src/glanced/liveness/analyzer.py +95 -0
  22. glanced-0.3.0/src/glanced/liveness/bezel.py +211 -0
  23. glanced-0.3.0/src/glanced/liveness/cues.py +405 -0
  24. glanced-0.3.0/src/glanced/liveness/features.py +245 -0
  25. glanced-0.3.0/src/glanced/liveness/frame.py +175 -0
  26. glanced-0.3.0/src/glanced/liveness/geometry.py +238 -0
  27. glanced-0.3.0/src/glanced/liveness/glare.py +85 -0
  28. glanced-0.3.0/src/glanced/liveness/planar.py +366 -0
  29. glanced-0.3.0/src/glanced/liveness/scoring.py +193 -0
  30. glanced-0.3.0/src/glanced/livetest.py +161 -0
  31. glanced-0.3.0/src/glanced/locksetup.py +143 -0
  32. glanced-0.3.0/src/glanced/models.py +102 -0
  33. glanced-0.3.0/src/glanced/pamsetup.py +179 -0
  34. glanced-0.3.0/src/glanced/paths.py +58 -0
  35. glanced-0.3.0/src/glanced/pipeline.py +137 -0
  36. glanced-0.3.0/src/glanced/poses.py +187 -0
  37. glanced-0.3.0/src/glanced/preview.py +147 -0
  38. glanced-0.3.0/src/glanced/scan.py +95 -0
  39. glanced-0.3.0/src/glanced/servicesetup.py +121 -0
  40. glanced-0.3.0/src/glanced/store.py +138 -0
  41. glanced-0.3.0/src/glanced.egg-info/PKG-INFO +317 -0
  42. glanced-0.3.0/src/glanced.egg-info/SOURCES.txt +57 -0
  43. glanced-0.3.0/src/glanced.egg-info/dependency_links.txt +1 -0
  44. glanced-0.3.0/src/glanced.egg-info/entry_points.txt +2 -0
  45. glanced-0.3.0/src/glanced.egg-info/requires.txt +13 -0
  46. glanced-0.3.0/src/glanced.egg-info/top_level.txt +1 -0
  47. glanced-0.3.0/tests/test_attention.py +389 -0
  48. glanced-0.3.0/tests/test_daemon.py +203 -0
  49. glanced-0.3.0/tests/test_enroll.py +104 -0
  50. glanced-0.3.0/tests/test_enroll_passphrase.py +146 -0
  51. glanced-0.3.0/tests/test_enroll_window.py +39 -0
  52. glanced-0.3.0/tests/test_liveness.py +353 -0
  53. glanced-0.3.0/tests/test_locksetup.py +36 -0
  54. glanced-0.3.0/tests/test_pamsetup.py +48 -0
  55. glanced-0.3.0/tests/test_passphrase_dialog.py +62 -0
  56. glanced-0.3.0/tests/test_paths.py +65 -0
  57. glanced-0.3.0/tests/test_poses.py +231 -0
  58. glanced-0.3.0/tests/test_preview.py +116 -0
  59. glanced-0.3.0/tests/test_servicesetup.py +103 -0
glanced-0.3.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ayan De
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
glanced-0.3.0/NOTICE ADDED
@@ -0,0 +1,15 @@
1
+ This project reimplements the liveness detection model from Glance
2
+ (https://github.com/jonnyoo/glance) by Jonathan Zhou, MIT licensed.
3
+
4
+ The five-cue deny/confirm design, the tuning constants, and the reasoning
5
+ recorded in the comments of src/glanced/liveness/ originate there. The Swift
6
+ implementation was not copied; each module was rewritten against MediaPipe,
7
+ OpenCV and NumPy, and the deviations are documented at each site.
8
+
9
+ The ArcFace recognition model is from InsightFace
10
+ (https://github.com/deepinsight/insightface).
11
+
12
+ The lock screen's success animation (patches/omarchy-lock-faceid/
13
+ unlock-spin.png) is frames 16-45 of the unlock video shipped with Glance
14
+ (Resources/unlockanimation.mp4), cut to the inside of its ring and reduced
15
+ to an alpha mask so it can take the current theme's colour.
glanced-0.3.0/PKG-INFO ADDED
@@ -0,0 +1,317 @@
1
+ Metadata-Version: 2.4
2
+ Name: glanced
3
+ Version: 0.3.0
4
+ Summary: Face unlock for Linux with real liveness detection
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ License-File: NOTICE
10
+ Requires-Dist: numpy>=1.26
11
+ Provides-Extra: runtime
12
+ Requires-Dist: opencv-python>=4.9; extra == "runtime"
13
+ Requires-Dist: onnxruntime>=1.17; extra == "runtime"
14
+ Requires-Dist: mediapipe>=0.10.14; extra == "runtime"
15
+ Requires-Dist: cryptography>=42; extra == "runtime"
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest>=8; extra == "dev"
18
+ Provides-Extra: gui
19
+ Requires-Dist: PySide6>=6.6; extra == "gui"
20
+ Dynamic: license-file
21
+
22
+ # glance-linux
23
+
24
+ Face unlock for Linux, with the liveness detection that face-unlock on Linux
25
+ usually doesn't have.
26
+
27
+ ![The lock screen's face unlock indicator and the Glance bar panel](plugin/preview.png)
28
+
29
+ A reimplementation of the liveness model from
30
+ [Glance](https://github.com/jonnyoo/glance) (macOS, MIT) around a PAM-based
31
+ unlock path. Not a port of the app — none of the Swift is portable — but the
32
+ part of Glance with real substance is: the five-cue liveness model that tells a
33
+ face from a photograph.
34
+
35
+ ## Why
36
+
37
+ [Howdy](https://github.com/boltgolt/howdy) already does face unlock on Arch. Its
38
+ well-known weakness is that it has essentially no liveness detection, so a
39
+ printed photo can unlock it. That is exactly the gap this fills.
40
+
41
+ The other half is that **Linux is a better platform for this than macOS**.
42
+ Glance's own README carries the caveat that *"macOS has no API that lets a
43
+ third-party app authorize a login, so Glance unlocks by typing your stored
44
+ password on the lock screen."* Linux has PAM. So this project:
45
+
46
+ - stores **no password** anywhere,
47
+ - injects **no keystrokes**,
48
+ - needs **no accessibility/input-injection permission**,
49
+ - and authorizes the session directly, through the same interface `sudo` and
50
+ `hyprlock` already use.
51
+
52
+ That removes the single most sensitive secret in the macOS design.
53
+
54
+ ### It is still not FaceID
55
+
56
+ A webcam sees a flat 2D image; an iPhone builds a 3D depth map. The liveness
57
+ cues here defeat a printed photo and a photo on a phone screen with reasonable
58
+ confidence. They do **not** reliably defeat a video of you. This is a
59
+ convenience feature, not a security upgrade.
60
+
61
+ Two guard rails come with that. Five failed scans in a row with a face in
62
+ view lock face unlock out for five minutes, so a looping hands-free lock
63
+ screen is not a free brute-force surface (`--max-failures`, `--lockout`). And
64
+ `SECURITY.md` spells out the trust boundary: the daemon runs as your user, so
65
+ code already running as you could subvert it — the same boundary Howdy and
66
+ the shell's own lock have, but one you should read before relying on it.
67
+
68
+ ## Status
69
+
70
+ | Piece | State |
71
+ |---|---|
72
+ | Liveness model (`src/glanced/liveness/`) | **Complete, ported, tested** |
73
+ | Geometry / homography | Complete |
74
+ | Enrollment store (AES-256-GCM) | Complete |
75
+ | Alignment + ArcFace embedding | Complete |
76
+ | Camera capture, landmarking, scan loop | Complete |
77
+ | Daemon: sockets, arming, status | Complete, tested |
78
+ | `glancectl` (enroll, arm, authenticate, status, live, selftest) | Complete |
79
+ | Guided enrollment (`glanced/poses.py`, `--gui` tick ring) | Complete — five directions, after the macOS onboarding sweep |
80
+ | `pam_glance` + `glancectl setup-pam` | Complete — see `pam/README.md` |
81
+ | Attention mode (`attention.sock`, `glancectl attention`) | Complete, tested — head pose for the desktop, see below |
82
+ | Omarchy plugin (`plugin/`) | Bar widget + panel — see `plugin/README.md` |
83
+ | Lock screen indicator (`patches/omarchy-lock-faceid/`) | Face ID-style capsule with a live camera view — a patch to Omarchy's lock plugin, applied by `glancectl setup-lock` and re-applied after `omarchy update` by a post-update hook |
84
+
85
+ ## Install
86
+
87
+ Once `glanced` is on the AUR, the packaged path is two commands and three
88
+ buttons — the package carries the daemon, the PAM module and both models, so
89
+ there is nothing to download and nothing to build:
90
+
91
+ ```bash
92
+ yay -S glanced
93
+ omarchy plugin add https://github.com/ayandexyz/omarchy-glance.git --enable
94
+ ```
95
+
96
+ Then click the bar icon and take the one button it offers, three times: **Start
97
+ daemon**, **Enroll** (the guided sweep opens in a window), **Wire lock screen**
98
+ (a terminal, for the one step that needs your password). A fourth, **Add lock
99
+ indicator**, is optional: the Face ID-style capsule on the lock screen, kept
100
+ in place across `omarchy update` by a hook. `packaging/aur/` holds
101
+ the PKGBUILD and the release runbook.
102
+
103
+ The package deliberately does not touch `/etc/pam.d` itself. Changing how the
104
+ machine authenticates you belongs to a command you run and watch, not to an
105
+ unattended pacman transaction editing files that belong to hyprland and
106
+ omarchy.
107
+
108
+ ## Setup from source
109
+
110
+ ```bash
111
+ python -m venv .venv && .venv/bin/pip install -e '.[runtime,gui,dev]'
112
+ .venv/bin/python -m pytest # no camera needed
113
+ .venv/bin/glancectl fetch-model # ~3MB landmarker + ~13MB ArcFace
114
+ .venv/bin/glancectl live --mode heavy # liveness against your webcam, no unlock
115
+ ```
116
+
117
+ Then the real thing:
118
+
119
+ ```bash
120
+ packaging/install.sh # user service + plugin symlink
121
+ glancectl enroll --gui --name "$USER" --remember # guided sweep, sets the passphrase
122
+ glancectl authenticate # one full scan: recognition + liveness
123
+ glancectl setup-pam # wire the lock screen (sudo; keep a root shell open)
124
+ glancectl setup-lock # optional: the lock screen indicator + its post-update hook
125
+ omarchy plugin enable io.github.ayandexyz.glance # the bar widget
126
+ ```
127
+
128
+ Lock the screen, press Enter (shell lock: any character then Enter), look at
129
+ the camera. `pam/README.md` explains the two lock screens Omarchy has had, the
130
+ on-demand vs `--hands-free` choice, and how to undo it.
131
+
132
+ `enroll` walks you through five head directions — centre, then left, up,
133
+ right and down — and takes two samples at each, gated on the yaw and pitch the
134
+ landmarker reports rather than on you being asked nicely to move. That is what
135
+ makes ten rows cover five poses instead of ten near-copies of a frontal
136
+ capture. The macOS app sweeps nine, adding the diagonals; those ask for a
137
+ compound turn that is harder to explain and to hold, and a template already
138
+ covering both profiles and both chin extremes has the corners bracketed. `--gui` shows the sweep as a Face ID-style tick ring around a
139
+ mirrored camera disc, each direction lighting its sector as it lands; without
140
+ it the same sweep runs against a one-line terminal readout. `--no-guide` falls
141
+ back to the old five prompted captures, for a camera whose landmarker reports
142
+ no head pose at all. No frame is written in any of the three: the preview is
143
+ pixels on the way to the screen and nothing else.
144
+
145
+ `enroll` asks for a passphrase the first time; it encrypts the embeddings at
146
+ rest. The daemon starts *disarmed* and cannot scan until it has that
147
+ passphrase: either `glancectl arm` after each login, or `--remember`, which
148
+ stores it 0600 under `~/.local/share/glance/` so the daemon arms itself. That
149
+ is a convenience/at-rest trade-off you make explicitly.
150
+
151
+ `glancectl authenticate` sends exactly the request `pam_glance` sends, so the
152
+ whole unlock path can be exercised without touching PAM.
153
+
154
+ ### What a plugin can and cannot do
155
+
156
+ The Omarchy plugin is QML and runs inside the shell; it can draw status and
157
+ run commands as you. It cannot install a PAM module, edit `/etc/pam.d`, or
158
+ ship a Python daemon. So "install the plugin and face unlock works" is not a
159
+ thing any marketplace plugin can deliver on its own. The intended shape is:
160
+
161
+ 1. a package (AUR `glanced`) that installs `glancectl`, the daemon service,
162
+ and `pam_glance.so`;
163
+ 2. one `glancectl enroll` and one `glancectl setup-pam` (the sudo step);
164
+ 3. the plugin, which shows the state, tells you which of those is missing,
165
+ and offers arm, disarm and test-scan.
166
+
167
+ `glancectl selftest` is the counterpart of Glance's hidden Face Lab: it drives
168
+ the real decision logic against synthetic faces and prints every cue's reading
169
+ and fire count, with no camera involved. `glancectl live` does the same against
170
+ real frames.
171
+
172
+ ## Attention mode
173
+
174
+ The daemon can also tell the desktop where you are looking. Connect to
175
+ `attention.sock` and it streams one JSON line per frame:
176
+
177
+ ```json
178
+ {"schemaVersion": 1, "t": 1234.5, "state": "tracking", "present": true, "yaw": -12.4, "pitch": 3.1, "conf": 1.0}
179
+ ```
180
+
181
+ `yaw` and `pitch` are degrees (yaw positive when your head turns to your
182
+ left, pitch positive when your chin comes down); `conf` is how far the face
183
+ is above the size the landmarker is trusted at. `state` is `starting`,
184
+ `tracking`, `paused` (an unlock scan has the camera) or `error` (it could not
185
+ be opened; the daemon retries). `glancectl attention` prints the stream.
186
+
187
+ This is what a blur shield — [omarchy-shy](https://github.com/ayandexyz/omarchy-shy),
188
+ the ShyGlass idea done without screen capture — or an idle inhibitor
189
+ subscribes to. Things that matter about it:
190
+
191
+ - **It is not auth mode.** Attention needs the landmarker and the pose it
192
+ already computes, nothing else: no ArcFace, no template, no arming, no
193
+ passphrase. It works on a daemon that has never been armed for a user who
194
+ has never enrolled or touched `/etc/pam.d`.
195
+ - **It is a third socket, publish-only.** Nothing a client sends is read, so
196
+ nothing on it can start or influence a scan. It emits *derived* values —
197
+ angles and a bool — never frames, never landmarks. `src/glanced/attention.py`
198
+ is short enough to check that claim in a minute.
199
+ - **The camera has one owner.** The tracker holds it only while a subscriber
200
+ is connected — nothing listening, camera closed, LED off — and hands it
201
+ over the moment a scan asks, taking it back when the scan ends. An auth
202
+ request never waits on it for more than three seconds.
203
+ - **It is cheap.** Eight landmarker passes a second at 640×360, about 8% of
204
+ one core on a laptop with the camera included. `--attention-fps` tunes it;
205
+ `--no-attention` removes the socket entirely.
206
+ - **Clients fail open.** Only `tracking` says anything about where you are
207
+ looking. A client covering the screen treats every other state, and
208
+ silence, as "come down".
209
+
210
+ ## The liveness model
211
+
212
+ Five cues, two roles, and deliberately **no overall liveness percentage**.
213
+
214
+ Upstream arrived at this after real-device testing killed an earlier design that
215
+ averaged ~11 signals into a weighted score: most were noise-limited at webcam
216
+ resolution, several actively *rewarded* the smooth motion of a hand holding up a
217
+ phone, and the resulting number wandered 30–80% on a live face while a phone
218
+ photo scored about the same. Only five cues separated a real face from a phone,
219
+ and each is individually decisive — which makes averaging exactly the wrong
220
+ combination rule.
221
+
222
+ **Deny cues** — evidence of a spoof. Either one firing fails the scan outright
223
+ and overrides any confirmation that already happened. A spoof tell does not get
224
+ outvoted.
225
+
226
+ | Cue | Fires when |
227
+ |---|---|
228
+ | Gloss/glare | One big flat specular blob (glass) rather than skin's small scattered shine |
229
+ | Device detected | A device-shaped rectangle overlaps the face |
230
+
231
+ **Confirm cues** — evidence of a real face. Any one is enough, and their
232
+ *absence is never a failure*: a live person can sit still and not blink for a
233
+ whole scan.
234
+
235
+ | Cue | Fires when |
236
+ |---|---|
237
+ | Flat vs 3D | Held-out nose points miss the best-fit homography — the face has depth |
238
+ | Depth/pose | Nose offset tracks head yaw at a magnitude only a real nose produces |
239
+ | Blink | Eye aspect ratio dipped and recovered |
240
+
241
+ `Light` runs the deny cues only — "confirmed unless proven wrong", which never
242
+ blocks a user who happens to sit still. `Heavy` also requires a confirm cue, and
243
+ can genuinely fail to unlock a motionless, unblinking live user. That cost is
244
+ pinned in a test so it is never mistaken for a regression.
245
+
246
+ ## A bug found while porting
247
+
248
+ The depth/pose cue upstream gates on the Pearson correlation between nose offset
249
+ and `tan(yaw)`, documented as *"a genuinely positive relationship of the kind
250
+ only a nose sitting off the eye plane produces."*
251
+
252
+ That is not true of a plane viewed in perspective. Perspective projection does
253
+ not preserve midpoints, so a tilted photo's apparent eye midpoint does shift
254
+ relative to its nose. The shift is tiny — but correlation is scale-free and
255
+ cannot tell a tiny systematic drift from a large one. Measured against
256
+ `tests/synthetic.py`, a flat photo scores **1.00** at zero landmark noise and
257
+ **0.95** at 0.25px, both well over the 0.8 fire threshold. It only drops below
258
+ the threshold around 1px of jitter: the cue was relying on landmark noise to
259
+ hide the artifact.
260
+
261
+ The fix is a magnitude gate on the regression slope, which is the physical
262
+ quantity the cue is actually reasoning about — the nose's depth as a fraction of
263
+ the interocular distance. It is ~0.24 for a real nose and ~0.026 for a plane,
264
+ stable across 0–1px of noise, so the gate sits at 0.08. See
265
+ `MIN_NOSE_DEPTH_RATIO` in `liveness/scoring.py` and the regression tests.
266
+
267
+ This affects the macOS app too. Its Heavy mode can confirm a printed photo that
268
+ shows no glare and no device edge, whenever landmark jitter is low.
269
+
270
+ ## Deviations from upstream
271
+
272
+ Each is documented at its own site; the significant ones:
273
+
274
+ - **Device bezel detection** is rebuilt on OpenCV contours. Upstream uses
275
+ `VNDetectRectanglesRequest`, which has no Linux equivalent. Parameters carry
276
+ over unchanged, but `minimum_edge_support` is an invented analogue of Vision's
277
+ confidence and is the knob most likely to need retuning against real footage.
278
+ - **Landmark regions** come from MediaPipe FaceMesh index groups rather than
279
+ Vision's named regions. Cross-frame correspondence is *guaranteed* here, which
280
+ is strictly better than what upstream must defend against.
281
+ - **`MEDIAN_LINE` is narrowed** to the two midline points between the brow and
282
+ lip lines. The probe set requires points geometrically inside the fit hull, so
283
+ that leftover error reads as depth rather than extrapolation; Vision's
284
+ forehead-to-chin median line violates that.
285
+ - **Eye aspect ratio** stays the bounding-box ratio even though MediaPipe would
286
+ support the classic 6-point formula, because the 0.65 dip and 0.7 recovery
287
+ thresholds were tuned against this definition.
288
+ - **No password, no keystroke injection** — see above.
289
+
290
+ ## Architecture
291
+
292
+ ```
293
+ src/glanced/ glanced camera -> landmarks -> {ArcFace embed, liveness} -> verdict
294
+ camera -> landmarks -> head pose (attention.sock)
295
+ pam/ pam_glance.so talks to the daemon over a 0600 unix socket
296
+ plugin/ Omarchy QML bar widget + panel: status, arm/disarm, test scan
297
+ ```
298
+
299
+ Unlock lives in PAM, in `hyprlock`'s stack, and works whether or not the shell
300
+ is running. The plugin is a thin client over a **separate, lower-privilege
301
+ status socket** and is presentation only — it can never cause or influence an
302
+ unlock. Separate sockets rather than one with a role field, so a compromised
303
+ shell plugin cannot reach the auth verb at all. The attention socket is the
304
+ same idea one step further: it has no verbs at all.
305
+
306
+ > When wiring `pam_glance`, keep a root TTY open. A broken PAM stack locks you
307
+ > out of your own machine.
308
+
309
+ ## Credit
310
+
311
+ - [Glance](https://github.com/jonnyoo/glance) — the liveness model and its
312
+ tuning, MIT © Jonathan Zhou. See `NOTICE`.
313
+ - [InsightFace](https://github.com/deepinsight/insightface) — the ArcFace model.
314
+
315
+ ## License
316
+
317
+ MIT
@@ -0,0 +1,296 @@
1
+ # glance-linux
2
+
3
+ Face unlock for Linux, with the liveness detection that face-unlock on Linux
4
+ usually doesn't have.
5
+
6
+ ![The lock screen's face unlock indicator and the Glance bar panel](plugin/preview.png)
7
+
8
+ A reimplementation of the liveness model from
9
+ [Glance](https://github.com/jonnyoo/glance) (macOS, MIT) around a PAM-based
10
+ unlock path. Not a port of the app — none of the Swift is portable — but the
11
+ part of Glance with real substance is: the five-cue liveness model that tells a
12
+ face from a photograph.
13
+
14
+ ## Why
15
+
16
+ [Howdy](https://github.com/boltgolt/howdy) already does face unlock on Arch. Its
17
+ well-known weakness is that it has essentially no liveness detection, so a
18
+ printed photo can unlock it. That is exactly the gap this fills.
19
+
20
+ The other half is that **Linux is a better platform for this than macOS**.
21
+ Glance's own README carries the caveat that *"macOS has no API that lets a
22
+ third-party app authorize a login, so Glance unlocks by typing your stored
23
+ password on the lock screen."* Linux has PAM. So this project:
24
+
25
+ - stores **no password** anywhere,
26
+ - injects **no keystrokes**,
27
+ - needs **no accessibility/input-injection permission**,
28
+ - and authorizes the session directly, through the same interface `sudo` and
29
+ `hyprlock` already use.
30
+
31
+ That removes the single most sensitive secret in the macOS design.
32
+
33
+ ### It is still not FaceID
34
+
35
+ A webcam sees a flat 2D image; an iPhone builds a 3D depth map. The liveness
36
+ cues here defeat a printed photo and a photo on a phone screen with reasonable
37
+ confidence. They do **not** reliably defeat a video of you. This is a
38
+ convenience feature, not a security upgrade.
39
+
40
+ Two guard rails come with that. Five failed scans in a row with a face in
41
+ view lock face unlock out for five minutes, so a looping hands-free lock
42
+ screen is not a free brute-force surface (`--max-failures`, `--lockout`). And
43
+ `SECURITY.md` spells out the trust boundary: the daemon runs as your user, so
44
+ code already running as you could subvert it — the same boundary Howdy and
45
+ the shell's own lock have, but one you should read before relying on it.
46
+
47
+ ## Status
48
+
49
+ | Piece | State |
50
+ |---|---|
51
+ | Liveness model (`src/glanced/liveness/`) | **Complete, ported, tested** |
52
+ | Geometry / homography | Complete |
53
+ | Enrollment store (AES-256-GCM) | Complete |
54
+ | Alignment + ArcFace embedding | Complete |
55
+ | Camera capture, landmarking, scan loop | Complete |
56
+ | Daemon: sockets, arming, status | Complete, tested |
57
+ | `glancectl` (enroll, arm, authenticate, status, live, selftest) | Complete |
58
+ | Guided enrollment (`glanced/poses.py`, `--gui` tick ring) | Complete — five directions, after the macOS onboarding sweep |
59
+ | `pam_glance` + `glancectl setup-pam` | Complete — see `pam/README.md` |
60
+ | Attention mode (`attention.sock`, `glancectl attention`) | Complete, tested — head pose for the desktop, see below |
61
+ | Omarchy plugin (`plugin/`) | Bar widget + panel — see `plugin/README.md` |
62
+ | Lock screen indicator (`patches/omarchy-lock-faceid/`) | Face ID-style capsule with a live camera view — a patch to Omarchy's lock plugin, applied by `glancectl setup-lock` and re-applied after `omarchy update` by a post-update hook |
63
+
64
+ ## Install
65
+
66
+ Once `glanced` is on the AUR, the packaged path is two commands and three
67
+ buttons — the package carries the daemon, the PAM module and both models, so
68
+ there is nothing to download and nothing to build:
69
+
70
+ ```bash
71
+ yay -S glanced
72
+ omarchy plugin add https://github.com/ayandexyz/omarchy-glance.git --enable
73
+ ```
74
+
75
+ Then click the bar icon and take the one button it offers, three times: **Start
76
+ daemon**, **Enroll** (the guided sweep opens in a window), **Wire lock screen**
77
+ (a terminal, for the one step that needs your password). A fourth, **Add lock
78
+ indicator**, is optional: the Face ID-style capsule on the lock screen, kept
79
+ in place across `omarchy update` by a hook. `packaging/aur/` holds
80
+ the PKGBUILD and the release runbook.
81
+
82
+ The package deliberately does not touch `/etc/pam.d` itself. Changing how the
83
+ machine authenticates you belongs to a command you run and watch, not to an
84
+ unattended pacman transaction editing files that belong to hyprland and
85
+ omarchy.
86
+
87
+ ## Setup from source
88
+
89
+ ```bash
90
+ python -m venv .venv && .venv/bin/pip install -e '.[runtime,gui,dev]'
91
+ .venv/bin/python -m pytest # no camera needed
92
+ .venv/bin/glancectl fetch-model # ~3MB landmarker + ~13MB ArcFace
93
+ .venv/bin/glancectl live --mode heavy # liveness against your webcam, no unlock
94
+ ```
95
+
96
+ Then the real thing:
97
+
98
+ ```bash
99
+ packaging/install.sh # user service + plugin symlink
100
+ glancectl enroll --gui --name "$USER" --remember # guided sweep, sets the passphrase
101
+ glancectl authenticate # one full scan: recognition + liveness
102
+ glancectl setup-pam # wire the lock screen (sudo; keep a root shell open)
103
+ glancectl setup-lock # optional: the lock screen indicator + its post-update hook
104
+ omarchy plugin enable io.github.ayandexyz.glance # the bar widget
105
+ ```
106
+
107
+ Lock the screen, press Enter (shell lock: any character then Enter), look at
108
+ the camera. `pam/README.md` explains the two lock screens Omarchy has had, the
109
+ on-demand vs `--hands-free` choice, and how to undo it.
110
+
111
+ `enroll` walks you through five head directions — centre, then left, up,
112
+ right and down — and takes two samples at each, gated on the yaw and pitch the
113
+ landmarker reports rather than on you being asked nicely to move. That is what
114
+ makes ten rows cover five poses instead of ten near-copies of a frontal
115
+ capture. The macOS app sweeps nine, adding the diagonals; those ask for a
116
+ compound turn that is harder to explain and to hold, and a template already
117
+ covering both profiles and both chin extremes has the corners bracketed. `--gui` shows the sweep as a Face ID-style tick ring around a
118
+ mirrored camera disc, each direction lighting its sector as it lands; without
119
+ it the same sweep runs against a one-line terminal readout. `--no-guide` falls
120
+ back to the old five prompted captures, for a camera whose landmarker reports
121
+ no head pose at all. No frame is written in any of the three: the preview is
122
+ pixels on the way to the screen and nothing else.
123
+
124
+ `enroll` asks for a passphrase the first time; it encrypts the embeddings at
125
+ rest. The daemon starts *disarmed* and cannot scan until it has that
126
+ passphrase: either `glancectl arm` after each login, or `--remember`, which
127
+ stores it 0600 under `~/.local/share/glance/` so the daemon arms itself. That
128
+ is a convenience/at-rest trade-off you make explicitly.
129
+
130
+ `glancectl authenticate` sends exactly the request `pam_glance` sends, so the
131
+ whole unlock path can be exercised without touching PAM.
132
+
133
+ ### What a plugin can and cannot do
134
+
135
+ The Omarchy plugin is QML and runs inside the shell; it can draw status and
136
+ run commands as you. It cannot install a PAM module, edit `/etc/pam.d`, or
137
+ ship a Python daemon. So "install the plugin and face unlock works" is not a
138
+ thing any marketplace plugin can deliver on its own. The intended shape is:
139
+
140
+ 1. a package (AUR `glanced`) that installs `glancectl`, the daemon service,
141
+ and `pam_glance.so`;
142
+ 2. one `glancectl enroll` and one `glancectl setup-pam` (the sudo step);
143
+ 3. the plugin, which shows the state, tells you which of those is missing,
144
+ and offers arm, disarm and test-scan.
145
+
146
+ `glancectl selftest` is the counterpart of Glance's hidden Face Lab: it drives
147
+ the real decision logic against synthetic faces and prints every cue's reading
148
+ and fire count, with no camera involved. `glancectl live` does the same against
149
+ real frames.
150
+
151
+ ## Attention mode
152
+
153
+ The daemon can also tell the desktop where you are looking. Connect to
154
+ `attention.sock` and it streams one JSON line per frame:
155
+
156
+ ```json
157
+ {"schemaVersion": 1, "t": 1234.5, "state": "tracking", "present": true, "yaw": -12.4, "pitch": 3.1, "conf": 1.0}
158
+ ```
159
+
160
+ `yaw` and `pitch` are degrees (yaw positive when your head turns to your
161
+ left, pitch positive when your chin comes down); `conf` is how far the face
162
+ is above the size the landmarker is trusted at. `state` is `starting`,
163
+ `tracking`, `paused` (an unlock scan has the camera) or `error` (it could not
164
+ be opened; the daemon retries). `glancectl attention` prints the stream.
165
+
166
+ This is what a blur shield — [omarchy-shy](https://github.com/ayandexyz/omarchy-shy),
167
+ the ShyGlass idea done without screen capture — or an idle inhibitor
168
+ subscribes to. Things that matter about it:
169
+
170
+ - **It is not auth mode.** Attention needs the landmarker and the pose it
171
+ already computes, nothing else: no ArcFace, no template, no arming, no
172
+ passphrase. It works on a daemon that has never been armed for a user who
173
+ has never enrolled or touched `/etc/pam.d`.
174
+ - **It is a third socket, publish-only.** Nothing a client sends is read, so
175
+ nothing on it can start or influence a scan. It emits *derived* values —
176
+ angles and a bool — never frames, never landmarks. `src/glanced/attention.py`
177
+ is short enough to check that claim in a minute.
178
+ - **The camera has one owner.** The tracker holds it only while a subscriber
179
+ is connected — nothing listening, camera closed, LED off — and hands it
180
+ over the moment a scan asks, taking it back when the scan ends. An auth
181
+ request never waits on it for more than three seconds.
182
+ - **It is cheap.** Eight landmarker passes a second at 640×360, about 8% of
183
+ one core on a laptop with the camera included. `--attention-fps` tunes it;
184
+ `--no-attention` removes the socket entirely.
185
+ - **Clients fail open.** Only `tracking` says anything about where you are
186
+ looking. A client covering the screen treats every other state, and
187
+ silence, as "come down".
188
+
189
+ ## The liveness model
190
+
191
+ Five cues, two roles, and deliberately **no overall liveness percentage**.
192
+
193
+ Upstream arrived at this after real-device testing killed an earlier design that
194
+ averaged ~11 signals into a weighted score: most were noise-limited at webcam
195
+ resolution, several actively *rewarded* the smooth motion of a hand holding up a
196
+ phone, and the resulting number wandered 30–80% on a live face while a phone
197
+ photo scored about the same. Only five cues separated a real face from a phone,
198
+ and each is individually decisive — which makes averaging exactly the wrong
199
+ combination rule.
200
+
201
+ **Deny cues** — evidence of a spoof. Either one firing fails the scan outright
202
+ and overrides any confirmation that already happened. A spoof tell does not get
203
+ outvoted.
204
+
205
+ | Cue | Fires when |
206
+ |---|---|
207
+ | Gloss/glare | One big flat specular blob (glass) rather than skin's small scattered shine |
208
+ | Device detected | A device-shaped rectangle overlaps the face |
209
+
210
+ **Confirm cues** — evidence of a real face. Any one is enough, and their
211
+ *absence is never a failure*: a live person can sit still and not blink for a
212
+ whole scan.
213
+
214
+ | Cue | Fires when |
215
+ |---|---|
216
+ | Flat vs 3D | Held-out nose points miss the best-fit homography — the face has depth |
217
+ | Depth/pose | Nose offset tracks head yaw at a magnitude only a real nose produces |
218
+ | Blink | Eye aspect ratio dipped and recovered |
219
+
220
+ `Light` runs the deny cues only — "confirmed unless proven wrong", which never
221
+ blocks a user who happens to sit still. `Heavy` also requires a confirm cue, and
222
+ can genuinely fail to unlock a motionless, unblinking live user. That cost is
223
+ pinned in a test so it is never mistaken for a regression.
224
+
225
+ ## A bug found while porting
226
+
227
+ The depth/pose cue upstream gates on the Pearson correlation between nose offset
228
+ and `tan(yaw)`, documented as *"a genuinely positive relationship of the kind
229
+ only a nose sitting off the eye plane produces."*
230
+
231
+ That is not true of a plane viewed in perspective. Perspective projection does
232
+ not preserve midpoints, so a tilted photo's apparent eye midpoint does shift
233
+ relative to its nose. The shift is tiny — but correlation is scale-free and
234
+ cannot tell a tiny systematic drift from a large one. Measured against
235
+ `tests/synthetic.py`, a flat photo scores **1.00** at zero landmark noise and
236
+ **0.95** at 0.25px, both well over the 0.8 fire threshold. It only drops below
237
+ the threshold around 1px of jitter: the cue was relying on landmark noise to
238
+ hide the artifact.
239
+
240
+ The fix is a magnitude gate on the regression slope, which is the physical
241
+ quantity the cue is actually reasoning about — the nose's depth as a fraction of
242
+ the interocular distance. It is ~0.24 for a real nose and ~0.026 for a plane,
243
+ stable across 0–1px of noise, so the gate sits at 0.08. See
244
+ `MIN_NOSE_DEPTH_RATIO` in `liveness/scoring.py` and the regression tests.
245
+
246
+ This affects the macOS app too. Its Heavy mode can confirm a printed photo that
247
+ shows no glare and no device edge, whenever landmark jitter is low.
248
+
249
+ ## Deviations from upstream
250
+
251
+ Each is documented at its own site; the significant ones:
252
+
253
+ - **Device bezel detection** is rebuilt on OpenCV contours. Upstream uses
254
+ `VNDetectRectanglesRequest`, which has no Linux equivalent. Parameters carry
255
+ over unchanged, but `minimum_edge_support` is an invented analogue of Vision's
256
+ confidence and is the knob most likely to need retuning against real footage.
257
+ - **Landmark regions** come from MediaPipe FaceMesh index groups rather than
258
+ Vision's named regions. Cross-frame correspondence is *guaranteed* here, which
259
+ is strictly better than what upstream must defend against.
260
+ - **`MEDIAN_LINE` is narrowed** to the two midline points between the brow and
261
+ lip lines. The probe set requires points geometrically inside the fit hull, so
262
+ that leftover error reads as depth rather than extrapolation; Vision's
263
+ forehead-to-chin median line violates that.
264
+ - **Eye aspect ratio** stays the bounding-box ratio even though MediaPipe would
265
+ support the classic 6-point formula, because the 0.65 dip and 0.7 recovery
266
+ thresholds were tuned against this definition.
267
+ - **No password, no keystroke injection** — see above.
268
+
269
+ ## Architecture
270
+
271
+ ```
272
+ src/glanced/ glanced camera -> landmarks -> {ArcFace embed, liveness} -> verdict
273
+ camera -> landmarks -> head pose (attention.sock)
274
+ pam/ pam_glance.so talks to the daemon over a 0600 unix socket
275
+ plugin/ Omarchy QML bar widget + panel: status, arm/disarm, test scan
276
+ ```
277
+
278
+ Unlock lives in PAM, in `hyprlock`'s stack, and works whether or not the shell
279
+ is running. The plugin is a thin client over a **separate, lower-privilege
280
+ status socket** and is presentation only — it can never cause or influence an
281
+ unlock. Separate sockets rather than one with a role field, so a compromised
282
+ shell plugin cannot reach the auth verb at all. The attention socket is the
283
+ same idea one step further: it has no verbs at all.
284
+
285
+ > When wiring `pam_glance`, keep a root TTY open. A broken PAM stack locks you
286
+ > out of your own machine.
287
+
288
+ ## Credit
289
+
290
+ - [Glance](https://github.com/jonnyoo/glance) — the liveness model and its
291
+ tuning, MIT © Jonathan Zhou. See `NOTICE`.
292
+ - [InsightFace](https://github.com/deepinsight/insightface) — the ArcFace model.
293
+
294
+ ## License
295
+
296
+ MIT