Development
This page covers source builds, tests, packaging, and Flatpak workflows for contributors.
For pull request workflow, testing expectations, and safety notes, see Contributing.
Prerequisites
Gaze targets Linux platform APIs (V4L2, PAM, TPM2/tss2, polkit, GTK4/libadwaita, Flatpak, SELinux) that do not exist on macOS or Windows, so none of this builds natively there.
There are three ways to get an environment, and they all end at the same just recipes. Take whichever asks the least of you.
Nix, the shortest path
nix developThat is the entire setup. The shell has the Rust toolchain and every native dependency (OpenCV, GStreamer, GTK4, ONNX Runtime, tpm2-tss) already wired up. See the Nix & NixOS guide.
Docker, if you are not on Linux
just docker build-rustAny recipe also runs inside a container that mirrors CI, so the host needs nothing but Docker. See Building without a Linux host.
Distro packages
Install the tooling:
- Current stable Rust, via rustup. The locked GTK and GStreamer dependencies require Rust 1.92 or newer.
just1.51+, the task runner everything below goes throughcargo-audit, forjust auditnfpm, only forjust packageflatpak-builder, only forjust build-flatpak
CI uses stable Rust and pins just in .github/workflows/ci.yml. Prepare the Rust tools for the required checks:
rustup update stable
rustup default stable
rustup component add rustfmt clippy
cargo install just --locked
cargo install cargo-audit --lockedThen install the system libraries. Runtime packages alone do not provide the headers and pkg-config files needed by just lint and just test:
sudo apt update
sudo apt install build-essential pkg-config clang libclang-dev \
libopencv-dev libv4l-dev libpam0g-dev libtss2-dev libssl-dev \
libgtk-4-dev libadwaita-1-dev \
libcairo2-dev libglib2.0-dev libgdk-pixbuf-2.0-dev \
libpango1.0-dev libgraphene-1.0-dev \
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \
gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-pipewire \
gettext-base \
flatpak flatpak-builder elfutilssudo dnf install @development-tools pkg-config clang clang-devel \
opencv-devel libv4l-devel pam-devel tpm2-tss-devel openssl-devel \
gtk4-devel libadwaita-devel \
gstreamer1-devel gstreamer1-plugins-base-devel \
gstreamer1-plugins-base gstreamer1-plugins-good pipewire-gstreamer \
checkpolicy policycoreutils \
gettext \
flatpak flatpak-builder elfutilssudo zypper install --no-recommends \
clang clang-devel opencv-devel libv4l-devel pam-devel tpm2-0-tss-devel \
libopenssl-devel gtk4-devel libadwaita-devel \
gstreamer-devel gstreamer-plugins-base-devel \
gstreamer-plugins-base gstreamer-plugins-good gstreamer-plugin-pipewire \
checkpolicy policycoreutils pkgconf-pkg-config envsubst gcc gcc-c++ \
flatpak flatpak-builder elfutilssudo pacman -S base-devel pkgconf clang llvm \
opencv v4l-utils pam tpm2-tss openssl \
gtk4 libadwaita \
gstreamer gst-plugins-base gst-plugins-good gst-plugin-pipewire \
gettext \
flatpak flatpak-builder elfutilslibtss2-dev/tpm2-tss-devel/tpm2-tss/tpm2-0-tss-devel and libssl-dev/openssl-devel/openssl/libopenssl-devel back the tss-esapi and openssl-sys crates (the daemon seals the face-template key to the TPM); gettext-base/gettext/envsubst provides envsubst, which the package recipe below needs.
Both OpenCV 4 and 5 work. On distros that ship OpenCV 5 (such as Arch Linux), the just recipes automatically point the opencv crate at the opencv5 pkg-config name; when running cargo directly, set OPENCV_PKGCONFIG_NAME=opencv5 yourself.
Only gaze-gui needs gtk4 and libadwaita (libgtk-4-dev/gtk4-devel/gtk4, libadwaita-1-dev/libadwaita-devel/libadwaita, and on Debian/Ubuntu the cairo, glib, gdk-pixbuf, pango, and graphene headers listed with them). For a TUI-only checkout, set GAZE_GUI=0 (also false, no, or off) and skip those packages: build-rust, test, and lint then leave gaze-gui out, and dev-link-system skips the binary it never built. The daemon, the gaze TUI, the CLI, and the PAM modules are unaffected. Like OPENCV_PKGCONFIG_NAME, this only covers those just recipes: a bare cargo build/cargo test still builds every workspace member, and the packaging paths (package, build-flatpak, and the spec srpm feeds) always include the GUI, so building packages still needs the GUI dependencies installed.
Setup
git clone https://github.com/gundulabs/gaze
cd gaze
just setup-hooks
just build-rust
just testThat is a full working checkout. just --list shows every other recipe.
Git hooks are local to each clone. just setup-hooks points Git at the tracked hook scripts so pre-commit checks stay up to date when the repo changes. CI still runs the same required checks for pushes and pull requests.
Workspace layout
Rust packages live under crates/; desktop integration source lives under integrations/. Package definitions and installed system configuration live under packaging/. Run build, test, and packaging commands from the repository root.
crates/gazed: thegazeddaemon, ML pipeline, and user database.crates/gaze-cli: thegazeCLI binary. It lives in its own crate so the client binary does not statically link ONNX Runtime (see warning below).crates/gaze-core: shared config/DBus/IR library. Deliberately light: no OpenCV, GStreamer, or ONNX Runtime, so the PAM modules can depend on it.crates/gaze-security: TPM sealing and the GNOME Keyring credential store. Links tpm2-tss plus pure-Rust crypto (aes-gcm,sha2) and nothing heavier, sopam-gazecan depend on it.crates/gaze-vision: camera capture, face detection, and inference. Detection sits behind thedetectioncargo feature. The workspace dependency turns default features off, so the CLI and GUI get camera support alone and onlygazedenablesdetection.crates/pam-gaze:cdylibPAM module. Depends ongaze-coreandgaze-securityfor TPM keyring unsealing, nevergaze-vision;just check-pam-linkenforces its library allowlist (see warning below).crates/pam-gaze-grosshack: deprecatedcdylibcompatibility shim that forcesPamMode::Simultaneousand prints a deprecation notice. It#[path]-includespam-gaze's own modules rather than duplicating them. Every package still installs it so legacy PAM lines keep working, and it is slated for removal; new work belongs inpam-gaze.crates/gaze-gui: GTK4/libadwaita app. Desktop integrations inintegrations/are packaged separately.
Build and test rust components
just build-rust
just test
just lint
just fmt-check
just audit # check dependencies for known CVEs
just check-pam-link # check the PAM modules' shared-library footprint
just fmt # apply formatting (fmt-check only checks)The standard daemon includes both Intel OpenVINO and AMD Vitis AI adapters and loads ONNX Runtime dynamically at startup. just build-rust stages the pinned CPU runtime and its notices beside gazed; package builds install it under /usr/lib/gaze.
Register vendor runtimes as described in Hardware Acceleration. To test a custom SDK without installing it, set ORT_DYLIB_PATH to its complete ONNX Runtime library and start the daemon with that SDK's LD_LIBRARY_PATH. Keep system-service libraries outside /home and /root, which gazed.service hides.
Keep the api-21 feature on the ort dependency
gazed and gaze-vision depend on ort with default-features = false and api-21, which pins the ONNX Runtime C API version the binaries ask for. ort defaults to the newest API its release targets, and a runtime older than that makes ONNX Runtime hand back a null API pointer, which ort turns into a panic during process teardown and a core dump. A runtime supplied from outside the pinned download (Nix, ORT_DYLIB_PATH, or a vendor SDK) can be as old as ONNX Runtime 1.21, so an ort upgrade must keep the api-21 feature rather than inherit the new default. gazed also checks the loaded runtime before touching ort, and gaze-vision's inference:: tests fail against a runtime that is too old.
api-21 is also the newest API level Gaze can ask for safely. From api-22 on, ort's session builder sets an automatic execution-provider selection policy on every session, which makes ONNX Runtime pick execution providers from the platform's hardware device list instead of installing the built-in CPU provider. ONNX Runtime 1.22 has no device discovery on Linux, so that list is empty and the selection code dereferences it unchecked and aborts the process, even though Gaze only ever asked for CPU inference. Gaze uses nothing that needs API 22 or newer, so staying on api-21 keeps session creation on the path that installs the CPU provider directly.
just lint compiles both vendor adapters and just test exercises configuration, hardware discovery, runtime/API validation, and CPU fallback without NPU hardware. CI's test job then reruns the inference tests against Intel's OpenVINO runtime (just test-openvino). Actual NPU execution, model operator coverage, driver compatibility, and recognition/liveness precision need hardware validation on both vendors.
Build with just build-rust, not cargo build --workspace
just build-rust builds the daemon and the clients in separate cargo invocations so feature unification cannot link ONNX Runtime into the CLI, GUI, or PAM modules. This keeps inference code out of the clients and PAM modules; the daemon checks CPU support before loading ONNX Runtime.
Never give the PAM modules a gaze-vision dependency
pam_gaze.so is dlopened into every process that authenticates through common-auth, including network services such as sshd and dovecot. Linking the vision stack there pulls in OpenCV, which pulls in OpenBLAS, whose ELF constructor reserves per-thread buffers sized for every core. Services that cap address space then abort on load, which has broken IMAP authentication before. A crate boundary, not a cargo feature, is what keeps this out, because features unify across packages built in one cargo build invocation. just check-pam-link verifies the built modules link only basic system libraries and, for the main PAM module, the TPM libraries needed for keyring unsealing. It runs as part of just build-rust and every package build.
Run a locally-built daemon
The daemon takes no CLI arguments; paths are compiled in:
- Config:
/etc/gaze/config.toml - User templates:
/var/lib/gaze/users - Models:
/var/cache/gaze
It also owns com.gundulabs.Gaze on the system DBus bus, which requires root. You cannot run a second daemon as your user.
Option A: link your build over the installed files (easier for repeated iteration):
This overlays your checkout onto an existing package install; it does not install the package itself. If you've never installed Gaze on this machine, build and install a package once first (just package rpm and sudo <package manager> install dist/packages/gaze-*.rpm, or the deb/archlinux equivalent); dev-link-system fails fast with a pointer back here if gazed.service isn't installed yet.
just build-rust
just dev-link-system # runs scripts/dev-link-system.sh under sudo itselfdev-link-system (scripts/dev-link-system.sh enable) does more than swap binaries:
- Links
/usr/bin/gazed,/usr/bin/gaze, the PAM modules, the polkit policy, and the GNOME extension (system-wide and current-user) over the package-installed files./usr/bin/gaze-guiis linked too when the build produced it, and skipped after aGAZE_GUI=0build. - Adds a
pam_gaze.soline to/etc/pam.d/sudoif one isn't already there. - Installs a systemd drop-in for
gazedthat clearsInaccessiblePaths=/home /rootso the packaged unit can execute a binary linked from your checkout, then restartsgazed. - If a TPM is present, turns on
[storage] encrypt_templatesand seals a key to it (setGAZE_DEV_TPM=0to skip this).
just dev-unlink-system reverses all of the above from backup, including the PAM line and the encryption setting. just dev-link-status shows what is currently linked, the TPM/encryption state, and how many templates on disk are encrypted.
Option B: run the daemon in the foreground:
sudo systemctl stop gazed
just build-rust
sudo RUST_LOG=debug ./target/release/gazedRUST_LOG accepts standard tracing filters (info, debug, gaze=trace, etc.). Ctrl-C to stop, then sudo systemctl start gazed when you're done to restore the system daemon.
If you've never installed Gaze on this machine, you also need the DBus policy and a config file in place before the daemon can claim its name or load. The simplest way is to install the package once, then iterate on the binary:
sudo install -Dm644 packaging/config/com.gundulabs.Gaze.conf \
/etc/dbus-1/system.d/com.gundulabs.Gaze.conf
sudo install -Dm644 packaging/config/config.toml /etc/gaze/config.toml
sudo systemctl reload dbusThe CLI and GUI need no special setup; they talk to whichever gazed currently owns the bus name:
./target/release/gaze list-faces
./target/release/gaze auth --verbose
./target/release/gaze-guiIterating on the PAM module
pam-gaze builds as a cdylib. After just build-rust you'll have:
target/release/libpam_gaze.sotarget/release/libpam_gaze_grosshack.so(the deprecated shim; only the openSUSE packages install it)
just build-rust also runs just check-pam-link over it, which fails the build if the module links anything beyond libc, libgcc, libm, the dynamic loader, and the keyring's libtss2-esys.so.0, libtss2-mu.so.0, and libtss2-tctildr.so.0. The TPM exception applies only to libpam_gaze.so, not the compatibility shim.
To exercise them through real PAM, copy into the system PAM library directory (path is distro-specific):
# Debian/Ubuntu up to 25.10
sudo cp target/release/libpam_gaze.so /lib/x86_64-linux-gnu/security/pam_gaze.so
# Ubuntu 26.04+ (libpam looks in /usr/lib/security)
sudo cp target/release/libpam_gaze.so /usr/lib/security/pam_gaze.so
# Fedora/RHEL
sudo cp target/release/libpam_gaze.so /lib64/security/pam_gaze.so
# Arch
sudo cp target/release/libpam_gaze.so /usr/lib/security/pam_gaze.soDon't lock yourself out
Before touching PAM files, keep a second terminal open with an active root shell (sudo -s). If the module crashes or misbehaves, you can revert from that shell. Test against a non-critical service first (e.g. add a line to /etc/pam.d/su or a custom service), not system-auth or sudo.
Quickest end-to-end test once the .so is in place:
sudo -k # invalidate cached sudo credentials
sudo -v # force a fresh PAM promptIterating on the GNOME extension
The extension source lives in integrations/gnome-shell/. To run it from the tree without packaging:
mkdir -p ~/.local/share/gnome-shell/extensions
ln -sfn "$PWD/integrations/gnome-shell" \
~/.local/share/gnome-shell/extensions/gaze@gundulabs.com
# compile the gsettings schema once
glib-compile-schemas ~/.local/share/gnome-shell/extensions/gaze@gundulabs.com/schemas
# on Xorg: Alt+F2 then `r`. On Wayland: log out and back in.
gnome-extensions enable gaze@gundulabs.com
gsettings set org.gnome.shell.extensions.gaze enable-face-authentication trueWatch shell logs while you iterate:
journalctl -f /usr/bin/gnome-shellFor the unlock-dialog session mode (lock screen), changes only take effect after a fresh lock, not a shell reload.
Testing GNOME Shell compatibility
With Node.js 22 or newer, run:
node scripts/test-gnome.mjsThis downloads the GNOME Shell 45.0 through 51.0 authentication source and runs it with the Gaze extension. The tests cover face startup and eligibility, confirmation by keyboard and button, password fallback, cancellation, stale D-Bus replies, GNOME 51 retry modes, Polkit session handlers, and removal of extension hooks. CI runs the same command. To use downloaded sources without network access, pass a directory containing 45.0/js/ through 51.0/js/ from those releases.
The harness simulates native widgets, GObject signals, and D-Bus. Test actual GDM login, lock screen unlock, and Polkit prompts in a GNOME desktop session before treating a new Shell release as fully verified.
Iterating on the Cinnamon extension
The extension source lives in integrations/cinnamon/. Cinnamon reads its settings schema from the extension directory, so no glib-compile-schemas step is needed:
mkdir -p ~/.local/share/cinnamon/extensions
ln -sfn "$PWD/integrations/cinnamon" \
~/.local/share/cinnamon/extensions/gaze@gundulabs.comReload Cinnamon with Alt + F2, r, Enter, then enable it from System Settings → Extensions. Watch its logs with:
journalctl -f /usr/bin/cinnamonjust dev-link-system also links this extension system-wide and for the invoking user when /usr/share/cinnamon exists, so an installed checkout picks it up without the symlink above.
Building the docs
The site is VitePress, driven through bun:
just build-docs # bun install && bun run docs:build
bun run docs:dev # live preview at http://localhost:5173scripts/prepare-docs.sh runs first in both cases and stages the archived versions under docs/archive/. Edit docs/src/; never edit generated output under docs/.vitepress/dist.
Checking the KDE lock screen without Plasma
just kde-harness drives a PAM service exactly the way KScreenLocker's greeter drives its noninteractive biometric slot: it renders error messages, discards info messages, and fails loudly if the module issues a prompt (which would hang the real greeter for the rest of the lock). Pass a service and a round count to emulate re-arming after a wrong password:
just kde-harness kde-fingerprint 2Testing KDE PAM compatibility
With Node.js 22.15 or newer, run:
node scripts/test-kde.mjsThis downloads the KDE lock screen and login greeter PAM stacks that each supported distribution ships (kscreenlocker, sddm, plasma-login-manager, and the base stacks they include) and runs gaze-kde-pam against them. Fedora runs twice, with the default authselect profile and with with-fingerprint with-faillock. The tests evaluate the edited stacks with Linux-PAM's dispatch rules and cover face unlock, account lockout and nologin gates, non-matches that must not count as failed logins, fingerprint readers keeping their own slot, password fallback at the login greeter, and byte-for-byte restoration on disable. CI runs the same command. To use downloaded sources without network access, pass a directory with one folder per target (such as arch or fedora-44) holding the files the script would otherwise download.
PAM modules are simulated. Unlock an actual Plasma session, or use just kde-harness, before treating a distribution release as fully verified.
Packaging
just package <deb | rpm | archlinux>Package output:
dist/packages/
Flatpak build
The build recipe adds the Flathub remote and installs or updates the GNOME runtime/SDK and Rust/LLVM extensions declared by the manifest, so their versions have a single source of truth. Build with:
just build-flatpakThis runs a [private] prep recipe first (prepare-flatpak-vendor), so the first run needs network access even though the sandboxed build itself is --offline: cargo vendor --locked --versioned-dirs populates .flatpak-cache/cargo from crates.io.
It is cached under .flatpak-cache/ (removed by just clean), so only the first build per checkout pays the network/OpenCV-from-source cost; expect that first build to take a while, since OpenCV compiles from source inside the sandbox.
Output bundle:
dist/packages/com.gundulabs.Gaze-<arch>.flatpak(e.g.com.gundulabs.Gaze-x86_64.flatpak)dist/packages/com.gundulabs.Gaze.flatpakrefand.flatpakrepo(only meaningful once published to a real repo; fine to ignore for local builds)
Set FLATPAK_GPG_SIGN=<key-id> to sign the repo/bundle; leave it unset for local builds.
Building without a Linux host (Docker)
macOS and Windows can't run any of the recipes above natively. just docker <target> runs the same just target inside a disposable Ubuntu container that mirrors the CI toolchain (packaging/docker/Dockerfile.build), so this works for build-rust, build-flatpak, package <deb|rpm|archlinux>, and so on, with no local Rust/OpenCV/Flatpak setup needed on the host:
just docker build-rust
just docker build-flatpak
just docker package debRequirements on the host:
- Docker (or a Docker-compatible runtime; Colima works on macOS).
- The container runs
--privileged, whichflatpak-builder's ostree backend needs.
Notes:
just docker-imagebuilds (and caches) the build image;just docker <target>builds it automatically on first use.- Cargo registry, build target, and Flatpak state persist in named Docker volumes across runs, so repeat builds don't re-download crates or the GNOME SDK.
- For
build-flatpak, the recipe installs the manifest's GNOME/Rust/LLVM dependencies into a volume the first time the target runs. - If your checkout lives on a sshfs-backed mount (e.g. a Colima VM on an external/network drive), Flatpak's ostree repo can't live on that mount. The wrapper already redirects
flatpak-builder's state/build/repo dirs to an in-VM Docker volume, so this works out of the box. Don't overrideFLATPAK_STATE_DIR/FLATPAK_BUILD_DIR/FLATPAK_REPO_DIRback onto the bind mount. - Artifacts land in
dist/on the host same as a native build, re-owned to your host user/group when the container runs as root (native Docker); on uid-mapping backends like Colima's sshfs, files already belong to you and are left alone.
Cleaning build artifacts
just clean