Files
middleclick-autoscroll/src/lib/patch.sh
T
Felitendo f42d80598f fix: keep Steam out of an endless update loop
Steam compares its installed files against its manifest at every start and
restores whatever differs, which is why its launcher entry carries
-noverifyfiles. The entry in ~/.config/autostart never got it: it points at
/usr/bin/steam, a shell script, so it fell through the Chromium filter in
mca_autostart_apply and was left alone.

A Steam started at login therefore verified, found the web helper script 46
bytes larger than the manifest says, restored it, and the path unit patched
it straight back - an update dialog that begins again every few seconds and
never finishes. The same client started from the menu was fine, which is
what made it look like a problem of Steam's own.

The autostart entry now carries the switch as well. It follows the Steam
setting rather than the autostart one, because leaving it out while Steam is
patched is exactly what causes the loop.

That covers the entry that exists; a terminal or a script still starts Steam
without the switch. So the patch no longer fights back either: a script that
was patched before, is not patched now, and belongs to a client that is
still running has just been restored by Steam, and doing it again would only
have the two of them undoing each other. It waits for the next apply with
Steam closed - the helper is started once, at the start, so patching it now
would not have helped that session anyway.

Also: Steam does not checksum that script, it compares sizes - the log says
"Verifying file sizes only" - while the comments and the documentation
claimed otherwise. And the status screen now tells a patch that is waiting
from one that is missing, instead of reporting both as not patched yet.
2026-08-24 10:01:27 +02:00

516 lines
17 KiB
Bash

# 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
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
# ---------------------------------------------------------------------------
# A generated entry and an entry that was edited in place look similar and must
# never be confused: the first one is ours to delete, the second one is the
# user's file with one line changed and has to be restored from its backup. They
# carry different markers so that the scan, the "already done" checks and the
# undo can all tell them apart.
MCA_MARK_SHADOW='X-MCA-Generated'
MCA_MARK_INPLACE='X-MCA-Patched'
# _mca_desktop_transform <file> <marker> [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" marker="$2" flags="${3:-}" where="${4:-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 '%s=%s\n' "$marker" "$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 "^$MCA_MARK_INPLACE=" "$src" 2>/dev/null && return 0
content="$(_mca_desktop_transform "$src" "$MCA_MARK_INPLACE" "$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"
return 0
fi
content="$(_mca_desktop_transform "$src" "$MCA_MARK_SHADOW" "$flags" "$where")"
[[ -n $content ]] || return 1
mkdir -p "$MCA_APPDIR" 2>/dev/null || return 1
# Something is already shadowing this entry. Unless it is a shadow of ours,
# it is the user's own file - possibly one we edited in place earlier - and
# overwriting it here would lose it.
if [[ -e $target ]] && ! grep -q "^$MCA_MARK_SHADOW=" "$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"
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.
#
# Steam is here too, even though it is not a Chromium process itself: its entry
# needs -noverifyfiles exactly like the menu one, and it is the entry most
# likely to exist, because Steam writes it as soon as "run at startup" is
# ticked. Without it, a Steam started at login restores the patched web helper
# script and the watcher patches it back, over and over.
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 "^$MCA_MARK_INPLACE=" "$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"
if [[ ${prog##*/} == steam || ${prog##*/} == steam-runtime ]]; then
[[ $CFG_STEAM == yes ]] || continue
content="$(_mca_desktop_transform "$file" "$MCA_MARK_INPLACE" \
"$MCA_STEAM_LAUNCH_FLAG" after-program)"
else
[[ $CFG_AUTOSTART == yes ]] || continue
mca_is_chromium "$prog" || continue
content="$(_mca_desktop_transform "$file" "$MCA_MARK_INPLACE")"
fi
[[ -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"
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:-}"
return 0
}
# ---------------------------------------------------------------------------
# Leftovers
# ---------------------------------------------------------------------------
# mca_prune_orphans
# Removes generated entries whose application is gone.
#
# Uninstalling something deletes its entry from /usr/share/applications, but the
# copy shadowing it is in the user's home and pacman knows nothing about it. It
# would sit in the menu forever, offering to start a program that is no longer
# installed - and the watcher would not notice, because a plain apply only ever
# looks at what is there now.
mca_prune_orphans() {
local kind path source line
local -a lines=()
[[ -f $MCA_LEDGER ]] || return 0
mapfile -t lines < "$MCA_LEDGER"
for line in "${lines[@]}"; do
IFS=$'\t' read -r kind path source <<< "$line"
[[ $kind == shadow && -n $source ]] || continue
[[ -e $source ]] && continue
# Only ever remove something still recognisably ours.
if [[ -f $path ]] && grep -q "^$MCA_MARK_SHADOW=" "$path" 2>/dev/null; then
rm -f -- "$path"
MCA_CHANGES=$(( MCA_CHANGES + 1 ))
fi
mca_ledger_forget "$path"
done
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 "^$MCA_MARK_SHADOW=" "$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
}