Last active
February 26, 2026 17:29
-
-
Save thebeline/53f0fd84e39ff3d3dce198126d9d2332 to your computer and use it in GitHub Desktop.
List XDG desktop applications with source classification and package metadata
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # list_apps.sh — List installed GUI applications from XDG desktop entries. | |
| # | |
| # Scans all directories in XDG_DATA_HOME and XDG_DATA_DIRS for .desktop files, | |
| # parses them, and prints each visible application with its name, installation | |
| # source, and package metadata in a three-column table: | |
| # | |
| # Name [source, flags] /path/to/file.desktop | |
| # | |
| # Source classification (second column) reflects where the app came from: | |
| # apt — system-wide apt install under /usr | |
| # <name> — named prefix (e.g. flatpak, snap, nix, guix) | |
| # profile — current user's home directory (~/.local, ~/.nix-profile, …) | |
| # profile[user] — another user's home directory (/home/user/…) | |
| # unknown — unrecognised path structure | |
| # | |
| # Additional flags appended to the source: | |
| # +manual — package was manually installed (apt-mark showmanual), apt-specific | |
| # hidden — Hidden=true or NoDisplay=true, or filtered by OnlyShowIn/NotShowIn | |
| # unavailable — Exec= binary not found on PATH or not executable | |
| # shadowed — overridden by a same-named .desktop in a higher-priority directory | |
| # primary — the canonical entry when the same app name appears more than once | |
| # | |
| # Usage: list_apps.010.sh [-H|--show-hidden] | |
| # -H, --show-hidden include hidden, unavailable, and shadowed entries | |
| # | |
| # Requires: awk, find, sort, column, readlink, basename, mktemp | |
| # Optional: apt-mark, dpkg (for manual/package annotations) | |
| # Compatible: sh, bash, zsh | |
| # | |
| # Not perfect, or beautiful, but it works. First version was more palatable, but | |
| # full disclosure, I subsequently rinsed it through Claude to make sure it would | |
| # work in `sh`/`bash`/`zsh` (maybe more, but I specifically targeted those), and it | |
| # got a little messy... Please forgive me... | |
| case "$1" in -H|--show-hidden) show_hidden=1 ;; *) show_hidden=0 ;; esac | |
| _tab=$(printf '\t') | |
| _type() { | |
| local _tp="$1" _in_home=0 _home_user="" _r="" _c="" _src="" _peek="" _pfx="" | |
| case "$_tp" in | |
| "$HOME"/*) _in_home=1 ;; | |
| /home/*/*) _in_home=1; _r="${_tp#/home/}"; _home_user="${_r%%/*}" ;; | |
| esac | |
| _r="${_tp%/applications/*}" | |
| while [ -n "$_r" ]; do | |
| _c="${_r##*/}" | |
| _r="${_r%/*}" | |
| case "$_c" in | |
| share|desktop|files|exports|"") ;; | |
| usr) _src="apt"; break ;; | |
| local) ;; | |
| default) _peek="${_r##*/}" | |
| [ "$_peek" = "profiles" ] && _r="${_r%/*}" ;; | |
| *) _src="$_c"; break ;; | |
| esac | |
| done | |
| case "$_src" in | |
| .?*) _src="${_src#.}" | |
| case "$_src" in | |
| local) ;; | |
| *) _src="${_src%-profile}"; _src="${_src%profile}"; _src="${_src%-}" ;; | |
| esac ;; | |
| esac | |
| if [ "$_in_home" = 1 ]; then | |
| _pfx=profile | |
| [ -n "$_home_user" ] && _pfx="profile[$_home_user]" | |
| [ -n "$_src" ] && printf '%s' "$_src, " | |
| printf '%s' "$_pfx" | |
| else | |
| printf '%s' "${_src:-unknown}" | |
| fi | |
| } | |
| _exec_bin() { | |
| local _eb="$1" _key="" _rest="" _val="" | |
| case "$_eb" in env\ *) _eb="${_eb#env }" ;; esac | |
| while true; do | |
| case "$_eb" in *=*) ;; *) break ;; esac | |
| _key="${_eb%%=*}" | |
| case "$_key" in [A-Za-z_]*) ;; *) break ;; esac | |
| case "$_key" in *[!A-Za-z0-9_]*) break ;; esac | |
| _rest="${_eb#*=}" | |
| case "$_rest" in | |
| *" "*) _val="${_rest%% *}" | |
| case "$_val" in "") break ;; esac | |
| _eb="${_rest#* }" ;; | |
| *) break ;; | |
| esac | |
| done | |
| printf '%s' "${_eb%% *}" | |
| } | |
| _exec_avail() { | |
| local _bin | |
| _bin=$(_exec_bin "$1") | |
| case "$(basename "$_bin")" in flatpak) return 0 ;; esac | |
| case "$_bin" in | |
| /*) [ -x "$_bin" ] ;; | |
| *) command -v "$_bin" >/dev/null 2>&1 || | |
| command -v "$(printf '%s' "$_bin" | awk '{print tolower($0)}')" >/dev/null 2>&1 ;; | |
| esac | |
| } | |
| _is_primary() { | |
| local _bin _bin_r _bname _cmd_r | |
| _bin=$(_exec_bin "$1") | |
| case "$(basename "$_bin")" in flatpak) return 1 ;; esac | |
| case "$_bin" in | |
| /*) ;; | |
| *) _bin=$(command -v "$_bin" 2>/dev/null) || return 1 ;; | |
| esac | |
| _bin_r=$(readlink -f "$_bin" 2>/dev/null) || return 1 | |
| _bname=$(basename "$_bin") | |
| _cmd_r=$(readlink -f "$(command -v "$_bname" 2>/dev/null || | |
| command -v "$(printf '%s' "$_bname" | awk '{print tolower($0)}')" 2>/dev/null)" 2>/dev/null) | |
| [ "$_bin_r" = "$_cmd_r" ] | |
| } | |
| _tmp_raw=$(mktemp) | |
| _tmp_manual=$(mktemp) | |
| _tmp_dpkg=$(mktemp) | |
| _tmp_names=$(mktemp) | |
| trap 'rm -f "$_tmp_raw" "$_tmp_manual" "$_tmp_dpkg" "$_tmp_names"' EXIT | |
| apt-mark showmanual 2>/dev/null > "$_tmp_manual" | |
| dpkg -S '*applications/*.desktop' 2>/dev/null | awk '{ | |
| idx = index($0, ": ") | |
| if (idx > 0) print substr($0, idx+2) "\t" substr($0, 1, idx-1) | |
| }' > "$_tmp_dpkg" | |
| # Collect, parse, and shadow-track all .desktop entries in one awk pass | |
| printf '%s\n' "${XDG_DATA_HOME:-$HOME/.local/share}:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" | \ | |
| awk -F: '{for(i=1;i<=NF;i++) if($i!=""&&!seen[$i]++) print $i}' | \ | |
| while IFS= read -r _d; do | |
| [ -d "${_d%/}/applications" ] || continue | |
| find "${_d%/}/applications" -maxdepth 1 -name '*.desktop' | |
| done | \ | |
| awk -v desktops="${XDG_CURRENT_DESKTOP:-GNOME}" ' | |
| function dmatch(list, i,n,d) { | |
| n = split(desktops, d, ":") | |
| for (i = 1; i <= n; i++) if (index(list, d[i] ";") > 0) return 1 | |
| return 0 | |
| } | |
| { | |
| path = $0 | |
| n = split(path, a, "/"); bname = a[n] | |
| shd = (bname in seen) + 0; seen[bname] = 1 | |
| e = 0; s = 0; t = ""; nm = ""; x = ""; only = ""; notin = "" | |
| while ((getline line < path) > 0) { | |
| if (line ~ /^\[Desktop Entry\]/) e = 1 | |
| else if (line ~ /^\[/) e = 0 | |
| if (e && line ~ /^(Hidden|NoDisplay)=true/) s = 1 | |
| if (e && line ~ /^Type=/) t = substr(line, index(line,"=")+1) | |
| if (e && !nm && line ~ /^Name=/) nm = substr(line, index(line,"=")+1) | |
| if (e && !x && line ~ /^Exec=/) x = substr(line, index(line,"=")+1) | |
| if (e && line ~ /^OnlyShowIn=/) only = substr(line, index(line,"=")+1) | |
| if (e && line ~ /^NotShowIn=/) notin = substr(line, index(line,"=")+1) | |
| } | |
| close(path) | |
| if (t != "Application" || !nm || !x) next | |
| if (!s && only != "" && !dmatch(only)) s = 1 | |
| if (!s && notin != "" && dmatch(notin)) s = 1 | |
| print nm "\t" x "\t" s "\t" shd "\t" path | |
| } | |
| ' | sort -t"$_tab" -k1,1 > "$_tmp_raw" | |
| awk -F"$_tab" '!seen[$1]++ {print $1}' "$_tmp_raw" > "$_tmp_names" | |
| while IFS= read -r _name; do | |
| _tmp_grp=$(mktemp) | |
| _tmp_vis=$(mktemp) | |
| _tmp_pri=$(mktemp) | |
| _tmp_sec=$(mktemp) | |
| awk -F"$_tab" -v name="$_name" '$1 == name' "$_tmp_raw" > "$_tmp_grp" | |
| # Pass 1: filter to visible entries | |
| _vis_unshd=0 | |
| while IFS="$_tab" read -r n x h shd p; do | |
| [ "$h" = "1" ] && [ "$show_hidden" = "0" ] && continue | |
| [ "$shd" = "1" ] && [ "$show_hidden" = "0" ] && continue | |
| _u=0; _exec_avail "$x" || _u=1 | |
| [ "$_u" = "1" ] && [ "$show_hidden" = "0" ] && continue | |
| printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$n" "$x" "$h" "$shd" "$p" "$_u" >> "$_tmp_vis" | |
| [ "$shd" = "0" ] && _vis_unshd=$(( _vis_unshd + 1 )) | |
| done < "$_tmp_grp" | |
| # Pass 2: classify and emit | |
| while IFS="$_tab" read -r n x h shd p u; do | |
| t=$(_type "$p") | |
| _pkg=$(awk -F"$_tab" -v p="$p" '$1 == p {print $2; exit}' "$_tmp_dpkg") | |
| [ -n "$_pkg" ] && grep -qxF "$_pkg" "$_tmp_manual" 2>/dev/null && t="$t+manual" | |
| [ "$h" = "1" ] && t="$t, hidden" | |
| [ "$u" = "1" ] && t="$t, unavailable" | |
| [ "$shd" = "1" ] && t="$t, shadowed" | |
| if [ "$shd" = "0" ] && [ "$_vis_unshd" -gt 1 ] && _is_primary "$x"; then | |
| printf '%s\t[%s, primary]\t%s\n' "$n" "$t" "$p" >> "$_tmp_pri" | |
| else | |
| printf '%s\t[%s]\t%s\n' "$n" "$t" "$p" >> "$_tmp_sec" | |
| fi | |
| done < "$_tmp_vis" | |
| cat "$_tmp_pri" "$_tmp_sec" 2>/dev/null | |
| rm -f "$_tmp_grp" "$_tmp_vis" "$_tmp_pri" "$_tmp_sec" | |
| done < "$_tmp_names" | column -t -s "$_tab" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment