Path

Executable & Desktop File Discovery

Comprehensive reference for how executables ($PATH) and .desktop files are discovered across all NixOS layers in this flake.


Why Discovery Paths Matter

Different process types see different PATH values:

Process typePATH sourceExample
Login shell (tty, SSH, DM login)PAM + /etc/set-environment + shell initzsh, bash -l
Non-login interactive shellInherits from parent + shell rczsh (no -l), terminal emulator tabs
Compositor spawn (niri, hyprland)Inherits from display manager / systemd user sessionMod+Spacehey @rofi appmenu
Systemd user service/etc/set-environment + environment.d/*.confdms.service, syncthing.service
Systemd system service/etc/environment + static unit Environment=nginx.service, sing-box.service
cron / at jobsMinimal PATH (/run/current-system/sw/bin only)btrbk, scheduled tasks

If an executable is only on the PATH for login shells, it won’t be found by compositor keybindings or systemd services. This was the root cause of the hey + niri Mod+Space bug.


Complete PATH Composition

When a user logs into this flake’s system with all desktop modules enabled, the final PATH for a login shell is:

PriorityPathMechanismSet in
1./bin (repo-local)shell.nix shellHookDev shell only (nix develop)
2~/.local/share/janet/jpm_tree/binenvironment.extraInitmodules/hey.nix
3~/.local/binenvironment.localBinInPathmodules/home.nix
4~/.config/emacs/binenvironment.variables.PATHmodules/editors/emacs.nix
5~/.local/share/cargo/binenvironment.variables.PATHmodules/dev/rust.nix
6~/.local/share/npm/binenvironment.variables.PATHmodules/dev/node.nix
7/run/wrappers/binNixOS built-insystem profile
8/etc/profiles/per-user/$USER/binNixOS + home-managermodules/home.nix (useUserPackages)
9/nix/var/nix/profiles/default/binNixOS built-indefault profile
10/run/current-system/sw/binNixOS built-inenvironment.systemPackages

For a systemd user service or compositor spawn, entries 1 and 2 may be absent (they depend on shell init order). Entry 3 is present because PAM injects it. Entries 4-10 are present because environment.variables and the NixOS profile are loaded by the systemd user manager.

For a non-login shell (terminal emulator tab), entries 1 and 2 are present if the terminal inherits from the login session and shell rc re-sources set-environment.


How Each Layer Works

environment.systemPackages

environment.systemPackages = [ pkgs.git pkgs.heyPackage ];

Each package’s bin/ directory is symlinked into /run/current-system/sw/bin/. This is the most reliable way to make an executable available to all process types. It’s always present, requires no shell init, and survives across login types.

Affects: All processes — login shells, non-login shells, systemd services, compositor spawn, cron.

environment.extraInit

environment.extraInit = mkAfter ''
  export PATH="${janetTreeDir}/bin:$PATH"
'';

Writes to /etc/set-environment, sourced by PAM on login. The mkAfter ensures it runs after other session variable setup. Entries here are available to login shells and systemd user services, but the actual sourcing order can vary across display managers and login methods.

Affects: Login shells reliably; systemd user services usually; compositor spawn depends on how the compositor was launched.

Use for: Paths that should be first (highest priority) — e.g., local dev builds that override system packages.

environment.variables.PATH

environment.variables.PATH = [ "$XDG_DATA_HOME/cargo/bin" ];

Also writes to /etc/set-environment. Multiple modules can set this — NixOS merges them via list concatenation. Order depends on module evaluation order.

Affects: Same as extraInit — login shells and systemd user services.

Pitfall: If two modules set environment.variables.PATH, both contribute. But if one uses mkForce or mkOverride, it can shadow others. Check with echo $PATH if entries are missing.

environment.localBinInPath

environment.localBinInPath = true;

A NixOS built-in that tells PAM to prepend $HOME/.local/bin to PATH during login. This is the standard XDG mechanism — it affects PAM-authenticated sessions only.

Affects: All PAM login sessions (tty, SSH, display manager). Systemd user services inherit it from PAM.

home-manager.useUserPackages

home-manager.useUserPackages = true;

When true, home-manager installs user packages to /etc/profiles/per-user/$USER/bin instead of ~/.nix-profile. This makes them visible system-wide (for that user) and compatible with NixOS VMs.


Debugging PATH Issues

Check what a specific process sees

# What does your shell see?
echo $PATH | tr ':' '\n'

# What does a systemd user service see?
systemctl --user show-environment | grep PATH

# What does niri see? (check its environment)
cat /proc/$(pgrep niri)/environ | tr '\0' '\n' | grep PATH

Common failure modes

  1. Executable works in terminal but not from keybinding: The binary is only on PATH for login shells (via extraInit or shell rc), not for the compositor’s spawn. Fix: add it to environment.systemPackages.

  2. Systemd service can’t find binary: The service’s PATH= is too restrictive. Fix: add path = [ pkgs.thePackage ] to the systemd service unit.

  3. PATH entry appears twice: The same path is added by two different mechanisms (e.g., environment.systemPackages + environment.extraInit). Harmless but messy.

  4. Order is wrong: A system binary shadows a local override. Check PATH order — entries higher in PATH win.

Quick PATH audit

# Show PATH with annotations for which layer each entry comes from
hey check path   # (planned, not yet implemented)

Manual audit:

# Which hey is actually being invoked?
which hey
type hey

# Follow the symlink chain
readlink -f $(which hey)

hey-Specific PATH Notes

The hey CLI has its own internal exec-path (in lib/hey/lib.janet) used when hey spawns child processes. This is separate from the system PATH. See toolchain.md §6 for details on how exec-path resolves the bin/ adjacent to lib/ in the JANET_PATH.

Two-tier hey availability:

BuildBinary locationOn PATH via
System (Nix package)/run/current-system/sw/bin/heyenvironment.systemPackages — all processes
Dev (local build)~/.local/bin/heyenvironment.localBinInPath — PAM sessions
Dev (jpm tree)~/.local/share/janet/jpm_tree/bin/heyenvironment.extraInit — login shells

The dev build at ~/.local/bin takes priority over the system build because it appears earlier in PATH. The system build serves as the universal fallback for all process types.


Desktop File Discovery

Where PATH finds executables for execvp/spawn, desktop files (.desktop) are what GUI launchers (rofi drun, GNOME Shell, application menus) use to list and launch applications. They are discovered via XDG_DATA_DIRS and $XDG_DATA_HOME, not PATH.

Discovery mechanism

Rofi’s drun mode (used by Mod+Spacehey @rofi appmenu) scans these locations:

LayerPathPopulated by
User data~/.local/share/applications/user.packages / home-manager home.packages
System profile/run/current-system/sw/share/applications/environment.systemPackages
Per-user profile/etc/profiles/per-user/$USER/share/applications/home-manager with useUserPackages = true
Default profile/nix/var/nix/profiles/default/share/applications/NixOS default profile
Flatpak~/.local/share/flatpak/exports/share/applications/Flatpak user installs
Flatpak system/var/lib/flatpak/exports/share/applications/Flatpak system installs

Rofi drun is configured in config/rofi/bin/appmenu.sh:

rofi -show drun -modi drun,run -theme appmenu.rasi $@

And appmenu.rasi (in modules/themes/config/rofi/themes/) enables both layers:

drun {
    parse-user:   true;   # ~/.local/share/applications/
    parse-system: true;   # XDG_DATA_DIRS paths
}

parse-system follows XDG_DATA_DIRS, which NixOS populates with system share/ paths (/run/current-system/sw/share/, etc.). The only place XDG_DATA_DIRS is overridden is inside nixpak sandboxes (see below).

System-level desktop entries

Any package in environment.systemPackages that ships share/applications/*.desktop files automatically exposes them via /run/current-system/sw/share/applications/. No extra configuration needed.

Some modules directly place mkLauncherEntry outputs into environment.systemPackages:

# modules/desktop/shell/dms.nix
environment.systemPackages = with pkgs; [
  (mkLauncherEntry "DMS: toggle night mode" {
    name = "dms_ipc_night_toogle";
    exec = "dms ipc night toogle";
    ...
  })
];

These launchers are system-wide — visible to all users and in all desktop environments.

User-level desktop entries

Most custom launchers go into user.packages (which is an alias for home-manager’s home.packages):

# modules/desktop/apps/rofi.nix
user.packages = [
  (mkLauncherEntry "Run an Application" {
    name = "launcher.rofi-appmenu";
    icon = "rofi";
    exec = "hey @rofi appmenu";
  })
  ...
];

These land in the user’s profile (/etc/profiles/per-user/$USER/share/applications/ when useUserPackages = true), making them per-user but still globally discoverable by rofi and other launchers.

mkLauncherEntry pattern

Defined in lib/pkgs.nix:54-76, this wraps pkgs.makeDesktopItem with conventions:

mkLauncherEntry = comment: {
  name,           # desktop filename (gets "launcher." prefix if no prefix given)
  exec,           # command to run
  icon ? null,    # icon name (from theme or absolute path)
  path ? "",      # additional PATH entries for the wrapper
  ...
}: ...

Key details:

  • Desktop file is named {prefix}.{name}.desktop — default prefix is "launcher", so name = "rofi-appmenu" becomes launcher.rofi-appmenu.desktop.
  • These names are used in MIME associations (e.g., launcher.firefox-proxy.desktop in modules/xdg.nix:233).
  • The Categories field is set to X-Hey to avoid cluttering default application menus while keeping them searchable in rofi.

Fake home desktop entries

Non-XDG-compliant apps (Steam, Firefox, Thunderbird, JetBrains IDEs, Electron apps) scatter config into $HOME. To contain the mess, this flake jails them to ~/.local/user/ (the “fake home”) via XDG_FAKE_HOME.

wrapFakeHome (lib/pkgs.nix:81-82): Simple wrapper that runs the original binary with HOME=$XDG_FAKE_HOME. The package’s own .desktop file is used as-is — the wrapper is in PATH and the original Exec= finds it.

# modules/desktop/browsers/firefox.nix
wrapFakeHome pkgs.firefox

mkFakeHomeEntry (lib/pkgs.nix:90-110): Creates a new .desktop entry whose Exec= explicitly sets HOME from XDG_FAKE_HOME. Defined but currently unused — all callers use mkLauncherEntry directly.

mkBwrapWrapper (lib/pkgs.nix:124-176): Full bubblewrap sandbox with HOME=$XDG_FAKE_HOME. Rewrites sister binaries from the same package to also route through the sandbox.

Nixpak sandbox (modules/sandbox.nix): For high-risk apps (QQ, WeChat, Discord, Zoom), a full nixpak sandbox is used. The mkNixPakApp helper:

  1. Sets bubblewrap.env.HOME = fakeHome (line 288)
  2. Rewrites the package’s .desktop files (lines 308-319): replaces the Exec= line to point through the sandbox wrapper, strips nix store hashes from Icon= paths
  3. Overlays the rewritten desktop files over the original package via symlinkJoin (line 323)

The sandbox also force-restricts XDG_DATA_DIRS to only icon themes + shared-mime-info (modules/sandbox.nix:85-91), preventing the sandboxed app from seeing system .desktop files.

Fake home initialization happens in modules/xdg.nix:341-375 (system.userActivationScripts.initXDG): creates the fake home directory, makes XDG subdirs, and symlinks .local, .config, .cache, .ssh from real home into fake home so the jailed apps can still access shared fonts, themes, and configurations.

Debugging desktop entries

# What .desktop files does rofi drun see?
rofi -show drun -dump-xresources 2>&1 | head

# Check all directories rofi scans
echo $XDG_DATA_DIRS | tr ':' '\n'

# List all .desktop files a specific package provides
ls /nix/store/*-firefox-*/share/applications/

