# shellcheck shell=bash # # Finding the applications and deciding which of them are Chromium underneath. # # The detection is deliberately conservative. A wrong "yes" appends an unknown # argument to something that is not Chromium, and plenty of programs treat an # unrecognised argument as a file name to open. So every rule here is a # positive one, and anything that cannot be identified is reported as unknown # rather than guessed at. # Files that only ever sit next to a Chromium or Electron binary. Any single one # is conclusive; together they cover both bundled Electron (app.asar, the # swiftshader libraries) and plain CEF (libcef). # # Deliberately not in here: libEGL.so and libffmpeg.so. Chromium ships both, but # so does the system (/usr/lib/libEGL.so exists on any machine with Mesa), and # a marker that can be somebody else's file is not a marker. MCA_MARKERS=( chrome_crashpad_handler chrome-sandbox chrome_100_percent.pak icudtl.dat v8_context_snapshot.bin snapshot_blob.bin resources.pak libvk_swiftshader.so LICENSES.chromium.html libcef.so ) # The markers that still mean something when the whole of a tree is searched # rather than just the directory the binary sits in. That is the case inside a # Flatpak, a snap or an AppImage, where there is no "next to the binary". # # Three of the names above are missing here on purpose. icudtl.dat is ICU's # data file and not Chromium's: a Flutter application ships one in data/, and # a whole-tree search would find it. snapshot_blob.bin and resources.pak are # the same kind of shared name. Next to a binary they are still evidence, # because nothing but Chromium unpacks its payload there. A few directories # deeper they are not. MCA_MARKERS_STRICT=( chrome_crashpad_handler chrome-sandbox chrome_100_percent.pak v8_context_snapshot.bin libvk_swiftshader.so LICENSES.chromium.html libcef.so app.asar ) # Shared directories, where a marker belongs to the system rather than to the # program that happens to live there. An application ships its payload in a # directory of its own; nothing unpacks Chromium straight into /usr/lib. MCA_SYSTEM_DIRS=( / /bin /lib /lib32 /lib64 /sbin /usr /usr/bin /usr/lib /usr/lib32 /usr/lib64 /usr/libexec /usr/sbin /usr/local /usr/local/bin /usr/local/lib /usr/local/libexec /opt ) # The same thing for the layouts that put a machine triplet in the path. # Debian and Ubuntu keep the shared libraries in /usr/lib/x86_64-linux-gnu # rather than /usr/lib, so that directory is every bit as shared as /usr/lib is # elsewhere and a marker sitting in it belongs to nobody in particular. MCA_SYSTEM_DIR_GLOBS=( '/usr/lib/*-linux-gnu*' '/usr/lib32/*-linux-gnu*' '/usr/lib64/*-linux-gnu*' '/usr/local/lib/*-linux-gnu*' ) # Where snapd mounts the installed snaps. /snap is the usual place and the one # the shims point into; distributions that keep /snap free of a top-level # directory use the second. MCA_SNAP_DIRS=(/snap /var/lib/snapd/snap) # Strings in a launcher script that mean it starts a Chromium or Electron # process, for the wrappers whose command line is assembled out of variables and # cannot be followed from the outside. # # Only strings that no other kind of program has a reason to contain. The word # "chromium" on its own is not one of them: /usr/bin/xdg-open lists every # browser it knows how to start, and that is not a browser. # # The flag file convention is one distribution's packaging habit and says # nothing about the engine either, so it is not in here. # # CHROMIUM_FLAGS and CHROME_WRAPPER earn their place: they are the variables # the Debian, Fedora and openSUSE Chromium wrappers and Google's own Chrome # wrapper build their command line out of, and nothing else sets them. # # updater_bootstrap is Discord's, and it is the only thing left that names it. # Discord no longer ships its application: /usr/bin/discord is a few lines of # shell that run that bootstrap, which downloads Electron into the user's # config directory on first start and execs it from there. So there is nothing # next to the launcher to find, and the path it hands over to is built out of # $HOME and the version it just downloaded. MCA_SCRIPT_HINTS='ELECTRON_|app\.asar|chrome-sandbox|libcef|enable-blink-features|ozone-platform-hint|CHROMIUM_FLAGS|CHROME_WRAPPER|CHROME_VERSION_EXTRA|updater_bootstrap' # --------------------------------------------------------------------------- # Desktop entries # --------------------------------------------------------------------------- # Where Flatpak and snapd put the launchers they export. Both add these to # XDG_DATA_DIRS themselves, through a file in /etc/profile.d. But only for a # session that was started after they were installed, and only for a session # manager that reads it at all. They are appended, after everything XDG names, # so a directory that is already in the search path keeps its own position and # the ones that were missing are still scanned. mca_extra_desktop_dirs() { printf '%s\n' \ "$MCA_XDG_DATA/flatpak/exports/share/applications" \ /var/lib/flatpak/exports/share/applications \ /var/lib/snapd/desktop/applications } # The directories a desktop entry can come from, most specific first. This is # also XDG lookup order, so the first file found for an id is the one that is # actually used. mca_desktop_dirs() { local dirs="${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" d local -A seen=() while IFS= read -r d; do [[ -n $d ]] || continue d="${d%/}" [[ -n ${seen[$d]+set} ]] && continue seen[$d]=1 printf '%s\n' "$d" done < <( printf '%s\n' "$MCA_APPDIR" while IFS= read -r -d: d; do [[ -n $d ]] && printf '%s/applications\n' "${d%/}" done <<< "${dirs}:" mca_extra_desktop_dirs ) } # _mca_desktop_read # Every key the scan needs, in one pass and without a single fork. There are a # couple of hundred desktop entries on an ordinary system, and doing this with # one awk per key per file is the difference between a menu that redraws and a # menu that pauses. DE_TYPE='' DE_HIDDEN='' DE_EXEC='' DE_NAME='' DE_CATEGORIES='' DE_MIME='' DE_OURS='' _mca_desktop_read() { local file="$1" line ingroup=0 DE_TYPE=''; DE_HIDDEN=''; DE_EXEC=''; DE_NAME='' DE_CATEGORIES=''; DE_MIME=''; DE_OURS='' while IFS= read -r line || [[ -n $line ]]; do case "$line" in '[Desktop Entry]'*) ingroup=1; continue ;; '['*) ingroup=0; continue ;; esac (( ingroup )) || continue # Locale variants are Name[de]= and never match these patterns, which # is what we want: the untranslated key is the identifying one. case "$line" in Exec=*) [[ -n $DE_EXEC ]] || DE_EXEC="${line#Exec=}" ;; Name=*) [[ -n $DE_NAME ]] || DE_NAME="${line#Name=}" ;; Type=*) DE_TYPE="${line#Type=}" ;; Hidden=*) DE_HIDDEN="${line#Hidden=}" ;; Categories=*) DE_CATEGORIES="${line#Categories=}" ;; MimeType=*) DE_MIME="${line#MimeType=}" ;; # Only a generated shadow. An entry we edited in place carries # X-MCA-Patched and is still the application's real entry, so it # has to stay in the scan. X-MCA-Generated=*) DE_OURS=1 ;; esac done < "$file" } # mca_exec_program # The program a desktop entry actually starts: the first token that is not an # environment prefix, resolved to an absolute path. The result is left in # MCA_PROG rather than printed. The scan calls this for every desktop entry on # the system, and a command substitution each time is a fork each time. # # Fails for entries this tool has no safe way to rewrite: anything routed # through a shell, where the real program is inside a quoted string. MCA_PROG='' mca_exec_program() { local line="$1" tok prog='' local -a tokens MCA_PROG='' # Field codes are placeholders, not arguments, and quotes only ever wrap # whole tokens here; splitting on whitespace is enough to find token one. read -r -a tokens <<< "$line" for tok in "${tokens[@]}"; do tok="${tok%\"}"; tok="${tok#\"}" tok="${tok%\'}"; tok="${tok#\'}" [[ -z $tok ]] && continue [[ $tok == *=* && $tok != /* ]] && continue # VAR=value prefix [[ $tok == env ]] && continue prog="$tok" break done [[ -n $prog ]] || return 1 case "${prog##*/}" in sh|bash|dash|zsh|fish) return 1 ;; esac # PATH is searched here rather than with `command -v`, which is a builtin # but would have to be read back through a command substitution, and that # is a fork per desktop entry. if [[ $prog != /* ]]; then local d found='' local -a pathdirs IFS=: read -r -a pathdirs <<< "$PATH" for d in "${pathdirs[@]}"; do [[ -n $d ]] || continue if [[ -x "$d/$prog" && ! -d "$d/$prog" ]]; then found="$d/$prog"; break; fi done [[ -n $found ]] || return 1 prog="$found" fi MCA_PROG="$prog" } # mca_exec_flatpak_id # The application id out of a `flatpak run ...` command line, in MCA_PROG. mca_exec_flatpak_id() { local line="$1" tok seen_run=0 local -a tokens read -r -a tokens <<< "$line" MCA_PROG='' for tok in "${tokens[@]}"; do if (( ! seen_run )); then [[ $tok == run ]] && seen_run=1 continue fi [[ $tok == -* || $tok == @@* || $tok == %* ]] && continue [[ $tok == *.*.* ]] || continue MCA_PROG="$tok" return 0 done return 1 } # mca_exec_is_steam_link # Whether an entry starts something inside Steam rather than starting Steam # itself: it carries a steam:// address of its own. Steam writes one of those # for every game somebody asks for a shortcut to, and the client's own entry # never has one. It takes an address from the outside, through %U. mca_exec_is_steam_link() { local line="$1" prog="$2" [[ $line == *steam://* ]] || return 1 mca_prog_is_steam "$prog" } # _mca_prog_is_steam_name # The launcher under one of the names Valve and the distributions give it. _mca_prog_is_steam_name() { case "${1##*/}" in steam|steam-runtime|steam-native|steam-jupiter) return 0 ;; esac return 1 } # _mca_prog_is_steam_wrapper # Whether a script in front of the client is a way of starting Steam. People # put one there to add a switch of their own, and an entry pointing at it is a # Steam start like any other. But the script is not named after Steam, so # nothing above recognises it. # # Getting this wrong is worse than it sounds: such a script tends to mention # the flag it is there to add, which is one of the markers that say "Chromium" # to the hint scan. The entry then ends up being handled as an application and # is given the flag on its command line, where Steam ignores it, instead of # being handed to the Steam module that knows how to reach the web helper. # # Only the handover is followed, and only one step of it: the client's own # launcher is already recognised by name, so a wrapper in front of it is the # whole of what is left. _mca_prog_is_steam_wrapper() { local prog="$1" head='' target [[ $prog == /* && -f $prog && -r $prog ]] || return 1 # Two characters, read in the shell: the scan asks this about every program # on the system, and most of them are binaries whose first line is the whole # file. read -r -N 2 head < "$prog" 2>/dev/null || return 1 [[ $head == '#!' ]] || return 1 target="$(_mca_script_target "$prog")" || return 1 [[ -n $target ]] || return 1 _mca_prog_is_steam_name "$target" } # mca_prog_is_steam # Whether running this program starts the Steam client. Every packaging is in # here and every name Valve and the distributions give the launcher, because # the answer decides whether an entry gets Steam's own switch. An entry that # starts Steam without it undoes the web helper patch on the way up. # # The program is what a scan leaves behind: an absolute path for a native # install, flatpak: or snap: for the other two. mca_prog_is_steam() { local prog="$1" _mca_prog_is_steam_name "$prog" && return 0 [[ $prog == flatpak:com.valvesoftware.Steam || $prog == snap:steam ]] && return 0 _mca_prog_is_steam_wrapper "$prog" } # --------------------------------------------------------------------------- # Is this Chromium? # --------------------------------------------------------------------------- # Answers are cached against size and mtime, because the systemd path unit can # fire several times in a row while a package installs and each miss costs a # scan of a 200 MB binary. # # The file is read once into memory rather than searched per lookup: a scan # asks about every program on the system, and an awk per question is most of # the time the scan takes. declare -A MCA_DETECT_MEMO=() declare -A MCA_DETECT_CACHE=() MCA_CACHE_LOADED=0 MCA_CACHE_DIRTY=0 # Size and mtime for every program the scan is about to ask about, collected in # one call. Checking a cache entry is still stale needs a stat, and one stat per # program on the system was most of what a warm scan spent its time on. declare -A MCA_STAT=() _mca_stat_batch() { local name st (( $# )) || return 0 while IFS=$'\t' read -r name st; do [[ -n $name ]] && MCA_STAT["$name"]="$st" done < <(stat -Lc '%n %s:%Y' -- "$@" 2>/dev/null) return 0 } # The first line of the cache file, and the reason it is there: the verdicts # below it are keyed on size and mtime, so an entry for a file that has not # changed is never looked at again. A cache written when a verdict meant # something else (before an AppImage could come out as anything but "no") # would therefore keep answering the old way forever. # # So the version is part of it, and every update drops the cache. The rules # change far more often than anyone remembers to bump a number by hand: v1.5.3 # taught the hint scan about Discord's launcher, and Discord stayed out of the # list anyway, because /usr/bin/discord had not changed and its old "no" was # still in here. MCA_CACHE_FORMAT="# middleclick-autoscroll detect 2 $MCA_VERSION" _mca_cache_load() { local path stamp verdict first=1 (( MCA_CACHE_LOADED )) && return 0 MCA_CACHE_LOADED=1 [[ -r "$MCA_CACHEDIR/detect" ]] || return 0 while IFS=$'\t' read -r path stamp verdict; do if (( first )); then first=0 [[ $path == "$MCA_CACHE_FORMAT" ]] || return 0 continue fi [[ -n $path && -n $stamp ]] || continue MCA_DETECT_CACHE["$path"]="$stamp"$'\t'"$verdict" done < "$MCA_CACHEDIR/detect" return 0 } # Written back once, at exit, instead of after every miss. mca_cache_flush() { local path entry tmp (( MCA_CACHE_DIRTY )) || return 0 mkdir -p "$MCA_CACHEDIR" 2>/dev/null || return 0 tmp="$(mktemp "$MCA_CACHEDIR/detect.XXXXXX")" || return 0 printf '%s\n' "$MCA_CACHE_FORMAT" > "$tmp" for path in "${!MCA_DETECT_CACHE[@]}"; do entry="${MCA_DETECT_CACHE[$path]}" printf '%s\t%s\n' "$path" "$entry" >> "$tmp" done mv -f "$tmp" "$MCA_CACHEDIR/detect" 2>/dev/null || rm -f "$tmp" MCA_CACHE_DIRTY=0 return 0 } # _mca_has_markers _mca_has_markers() { local dir="$1" m s [[ -d $dir ]] || return 1 dir="${dir%/}" for s in "${MCA_SYSTEM_DIRS[@]}"; do [[ $dir == "$s" ]] && return 1 done for s in "${MCA_SYSTEM_DIR_GLOBS[@]}"; do # Unquoted on purpose: these are patterns, not names. # shellcheck disable=SC2053 [[ $dir == $s ]] && return 1 done for m in "${MCA_MARKERS[@]}"; do [[ -e "$dir/$m" ]] && return 0 done [[ -e "$dir/resources/app.asar" || -e "$dir/app.asar" ]] && return 0 return 1 } # _mca_find_markers # The same question for a whole tree, which is the shape a Flatpak, a snap and # an unpacked AppImage come in: everything the application ships is somewhere # under one root and there is no single directory that is "next to the binary". # Hence the narrower list, see MCA_MARKERS_STRICT. _mca_find_markers() { local root="$1" depth="$2" m local -a names=() [[ -d $root ]] || return 1 for m in "${MCA_MARKERS_STRICT[@]}"; do (( ${#names[@]} )) && names+=(-o) names+=(-name "$m") done [[ -n "$(find "$root" -maxdepth "$depth" \ \( "${names[@]}" \) -print -quit 2>/dev/null)" ]] } # The plain assignments the script made before it handed over, for # _mca_script_subst to read. A variable of its own rather than something passed # around: every caller of _mca_script_target reads it through a command # substitution, so each call already works on a copy and there is nothing here # that two of them could collide over. declare -A MCA_SCRIPT_VARS=() # _mca_script_subst # The text with $NAME and ${NAME} replaced by what the script assigned to them, # left in MCA_SUBST. # # Fails as soon as something turns up that only a running shell could work out, # like a positional parameter, a name the script never set or a default value. # That is the point: an unresolvable path has to come out as no path at all, never # as a wrong one. MCA_SUBST='' _mca_script_subst() { local text="$1" out='' rest name while [[ $text == *'$'* ]]; do out+="${text%%\$*}" rest="${text#*\$}" if [[ $rest == '{'* ]]; then [[ $rest == *'}'* ]] || return 1 name="${rest%%\}*}"; name="${name#\{}" rest="${rest#*\}}" else name="${rest%%[!A-Za-z0-9_]*}" rest="${rest:${#name}}" fi [[ $name =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || return 1 [[ -n ${MCA_SCRIPT_VARS[$name]+set} ]] || return 1 out+="${MCA_SCRIPT_VARS[$name]}" text="$rest" done MCA_SUBST="$out$text" return 0 } # _mca_script_target