Skip to content

Instantly share code, notes, and snippets.

@thebeline
Last active February 26, 2026 17:29
Show Gist options
  • Select an option

  • Save thebeline/53f0fd84e39ff3d3dce198126d9d2332 to your computer and use it in GitHub Desktop.

Select an option

Save thebeline/53f0fd84e39ff3d3dce198126d9d2332 to your computer and use it in GitHub Desktop.
List XDG desktop applications with source classification and package metadata
# 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