# Find which .desktop file provides a given app name
find /run/current-system/sw/share/applications \
     ~/.local/share/applications \
     /etc/profiles/per-user/$USER/share/applications \
     -name "*.desktop" | xargs grep -l "Name=Firefox"

# Check what Exec= line a desktop entry resolves to
grep "^Exec=" /run/current-system/sw/share/applications/firefox.desktop

# Verify a fake-home app: check that HOME is redirected
cat /proc/$(pgrep firefox)/environ | tr '\0' '\n' | grep HOME

Common desktop entry failure modes

  1. App appears in terminal but not in rofi drun: The binary is on PATH but no .desktop file exists in a scanned directory. Either the package doesn’t ship one, or the package isn’t in environment.systemPackages / user.packages. Fix: add a mkLauncherEntry.

  2. Desktop entry exists but clicking does nothing: The Exec= line references a binary that’s not on the PATH seen by the compositor. This is the same class of bug as the PATH section above — fix by ensuring the binary is in environment.systemPackages.

  3. Jailed app can’t access shared fonts/themes: The symlinks from fake home to real home (~/.local/user/.config~/.config, etc.) are missing. Re-run activation or check initXDG.

  4. Sandboxed app (nixpak) sees no .desktop files: This is intentional — XDG_DATA_DIRS is restricted. The sandboxed app doesn’t need to launch other apps via desktop entries.