Files
middleclick-autoscroll/src/lib/patch.sh
T
Felitendo 5b1fc84710 Never mistake an entry edited in place for one we generated
A desktop entry that already lives in ~/.local/share/applications is the
user's own file, patched in place with the original kept aside. It used to
get the same X-MCA-Generated marker as a shadow copy, which made the next
scan skip it, fall back to the system entry, and overwrite the user's file
with a shadow - and then delete it on revert instead of restoring it.

The two cases now carry different markers. Only a shadow is skipped by the
scan and deleted when undoing; an entry patched in place stays in the scan
and is restored from its backup.

Also: resolve the applications screen's labels once instead of per row per
keypress, and drop code nothing calls.
2026-08-16 19:56:19 +02:00

469 lines
15 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.
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"
mca_is_chromium "$prog" || continue
content="$(_mca_desktop_transform "$file" "$MCA_MARK_INPLACE")"
[[ -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
}
# ---------------------------------------------------------------------------
# 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
}