# 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/-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 # 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 # 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 # 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 # 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 [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 [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 [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 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 [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 [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 # 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 # 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 }