middleclick-autoscroll 1.0.0

Middle-click autoscroll for every Chromium-based application on the system:
Electron and CEF applications, Chromium-based browsers, Flatpaks, Spotify and
Steam, plus anything installed later.

The flag goes into an Arch wrapper's <name>-flags.conf where one exists, and
into a shadowing desktop entry where it does not. Steam gets its web helper
script patched and -noverifyfiles on its launcher, because it restores its own
files otherwise. Every change is recorded so it can be taken back exactly.
This commit is contained in:
Felitendo committed 2026-08-16 19:48:54 +02:00
commit d3c82fdff8
18 files changed
+4208

No files matched your search

+211
View File
@@ -0,0 +1,211 @@
# shellcheck shell=bash
#
# Deciding what happens to each application, and doing it.
#
# The routing lives in mca_route and is used twice: once by mca_apply to patch
# and once by the applications screen to show what would happen. Keeping it in
# one function is what makes that screen trustworthy - it is not a description
# of the behaviour, it is the behaviour.
MCA_CHANGES=0
MCA_TOUCHED=()
MCA_ROUTES=()
# mca_route <kind> <id> <program>
# What happens to one application, left in MCA_ROUTE:
# flags - the launcher reads a flag file; write it there
# desktop - no flag file; shadow or edit the desktop entry
# steam - Steam's own two-part treatment
# unknown - cannot tell what engine this is (an AppImage), so nothing is done
# off - detected, but switched off
#
# Assigns rather than prints so the memo inside mca_has_flags_file survives:
# from inside a command substitution it would be filled in a subshell and
# thrown away, which is the whole point of having it.
MCA_ROUTE=''
mca_route() {
local kind="$1" id="$2" prog="$3"
if [[ $kind == steam ]]; then
if mca_config_list_has Skip "$id" || [[ $CFG_STEAM != yes ]]; then
MCA_ROUTE=off
else
MCA_ROUTE=steam
fi
return
fi
if ! mca_kind_wanted "$kind" "$id"; then
[[ $kind == unknown ]] && MCA_ROUTE=unknown || MCA_ROUTE=off
return
fi
# A Flatpak has its own copy of everything and none of the host's wrappers,
# so the desktop entry is the only way in.
if [[ $kind == flatpak ]]; then
MCA_ROUTE=desktop
return
fi
if mca_has_flags_file "$prog"; then
MCA_ROUTE=flags
else
MCA_ROUTE=desktop
fi
}
# mca_apply
# Brings everything into line with the current settings. Safe to run as often
# as it likes to be - it writes only what differs, which is what keeps the
# watcher from chasing its own changes.
mca_apply() {
local i id file prog kind route steam_done=0
MCA_CHANGES=0
MCA_TOUCHED=()
MCA_ROUTES=()
MCA_N_ON=0; MCA_N_OFF=0; MCA_N_UNKNOWN=0
mca_config_load
mca_scan
for i in "${!MCA_IDS[@]}"; do
id="${MCA_IDS[i]}"
file="${MCA_FILES[i]}"
prog="${MCA_PROGS[i]}"
kind="${MCA_KINDS[i]}"
mca_route "$kind" "$id" "$prog"
route="$MCA_ROUTE"
MCA_ROUTES[i]="$route"
case "$route" in
unknown) MCA_N_UNKNOWN=$(( MCA_N_UNKNOWN + 1 )) ;;
off) MCA_N_OFF=$(( MCA_N_OFF + 1 )) ;;
*) MCA_N_ON=$(( MCA_N_ON + 1 )) ;;
esac
case "$route" in
flags)
mca_flags_apply "$prog" || mca_desktop_apply "$id" "$file"
;;
desktop)
mca_desktop_apply "$id" "$file"
;;
steam)
mca_steam_desktop_apply "$id" "$file"
if (( ! steam_done )); then
mca_steam_apply
steam_done=1
fi
;;
esac
done
# Steam is worth patching even when its desktop entry is missing - a user
# who starts it from a script or a game launcher still gets the interface.
if [[ $CFG_STEAM == yes ]] && (( ! steam_done )) && mca_steam_installed; then
mca_steam_apply
fi
[[ $CFG_AUTOSTART == yes ]] && mca_autostart_apply
[[ $CFG_SPOTIFY == yes ]] && mca_spotify_apply
if (( MCA_CHANGES )) && mca_have update-desktop-database; then
update-desktop-database "$MCA_APPDIR" 2>/dev/null || true
fi
mca_state_write last_apply "$(date +%s)"
mca_state_write covered "${#MCA_TOUCHED[@]}"
return 0
}
# mca_revert
# Undoes every recorded change and forgets them.
mca_revert() {
MCA_CHANGES=0
mca_revert_all
if (( MCA_CHANGES )) && mca_have update-desktop-database; then
update-desktop-database "$MCA_APPDIR" 2>/dev/null || true
fi
mca_state_write covered 0
return 0
}
# ---------------------------------------------------------------------------
# The watcher
# ---------------------------------------------------------------------------
# A systemd user path unit watching every directory a desktop entry can appear
# in. That covers a package installed with pacman, a Flatpak, an AppImage
# registered by hand and a Steam client update, without a hook per package
# manager.
MCA_UNIT_PATH="middleclick-autoscroll.path"
MCA_UNIT_SERVICE="middleclick-autoscroll.service"
mca_watch_available() {
mca_have systemctl && [[ -n ${XDG_RUNTIME_DIR:-} || -S "/run/user/$(id -u)/bus" ]]
}
mca_watch_enabled() {
mca_watch_available || return 1
systemctl --user is-enabled --quiet "$MCA_UNIT_PATH" 2>/dev/null
}
mca_watch_enable() {
mca_watch_available || return 1
systemctl --user enable --now "$MCA_UNIT_PATH" > /dev/null 2>&1 || return 1
systemctl --user enable "$MCA_UNIT_SERVICE" > /dev/null 2>&1 || true
return 0
}
mca_watch_disable() {
mca_watch_available || return 0
systemctl --user disable --now "$MCA_UNIT_PATH" > /dev/null 2>&1 || true
systemctl --user disable "$MCA_UNIT_SERVICE" > /dev/null 2>&1 || true
return 0
}
# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------
# mca_count_routes
# Fills MCA_N_ON / MCA_N_OFF / MCA_N_UNKNOWN from a scan, without writing
# anything. Used by the status block and the menu header.
MCA_N_ON=0
MCA_N_OFF=0
MCA_N_UNKNOWN=0
mca_count_routes() {
local i route
MCA_N_ON=0; MCA_N_OFF=0; MCA_N_UNKNOWN=0
MCA_ROUTES=()
for i in "${!MCA_IDS[@]}"; do
mca_route "${MCA_KINDS[i]}" "${MCA_IDS[i]}" "${MCA_PROGS[i]}"
route="$MCA_ROUTE"
MCA_ROUTES[i]="$route"
case "$route" in
unknown) MCA_N_UNKNOWN=$(( MCA_N_UNKNOWN + 1 )) ;;
off) MCA_N_OFF=$(( MCA_N_OFF + 1 )) ;;
*) MCA_N_ON=$(( MCA_N_ON + 1 )) ;;
esac
done
}
# mca_route_label <route>
mca_route_label() {
case "$1" in
flags) mca_msg "flag file" ;;
desktop) mca_msg "launcher" ;;
steam) mca_msg "Steam" ;;
unknown) mca_msg "cannot tell" ;;
*) mca_msg "off" ;;
esac
printf '\n'
}
+293
View File
@@ -0,0 +1,293 @@
# shellcheck shell=bash
#
# Paths, translations, output helpers and the change ledger.
#
# Everything here runs unprivileged. This tool only ever writes inside the
# user's own home - flag files, desktop entries, Steam's own scripts - so there
# is no system state to guard and nothing that needs root.
MCA_VERSION="@VERSION@"
MCA_NAME="middleclick-autoscroll"
MCA_PRETTY="Middle-Click Autoscroll"
# The whole point of the package. Blink implements Windows-style autoscroll
# behind a runtime flag that Chromium does not turn on for Linux, because
# middle click is taken by primary-selection paste there.
MCA_FLAG="--enable-blink-features=MiddleClickAutoscroll"
# The Blink feature name on its own, for merging into an --enable-blink-features
# list that an app (or the user) already carries.
MCA_FEATURE="MiddleClickAutoscroll"
MCA_LIBDIR="${MCA_LIBDIR:-@LIBDIR@}"
MCA_LOCALEDIR="${MCA_LOCALEDIR:-@LOCALEDIR@}"
MCA_XDG_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}"
MCA_XDG_DATA="${XDG_DATA_HOME:-$HOME/.local/share}"
MCA_XDG_STATE="${XDG_STATE_HOME:-$HOME/.local/state}"
MCA_XDG_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}"
MCA_CONFDIR="${MCA_CONFDIR:-${MCA_XDG_CONFIG}/${MCA_NAME}}"
MCA_CONFIG="${MCA_CONFIG:-${MCA_CONFDIR}/config}"
MCA_STATEDIR="${MCA_STATEDIR:-${MCA_XDG_STATE}/${MCA_NAME}}"
MCA_CACHEDIR="${MCA_CACHEDIR:-${MCA_XDG_CACHE}/${MCA_NAME}}"
# Where generated desktop entries go. A file here shadows the one with the same
# name in /usr/share/applications, which is how an application gets extra
# command line arguments without touching anything pacman owns.
MCA_APPDIR="${MCA_XDG_DATA}/applications"
# Copies of every file that is edited in place rather than shadowed.
MCA_BACKUPDIR="${MCA_STATEDIR}/backup"
# One line per change, so `revert` can undo exactly what was done and nothing
# else. See mca_ledger_add.
MCA_LEDGER="${MCA_STATEDIR}/ledger"
# ---------------------------------------------------------------------------
# Translations
# ---------------------------------------------------------------------------
export TEXTDOMAIN="middleclick-autoscroll"
export TEXTDOMAINDIR="${MCA_LOCALEDIR}"
mca_ui_locale() {
local l="${MCA_UI_LOCALE:-}"
if [[ -z $l ]]; then
l="${LC_ALL:-}"
[[ -z $l ]] && l="${LC_MESSAGES:-}"
[[ -z $l ]] && l="${LANG:-}"
fi
if [[ -z $l && -r /etc/locale.conf ]]; then
l="$(sed -n 's/^LANG=//p' /etc/locale.conf | tr -d '"' | head -n1)"
fi
printf '%s\n' "${l:-C}"
}
# Every gettext lookup is a fork and the settings screen redraws a screenful of
# labels per keypress, so results are memoized.
declare -A MCA_MSG_CACHE=()
MCA_MSG_RESULT=''
# mca_msg_into <locale> <msgid>
# Plain lookup with the result in MCA_MSG_RESULT and no printf formatting, for
# callers that would otherwise pay a fork per label per frame.
mca_msg_into() {
local locale="$1" msgid="$2" cachekey
cachekey="${locale}"$'\x1f'"${msgid}"
if [[ -n ${MCA_MSG_CACHE[$cachekey]+set} ]]; then
MCA_MSG_RESULT="${MCA_MSG_CACHE[$cachekey]}"
return 0
fi
MCA_MSG_RESULT="$(LC_ALL="$locale" LANGUAGE="${locale%%.*}" gettext -- "$msgid" 2>/dev/null)"
[[ -n $MCA_MSG_RESULT ]] || MCA_MSG_RESULT="$msgid"
MCA_MSG_CACHE[$cachekey]="$MCA_MSG_RESULT"
return 0
}
# mca_msg_in <locale> <msgid> [printf args...]
mca_msg_in() {
local locale="$1" msgid="$2" translated cachekey
shift 2
cachekey="${locale}"$'\x1f'"${msgid}"
if [[ -n ${MCA_MSG_CACHE[$cachekey]+set} ]]; then
translated="${MCA_MSG_CACHE[$cachekey]}"
else
translated="$(LC_ALL="$locale" LANGUAGE="${locale%%.*}" gettext -- "$msgid" 2>/dev/null)"
[[ -n $translated ]] || translated="$msgid"
MCA_MSG_CACHE[$cachekey]="$translated"
fi
# With no arguments the message is plain text, not a format string. Feeding
# it to printf anyway would turn a literal percent sign in a translation
# into an invalid conversion.
if (( $# == 0 )); then
printf '%s' "$translated"
return
fi
# shellcheck disable=SC2059 # the format string is the translated message
printf -- "$translated" "$@"
}
# mca_msg <msgid> [printf args...]
mca_msg() { mca_msg_in "$(mca_ui_locale)" "$@"; }
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
# Decided once, while stdout is still whatever the process was started with:
# testing -t 1 at the point of use is wrong for anything called through $(...),
# which sees a pipe and would conclude nobody is watching.
MCA_INTERACTIVE=''
[[ -t 1 ]] && MCA_INTERACTIVE=1
if [[ -n $MCA_INTERACTIVE && -z ${NO_COLOR:-} ]]; then
MCA_C_RESET=$'\033[0m'
MCA_C_BOLD=$'\033[1m'
MCA_C_DIM=$'\033[2m'
MCA_C_BLUE=$'\033[38;2;23;147;209m'
MCA_C_GREEN=$'\033[32m'
MCA_C_YELLOW=$'\033[33m'
MCA_C_RED=$'\033[31m'
else
MCA_C_RESET='' MCA_C_BOLD='' MCA_C_DIM='' MCA_C_BLUE=''
MCA_C_GREEN='' MCA_C_YELLOW='' MCA_C_RED=''
fi
# Set by mca_bad and mca_note. The menu redraws straight after an action, which
# would wipe the screen; this marks that something was printed the user still
# has to read. A plain success needs no acknowledgement - the status block at
# the top of the menu already shows the new state.
MCA_UI_NEEDS_ACK=''
# --quiet silences progress chatter; errors still go to stderr. Used by the
# systemd unit, which has no terminal and logs to the journal anyway.
MCA_QUIET=''
mca_say() { [[ -n $MCA_QUIET ]] || printf '%s\n' "$*"; }
mca_head() { printf '\n%s%s%s\n\n' "$MCA_C_BOLD$MCA_C_BLUE" "$*" "$MCA_C_RESET"; }
mca_ok() { [[ -n $MCA_QUIET ]] || printf '%s✔%s %s\n' "$MCA_C_GREEN" "$MCA_C_RESET" "$*"; }
mca_bad() { MCA_UI_NEEDS_ACK=1; printf '%s✘%s %s\n' "$MCA_C_RED" "$MCA_C_RESET" "$*" >&2; }
mca_note() { MCA_UI_NEEDS_ACK=1; [[ -n $MCA_QUIET ]] || printf '%s•%s %s\n' "$MCA_C_DIM" "$MCA_C_RESET" "$*"; }
mca_debug() { [[ -n ${MCA_DEBUG:-} ]] && printf 'debug: %s\n' "$*" >&2; return 0; }
mca_have() { command -v "$1" > /dev/null 2>&1; }
# Human-readable "x minutes ago" for a unix timestamp. Empty input yields the
# translated "never".
mca_time_ago() {
local ts="$1" now delta
[[ $ts =~ ^[0-9]+$ ]] || { mca_msg "never"; printf '\n'; return; }
now="$(date +%s)"
delta=$(( now - ts ))
(( delta < 0 )) && delta=0
if (( delta < 60 )); then mca_msg "just now"
elif (( delta < 3600 )); then mca_msg "%d minutes ago" "$(( delta / 60 ))"
elif (( delta < 86400 )); then mca_msg "%d hours ago" "$(( delta / 3600 ))"
else mca_msg "%d days ago" "$(( delta / 86400 ))"
fi
printf '\n'
}
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
mca_state_read() {
local key="$1" default="${2:-}"
if [[ -r "$MCA_STATEDIR/$key" ]]; then
cat "$MCA_STATEDIR/$key"
else
printf '%s\n' "$default"
fi
}
mca_state_write() {
local key="$1"
shift
mkdir -p "$MCA_STATEDIR" 2>/dev/null || return 1
printf '%s\n' "$*" > "$MCA_STATEDIR/$key"
}
# ---------------------------------------------------------------------------
# The change ledger
# ---------------------------------------------------------------------------
# Reverting by pattern - "delete every desktop entry that mentions the flag" -
# would also delete entries the user wrote by hand. So every change is recorded
# instead, and `revert` replays the ledger backwards.
#
# Format, tab separated:
# <kind> <path> <detail>
#
# shadow <generated desktop entry> <source entry it was generated from>
# inplace <edited file> <basename of its backup copy>
# flags <flag file> created | appended | merged
#
# `inplace` and `flags` differ in how they are undone: a backup is restored
# wholesale, a flag file only loses the one line that was added to it.
mca_ledger_add() {
local kind="$1" path="$2" detail="${3:-}"
mkdir -p "$MCA_STATEDIR" 2>/dev/null || return 1
# Never record the same path twice: applying repeatedly is normal (the path
# unit fires on every desktop file that appears) and the ledger has to stay
# a set, not a log.
mca_ledger_forget "$path"
printf '%s\t%s\t%s\n' "$kind" "$path" "$detail" >> "$MCA_LEDGER"
}
mca_ledger_forget() {
local path="$1" tmp
[[ -f $MCA_LEDGER ]] || return 0
tmp="$(mktemp "${MCA_LEDGER}.XXXXXX")" || return 1
awk -F'\t' -v p="$path" '$2 != p' "$MCA_LEDGER" > "$tmp" 2>/dev/null \
&& mv -f "$tmp" "$MCA_LEDGER" || rm -f "$tmp"
return 0
}
# mca_ledger_has <path>
mca_ledger_has() {
[[ -f $MCA_LEDGER ]] || return 1
awk -F'\t' -v p="$1" '$2 == p { found = 1 } END { exit !found }' "$MCA_LEDGER"
}
# mca_ledger_detail <path>
mca_ledger_detail() {
[[ -f $MCA_LEDGER ]] || return 1
awk -F'\t' -v p="$1" '$2 == p { print $3; exit }' "$MCA_LEDGER"
}
mca_ledger_count() {
[[ -f $MCA_LEDGER ]] || { printf '0\n'; return; }
grep -c . "$MCA_LEDGER" 2>/dev/null || printf '0\n'
}
# mca_backup <file>
# Copies a file aside before it is edited in place, and prints the name the
# copy was stored under. Existing backups are never overwritten: the first copy
# is the pristine one, and a second apply must not replace it with an already
# patched version.
mca_backup() {
local file="$1" name
name="$(printf '%s' "$file" | sed 's|/|%|g')"
mkdir -p "$MCA_BACKUPDIR" 2>/dev/null || return 1
if [[ ! -e "$MCA_BACKUPDIR/$name" ]]; then
cp -p -- "$file" "$MCA_BACKUPDIR/$name" 2>/dev/null || return 1
fi
printf '%s\n' "$name"
}
# mca_write_if_changed <path> <content>
# Writing a file that is already correct would touch its mtime, and the systemd
# path unit watches these directories: an unconditional write would retrigger
# the service, which would write again, forever.
mca_write_if_changed() {
local path="$1" content="$2" current=''
[[ -r $path ]] && current="$(< "$path")"
# $(< file) drops trailing newlines and callers pass content that ends in
# one, so both sides are trimmed before comparing. Getting this wrong makes
# every file look changed on every run, which is exactly the feedback loop
# this function exists to prevent.
[[ "${current%"${current##*[!$'\n']}"}" == "${content%"${content##*[!$'\n']}"}" ]] && return 1
mkdir -p "$(dirname "$path")" 2>/dev/null || return 2
printf '%s' "$content" > "$path" || return 2
return 0
}
+162
View File
@@ -0,0 +1,162 @@
# shellcheck shell=bash
#
# Reading and writing ~/.config/middleclick-autoscroll/config.
#
# The file exists so the tool has somewhere to remember what it was told; it is
# not meant to be edited. Every option it holds is reachable from the interface,
# which is why there is no commented template shipped anywhere and no reference
# to the path in the interface itself.
#
# It is parsed rather than sourced. One "Key=Value" per line, '#' comments.
MCA_CONFIG_CACHE=''
MCA_CONFIG_CACHED=0
_mca_config_slurp() {
(( MCA_CONFIG_CACHED )) && return 0
MCA_CONFIG_CACHE=''
[[ -r $MCA_CONFIG ]] && MCA_CONFIG_CACHE="$(< "$MCA_CONFIG")"
MCA_CONFIG_CACHED=1
return 0
}
# _mca_config_lookup <Key> [default]
# Result in MCA_CONFIG_VALUE. Assigning rather than printing matters on the
# settings screen, which reads every key on every frame: a command substitution
# there is a fork, and forks are the whole cost of a redraw.
MCA_CONFIG_VALUE=''
_mca_config_lookup() {
local key="$1" default="${2:-}" val='' line
MCA_CONFIG_VALUE="$default"
[[ -r $MCA_CONFIG ]] || return 0
_mca_config_slurp
while IFS= read -r line; do
[[ $line == *"$key"* ]] || continue
[[ $line =~ ^[[:space:]]*"$key"[[:space:]]*=(.*)$ ]] || continue
val="${BASH_REMATCH[1]}"
done <<< "$MCA_CONFIG_CACHE"
val="${val%%#*}"
val="${val#"${val%%[![:space:]]*}"}"
val="${val%"${val##*[![:space:]]}"}"
val="${val%\"}"
val="${val#\"}"
[[ -n $val ]] && MCA_CONFIG_VALUE="$val"
return 0
}
# mca_config_get <Key> [default]
mca_config_get() {
_mca_config_lookup "$@"
printf '%s\n' "$MCA_CONFIG_VALUE"
}
# mca_config_bool <Key> <default: yes|no>
mca_config_bool() {
local val
val="$(mca_config_get "$1" "$2")"
case "${val,,}" in
yes|y|true|1|on|enabled) return 0 ;;
*) return 1 ;;
esac
}
# mca_config_set <Key> <Value>
mca_config_set() {
local key="$1" value="$2" tmp
if [[ ! -e $MCA_CONFIG ]]; then
mkdir -p "$(dirname "$MCA_CONFIG")" || return 1
{
printf '# %s\n' "$MCA_PRETTY"
printf '#\n'
printf '# Written by `%s`. Nothing here needs editing by hand -\n' "$MCA_NAME"
printf '# every option is reachable from `%s` itself.\n' "$MCA_NAME"
} > "$MCA_CONFIG" || return 1
fi
[[ -w $MCA_CONFIG ]] || return 1
tmp="$(mktemp "${MCA_CONFIG}.XXXXXX")" || return 1
chmod --reference="$MCA_CONFIG" "$tmp" 2>/dev/null || chmod 0644 "$tmp"
if grep -qE "^[[:space:]]*#?[[:space:]]*${key}[[:space:]]*=" "$MCA_CONFIG"; then
awk -v key="$key" -v value="$value" '
!done && $0 ~ "^[[:space:]]*#?[[:space:]]*" key "[[:space:]]*=" {
print key "=" value; done = 1; next
}
# drop any further occurrences so the file cannot grow duplicates
$0 ~ "^[[:space:]]*" key "[[:space:]]*=" { next }
{ print }
' "$MCA_CONFIG" > "$tmp" || { rm -f "$tmp"; return 1; }
else
cat "$MCA_CONFIG" > "$tmp" || { rm -f "$tmp"; return 1; }
printf '%s=%s\n' "$key" "$value" >> "$tmp"
fi
mv -f "$tmp" "$MCA_CONFIG"
MCA_CONFIG_CACHED=0
}
# mca_config_list_has <Key> <item>
# The per-application overrides are stored as space separated lists.
mca_config_list_has() {
local list item="$2" entry
list="$(mca_config_get "$1" '')"
for entry in $list; do
[[ $entry == "$item" ]] && return 0
done
return 1
}
# mca_config_list_add <Key> <item> / mca_config_list_del <Key> <item>
mca_config_list_add() {
local key="$1" item="$2" list
mca_config_list_has "$key" "$item" && return 0
list="$(mca_config_get "$key" '')"
mca_config_set "$key" "${list:+$list }$item"
}
mca_config_list_del() {
local key="$1" item="$2" list entry out=''
list="$(mca_config_get "$key" '')"
for entry in $list; do
[[ $entry == "$item" ]] && continue
out="${out:+$out }$entry"
done
mca_config_set "$key" "$out"
}
# ---------------------------------------------------------------------------
# Resolved settings
# ---------------------------------------------------------------------------
mca_config_load() {
CFG_ENABLED=no; mca_config_bool Enabled no && CFG_ENABLED=yes
CFG_APPS=no; mca_config_bool PatchApps yes && CFG_APPS=yes
CFG_BROWSERS=no; mca_config_bool PatchBrowsers yes && CFG_BROWSERS=yes
CFG_FLATPAK=no; mca_config_bool PatchFlatpak yes && CFG_FLATPAK=yes
CFG_AUTOSTART=no; mca_config_bool PatchAutostart yes && CFG_AUTOSTART=yes
CFG_STEAM=no; mca_config_bool PatchSteam yes && CFG_STEAM=yes
CFG_SPOTIFY=no; mca_config_bool PatchSpotify yes && CFG_SPOTIFY=yes
CFG_WATCH=no; mca_config_bool WatchNewApps yes && CFG_WATCH=yes
CFG_EXTRA_FLAGS="$(mca_config_get ExtraFlags '')"
# Applications the user turned on or off by hand on the applications
# screen, by desktop file id. Include wins over detection, Skip wins over
# everything.
CFG_INCLUDE="$(mca_config_get Include '')"
CFG_SKIP="$(mca_config_get Skip '')"
}
# The full argument string that gets injected. Kept in one place so the flag
# file writer and the desktop entry writer cannot drift apart.
mca_flags() {
printf '%s' "$MCA_FLAG"
[[ -n ${CFG_EXTRA_FLAGS:-} ]] && printf ' %s' "$CFG_EXTRA_FLAGS"
printf '\n'
}
+571
View File
@@ -0,0 +1,571 @@
# 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
)
# 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 /opt
)
# 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 an Arch packaging habit and says nothing about the
# engine either, so it is not in here.
MCA_SCRIPT_HINTS='ELECTRON_|app\.asar|chrome-sandbox|libcef|enable-blink-features|ozone-platform-hint'
# ---------------------------------------------------------------------------
# Desktop entries
# ---------------------------------------------------------------------------
# The directories a desktop entry can come from, most specific first - which 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
printf '%s\n' "$MCA_APPDIR"
while IFS= read -r -d: d; do
[[ -n $d ]] && printf '%s/applications\n' "${d%/}"
done <<< "${dirs}:"
}
# mca_desktop_get <file> <key>
# A single value from the [Desktop Entry] group. Desktop Action groups repeat
# the same keys, and reading past the first group would pick up the wrong one.
mca_desktop_get() {
local file="$1" key="$2"
awk -v key="$key" '
/^[[:space:]]*\[/ { inentry = ($0 ~ /^[[:space:]]*\[Desktop Entry\][[:space:]]*$/); next }
inentry && index($0, key "=") == 1 { print substr($0, length(key) + 2); exit }
' "$file" 2>/dev/null
}
# _mca_desktop_read <file>
# 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=}" ;;
X-MCA-Generated=*) DE_OURS=1 ;;
esac
done < "$file"
}
# mca_exec_program <exec line>
# 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 <exec line>
# 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
}
# ---------------------------------------------------------------------------
# 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
}
_mca_cache_load() {
local path stamp verdict
(( MCA_CACHE_LOADED )) && return 0
MCA_CACHE_LOADED=1
[[ -r "$MCA_CACHEDIR/detect" ]] || return 0
while IFS=$'\t' read -r path stamp verdict; do
[[ -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
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 <directory>
_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 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_script_target <script>
# The program a wrapper script hands over to, so a chain like
# heroic -> electron43 -> /usr/lib/electron43/electron can be followed.
_mca_script_target() {
local script="$1" line tok
local -a tokens
while IFS= read -r line; do
[[ $line =~ ^[[:space:]]*exec[[:space:]]+(.*)$ ]] || continue
read -r -a tokens <<< "${BASH_REMATCH[1]}"
for tok in "${tokens[@]}"; do
[[ $tok == env ]] && continue
[[ $tok == *=* && $tok != /* ]] && continue
[[ $tok == -* ]] && continue
# Anything still carrying a shell variable cannot be resolved from
# the outside; the hint scan has to answer for those.
[[ $tok == *'$'* ]] && return 1
if [[ $tok == /* ]]; then
printf '%s\n' "$tok"
else
command -v "$tok" 2>/dev/null
fi
return $?
done
done < "$script"
return 1
}
# mca_flags_candidates <program>
# The flag files a launcher reads, in the order it reads them. Arch's Electron
# and Chromium wrappers all take extra arguments from
# $XDG_CONFIG_HOME/<name>-flags.conf, which is a far better place to inject than
# a desktop entry: it survives package updates and it applies to a launch from
# the terminal too.
mca_flags_candidates() {
local prog="$1" depth="${2:-0}" target
(( depth > 3 )) && return 0
[[ -r $prog ]] || return 0
head -c2 -- "$prog" 2>/dev/null | grep -q '#!' || return 0
grep -oE '[A-Za-z0-9_.+-]+-flags\.conf' -- "$prog" 2>/dev/null
# A wrapper that only execs another wrapper (heroic -> electron43) inherits
# that one's flag files, plus the specific name it builds from a variable.
if target="$(_mca_script_target "$prog")" && [[ -n $target ]]; then
printf '%s-flags.conf\n' "${target##*/}"
mca_flags_candidates "$target" $(( depth + 1 ))
fi
}
# mca_is_chromium <program>
# Succeeds when the program is a Chromium, CEF or Electron process.
mca_is_chromium() {
local prog="$1" verdict real stamp cached
[[ -n $prog && -e $prog ]] || return 1
# Memoized under the path as given, so the same launcher named twice in a
# scan costs nothing at all the second time.
if [[ -n ${MCA_DETECT_MEMO[$prog]+set} ]]; then
[[ ${MCA_DETECT_MEMO[$prog]} == yes ]]
return $?
fi
_mca_cache_load
if [[ -n ${MCA_STAT[$prog]+set} ]]; then
stamp="${MCA_STAT[$prog]}"
else
stamp="$(stat -Lc '%s:%Y' -- "$prog" 2>/dev/null)" || stamp=''
fi
if [[ -n $stamp && -n ${MCA_DETECT_CACHE[$prog]+set} ]]; then
cached="${MCA_DETECT_CACHE[$prog]}"
if [[ "${cached%%$'\t'*}" == "$stamp" ]]; then
verdict="${cached#*$'\t'}"
MCA_DETECT_MEMO[$prog]="$verdict"
[[ $verdict == yes ]]
return $?
fi
fi
real="$(readlink -f -- "$prog" 2>/dev/null)" || real="$prog"
verdict=no
if _mca_detect_uncached "$real"; then verdict=yes; fi
MCA_DETECT_MEMO[$prog]="$verdict"
if [[ -n $stamp ]]; then
MCA_DETECT_CACHE["$prog"]="$stamp"$'\t'"$verdict"
MCA_CACHE_DIRTY=1
fi
[[ $verdict == yes ]]
}
_mca_detect_uncached() {
local real="$1" depth="${2:-0}" dir target
(( depth > 3 )) && return 1
[[ -r $real ]] || return 1
if head -c2 -- "$real" 2>/dev/null | grep -q '#!'; then
# A launcher script. Following where it hands over is the reliable
# answer; the hint scan catches the ones that build the command line
# out of variables (vesktop, discord and most vendor wrappers).
if target="$(_mca_script_target "$real")" && [[ -n $target ]]; then
_mca_detect_uncached "$(readlink -f -- "$target" 2>/dev/null || printf '%s' "$target")" \
$(( depth + 1 )) && return 0
fi
grep -qE "$MCA_SCRIPT_HINTS" -- "$real" 2>/dev/null && return 0
return 1
fi
# A binary. Everything Chromium ships is unpacked next to it, either in the
# same directory or - for /opt/thing/bin/Thing layouts - one level up.
dir="$(dirname -- "$real")"
_mca_has_markers "$dir" && return 0
[[ ${dir##*/} == bin ]] && _mca_has_markers "${dir%/*}" && return 0
# Last resort: Chromium's own argument table is in the binary. -m1 stops at
# the first hit, so this reads far less than the file size suggests.
#
# AppImages are the one thing this cannot see through - their payload is a
# compressed filesystem - which is why they come out as unknown and are
# left to the applications screen.
grep -qaFm1 -- 'enable-blink-features' "$real" 2>/dev/null && return 0
grep -qaFm1 -- 'CHROME_VERSION_EXTRA' "$real" 2>/dev/null && return 0
return 1
}
# ---------------------------------------------------------------------------
# The scan
# ---------------------------------------------------------------------------
# Results land in parallel arrays rather than being printed, so the caller can
# use them for both patching and the applications screen without scanning twice.
MCA_IDS=() # desktop file id, without the .desktop suffix
MCA_FILES=() # the desktop entry that is in effect for that id
MCA_NAMES=() # display name
MCA_PROGS=() # resolved program, or a flatpak app id
MCA_KINDS=() # app | browser | flatpak | steam | unknown | no
# A scan reads every desktop entry on the system, so the menu does it once and
# then redraws from what it found. Anything that changes the answer - applying,
# reverting - invalidates it explicitly.
MCA_SCANNED=0
mca_scan_once() {
(( MCA_SCANNED )) && return 0
mca_scan
}
mca_scan_invalidate() { MCA_SCANNED=0; }
mca_scan() {
local dir file id name exec_line prog kind i
local -A seen=()
local -a c_ids=() c_files=() c_names=() c_progs=() c_browser=() c_stat=()
MCA_IDS=(); MCA_FILES=(); MCA_NAMES=(); MCA_PROGS=(); MCA_KINDS=()
# Pass one: read the entries and work out what each of them starts. No
# detection yet - that needs a stat per program, and those are collected so
# they can be asked for all at once.
while IFS= read -r dir; do
[[ -d $dir ]] || continue
for file in "$dir"/*.desktop; do
[[ -f $file ]] || continue
id="${file##*/}"; id="${id%.desktop}"
[[ -n ${seen[$id]+set} ]] && continue
seen[$id]=1
_mca_desktop_read "$file"
# One of our own generated entries. It describes the same
# application as the system one it shadows, so it is skipped and
# the id left free for the original further down the search path.
[[ -n $DE_OURS ]] && { unset "seen[$id]"; continue; }
[[ $DE_TYPE == Application ]] || continue
[[ $DE_HIDDEN == true ]] && continue
exec_line="$DE_EXEC"
[[ -n $exec_line ]] || continue
name="$DE_NAME"
[[ -n $name ]] || name="$id"
mca_exec_program "$exec_line" || continue
prog="$MCA_PROG"
if [[ ${prog##*/} == flatpak ]]; then
mca_exec_flatpak_id "$exec_line" || continue
prog="flatpak:$MCA_PROG"
fi
c_ids+=("$id"); c_files+=("$file"); c_names+=("$name")
c_progs+=("$prog")
if mca_desktop_is_browser; then c_browser+=(1); else c_browser+=(0); fi
[[ $prog == /* ]] && c_stat+=("$prog")
done
done < <(mca_desktop_dirs)
MCA_STAT=()
_mca_stat_batch "${c_stat[@]}"
# Pass two: decide what each one is.
for i in "${!c_ids[@]}"; do
prog="${c_progs[i]}"
kind=no
if [[ $prog == flatpak:* ]]; then
prog="${prog#flatpak:}"
mca_flatpak_is_chromium "$prog" && kind=flatpak
elif [[ ${prog##*/} == steam || ${prog##*/} == steam-runtime ]]; then
# Steam is Chromium inside, but nothing about it can be changed
# from a command line argument; it has its own module.
kind=steam
elif mca_is_chromium "$prog"; then
(( c_browser[i] )) && kind=browser || kind=app
elif [[ $prog == *.AppImage || $prog == *.appimage ]]; then
kind=unknown
fi
[[ $kind == no ]] && continue
MCA_IDS+=("${c_ids[i]}")
MCA_FILES+=("${c_files[i]}")
MCA_NAMES+=("${c_names[i]}")
MCA_PROGS+=("$prog")
MCA_KINDS+=("$kind")
done
mca_cache_flush
MCA_SCANNED=1
}
# mca_has_flags_file <program>
# Whether the program's launcher reads a flag file. Memoized: the status block
# asks this for every application it lists, and answering it means reading the
# launcher script.
declare -A MCA_FLAGS_MEMO=()
mca_has_flags_file() {
local prog="$1"
if [[ -z ${MCA_FLAGS_MEMO[$prog]+set} ]]; then
if [[ -n "$(mca_flags_candidates "$prog")" ]]; then
MCA_FLAGS_MEMO[$prog]=yes
else
MCA_FLAGS_MEMO[$prog]=no
fi
fi
[[ ${MCA_FLAGS_MEMO[$prog]} == yes ]]
}
# A browser is anything that offers itself for http. That is the property that
# matters here: those are the applications where middle click currently pastes
# a URL, so a user may well want them left alone.
#
# Reads the keys _mca_desktop_read left behind, so it only makes sense straight
# after that call.
mca_desktop_is_browser() {
[[ $DE_CATEGORIES == *WebBrowser* ]] && return 0
[[ $DE_MIME == *x-scheme-handler/http* ]] && return 0
return 1
}
# mca_flatpak_is_chromium <app id>
# Flatpak keeps every application in its own tree, so the marker check works the
# same way once that tree has been located.
mca_flatpak_is_chromium() {
local id="$1" loc
mca_have flatpak || return 1
if [[ -n ${MCA_DETECT_MEMO[flatpak:$id]+set} ]]; then
[[ ${MCA_DETECT_MEMO[flatpak:$id]} == yes ]]
return $?
fi
loc="$(flatpak info --show-location "$id" 2>/dev/null)"
if [[ -n $loc && -d "$loc/files" ]] \
&& [[ -n "$(find "$loc/files" -maxdepth 4 \
\( -name 'chrome_crashpad_handler' -o -name 'app.asar' \
-o -name 'libcef.so' -o -name 'v8_context_snapshot.bin' \) \
-print -quit 2>/dev/null)" ]]
then
MCA_DETECT_MEMO[flatpak:$id]=yes
return 0
fi
MCA_DETECT_MEMO[flatpak:$id]=no
return 1
}
# mca_kind_wanted <kind> <id>
# Whether the current settings say this entry should be patched. Skip beats
# everything, an explicit include beats detection, and detection beats nothing.
mca_kind_wanted() {
local kind="$1" id="$2"
mca_config_list_has Skip "$id" && return 1
mca_config_list_has Include "$id" && return 0
case "$kind" in
app) [[ $CFG_APPS == yes ]] ;;
browser) [[ $CFG_BROWSERS == yes ]] ;;
flatpak) [[ $CFG_FLATPAK == yes && $CFG_APPS == yes ]] ;;
*) return 1 ;;
esac
}
+523
View File
@@ -0,0 +1,523 @@
# shellcheck shell=bash
#
# The interactive front end.
#
# This is the whole configuration interface. There is a file behind it, but no
# part of the program ever asks anyone to open it: one switch on the front
# screen, a settings list for the categories, and a list of the applications
# themselves for the cases where the automatic answer is not the wanted one.
# Terminal mode.
#
# bash flips the terminal into non-canonical mode for each `read -sn1` and back
# out again in between. That gap matters: in canonical mode DEL is the ERASE
# character, so the line discipline eats it instead of delivering it, and a
# backspace typed while the interface was between reads simply vanishes.
# Holding non-canonical mode for the whole interface removes the gap.
MCA_TERM_SAVED=''
mca_ui_term_raw() {
mca_have stty || return 0
[[ -t 0 ]] || return 0
[[ -n $MCA_TERM_SAVED ]] && return 0
MCA_TERM_SAVED="$(stty -g 2>/dev/null)" || { MCA_TERM_SAVED=''; return 0; }
stty -icanon -echo min 1 time 0 2>/dev/null || true
}
mca_ui_term_restore() {
[[ -n $MCA_TERM_SAVED ]] || return 0
stty "$MCA_TERM_SAVED" 2>/dev/null || true
MCA_TERM_SAVED=''
}
# Runs an action with the terminal handed back to normal line mode, so anything
# it prints - or prompts for - behaves the way a program expects.
mca_ui_cooked() {
mca_ui_term_restore
"$@"
local rc=$?
mca_ui_term_raw
return $rc
}
# mca_read_key
# One keypress, resolved to a symbolic name. Arrow keys arrive as ESC [ A, so
# the tail of the sequence is consumed here rather than being mistaken for
# three separate presses.
mca_read_key() {
local k rest
IFS= read -rsn1 k || return 1
case "$k" in
$'\e')
if IFS= read -rsn2 -t 0.05 rest; then
case "$rest" in
'[A') printf 'up\n' ;;
'[B') printf 'down\n' ;;
'[C') printf 'right\n' ;;
'[D') printf 'left\n' ;;
*) printf 'escape\n' ;;
esac
else
printf 'escape\n'
fi
;;
''|$'\r') printf 'enter\n' ;;
$'\x7f'|$'\b') printf 'backspace\n' ;;
' ') printf 'space\n' ;;
*) printf '%s\n' "$k" ;;
esac
}
# mca_ui_read_line <initial>
# A minimal line editor built on mca_read_key, with the result in
# MCA_LINE_RESULT. This exists instead of bash's own `read -r` because mixing
# line mode into a single-key interface breaks it: after one cooked-mode read
# the following `read -sn1` stops receiving keystrokes entirely.
MCA_LINE_RESULT=''
mca_ui_read_line() {
local buf="${1:-}" key
MCA_LINE_RESULT=''
printf '%s' "$buf"
while true; do
key="$(mca_read_key)" || { printf '\n'; return 1; }
case "$key" in
enter)
printf '\n'
MCA_LINE_RESULT="$buf"
return 0
;;
escape)
printf '\n'
return 1
;;
backspace)
if [[ -n $buf ]]; then
buf="${buf%?}"
printf '\b \b'
fi
;;
space)
buf+=' '
printf ' '
;;
up|down|left|right) ;;
*)
[[ ${#key} -eq 1 ]] || continue
buf+="$key"
printf '%s' "$key"
;;
esac
done
}
mca_pause() {
printf '\n %s' "$(mca_msg "Press any key to continue...")"
read -rsn1 _ || true
printf '\n'
}
# _mca_row <label> <value>
# printf's %-28s pads by bytes, so a label containing "ü" comes out one column
# short. ${#s} counts characters in a UTF-8 locale, so the padding is computed
# here instead - and applied inline, because command substitution would eat the
# trailing spaces again.
_mca_row() {
local label="$1" value="$2" pad
pad=$(( 30 - ${#label} ))
(( pad < 0 )) && pad=0
printf ' %s%*s %s\n' "$label" "$pad" '' "$value"
}
_mca_onoff() {
if [[ $1 == yes ]]; then
printf '%s%s%s' "$MCA_C_GREEN" "$(mca_msg "ON")" "$MCA_C_RESET"
else
printf '%s%s%s' "$MCA_C_DIM" "$(mca_msg "OFF")" "$MCA_C_RESET"
fi
}
# ---------------------------------------------------------------------------
# Status
# ---------------------------------------------------------------------------
# mca_ui_status
# Shared by the `status` subcommand and the menu header. Scans, so the numbers
# describe what is on disk right now rather than what was true at the last run.
mca_ui_status() {
local last covered
mca_scan_once
mca_count_routes
last="$(mca_state_read last_apply '')"
_mca_row "$(mca_msg "Autoscroll")" "$(_mca_onoff "$CFG_ENABLED")"
printf '\n'
if [[ $CFG_ENABLED == yes ]]; then
covered="$MCA_N_ON"
else
covered=0
fi
_mca_row "$(mca_msg "Applications covered")" \
"$(mca_msg "%d of %d" "$covered" "$(( MCA_N_ON + MCA_N_OFF + MCA_N_UNKNOWN ))")"
if (( MCA_N_UNKNOWN )); then
_mca_row "$(mca_msg "Not identified")" \
"${MCA_C_DIM}$(mca_msg "%d - see the applications list" "$MCA_N_UNKNOWN")${MCA_C_RESET}"
fi
if mca_steam_installed; then
if mca_steam_patched; then
_mca_row "$(mca_msg "Steam")" "$(_mca_onoff yes)"
elif [[ $CFG_STEAM == yes && $CFG_ENABLED == yes ]]; then
_mca_row "$(mca_msg "Steam")" \
"${MCA_C_YELLOW}$(mca_msg "not patched yet")${MCA_C_RESET}"
else
_mca_row "$(mca_msg "Steam")" "$(_mca_onoff no)"
fi
fi
if mca_watch_available; then
if mca_watch_enabled; then
_mca_row "$(mca_msg "New applications")" "$(_mca_onoff yes)"
else
_mca_row "$(mca_msg "New applications")" "$(_mca_onoff no)"
fi
fi
_mca_row "$(mca_msg "Last applied")" "$(mca_time_ago "$last")"
if [[ $CFG_ENABLED == yes ]]; then
printf '\n %s%s%s\n' "$MCA_C_DIM" \
"$(mca_msg "Applications pick this up the next time they are started.")" \
"$MCA_C_RESET"
fi
}
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
# Format: Key|type|default|label-msgid
# type is bool or text.
MCA_SETTINGS=(
"PatchApps|bool|yes|Electron and CEF applications"
"PatchBrowsers|bool|yes|Chromium-based browsers"
"PatchFlatpak|bool|yes|Flatpak applications"
"PatchAutostart|bool|yes|Programs that start themselves at login"
"PatchSteam|bool|yes|Steam"
"PatchSpotify|bool|yes|Spotify"
"WatchNewApps|bool|yes|Apply to newly installed applications"
"ExtraFlags|text||Additional Chromium arguments"
)
_mca_is_true() {
case "${1,,}" in
yes|y|true|1|on|enabled) return 0 ;;
*) return 1 ;;
esac
}
MCA_SETTING_SHOWN=''
MCA_LBL_ON=''
MCA_LBL_OFF=''
MCA_LBL_NONE=''
_mca_setting_display() {
local type="$1" value="$2"
case "$type" in
bool)
if _mca_is_true "$value"; then
MCA_SETTING_SHOWN="${MCA_C_GREEN}${MCA_LBL_ON}${MCA_C_RESET}"
else
MCA_SETTING_SHOWN="${MCA_C_DIM}${MCA_LBL_OFF}${MCA_C_RESET}"
fi
;;
text)
if [[ -n $value ]]; then
MCA_SETTING_SHOWN="$value"
else
MCA_SETTING_SHOWN="${MCA_C_DIM}${MCA_LBL_NONE}${MCA_C_RESET}"
fi
;;
*) MCA_SETTING_SHOWN="$value" ;;
esac
}
# mca_ui_settings
# A cursor list rather than a numbered menu. The frame is assembled in memory
# and written once, and everything constant is resolved before the loop:
# drawing the naive way costs a command substitution per label per frame, which
# is slow enough that arrow keys feel like the console is reloading.
#
# Returns 0 when something was changed, so the caller knows to re-apply.
mca_ui_settings() {
local count=${#MCA_SETTINGS[@]}
local -a names=() types=() defaults=() labels=() values=()
local spec name type default label locale i key frame row pad dirty=1 cursor=0
local touched=0
locale="$(mca_ui_locale)"
mca_msg_into "$locale" "ON"; MCA_LBL_ON="$MCA_MSG_RESULT"
mca_msg_into "$locale" "OFF"; MCA_LBL_OFF="$MCA_MSG_RESULT"
mca_msg_into "$locale" "(none)"; MCA_LBL_NONE="$MCA_MSG_RESULT"
for spec in "${MCA_SETTINGS[@]}"; do
IFS='|' read -r name type default label <<< "$spec"
names+=("$name"); types+=("$type"); defaults+=("$default")
mca_msg_into "$locale" "$label"
labels+=("$MCA_MSG_RESULT")
done
local title hint
mca_msg_into "$locale" "Settings"; title="$MCA_MSG_RESULT"
mca_msg_into "$locale" "Up/Down select - Space or Right changes - q goes back"
hint="$MCA_MSG_RESULT"
local clearseq
clearseq="$(clear 2>/dev/null)" || clearseq=$'\033[H\033[2J'
while true; do
if (( dirty )); then
for i in "${!names[@]}"; do
_mca_config_lookup "${names[i]}" "${defaults[i]}"
values[i]="$MCA_CONFIG_VALUE"
done
dirty=0
fi
frame="$clearseq"$'\n'"${MCA_C_BOLD}${MCA_C_BLUE} ${title}${MCA_C_RESET}"$'\n\n'
local marker selected="${MCA_C_BLUE}▸${MCA_C_RESET} "
for i in "${!names[@]}"; do
_mca_setting_display "${types[i]}" "${values[i]}"
pad=$(( 46 - ${#labels[i]} ))
(( pad < 0 )) && pad=0
if (( i == cursor )); then marker="$selected"; else marker=' '; fi
printf -v row ' %s%s%*s %s' \
"$marker" "${labels[i]}" "$pad" '' "$MCA_SETTING_SHOWN"
frame+="$row"$'\n'
done
frame+=$'\n'" ${MCA_C_DIM}${hint}${MCA_C_RESET}"$'\n'
printf '%s' "$frame"
key="$(mca_read_key)" || return "$(( ! touched ))"
type="${types[cursor]}"
name="${names[cursor]}"
case "$key" in
up|k) cursor=$(( (cursor - 1 + count) % count )) ;;
down|j) cursor=$(( (cursor + 1) % count )) ;;
space|enter|right|l|left|h)
if [[ $type == text ]]; then
[[ $key == left || $key == h ]] && continue
mca_ui_edit_text "$name" "${values[cursor]}" && touched=1
else
if _mca_is_true "${values[cursor]}"; then
mca_config_set "$name" no
else
mca_config_set "$name" yes
fi || { mca_bad "$(mca_msg "Could not save the setting.")"; mca_pause; }
touched=1
fi
dirty=1
;;
q|Q|escape) return "$(( ! touched ))" ;;
*) ;;
esac
done
}
# mca_ui_edit_text <key> <current>
mca_ui_edit_text() {
local name="$1" current="$2"
printf '\n %s\n' "$(mca_msg "Arguments separated by spaces, empty for none:")"
printf ' > '
if mca_ui_read_line "$current"; then
mca_config_set "$name" "$MCA_LINE_RESULT" \
|| { mca_bad "$(mca_msg "Could not save the setting.")"; mca_pause; }
return 0
fi
return 1
}
# ---------------------------------------------------------------------------
# Applications
# ---------------------------------------------------------------------------
# mca_ui_apps
# Every application that was found, what will happen to it, and a key to
# change that. This is where an AppImage that could not be identified gets
# turned on, and where a single application gets left out without having to
# switch off its whole category.
#
# Returns 0 when something was changed.
mca_ui_apps() {
local count key frame row pad i cursor=0 dirty=1 touched=0 locale
local -a labels=() states=()
locale="$(mca_ui_locale)"
local title hint legend
mca_msg_into "$locale" "Applications"; title="$MCA_MSG_RESULT"
mca_msg_into "$locale" "Up/Down select - Space turns one on or off - q goes back"
hint="$MCA_MSG_RESULT"
mca_msg_into "$locale" "Anything not identified is left alone until it is turned on here."
legend="$MCA_MSG_RESULT"
local clearseq
clearseq="$(clear 2>/dev/null)" || clearseq=$'\033[H\033[2J'
printf '%s\n %s\n' "$clearseq" "$(mca_msg "Looking at the installed applications...")"
mca_config_load
mca_scan_once
count=${#MCA_IDS[@]}
if (( count == 0 )); then
printf '\n %s\n' "$(mca_msg "No Chromium-based applications found.")"
mca_pause
return 1
fi
while true; do
if (( dirty )); then
mca_config_load
mca_count_routes
labels=(); states=()
for i in "${!MCA_IDS[@]}"; do
labels+=("${MCA_NAMES[i]}")
states+=("${MCA_ROUTES[i]}")
done
dirty=0
fi
frame="$clearseq"$'\n'"${MCA_C_BOLD}${MCA_C_BLUE} ${title}${MCA_C_RESET}"$'\n\n'
local marker selected="${MCA_C_BLUE}▸${MCA_C_RESET} " shown
for i in "${!labels[@]}"; do
case "${states[i]}" in
off) shown="${MCA_C_DIM}$(mca_msg "off")${MCA_C_RESET}" ;;
unknown) shown="${MCA_C_DIM}$(mca_msg "cannot tell")${MCA_C_RESET}" ;;
steam) shown="${MCA_C_GREEN}$(mca_msg "on")${MCA_C_RESET} ${MCA_C_DIM}($(mca_msg "Steam"))${MCA_C_RESET}" ;;
flags) shown="${MCA_C_GREEN}$(mca_msg "on")${MCA_C_RESET} ${MCA_C_DIM}($(mca_msg "flag file"))${MCA_C_RESET}" ;;
*) shown="${MCA_C_GREEN}$(mca_msg "on")${MCA_C_RESET} ${MCA_C_DIM}($(mca_msg "launcher"))${MCA_C_RESET}" ;;
esac
pad=$(( 34 - ${#labels[i]} ))
(( pad < 0 )) && pad=0
if (( i == cursor )); then marker="$selected"; else marker=' '; fi
printf -v row ' %s%s%*s %s' "$marker" "${labels[i]}" "$pad" '' "$shown"
frame+="$row"$'\n'
done
frame+=$'\n'
# Without this the list reads as a list of what is switched on, which
# it is not while the whole thing is off.
if [[ $CFG_ENABLED != yes ]]; then
frame+=" ${MCA_C_YELLOW}$(mca_msg "Autoscroll is off - this is what would be covered.")${MCA_C_RESET}"$'\n'
fi
frame+=" ${MCA_C_DIM}${legend}${MCA_C_RESET}"$'\n'
frame+=" ${MCA_C_DIM}${hint}${MCA_C_RESET}"$'\n'
printf '%s' "$frame"
key="$(mca_read_key)" || return "$(( ! touched ))"
case "$key" in
up|k) cursor=$(( (cursor - 1 + count) % count )) ;;
down|j) cursor=$(( (cursor + 1) % count )) ;;
space|enter|right|left|l|h)
case "${states[cursor]}" in
off|unknown)
mca_config_list_del Skip "${MCA_IDS[cursor]}"
mca_config_list_add Include "${MCA_IDS[cursor]}"
;;
*)
mca_config_list_del Include "${MCA_IDS[cursor]}"
mca_config_list_add Skip "${MCA_IDS[cursor]}"
;;
esac
touched=1
dirty=1
;;
q|Q|escape) return "$(( ! touched ))" ;;
*) ;;
esac
done
}
# ---------------------------------------------------------------------------
# The menu
# ---------------------------------------------------------------------------
mca_ui_menu() {
local choice
mca_ui_term_raw
trap 'mca_ui_term_restore' EXIT INT TERM
while true; do
mca_config_load
clear 2>/dev/null || true
mca_head " $MCA_PRETTY"
mca_ui_status
printf '\n'
printf ' [1] %s\n' "$(mca_msg "Turn autoscroll on or off")"
printf ' [2] %s\n' "$(mca_msg "Apply now")"
printf ' [3] %s\n' "$(mca_msg "Applications")"
printf ' [4] %s\n' "$(mca_msg "Settings")"
printf ' [q] %s\n' "$(mca_msg "Quit")"
printf '\n > '
choice="$(mca_read_key)" || {
printf '\n'; mca_ui_term_restore; trap - EXIT INT TERM; return 0
}
case "$choice" in
enter|space|up|down|left|right|escape) choice='' ;;
esac
printf '%s\n' "$choice"
case "$choice" in
1)
MCA_UI_NEEDS_ACK=''
if [[ $CFG_ENABLED == yes ]]; then
mca_ui_cooked mca_do_disable
else
mca_ui_cooked mca_do_enable
fi
[[ -n $MCA_UI_NEEDS_ACK ]] && mca_pause
;;
2)
mca_ui_cooked mca_do_apply
mca_pause
;;
3)
if mca_ui_apps; then
[[ $CFG_ENABLED == yes ]] && mca_ui_cooked mca_do_apply --changed
fi
;;
4)
if mca_ui_settings; then
[[ $CFG_ENABLED == yes ]] && mca_ui_cooked mca_do_apply --changed
fi
;;
q|Q) mca_ui_term_restore; trap - EXIT INT TERM; return 0 ;;
# Anything else - Enter, arrow keys, stray characters - just
# redraws. Escape is deliberately not a quit key, so a mistyped
# arrow key cannot close the menu.
*) ;;
esac
done
}
+466
View File
@@ -0,0 +1,466 @@
# shellcheck shell=bash
#
# Putting the flag where the application will read it, and taking it back out.
#
# There are two ways in, and the order matters:
#
# 1. A flag file. Arch's Electron and Chromium wrappers read extra arguments
# from $XDG_CONFIG_HOME/<name>-flags.conf. This is the good one - it is the
# supported way to pass arguments, it survives package upgrades untouched,
# and it applies however the program is started, including from a terminal.
#
# 2. A desktop entry. For applications that ship their own binary with no
# wrapper - most vendor packages, every Flatpak - there is nowhere else to
# put an argument, so a copy of the entry with the flag appended goes into
# ~/.local/share/applications, where it shadows the system one.
#
# Nothing outside $HOME is ever written to.
MCA_MARK_BEGIN='# >>> middleclick-autoscroll'
MCA_MARK_END='# <<< middleclick-autoscroll'
# ---------------------------------------------------------------------------
# Command lines
# ---------------------------------------------------------------------------
# _mca_exec_insert_at <exec line>
# Where an argument has to go: after the program and its own arguments, but
# before the first field code (%U and friends) or Flatpak's @@ markers, which
# are placeholders the launcher expands rather than arguments.
_mca_exec_insert_at() {
local rest="$1" tok idx=0
while [[ -n $rest ]]; do
while [[ $rest == ' '* ]]; do rest="${rest# }"; idx=$(( idx + 1 )); done
[[ -n $rest ]] || break
tok="${rest%% *}"
case "$tok" in
%[a-zA-Z]|@@|@@u) printf '%s\n' "$idx"; return 0 ;;
esac
rest="${rest:${#tok}}"
idx=$(( idx + ${#tok} ))
done
printf '%s\n' "$(( ${#1} ))"
}
# _mca_exec_after_program <exec line>
# The position just after the program name, for arguments that a program only
# accepts before its positional ones.
_mca_exec_after_program() {
local rest="$1" idx=0 tok
while [[ $rest == ' '* ]]; do rest="${rest# }"; idx=$(( idx + 1 )); done
tok="${rest%% *}"
printf '%s\n' "$(( idx + ${#tok} ))"
}
# mca_exec_inject <exec line> [flags] [position: before-fields|after-program]
# The same line with the given flags added, defaulting to the configured ones.
# Prints nothing and returns 1 when there was nothing to change, so callers can
# tell a real edit from a no-op without diffing.
#
# An --enable-blink-features that is already there is extended rather than
# repeated: Chromium keeps the last occurrence of the option and drops the
# rest, so a second one would silently disable whatever the first turned on.
mca_exec_inject() {
local line="$1" flagstr="${2:-}" where="${3:-before-fields}"
local flag value existing at changed=0
local -a flags
[[ -n $flagstr ]] || flagstr="$(mca_flags)"
read -r -a flags <<< "$flagstr"
for flag in "${flags[@]}"; do
if [[ $flag == --enable-blink-features=* ]]; then
value="${flag#--enable-blink-features=}"
if [[ $line =~ --enable-blink-features=([A-Za-z0-9,_-]*) ]]; then
existing="${BASH_REMATCH[1]}"
[[ ",$existing," == *",$value,"* ]] && continue
line="${line/--enable-blink-features=$existing/--enable-blink-features=${existing:+$existing,}$value}"
changed=1
continue
fi
fi
[[ " $line " == *" $flag "* ]] && continue
if [[ $where == after-program ]]; then
at="$(_mca_exec_after_program "$line")"
line="${line:0:$at} $flag${line:$at}"
else
at="$(_mca_exec_insert_at "$line")"
if (( at >= ${#line} )); then
line="${line% } $flag"
else
line="${line:0:$at}$flag ${line:$at}"
fi
fi
changed=1
done
(( changed )) || return 1
printf '%s\n' "$line"
}
# ---------------------------------------------------------------------------
# Flag files
# ---------------------------------------------------------------------------
# mca_flags_apply <program>
# Writes the flags into the file the program's launcher reads. Returns 0 when
# the program has such a file (whether or not anything needed changing), 1 when
# it has none and the desktop entry route has to be used instead.
mca_flags_apply() {
local prog="$1" name target='' existing_target=''
local -a candidates=()
mapfile -t candidates < <(mca_flags_candidates "$prog" | awk '!seen[$0]++')
(( ${#candidates[@]} )) || return 1
# A wrapper that offers several files reads the first one that exists and
# ignores the rest, so an existing file is always the right one to edit.
for name in "${candidates[@]}"; do
if [[ -f "$MCA_XDG_CONFIG/$name" ]]; then
existing_target="$name"
_mca_flags_write "$MCA_XDG_CONFIG/$name"
fi
done
[[ -n $existing_target ]] && return 0
# None exists yet. The last candidate is the wrapper's own fallback - the
# generic electron-flags.conf rather than electron43-flags.conf - so
# creating that one also covers every other application using the same
# shared Electron.
target="${candidates[-1]}"
_mca_flags_write "$MCA_XDG_CONFIG/$target"
return 0
}
# _mca_flags_write <file>
# Adds the flags as a marked block, or merges into an --enable-blink-features
# line that is already in the file.
_mca_flags_write() {
local file="$1" content='' line flag value existing merged=0 block=''
local -a flags
local created=0
read -r -a flags <<< "$(mca_flags)"
[[ -f $file ]] || created=1
if (( ! created )); then
content="$(< "$file")"
# Already ours: the block is rewritten below, so drop the old one first.
if [[ $content == *"$MCA_MARK_BEGIN"* ]]; then
content="$(_mca_flags_drop_block "$file")"
fi
fi
# Merge into a blink-features line the user (or a distribution) put there.
if [[ $content == *--enable-blink-features=* ]]; then
local out=''
while IFS= read -r line; do
if [[ $line =~ ^[[:space:]]*--enable-blink-features=([A-Za-z0-9,_-]*)[[:space:]]*$ ]]; then
existing="${BASH_REMATCH[1]}"
if [[ ",$existing," != *",$MCA_FEATURE,"* ]]; then
line="--enable-blink-features=${existing:+$existing,}$MCA_FEATURE"
fi
merged=1
fi
out+="$line"$'\n'
done <<< "$content"
content="${out%$'\n'}"
fi
for flag in "${flags[@]}"; do
(( merged )) && [[ $flag == --enable-blink-features=* ]] && continue
block+="$flag"$'\n'
done
if [[ -n $block ]]; then
content="${content:+$content$'\n'}$MCA_MARK_BEGIN"$'\n'"$block$MCA_MARK_END"
fi
if mca_write_if_changed "$file" "$content"$'\n'; then
MCA_CHANGES=$(( MCA_CHANGES + 1 ))
fi
if (( created )); then
mca_ledger_add flags "$file" created
elif (( merged )) && [[ -z $block ]]; then
mca_ledger_add flags "$file" merged
elif (( merged )); then
mca_ledger_add flags "$file" merged+block
else
mca_ledger_add flags "$file" block
fi
# Every application whose flag file was touched counts as covered, so the
# status line can say how many there are.
MCA_TOUCHED+=("$file")
return 0
}
_mca_flags_drop_block() {
awk -v b="$MCA_MARK_BEGIN" -v e="$MCA_MARK_END" '
$0 == b { skip = 1; next }
$0 == e { skip = 0; next }
!skip
' "$1" 2>/dev/null
}
# mca_flags_revert <file> <detail>
mca_flags_revert() {
local file="$1" detail="$2" content line out='' existing new
[[ -f $file ]] || return 0
case "$detail" in
created)
# Only remove the file if nothing but our block ended up in it;
# anything else means the user started using it in the meantime.
content="$(_mca_flags_drop_block "$file")"
if [[ -z ${content//[[:space:]]/} ]]; then
rm -f -- "$file"
return 0
fi
;;
esac
content="$(_mca_flags_drop_block "$file")"
if [[ $detail == merged* ]]; then
while IFS= read -r line; do
if [[ $line =~ ^[[:space:]]*--enable-blink-features=([A-Za-z0-9,_-]*)[[:space:]]*$ ]]; then
existing="${BASH_REMATCH[1]}"
new="${existing//$MCA_FEATURE/}"
new="${new//,,/,}"; new="${new#,}"; new="${new%,}"
[[ -z $new ]] && continue
line="--enable-blink-features=$new"
fi
out+="$line"$'\n'
done <<< "$content"
content="${out%$'\n'}"
fi
if [[ -z ${content//[[:space:]]/} ]]; then
rm -f -- "$file"
else
printf '%s\n' "$content" > "$file"
fi
return 0
}
# ---------------------------------------------------------------------------
# Desktop entries
# ---------------------------------------------------------------------------
# _mca_desktop_transform <file> [flags] [position]
# The whole file with every Exec line rewritten - the main one and the one in
# each Desktop Action, because those are the right-click menu entries and a
# user who starts Steam from "Library" expects the same behaviour there.
_mca_desktop_transform() {
local file="$1" flags="${2:-}" where="${3:-before-fields}"
local line rest new marked=0
while IFS= read -r line || [[ -n $line ]]; do
case "$line" in
Exec=*)
rest="${line#Exec=}"
new="$(mca_exec_inject "$rest" "$flags" "$where")" && line="Exec=$new"
;;
# A D-Bus activated entry is started through its service file and
# the Exec line is never used, so the flag would go nowhere.
DBusActivatable=true)
line='DBusActivatable=false'
;;
# A marker from an older version; the current one is written below.
X-MCA-*)
continue
;;
esac
printf '%s\n' "$line"
if (( ! marked )) && [[ $line =~ ^[[:space:]]*\[Desktop\ Entry\][[:space:]]*$ ]]; then
printf 'X-MCA-Generated=%s\n' "$MCA_VERSION"
marked=1
fi
done < "$file"
}
# mca_desktop_apply <id> <source entry> [flags] [position]
# Puts a patched copy in ~/.local/share/applications, or patches the entry in
# place when it already lives there.
mca_desktop_apply() {
local id="$1" src="$2" flags="${3:-}" where="${4:-before-fields}"
local target="$MCA_APPDIR/$id.desktop" content backup
if [[ "$src" == "$target" ]]; then
# The user's own entry - an AppImage, a web app shortcut, something
# installed by hand. There is nowhere to shadow it from, so it is
# edited directly and the original is kept.
grep -q '^X-MCA-Generated=' "$src" 2>/dev/null && return 0
content="$(_mca_desktop_transform "$src" "$flags" "$where")"
[[ -n $content ]] || return 1
[[ "$content" == "$(< "$src")" ]] && return 0
backup="$(mca_backup "$src")" || return 1
if mca_write_if_changed "$src" "$content"$'\n'; then
MCA_CHANGES=$(( MCA_CHANGES + 1 ))
fi
mca_ledger_add inplace "$src" "$backup"
MCA_TOUCHED+=("$src")
return 0
fi
content="$(_mca_desktop_transform "$src" "$flags" "$where")"
[[ -n $content ]] || return 1
mkdir -p "$MCA_APPDIR" 2>/dev/null || return 1
# Something is already shadowing this entry. If it is not ours it is the
# user's own override and is left exactly as it is.
if [[ -e $target ]] && ! grep -q '^X-MCA-Generated=' "$target" 2>/dev/null; then
return 1
fi
if mca_write_if_changed "$target" "$content"$'\n'; then
MCA_CHANGES=$(( MCA_CHANGES + 1 ))
fi
mca_ledger_add shadow "$target" "$src"
MCA_TOUCHED+=("$target")
return 0
}
# ---------------------------------------------------------------------------
# Autostart entries
# ---------------------------------------------------------------------------
# Applications that start themselves at login write their own entry into
# ~/.config/autostart, pointing straight at their binary. Those bypass the
# desktop entry in the menu completely, which is why Discord launched at login
# used to behave differently from Discord launched from the menu.
mca_autostart_apply() {
local dir="$MCA_XDG_CONFIG/autostart" file exec_line prog content backup
[[ -d $dir ]] || return 0
for file in "$dir"/*.desktop; do
[[ -f $file ]] || continue
grep -q '^X-MCA-Generated=' "$file" 2>/dev/null && continue
exec_line="$(mca_desktop_get "$file" Exec)"
[[ -n $exec_line ]] || continue
mca_exec_program "$exec_line" || continue
prog="$MCA_PROG"
mca_is_chromium "$prog" || continue
content="$(_mca_desktop_transform "$file")"
[[ -n $content ]] || continue
[[ "$content" == "$(< "$file")" ]] && continue
backup="$(mca_backup "$file")" || continue
if mca_write_if_changed "$file" "$content"$'\n'; then
MCA_CHANGES=$(( MCA_CHANGES + 1 ))
fi
mca_ledger_add inplace "$file" "$backup"
MCA_TOUCHED+=("$file")
done
}
# ---------------------------------------------------------------------------
# Spotify
# ---------------------------------------------------------------------------
# The official client is CEF rather than Electron and, when installed through
# spotify-launcher, is started by a program that builds its own command line.
# That launcher has a configuration file with a slot for exactly this.
mca_spotify_apply() {
local file="$MCA_XDG_CONFIG/spotify-launcher.conf" content line out='' backup
local found_section=0 found_args=0
mca_have spotify-launcher || return 0
if [[ -f $file ]]; then
content="$(< "$file")"
while IFS= read -r line; do
if [[ $line =~ ^[[:space:]]*\[spotify\][[:space:]]*$ ]]; then
found_section=1
elif [[ $line =~ ^[[:space:]]*extra_arguments[[:space:]]*=[[:space:]]*\[(.*)\][[:space:]]*$ ]]; then
local args="${BASH_REMATCH[1]}"
found_args=1
if [[ $args != *"$MCA_FEATURE"* ]]; then
line="extra_arguments = [${args:+$args, }\"$MCA_FLAG\"]"
fi
fi
out+="$line"$'\n'
done <<< "$content"
content="${out%$'\n'}"
fi
if (( ! found_args )); then
(( found_section )) || content="${content:+$content$'\n'}[spotify]"
content="${content:+$content$'\n'}extra_arguments = [\"$MCA_FLAG\"]"
fi
[[ -f $file ]] && backup="$(mca_backup "$file")"
if mca_write_if_changed "$file" "$content"$'\n'; then
MCA_CHANGES=$(( MCA_CHANGES + 1 ))
fi
mca_ledger_add inplace "$file" "${backup:-}"
MCA_TOUCHED+=("$file")
return 0
}
# ---------------------------------------------------------------------------
# Undo
# ---------------------------------------------------------------------------
mca_revert_all() {
local kind path detail
[[ -f $MCA_LEDGER ]] || return 0
# Backwards, so a file that was recorded twice ends up at its oldest state.
while IFS=$'\t' read -r kind path detail; do
[[ -n $path ]] || continue
case "$kind" in
shadow)
# Only remove what is still recognisably ours.
if [[ -f $path ]] && grep -q '^X-MCA-Generated=' "$path" 2>/dev/null; then
rm -f -- "$path"
MCA_CHANGES=$(( MCA_CHANGES + 1 ))
fi
;;
inplace)
if [[ -n $detail && -f "$MCA_BACKUPDIR/$detail" ]]; then
if [[ -e $path ]] || [[ -d "$(dirname -- "$path")" ]]; then
cp -p -- "$MCA_BACKUPDIR/$detail" "$path" 2>/dev/null \
&& MCA_CHANGES=$(( MCA_CHANGES + 1 ))
fi
rm -f -- "$MCA_BACKUPDIR/$detail"
elif [[ -f $path ]]; then
# No backup: the file did not exist before we wrote it.
rm -f -- "$path"
MCA_CHANGES=$(( MCA_CHANGES + 1 ))
fi
;;
flags)
mca_flags_revert "$path" "$detail" \
&& MCA_CHANGES=$(( MCA_CHANGES + 1 ))
;;
steam)
mca_steam_revert "$path" "$detail" \
&& MCA_CHANGES=$(( MCA_CHANGES + 1 ))
;;
esac
done < <(tac "$MCA_LEDGER" 2>/dev/null)
rm -f -- "$MCA_LEDGER"
rmdir "$MCA_BACKUPDIR" 2>/dev/null || true
return 0
}
+163
View File
@@ -0,0 +1,163 @@
# shellcheck shell=bash
#
# Steam.
#
# Steam's interface is CEF, so the feature is there - but Steam builds the
# command line for its web helper itself and offers no way to add to it. The
# only place to get an argument in is the shell script that starts the helper,
# which lives inside Steam's own installation:
#
# ~/.local/share/Steam/ubuntu12_64/steamwebhelper_sniper_wrap.sh
#
# Steam checksums that script at every start and restores it when it differs,
# which is why the launcher gets -noverifyfiles as well. The trade-off is real
# and belongs to the user, so this is a switch of its own rather than part of
# the general application handling: with verification off, Steam no longer
# repairs a damaged installation by itself.
#
# The file comes back on every client update, and the watcher re-applies the
# patch when that happens.
MCA_STEAM_EXEC='exec ./steamwebhelper "$@"'
MCA_STEAM_LAUNCH_FLAG='-noverifyfiles'
# Every place a Steam installation is known to live, resolved and de-duplicated
# because ~/.steam/steam is normally a symlink into ~/.local/share.
mca_steam_roots() {
local candidates=(
"$MCA_XDG_DATA/Steam"
"$HOME/.steam/steam"
"$HOME/.steam/root"
"$HOME/.steam/debian-installation"
"$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam"
)
local dir real
local -A seen=()
for dir in "${candidates[@]}"; do
[[ -d $dir ]] || continue
real="$(readlink -f -- "$dir" 2>/dev/null)" || continue
[[ -n ${seen[$real]+set} ]] && continue
seen[$real]=1
printf '%s\n' "$real"
done
}
# The script to patch inside a Steam installation. Newer clients start the
# helper through a wrapper inside the container runtime; older ones exec it
# from steamwebhelper.sh directly, and that one forwards its arguments too.
mca_steam_script() {
local root="$1" f
for f in \
"$root/ubuntu12_64/steamwebhelper_sniper_wrap.sh" \
"$root/ubuntu12_64/steamwebhelper.sh"
do
[[ -f $f && -w $f ]] && { printf '%s\n' "$f"; return 0; }
done
return 1
}
mca_steam_installed() {
local root
while IFS= read -r root; do
mca_steam_script "$root" > /dev/null && return 0
done < <(mca_steam_roots)
return 1
}
# mca_steam_script_patched <script>
mca_steam_script_patched() {
grep -q -- "$MCA_FEATURE" "$1" 2>/dev/null
}
mca_steam_patched() {
local root script
while IFS= read -r root; do
script="$(mca_steam_script "$root")" || continue
mca_steam_script_patched "$script" && return 0
done < <(mca_steam_roots)
return 1
}
# _mca_steam_exec_line <script>
# The line number of the command that starts the web helper: the last
# uncommented line that both names steamwebhelper and forwards "$@". Both the
# current layout (exec ./steamwebhelper "$@" inside the container wrapper) and
# the older one (steamwebhelper.sh handing the arguments to the runtime entry
# point) end in exactly that.
_mca_steam_exec_line() {
awk '
/^[[:space:]]*#/ { next }
/steamwebhelper/ && /"\$@"/ { n = NR }
END { if (n) print n }
' "$1" 2>/dev/null
}
# mca_steam_apply
# Appends the flags to the line that starts the web helper. Everything else in
# the script - setting up the container runtime, deciding whether the sandbox
# can be used - is left alone.
mca_steam_apply() {
local root script backup flags lineno found=0 out
flags="$(mca_flags)"
while IFS= read -r root; do
script="$(mca_steam_script "$root")" || continue
found=1
mca_steam_script_patched "$script" && { MCA_TOUCHED+=("$script"); continue; }
lineno="$(_mca_steam_exec_line "$script")"
if [[ -z $lineno ]]; then
# A client update changed how the helper is started. Leaving the
# file alone is the only safe answer: this script is what starts
# Steam's entire interface.
mca_note "$(mca_msg "Steam starts its interface in a way this version does not recognise; leaving it alone.")"
continue
fi
out="$(awk -v n="$lineno" -v f=" $flags" 'NR == n { $0 = $0 f } { print }' "$script")" || continue
backup="$(mca_backup "$script")" || continue
if mca_write_if_changed "$script" "$out"$'\n'; then
MCA_CHANGES=$(( MCA_CHANGES + 1 ))
fi
chmod +x -- "$script" 2>/dev/null || true
mca_ledger_add steam "$script" "$backup"
MCA_TOUCHED+=("$script")
done < <(mca_steam_roots)
(( found ))
}
# mca_steam_revert <script> <backup name>
mca_steam_revert() {
local script="$1" backup="$2"
if [[ -n $backup && -f "$MCA_BACKUPDIR/$backup" ]]; then
if [[ -e $script ]]; then
cp -p -- "$MCA_BACKUPDIR/$backup" "$script" 2>/dev/null || true
chmod +x -- "$script" 2>/dev/null || true
fi
rm -f -- "$MCA_BACKUPDIR/$backup"
return 0
fi
# No backup to fall back on - take the flags back out of the exec line.
[[ -f $script ]] || return 1
local tmp
tmp="$(mktemp "${script}.XXXXXX")" || return 1
sed "s| $MCA_FLAG||g" "$script" > "$tmp" && mv -f "$tmp" "$script" || { rm -f "$tmp"; return 1; }
chmod +x -- "$script" 2>/dev/null || true
return 0
}
# mca_steam_desktop_apply <id> <source entry>
# Steam restores its own files at every start unless it is told not to verify
# them, so the launcher entry carries that switch. It goes directly after the
# program because Steam reads its options before the steam:// argument that the
# right-click actions pass.
mca_steam_desktop_apply() {
mca_desktop_apply "$1" "$2" "$MCA_STEAM_LAUNCH_FLAG" after-program
}