382 lines
14 KiB
Bash
382 lines
14 KiB
Bash
# 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 <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, because 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_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:
|
|
# <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
|
|
# kwin <kwinrc> 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 <path>
|
|
# 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 <path>
|
|
# 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 <file>
|
|
# 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 <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="$(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 <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
|
|
}
|