Files
middleclick-autoscroll/src/lib/patch.sh
T

684 lines
22 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. Some distributions wrap Electron and Chromium in a
# launcher script that reads extra arguments from
# $XDG_CONFIG_HOME/<name>-flags.conf. Arch and its derivatives do, and
# their packages follow that convention. Where it exists, this is the good
# way in: 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.
#
# Nothing is assumed about which distribution this is. The launcher itself
# is read, and the route is taken only for a launcher that really does
# name such a file. On Debian, Ubuntu, Fedora and openSUSE the
# equivalent file lives under /etc and is the system's to write. There is
# no flag file to use there, and route 2 answers for everything.
#
# 2. A desktop entry. For applications that ship their own binary with no
# wrapper, and for everything a Flatpak or a snap contains, 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'
# ---------------------------------------------------------------------------
# Feature lists
# ---------------------------------------------------------------------------
# Both --enable-features and --enable-blink-features take a comma separated
# list, and Chromium keeps the last one it is given and drops the rest. A
# second one of the same kind would therefore silently switch off whatever the
# first one turned on, so an option that is already there is extended instead.
# _mca_list_merge <list> <wanted>
# The two lists as one, in the order they came and without repeating anything.
_mca_list_merge() {
local out="$1" item
for item in ${2//,/ }; do
[[ ",$out," == *",$item,"* ]] && continue
out="${out:+$out,}$item"
done
printf '%s' "$out"
}
# _mca_list_strip <list> <unwanted>
# The list without any of the named entries.
_mca_list_strip() {
local out='' item
for item in ${1//,/ }; do
[[ ",$2," == *",$item,"* ]] && continue
out="${out:+$out,}$item"
done
printf '%s' "$out"
}
# The two options, as a regular expression branch, for the places that have to
# recognise either of them.
MCA_FEATURE_OPTS='--enable-blink-features|--enable-features'
# ---------------------------------------------------------------------------
# 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.
#
# A bare "--" stops it too. That is where Chromium stops reading switches and
# starts treating everything as an address, so a flag put after it is read as a
# page to open and the feature never comes on. Discord's entry is exactly that
# shape: `discord --url -- %u`.
_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.
#
# A feature list that is already on the line is extended rather than repeated.
# See _mca_list_merge.
mca_exec_inject() {
local line="$1" flagstr="${2:-}" where="${3:-before-fields}"
local flag opt value existing merged re at changed=0
local -a flags
[[ -n $flagstr ]] || flagstr="$(mca_flags)"
read -r -a flags <<< "$flagstr"
for flag in "${flags[@]}"; do
case "$flag" in
--enable-blink-features=*|--enable-features=*)
opt="${flag%%=*}"
value="${flag#*=}"
re="(^|[[:space:]])$opt=([^[:space:]]*)"
if [[ $line =~ $re ]]; then
existing="${BASH_REMATCH[2]}"
merged="$(_mca_list_merge "$existing" "$value")"
[[ $merged == "$existing" ]] && continue
line="${line/$opt=$existing/$opt=$merged}"
changed=1
continue
fi
;;
esac
[[ " $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> [kind]
# 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" kind="${2:-app}" 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" "$kind"
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" "$kind"
return 0
}
# _mca_flags_write <file> [kind]
# Adds the flags as a marked block, or merges into a feature list that is
# already in the file.
_mca_flags_write() {
local file="$1" kind="${2:-app}"
local content='' line flag opt want existing merged=0 block=''
local -a flags
local -A merged_opts=()
local created=0
read -r -a flags <<< "$(mca_flags "$kind")"
[[ -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 feature list the user (or a distribution) put there, rather
# than write a second line of the same option further down.
if [[ $content == *--enable-* ]]; then
local out=''
while IFS= read -r line; do
if [[ $line =~ ^[[:space:]]*($MCA_FEATURE_OPTS)=([^[:space:]]*)[[:space:]]*$ ]]; then
opt="${BASH_REMATCH[1]}"
existing="${BASH_REMATCH[2]}"
want=''
for flag in "${flags[@]}"; do
[[ $flag == "$opt="* ]] && want="${flag#*=}"
done
if [[ -n $want ]]; then
line="$opt=$(_mca_list_merge "$existing" "$want")"
merged_opts["$opt"]=1
merged=1
fi
fi
out+="$line"$'\n'
done <<< "$content"
content="${out%$'\n'}"
fi
for flag in "${flags[@]}"; do
[[ -n ${merged_opts["${flag%%=*}"]:-} ]] && 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='' opt 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:]]*($MCA_FEATURE_OPTS)=([^[:space:]]*)[[:space:]]*$ ]]; then
opt="${BASH_REMATCH[1]}"
existing="${BASH_REMATCH[2]}"
# Either name, whichever version of this program put it there.
new="$(_mca_list_strip "$existing" "$MCA_BROWSER_FEATURES")"
[[ -z $new ]] && continue
line="$opt=$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, like an AppImage, a web app shortcut or
# 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_entry_write "$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
}
# ---------------------------------------------------------------------------
# Entries outside the search path
# ---------------------------------------------------------------------------
# Two directories hold desktop entries that no part of the XDG search path
# looks at, so nothing the scan does can reach them:
#
# ~/.config/autostart, where applications that start themselves at login
# write their own entry, pointing straight at their binary. Those bypass the
# entry in the menu completely, which is why Discord launched at login used
# to behave differently from Discord launched from the menu.
#
# The desktop folder, where a shortcut somebody dragged out of the menu
# (or asked Steam for) lives and nowhere else.
#
# Neither can be shadowed from anywhere, so both are edited where they stand,
# with the original kept.
# _mca_entry_write <file> <content>
# Writes an entry edited in place, like mca_write_if_changed. A symlink is
# replaced by the edited file instead of written through: it usually points at
# a package's entry in /usr, which is not ours to change. Undo puts the link
# back from the backup.
_mca_entry_write() {
local file="$1" content="$2" tmp
[[ -L $file ]] || { mca_write_if_changed "$file" "$content"; return; }
tmp="$(mktemp "$file.XXXXXX")" || return 2
chmod --reference="$file" -- "$tmp" 2>/dev/null || chmod 644 -- "$tmp"
# A link into /usr/share/applications is trusted for where it points. A
# copy on the desktop is only trusted by KDE when it can be run.
chmod u+x -- "$tmp" 2>/dev/null || true
if printf '%s' "$content" > "$tmp" && mv -f -- "$tmp" "$file"; then
return 0
fi
rm -f -- "$tmp"
return 2
}
# _mca_entry_patch_inplace <file>
# One desktop entry that lives outside the XDG search path, edited where it is
# because there is nowhere to shadow it from. It follows the applications list
# under its own file name, which is the name of the application's own entry.
_mca_entry_patch_inplace() {
local file="$1"
local id prog packaging=native content backup kind copy
# Already ours, and normally that is the end of it: what is in there is what
# this version put there. Not so for a file the ledger has no record of -
# nothing would ever look at that one again, neither the undo a change of
# flags goes through nor this, which leaves a marked file alone, and it would
# keep an answer from an older version for good. The copy taken before the
# edit is named after the path and is still there, so the file goes back to
# what it was and is written again below.
if grep -q "^$MCA_MARK_INPLACE=" "$file" 2>/dev/null; then
mca_ledger_has "$file" && return 0
copy="$MCA_BACKUPDIR/$(mca_backup_name "$file")"
[[ -f $copy ]] || return 0
cp -p -- "$copy" "$file" 2>/dev/null || return 0
fi
_mca_desktop_read "$file"
[[ -n $DE_EXEC ]] || return 0
[[ $DE_HIDDEN == true ]] && return 0
mca_exec_program "$DE_EXEC" || return 0
prog="$MCA_PROG"
# A Flatpak or a snap entry names the wrapper rather than the application.
# The scan resolves those into an id; outside the scan the same has to
# happen here, or every Flatpak looks like the flatpak program itself.
if [[ ${prog##*/} == flatpak ]]; then
mca_exec_flatpak_id "$DE_EXEC" || return 0
prog="flatpak:$MCA_PROG"
packaging=flatpak
elif mca_snap_name "$prog"; then
prog="snap:$MCA_PROG"
packaging=snap
fi
# An entry that starts Steam is left where it is. Steam takes nothing
# from a command line that would help, and what it does take belongs to
# the web helper script in its own installation.
mca_prog_is_steam "$prog" && return 0
case "$packaging" in
flatpak) mca_flatpak_is_chromium "${prog#flatpak:}" || return 0 ;;
snap) mca_snap_is_chromium "${prog#snap:}" || return 0 ;;
*) mca_is_chromium "$prog" || return 0 ;;
esac
mca_desktop_is_browser && kind=browser || kind=app
id="${file##*/}"; id="${id%.desktop}"
mca_kind_wanted "$kind" "$id" || return 0
content="$(_mca_desktop_transform "$file" "$MCA_MARK_INPLACE" \
"$(mca_flags "$kind")")"
[[ -n $content ]] || return 0
[[ "$content" == "$(< "$file")" ]] && return 0
backup="$(mca_backup "$file")" || return 0
if _mca_entry_write "$file" "$content"$'\n'; then
MCA_CHANGES=$(( MCA_CHANGES + 1 ))
fi
mca_ledger_add inplace "$file" "$backup"
}
mca_autostart_apply() {
local dir="$MCA_XDG_CONFIG/autostart" file
[[ -d $dir ]] || return 0
for file in "$dir"/*.desktop; do
[[ -f $file ]] || continue
_mca_entry_patch_inplace "$file"
done
}
mca_shortcuts_apply() {
local dir file
dir="$(mca_desktop_folder)"
[[ -d $dir ]] || return 0
for file in "$dir"/*.desktop; do
[[ -f $file ]] || continue
_mca_entry_patch_inplace "$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:-}"
return 0
}
# ---------------------------------------------------------------------------
# Leftovers
# ---------------------------------------------------------------------------
# mca_prune_orphans
# Forgets changes there is no longer anything to undo.
#
# 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. A file that was edited where it stood and has
# since been deleted is the same problem from the other side: its record would
# have `disable` put the file back.
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"
# An entry that was edited where it stood and has since been deleted -
# a game shortcut dragged to the wastebasket, an autostart entry the
# application removed. There is nothing left to put back, and keeping
# the record would have `disable` recreate a file the user threw away.
if [[ $kind == inplace && ! -e $path ]]; then
[[ -n $source ]] && rm -f -- "$MCA_BACKUPDIR/$source"
mca_ledger_forget "$path"
continue
fi
[[ $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 backup
[[ -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)
backup="$MCA_BACKUPDIR/$detail"
if [[ -n $detail ]] && [[ -f $backup || -L $backup ]]; then
if [[ -e $path ]] || [[ -d "$(dirname -- "$path")" ]]; then
# A link goes back as a link, not written through.
[[ -L $backup ]] && rm -f -- "$path"
cp -P -p -- "$backup" "$path" 2>/dev/null \
&& MCA_CHANGES=$(( MCA_CHANGES + 1 ))
fi
rm -f -- "$backup"
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 ))
;;
kwin)
mca_kde_paste_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
}