Skip to content

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 ​

bash
nix develop

That 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 ​

bash
just docker build-rust

Any 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.
  • just 1.51+, the task runner everything below goes through
  • cargo-audit, for just audit
  • nfpm, only for just package
  • flatpak-builder, only for just build-flatpak

CI uses stable Rust and pins just in .github/workflows/ci.yml. Prepare the Rust tools for the required checks:

bash
rustup update stable
rustup default stable
rustup component add rustfmt clippy
cargo install just --locked
cargo install cargo-audit --locked

Then install the system libraries. Runtime packages alone do not provide the headers and pkg-config files needed by just lint and just test:

bash
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 elfutils
bash
sudo 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 elfutils
bash
sudo 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 elfutils
bash
sudo 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 elfutils

libtss2-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 ​

bash
git clone https://github.com/gundulabs/gaze
cd gaze
just setup-hooks
just build-rust
just test

That 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: the gazed daemon, ML pipeline, and user database.
  • crates/gaze-cli: the gaze CLI 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, so pam-gaze can depend on it.
  • crates/gaze-vision: camera capture, face detection, and inference. Detection sits behind the detection cargo feature. The workspace dependency turns default features off, so the CLI and GUI get camera support alone and only gazed enables detection.
  • crates/pam-gaze: cdylib PAM module. Depends on gaze-core and gaze-security for TPM keyring unsealing, never gaze-vision; just check-pam-link enforces its library allowlist (see warning below).
  • crates/pam-gaze-grosshack: deprecated cdylib compatibility shim that forces PamMode::Simultaneous and prints a deprecation notice. It #[path]-includes pam-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 in pam-gaze.
  • crates/gaze-gui: GTK4/libadwaita app. Desktop integrations in integrations/ are packaged separately.

Build and test rust components ​

bash
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.

bash
just build-rust
just dev-link-system    # runs scripts/dev-link-system.sh under sudo itself

dev-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-gui is linked too when the build produced it, and skipped after a GAZE_GUI=0 build.
  • Adds a pam_gaze.so line to /etc/pam.d/sudo if one isn't already there.
  • Installs a systemd drop-in for gazed that clears InaccessiblePaths=/home /root so the packaged unit can execute a binary linked from your checkout, then restarts gazed.
  • If a TPM is present, turns on [storage] encrypt_templates and seals a key to it (set GAZE_DEV_TPM=0 to 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:

bash
sudo systemctl stop gazed
just build-rust
sudo RUST_LOG=debug ./target/release/gazed

RUST_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:

bash
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 dbus

The CLI and GUI need no special setup; they talk to whichever gazed currently owns the bus name:

bash
./target/release/gaze list-faces
./target/release/gaze auth --verbose
./target/release/gaze-gui

Iterating on the PAM module ​

pam-gaze builds as a cdylib. After just build-rust you'll have:

  • target/release/libpam_gaze.so
  • target/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):

bash
# 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.so

Don'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:

bash
sudo -k   # invalidate cached sudo credentials
sudo -v   # force a fresh PAM prompt

Iterating on the GNOME extension ​

The extension source lives in integrations/gnome-shell/. To run it from the tree without packaging:

bash
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 true

Watch shell logs while you iterate:

bash
journalctl -f /usr/bin/gnome-shell

For 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:

bash
node scripts/test-gnome.mjs

This 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:

bash
mkdir -p ~/.local/share/cinnamon/extensions
ln -sfn "$PWD/integrations/cinnamon" \
  ~/.local/share/cinnamon/extensions/gaze@gundulabs.com

Reload Cinnamon with Alt + F2, r, Enter, then enable it from System Settings → Extensions. Watch its logs with:

bash
journalctl -f /usr/bin/cinnamon

just 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:

bash
just build-docs        # bun install && bun run docs:build
bun run docs:dev       # live preview at http://localhost:5173

scripts/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:

bash
just kde-harness kde-fingerprint 2

Testing KDE PAM compatibility ​

With Node.js 22.15 or newer, run:

bash
node scripts/test-kde.mjs

This 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 ​

bash
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:

bash
just build-flatpak

This 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.flatpakref and .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:

bash
just docker build-rust
just docker build-flatpak
just docker package deb

Requirements on the host:

  • Docker (or a Docker-compatible runtime; Colima works on macOS).
  • The container runs --privileged, which flatpak-builder's ostree backend needs.

Notes:

  • just docker-image builds (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 override FLATPAK_STATE_DIR/FLATPAK_BUILD_DIR/FLATPAK_REPO_DIR back 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 ​

bash
just clean