# 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. # # There are two ways to ask for that flag, and which one is right depends on # what is being started: # # --enable-blink-features=MiddleClickAutoscroll is the one that works # everywhere, and the only one that works in the Chromium versions embedded # applications ship: Steam's CEF, an Electron a few years old, anything # before Chromium 124. A browser started with it puts a yellow bar above the # page saying an unsupported command line flag is in use, because Chromium # keeps a list of flags worth warning about and this is on it. # # --enable-features=MiddleClickAutoscroll is not on that list, so a browser # started with it says nothing. Blink generates a feature of the same name # for every one of its runtime flags, which is what makes the two the same # request. But only since Chromium 124. Before that the name means nothing # and is ignored without a word. # # So a browser, which is the only thing that shows the bar and the one thing # that keeps itself up to date, is given the quiet one; everything else keeps # the one that always works. MCA_FLAG="--enable-blink-features=MiddleClickAutoscroll" # Two browsers do not answer to the Chromium name. Helium ships the feature # under its own name and sets the runtime flag from that alone. Brave takes # the generated feature away and switches the flag on from one of its own, # spelled MiddelButtonClickAutoscroll. The typo is in Brave's source, and the # correct spelling does nothing. So browsers are asked for all three. A name a # browser does not know is ignored, which is what makes one list safe to hand # to all of them. MCA_BROWSER_FLAG="--enable-features=MiddleClickAutoscroll,HeliumMiddleClickAutoscroll,MiddelButtonClickAutoscroll" # The feature names on their own, for merging into a list that an application # (or the user) already carries, and for taking them back out again. MCA_FEATURE="MiddleClickAutoscroll" MCA_BROWSER_FEATURES="MiddleClickAutoscroll,HeliumMiddleClickAutoscroll,MiddelButtonClickAutoscroll" # Bumped when the flags above change, and when what gets written where does. # An installation that was set up by a version with a different answer is taken # back and done again once, because a file that is already patched is otherwise # left alone. See mca_apply. # # 3 is what takes -noverifyfiles back out of the Steam entries an older version # put it in. # # 4 adds Brave's name to the browser list, which an entry edited in place would # otherwise never get. MCA_FLAG_SCHEME=4 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 a file the package manager 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" # The folder the desktop itself shows. Nothing in the XDG search path looks at # it, so an entry that lives only there is invisible to a scan. A game # shortcut dragged onto the desktop is exactly that. # # Its name is translated: Schreibtisch, Bureau, Escritorio. The name in use is # in the file xdg-user-dirs writes, so it is read rather than guessed, and # ~/Desktop is only the fallback for a system that has no such file. mca_desktop_folder() { local file="$MCA_XDG_CONFIG/user-dirs.dirs" line dir='' if [[ -r $file ]]; then while IFS= read -r line; do [[ $line == XDG_DESKTOP_DIR=* ]] || continue dir="${line#XDG_DESKTOP_DIR=}" dir="${dir%\"}"; dir="${dir#\"}" done < "$file" dir="${dir/#\$HOME/$HOME}" fi [[ $dir == /* ]] || dir="$HOME/Desktop" printf '%s\n' "${dir%/}" } # --------------------------------------------------------------------------- # 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 # systemd writes /etc/locale.conf and most distributions use it; Debian and # Ubuntu keep the same LANG= line in /etc/default/locale instead. if [[ -z $l ]]; then local f for f in /etc/locale.conf /etc/default/locale; do [[ -r $f ]] || continue l="$(sed -n 's/^LANG=//p' "$f" | tr -d '"' | head -n1)" [[ -n $l ]] && break done 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 # 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 [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 [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 # --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() { printf '%s✘%s %s\n' "$MCA_C_RED" "$MCA_C_RESET" "$*" >&2; } mca_note() { [[ -n $MCA_QUIET ]] || printf '%s•%s %s\n' "$MCA_C_DIM" "$MCA_C_RESET" "$*"; } 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 < 120 )); then mca_msg "1 minute ago" elif (( delta < 3600 )); then mca_msg "%d minutes ago" "$(( delta / 60 ))" elif (( delta < 7200 )); then mca_msg "1 hour ago" elif (( delta < 86400 )); then mca_msg "%d hours ago" "$(( delta / 3600 ))" elif (( delta < 172800 )); then mca_msg "1 day ago" 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: # # # shadow # inplace # flags created | appended | merged # kwin what EnablePrimarySelection said before # # The kinds differ in how they are undone: a backup is restored wholesale, a # flag file only loses the one line that was added to it, and kwinrc gets one # key written back. It is a file KDE writes to itself, and putting a whole # copy of it back would take everything else settled since with 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_has # Whether the ledger still knows about that file. Worth asking because the copy # kept beside it is named after the path and outlives a ledger that was lost or # truncated: a file nothing has a record of any more is one nothing would ever # look at again. 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 # The third column of that file's line, empty when the ledger has none. What it # means is the kind's business; for `kwin` it is the value to put back. mca_ledger_detail() { [[ -f $MCA_LEDGER ]] || return 1 awk -F'\t' -v p="$1" '$2 == p { d = $3 } END { if (!length(d)) exit 1; print d }' \ "$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_backup_name # The name a backup copy of that file is stored under. Derived from the path # rather than remembered, so a caller can ask whether a file has been backed up # before without reading the ledger. mca_backup_name() { printf '%s' "$1" | sed 's|/|%|g' } # mca_backup # 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="$(mca_backup_name "$file")" 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 # 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 }