Compare commits

..
16 Commits
Author SHA1 Message Date
Felitendo e1c77f049f chore: 1.3.0
release / Debian package (push) Failing after 13s
release / RPM package (push) Failing after 26s
release / Release and repositories (push) Skipped
release / Install from the RPM repository (push) Skipped
A browser no longer shows the "unsupported command-line flag" bar. It is given
--enable-features=MiddleClickAutoscroll, which asks for the same feature
without being on the list of flags Chromium warns about; everything that embeds
an older Chromium keeps the flag that works there, and shows no bar either way.

Helium gets its own name for the feature alongside it, so autoscroll works
there for the first time.

An installation from 1.2.0 or earlier is taken back and written again on the
next apply.
2026-09-05 00:44:52 +02:00
Felitendo fa624380b1 fix: keep the unsupported-flag warning off browsers
--enable-blink-features is on the list of flags Chromium warns about, so every
browser the flag was applied to put a yellow "unsupported command-line flag"
bar above each page.

Browsers are given --enable-features=MiddleClickAutoscroll instead. It asks for
the same thing - Blink generates a feature of the same name for each of its
runtime flags - and is not on that list. That spelling only works from Chromium
124 onwards, so everything else keeps the flag that works everywhere: an
application embedding an older Chromium, Steam's CEF among them, has no such
bar to show anyway.

Helium knows the feature under a name of its own and ignores the Chromium one,
so browsers are asked for HeliumMiddleClickAutoscroll as well; autoscroll never
worked there before. A name a browser does not know is ignored, which is what
makes one list safe for all of them.

An installation set up by an earlier version is taken back and written again
once, because a file that is already marked as patched would otherwise be left
alone with the old flag in it.
2026-09-05 00:44:47 +02:00
Felitendo d8900ef80f docs: show the TUI in the readme 2026-09-04 13:02:13 +02:00
Felitendo 46e63e9faa docs: rewrite the readme in plain words 2026-09-04 12:59:50 +02:00
Felitendo af82ffec78 chore: 1.2.0
release / Debian package (push) Failing after 14s
release / Release and repositories (push) Skipped
release / Install from the RPM repository (push) Skipped
release / RPM package (push) Failing after 30s
Steam's autoscroll stops depending on -noverifyfiles: the patch is written at
the size and timestamp Steam recorded, so the client's own file check finds
nothing to repair and the flag survives whichever way Steam was started. An
installation patched by 1.1.0 is redone at the original size on the next apply.

Also picks up the launch paths that were never covered - shortcuts on the
desktop, Steam as a Flatpak, steam-native and steam-jupiter.
2026-09-01 13:24:20 +02:00
Felitendo b7e0ff6fd1 fix: make the Steam patch survive Steam's own file check
Steam's autoscroll worked, except when it didn't. Every report of that came
down to the same thing: Steam started in a way that carried no -noverifyfiles,
noticed the patched web helper script, put its own copy back, and spent the
session without the flag. There is no way to make every possible way of
starting Steam carry an argument - a game launcher, a shortcut, a terminal,
another program calling `steam steam://` - so the patch stops depending on it.

The check is on size and timestamp, not on content, so the patch is now written
to look untouched: the bytes the flag costs come back out of the script's own
comments, and the timestamp of the untouched copy is restored afterwards. The
file Steam finds is exactly as long and exactly as old as the one it wrote. A
client that verifies its files finds nothing to repair. -noverifyfiles stays as
the fallback for a script with no comments left to pay for the flag.

Two things fall out of that. A patch that keeps the size no longer has to wait
for Steam to close, because there is no size mismatch for the client to chase;
only the growing one still defers. And an installation patched by the earlier
version is quietly redone at the original size on the next apply, so the fix
arrives without anyone having to know about it.

Then the launch paths that were never covered:

- Shortcuts on the desktop itself. Nothing in the XDG search path looks at
  that folder, so nothing had ever seen them - and Steam writes one there for
  every game somebody asks for a shortcut to. Its name is translated, so it is
  read from user-dirs.dirs and handed to the watcher in a drop-in.
- Steam as a Flatpak was never recognised as Steam, so its entry got nothing
  while its script got patched - the worst of both. The switch goes after the
  application id there, where flatpak passes it on rather than reading it.
- steam-native and steam-jupiter, which are ordinary Steam starts under
  another name, and Flatpak and snap entries in ~/.config/autostart.

Two things found on the way: a client update left the undo copy holding the
script from before the update, so undoing would have put an old version back;
and a shortcut edited in place and later deleted would have been recreated by
`disable`.
2026-09-01 13:21:57 +02:00
Felitendo ced6f57dfd chore: 1.1.0
release / Debian package (push) Failing after 31s
release / RPM package (push) Failing after 31s
release / Release and repositories (push) Skipped
release / Install from the RPM repository (push) Skipped
Two features since 1.0.3: it works on distributions other than Arch, and
there are packages and a signed repository to get it from.
2026-08-24 11:09:13 +02:00
Felitendo 21e29d99c3 docs: get the Pages setup steps into an order that works
The branch is created by the first release, and a branch that does not exist
cannot be selected in the Pages settings - so telling somebody to set Pages
first sends them into a wall. Release first, point Pages at it second.

Also says what happens if Pages is left on main: the source tree gets served
at the address the install instructions name, and every one of them 404s.
2026-08-24 11:08:05 +02:00
Felitendo c31c7b92e5 docs: shorten and rewrite the README 2026-08-24 11:01:02 +02:00
Felitendo 0bbf0cbd9f docs: how to try the release path before tagging 2026-08-24 10:55:48 +02:00
Felitendo 70f6748f3c test: make the release path runnable without cutting a tag
The publish job had never run. It is the part that produces the apt and dnf
repositories, which is to say it is the whole update mechanism, and finding
out whether it works when a tag is already pushed is the wrong time.

A dry run now builds both repositories with a key generated on the spot,
verifies the three signatures it wrote, and then installs the packages back
out of them - apt on the runner, dnf in a Fedora container - so the thing
being tested is the thing that runs. Nothing is pushed and no release is
created.
2026-08-24 10:53:30 +02:00
Felitendo aaa41f0384 chore: move the workflows off the deprecated Node 20 actions
The runners force actions/checkout@v4 and the artifact actions onto Node 24
already and warn about it on every run. Current majors instead, so the
annotation goes away and the pin says what is actually running.
2026-08-24 10:42:07 +02:00
Felitendo a053b4e555 feat: packages and a signed repository for Debian and Fedora
The code works on those distributions now; there was still nothing to
install. This adds the two packages and, more to the point, somewhere for
them to live that hands out updates - a package a user has to notice a new
version of and download again is not much better than a checkout.

Both are built from `make install` and nothing else. A packaging script that
lists the installed files a second time is a second description of the
layout, and the two drift the first time a file moves; here the Makefile
stays the only place that says where anything goes. The .deb is staged and
wrapped with dpkg-deb, the .rpm goes through a spec whose %install is the
same make invocation. Both are architecture-independent, so one file each
covers Debian, Ubuntu and their derivatives on one side and Fedora, RHEL and
openSUSE on the other.

The version is not written down twice either. The Makefile has it, the
control file and the spec take it as a placeholder, and check-version.sh
refuses a tag that disagrees - otherwise a v1.0.4 release quietly ships a
program that reports 1.0.3.

The release workflow builds both in a Debian and a Fedora container, signs
the RPM where there is a native rpm-sign, attaches both to the GitHub
release, and then adds them to an APT and a DNF repository on gh-pages,
regenerating the indexes over every version ever published so that pinning
and going back to one still work. Missing the signing key is not an error:
it builds, it says in the log that the repositories were left alone, and the
packages are still on the release.

There is also a check workflow, which is the first time shellcheck actually
runs on this.

Neither package carries a maintainer script. The units are enabled per user
by the program itself, so there is nothing for a package to do as root - and
nothing it could do about the changes in a user's home either, which is why
both descriptions say to run `disable` before removing.

packaging/README.md has the two things that cannot be automated: making the
signing key, and pointing Pages at the branch.
2026-08-24 10:40:15 +02:00
Felitendo 36498f2acf feat: work on distributions other than Arch
The README asked for "Arch or an Arch derivative" and the code had two
reasons for it. Neither of them was the mechanism, which is why this is
mostly a matter of not assuming.

The first was the flag file. Arch wraps Electron and Chromium in launchers
that read $XDG_CONFIG_HOME/<name>-flags.conf, and that route is the good
one - it survives upgrades and applies to a launch from a terminal. Debian,
Ubuntu, Fedora and openSUSE keep the equivalent under /etc, where it is the
system's file and not the user's, so there is nothing to write and those
applications have to go through their desktop entry instead. That already
worked, because a launcher is read rather than assumed - but only if the
launcher was recognised as Chromium at all, and it was not:

    APPNAME=chromium
    LIBDIR=/usr/lib/chromium
    exec -a "$APPNAME" "$LIBDIR/$APPNAME" $CHROMIUM_FLAGS "$@"

is the shape every one of those wrappers has, and following it needs the
assignments above resolved and -a understood as renaming the process rather
than naming the program. Both are done now, and the wrappers that still
cannot be followed are caught by CHROMIUM_FLAGS and CHROME_WRAPPER, which
nothing but a Chromium launcher sets. Two applications on the machine this
was written on turn out to have been missed for the same reason: Helium,
whose wrapper is followed to a payload full of markers, and ONLYOFFICE,
which ships libcef.so.

Resolving more wrappers made an old inference dangerous. Any launcher that
could be followed also had "<target>-flags.conf" invented for it, on the
theory that a wrapper builds that name from a variable at runtime. For an
application that simply execs its own binary that file is read by nobody:
the flag would have gone to ~/.config/DesktopEditors-flags.conf and the
desktop entry that would have worked was skipped. The name is now derived
only once the target has shown it reads a flag file at all.

The second reason was snaps. /snap/bin/<name> is a symlink to snapd, so
following it lands on /usr/bin/snap and says nothing; the payload is in the
mounted revision, and that tree takes the same marker check as anything
else. They get a settings switch of their own next to Flatpak, and the
launcher entry as their only way in.

Packaging is now a gate in front of the category rather than a category
beside it. A Chromium installed as a snap or a Flatpak was filed as neither
an application nor a browser, so turning browsers off did not reach it -
which on Ubuntu means the default browser. It is a browser that happens to
be packaged as a snap, and both switches apply.

The rest is the same not-assuming: Steam is found in Debian's
~/.steam/debian-installation and in the snap's private tree, the Flatpak
and snap export directories are scanned even when a session started before
they were installed left them out of XDG_DATA_DIRS, /usr/lib/x86_64-linux-gnu
counts as a shared directory the way /usr/lib does, the watcher covers
snapd's export directory and the NixOS and Guix profiles, LANG is read from
/etc/default/locale as well as /etc/locale.conf, and the systemd user unit
directory is asked of systemd instead of guessed - while still following a
PREFIX that was asked for.
2026-08-24 10:23:47 +02:00
Felitendo bd84c6645a Stop offering Steam games as applications
Steam writes a desktop entry for every game somebody asks for a shortcut
to, and it starts the same program the client's own entry does:

    Exec=steam steam://rungameid/3527290

The scan only ever looked at the first token, found steam there, and filed
the game under the same kind as the client. So PEAK sat in the applications
list reading "on (Steam)", counted towards the applications covered, and
offered a space bar to switch it off again - as though it were something
this program had anything to offer. It is not: a game is whatever engine it
was built with, and none of them reads a Chromium argument.

An entry carrying a steam:// address of its own is not a program, it is one
more way of starting Steam. The client's own entry never has one - it takes
an address from the outside, through %U - and that is what tells the two
apart. Those entries go to the Steam module now instead of into the list.

They keep -noverifyfiles, for the same reason the rest of the Steam handling
has it: starting a game with the client closed is a Steam start like any
other, and without the switch it finds the patched web helper script, puts
its own copy back, and the interface loses autoscroll for the rest of the
session.
2026-08-24 10:06:29 +02:00
Felitendo f42d80598f fix: keep Steam out of an endless update loop
Steam compares its installed files against its manifest at every start and
restores whatever differs, which is why its launcher entry carries
-noverifyfiles. The entry in ~/.config/autostart never got it: it points at
/usr/bin/steam, a shell script, so it fell through the Chromium filter in
mca_autostart_apply and was left alone.

A Steam started at login therefore verified, found the web helper script 46
bytes larger than the manifest says, restored it, and the path unit patched
it straight back - an update dialog that begins again every few seconds and
never finishes. The same client started from the menu was fine, which is
what made it look like a problem of Steam's own.

The autostart entry now carries the switch as well. It follows the Steam
setting rather than the autostart one, because leaving it out while Steam is
patched is exactly what causes the loop.

That covers the entry that exists; a terminal or a script still starts Steam
without the switch. So the patch no longer fights back either: a script that
was patched before, is not patched now, and belongs to a client that is
still running has just been restored by Steam, and doing it again would only
have the two of them undoing each other. It waits for the next apply with
Steam closed - the helper is started once, at the start, so patching it now
would not have helped that session anyway.

Also: Steam does not checksum that script, it compares sizes - the log says
"Verifying file sizes only" - while the comments and the documentation
claimed otherwise. And the status screen now tells a patch that is waiting
from one that is missing, instead of reporting both as not patched yet.
2026-08-24 10:01:27 +02:00
27 changed files with 2022 additions and 302 deletions

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
name: check
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
check:
name: syntax and shellcheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# The Makefile skips whatever is missing rather than failing, so the
# tools it looks for have to be here or the check checks less than it
# appears to.
- name: Install the tools the Makefile looks for
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends \
shellcheck gettext scdoc
- run: make check
- name: Build the catalogs and the man page
run: make build
+256
View File
@@ -0,0 +1,256 @@
name: release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
dry_run:
description: >-
Build the repositories with a throwaway key and install from them,
without publishing anything. This is how the release path gets
exercised without cutting a tag.
type: boolean
default: false
permissions:
contents: write
jobs:
deb:
name: Debian package
runs-on: ubuntu-latest
container: debian:stable
steps:
- name: Install the build tools
run: |
apt-get update -qq
apt-get install -y --no-install-recommends \
ca-certificates git make gettext scdoc dpkg-dev
- uses: actions/checkout@v7
- name: Check the tag against the Makefile
run: packaging/check-version.sh "${{ github.ref_name }}"
- run: packaging/build-deb.sh
- name: Look inside what was built
run: |
dpkg-deb --info dist/*.deb
dpkg-deb --contents dist/*.deb
- uses: actions/upload-artifact@v7
with:
name: deb
path: dist/*.deb
if-no-files-found: error
rpm:
name: RPM package
runs-on: ubuntu-latest
container: fedora:latest
steps:
- name: Install the build tools
run: |
dnf install -y --setopt=install_weak_deps=False \
git make gettext scdoc tar rpm-build rpm-sign systemd-rpm-macros
- uses: actions/checkout@v7
- name: Check the tag against the Makefile
run: packaging/check-version.sh "${{ github.ref_name }}"
- run: packaging/build-rpm.sh
# Signed here rather than alongside the APT repository, because this is
# the one place with a native rpm-sign. A dry run signs with a key it
# makes on the spot, so the command itself is still exercised.
- name: Sign the package
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
if [ "$DRY_RUN" = "true" ]; then
gpg --batch --passphrase '' --quick-generate-key \
'dry run <dry-run@example.invalid>' rsa2048 sign never
elif [ -n "${GPG_PRIVATE_KEY:-}" ]; then
printf '%s' "$GPG_PRIVATE_KEY" | gpg --batch --import
else
echo "No GPG_PRIVATE_KEY secret; leaving the package unsigned."
exit 0
fi
keyid="$(gpg --list-secret-keys --with-colons | awk -F: '/^sec:/ { print $5; exit }')"
rpmsign --define "_gpg_name $keyid" --addsign dist/*.rpm
gpg --armor --export "$keyid" > dist/rpm-signer.asc
rpm --import dist/rpm-signer.asc
rpm --checksig dist/*.rpm
- name: Look inside what was built
run: |
rpm -qip dist/*.rpm
rpm -qlp dist/*.rpm
- uses: actions/upload-artifact@v7
with:
name: rpm
path: |
dist/*.rpm
dist/rpm-signer.asc
if-no-files-found: error
publish:
name: Release and repositories
needs: [deb, rpm]
if: startsWith(github.ref, 'refs/tags/v') || inputs.dry_run
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
path: incoming
merge-multiple: true
- name: Attach the packages to the release
if: ${{ !inputs.dry_run }}
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${{ github.ref_name }}" \
--title "${{ github.ref_name }}" \
--generate-notes \
incoming/*.deb incoming/*.rpm \
|| gh release upload "${{ github.ref_name }}" \
incoming/*.deb incoming/*.rpm --clobber
- name: Install the repository tools
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends \
dpkg-dev apt-utils createrepo-c
- name: Get a signing key
id: key
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
if [ "$DRY_RUN" = "true" ]; then
gpg --batch --passphrase '' --quick-generate-key \
'dry run <dry-run@example.invalid>' rsa2048 sign never
elif [ -n "${GPG_PRIVATE_KEY:-}" ]; then
printf '%s' "$GPG_PRIVATE_KEY" | gpg --batch --import
else
echo "present=no" >> "$GITHUB_OUTPUT"
echo "::warning::No GPG_PRIVATE_KEY secret - the apt and dnf repositories were not updated. The packages are on the release."
exit 0
fi
echo "present=yes" >> "$GITHUB_OUTPUT"
- name: Check out the published repositories
if: steps.key.outputs.present == 'yes' && !inputs.dry_run
uses: actions/checkout@v7
with:
ref: gh-pages
path: pages
continue-on-error: true
- name: Update the repositories
if: steps.key.outputs.present == 'yes'
run: |
# First release, or a dry run: there is no branch to start from.
if [ ! -d pages/.git ]; then
rm -rf pages && mkdir pages
git -C pages init -q -b gh-pages
git -C pages remote add origin "https://github.com/${{ github.repository }}.git"
fi
rm -f incoming/rpm-signer.asc
packaging/publish-repos.sh pages incoming
- name: Check that what was written can be verified
if: steps.key.outputs.present == 'yes'
run: |
find pages -type f -not -path '*/.git/*' | sort
echo '--- Release ---'; cat pages/deb/Release
echo '--- Packages ---'; cat pages/deb/Packages
gpg --verify pages/deb/InRelease
gpg --verify pages/deb/Release.gpg pages/deb/Release
gpg --verify pages/rpm/repodata/repomd.xml.asc pages/rpm/repodata/repomd.xml
# The proof that the repository works is apt reading it: the signature,
# the index, the dependencies and the program that comes out the far end.
- name: Install from the repository that was just built
if: inputs.dry_run
run: |
sudo install -d -m 0755 /etc/apt/keyrings
sudo gpg --dearmor -o /etc/apt/keyrings/middleclick-autoscroll.gpg < pages/KEY.gpg
echo "deb [signed-by=/etc/apt/keyrings/middleclick-autoscroll.gpg] file://$PWD/pages/deb ./" \
| sudo tee /etc/apt/sources.list.d/middleclick-autoscroll.list
sudo apt-get update
sudo apt-get install -y middleclick-autoscroll
middleclick-autoscroll --version
middleclick-autoscroll list
- uses: actions/upload-artifact@v7
if: inputs.dry_run
with:
name: pages
path: pages
include-hidden-files: true
- name: Push them
if: steps.key.outputs.present == 'yes' && !inputs.dry_run
env:
GH_TOKEN: ${{ github.token }}
run: |
cd pages
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
git diff --quiet --cached && { echo "nothing changed"; exit 0; }
git commit -q -m "Publish ${{ github.ref_name }}"
git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" HEAD:gh-pages
verify-dnf:
name: Install from the RPM repository
needs: publish
if: inputs.dry_run
runs-on: ubuntu-latest
container: fedora:latest
steps:
- uses: actions/download-artifact@v8
with:
name: pages
path: pages
# A real run signs the package and the repository metadata with the one
# key from the secret. A dry run has no secret, so each job made a key of
# its own and both public halves are needed to check both signatures.
- uses: actions/download-artifact@v8
with:
name: rpm
path: signer
- name: Install from the repository that was just built
run: |
rpm --import pages/KEY.gpg
rpm --import signer/rpm-signer.asc
cat > /etc/yum.repos.d/middleclick-autoscroll.repo <<EOF
[middleclick-autoscroll]
name=middleclick-autoscroll
baseurl=file://$PWD/pages/rpm
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=file://$PWD/pages/KEY.gpg
EOF
# util-linux is for runuser below; the base image does not carry it,
# and util-linux-core is not the half that has it.
dnf install -y middleclick-autoscroll util-linux
# As somebody, not as root: running it as root is refused, which is
# the point of it, and a container is root by default.
useradd -m tester
runuser -u tester -- middleclick-autoscroll --version
runuser -u tester -- middleclick-autoscroll list
+3
View File
@@ -1,3 +1,6 @@
# build artifacts
po/*.mo
doc/*.1
# packages
dist/
+21 -2
View File
@@ -8,7 +8,7 @@
# Overridable so a packager can pass the version it is actually building
# (`make VERSION=$pkgver`). The literal below is the fallback for builds
# straight from a checkout, and is what a release tag has to carry.
VERSION ?= 1.0.3
VERSION ?= 1.3.0
PREFIX ?= /usr
DESTDIR ?=
@@ -17,7 +17,20 @@ DATADIR ?= $(PREFIX)/share
LIBDIR ?= $(DATADIR)/middleclick-autoscroll/lib
LOCALEDIR ?= $(DATADIR)/locale
MANDIR ?= $(DATADIR)/man
# Where systemd looks for user units. For a normal install into /usr this is
# asked of systemd itself, because the answer is not the same everywhere - a
# distribution that still keeps /lib separate from /usr/lib says so here - and
# only guessed at when there is no systemd installed to ask.
#
# A build with a prefix of its own keeps the units under that prefix instead.
# systemd searches $(PREFIX)/lib/systemd/user as well, and a file outside the
# prefix it was asked for is not this build's to place.
ifeq ($(PREFIX),/usr)
USERUNITDIR ?= $(shell pkg-config --variable=systemduserunitdir systemd 2>/dev/null || echo /usr/lib/systemd/user)
else
USERUNITDIR ?= $(PREFIX)/lib/systemd/user
endif
LINGUAS := de
MOFILES := $(patsubst %,po/%.mo,$(LINGUAS))
@@ -28,12 +41,18 @@ LIBS := $(wildcard src/lib/*.sh)
MSGFMT := $(shell command -v msgfmt 2>/dev/null)
SCDOC := $(shell command -v scdoc 2>/dev/null)
.PHONY: all build install uninstall check clean
.PHONY: all build install uninstall check clean version
all: build
build: $(MOFILES) $(MANPAGE)
# The one place the version is written down, for everything that has to agree
# with it: the packaging scripts, and the release workflow checking that the
# tag it was handed says the same thing.
version:
@echo $(VERSION)
po/%.mo: po/%.po
ifdef MSGFMT
$(MSGFMT) --check --output-file=$@ $<
+94 -129
View File
@@ -1,60 +1,60 @@
# middleclick-autoscroll
Middle-click autoscroll — hold the middle mouse button, move the pointer, the
page scrolls — in every application on the system that can do it, and in
anything installed later.
Middle-click autoscroll is a CLI for Linux that enables autoscroll in every application that supports it.
Works with browsers, Electron apps like Discord and Spotify, Steam, and anything else that runs on Chromium under the hood.
There is no list of supported applications to check against and none to keep up
to date. Every launcher on the system is examined, the ones running on Chromium
underneath are identified by what they ship rather than by their name, and each
of them is handled.
There is no app list. My tool just looks at what uses chromium under the hood and applies the necessary steps to get it working.
## How to install
**Arch**
```bash
paru -S middleclick-autoscroll
middleclick-autoscroll enable
yay -S middleclick-autoscroll
```
That is the whole setup. Nothing else has to be configured, and no file has to
be edited.
**Fedora**
## Why this needs a program at all
Blink — the engine inside Chromium, Electron and CEF — has had Windows-style
autoscroll for years. On Linux it is switched off, because middle click is
already taken by primary-selection paste. One command line argument turns it
back on:
```
--enable-blink-features=MiddleClickAutoscroll
```bash
sudo curl -fsSL -o /etc/yum.repos.d/middleclick-autoscroll.repo \
https://felitendo.github.io/middleclick-autoscroll/middleclick-autoscroll.repo
sudo dnf install middleclick-autoscroll
```
Getting that argument into one application is a five-minute job. Getting it into
all of them, in a way that survives the next package upgrade, is not:
**Debian**
- Some applications read a flag file, some don't.
- Some ship their own copy of Electron, some use the system one.
- Flatpaks see none of the host's configuration.
- An application that starts itself at login uses a different entry than the one
in the menu, and Discord launched at login used to behave differently from
Discord launched by hand.
- Steam takes no arguments for its interface at all, and puts back any file you
change.
- Every upgrade can undo the lot.
```bash
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://felitendo.github.io/middleclick-autoscroll/KEY.gpg \
| sudo gpg --dearmor -o /etc/apt/keyrings/middleclick-autoscroll.gpg
echo "deb [signed-by=/etc/apt/keyrings/middleclick-autoscroll.gpg] https://felitendo.github.io/middleclick-autoscroll/deb ./" \
| sudo tee /etc/apt/sources.list.d/middleclick-autoscroll.list
sudo apt update && sudo apt install middleclick-autoscroll
```
## The interface
**openSUSE**
`middleclick-autoscroll` on its own:
```bash
sudo rpm --import https://felitendo.github.io/middleclick-autoscroll/KEY.gpg
sudo zypper addrepo --gpgcheck --refresh \
https://felitendo.github.io/middleclick-autoscroll/rpm middleclick-autoscroll
sudo zypper install middleclick-autoscroll
```
## How to use
Just run `middleclick-autoscroll`. This will open the configuration TUI that looks like this:
```
Middle-Click Autoscroll
Autoscroll ON
Applications covered 14 of 15
Applications covered 11 of 13
Not identified 1 - see the applications list
Steam ON
New applications ON
Last applied 3 minutes ago
Last applied 2 minutes ago
Applications pick this up the next time they are started.
@@ -63,124 +63,84 @@ all of them, in a way that survives the next package upgrade, is not:
[3] Applications
[4] Settings
[q] Quit
>
```
**[3] Applications** lists everything that was found, how each one is handled,
and lets a single application be switched off — or an unrecognised one switched
on — with the space bar:
`[1]` is all you need for the normal case. `[3]` lists every app that was found
and what is being done with it, so you can leave a single one out or switch on
an AppImage that couldn't be identified:
```
Applications
▸ Vesktop on (flag file)
▸ Steam on (Steam)
Chromium on (launcher)
Discord on (flag file)
Code - OSS on (flag file)
Obsidian on (flag file)
Signal on (flag file)
Obsidian on (launcher)
Spotify on (launcher)
Steam on (Steam)
Slack off
Cursor cannot tell
Anything not identified is left alone until it is turned on here.
Up/Down select - Space turns one on or off - q goes back
```
**[4] Settings** has the categories — Electron and CEF applications, browsers,
Flatpaks, autostart entries, Steam, Spotify, whether to watch for new
applications, and a field for extra Chromium arguments if you want any.
`[4]` has the same switches per category - browsers, Electron and CEF apps,
Flatpaks, snaps, autostart entries, Steam, Spotify - plus a field for extra
Chromium arguments.
There is a configuration file behind all of this. You are never asked to open
it.
## How it works
## Where the argument actually goes
Blink (the engine in Chromium, Electron, and CEF) already has autoscroll, but
it's off on Linux because middle click does primary-selection paste there. This
flag turns it on:
Two routes, picked per application.
--enable-blink-features=MiddleClickAutoscroll
| | |
|---|---|
| **Flag file** | Arch's Electron and Chromium wrappers read extra arguments from `~/.config/<name>-flags.conf`. This is the good one: it is the supported way to pass arguments, it survives package upgrades untouched, and it applies to a launch from a terminal as much as one from the menu. |
| **Desktop entry** | For applications that ship their own binary with no wrapper, and for Flatpaks, a copy of the entry with the argument appended goes into `~/.local/share/applications`, where it shadows the system one. |
But doing that for every app is kinda bothersome and it also might break with updates.
That's why I created this small tool to automate that.
Entries that already live in `~/.local/share/applications` — AppImages, web app
shortcuts — are edited in place and the original is kept. So are the entries in
`~/.config/autostart`, so an application that starts itself at login gets the
same treatment as one started from the menu.
Browsers get the same thing asked for differently:
An `--enable-blink-features` that is already there is **extended**, never
repeated. Chromium keeps only the last occurrence of that option, so a second
one would silently switch off whatever the first one enabled.
--enable-features=MiddleClickAutoscroll
Both mean the same feature - Blink generates a matching feature name for each of
its runtime flags - but the first one is on Chromium's list of flags worth
warning about, so a browser started with it shows a yellow "unsupported
command-line flag" bar over every page. The second one is not on that list, so
there's no bar. It only works from Chromium 124 on, which is why apps that
embed something older (Steam's CEF, older Electron) keep the first one - they
have no such bar to begin with. Helium, which carries the feature under its own
name, is asked for `HeliumMiddleClickAutoscroll` alongside it.
New apps are picked up by a systemd path unit that watches the
relevant directories. Without systemd, `middleclick-autoscroll apply` does the
same thing manually.
## Steam
Steam's interface is CEF and supports the feature perfectly well, but Steam
builds the command line for its web helper itself and offers no way to add to
it. The only place an argument fits is the script that starts the helper, inside
Steam's own installation:
Steam's web UI supports autoscroll but has no way to pass extra arguments to its
helper, so the program patches the script that starts it. Steam checks its own
files at every start and repairs whatever looks changed, so the patch is written
to look unchanged.
```
~/.local/share/Steam/ubuntu12_64/steamwebhelper_sniper_wrap.sh
```
Steam checksums that script at every start and restores it when it differs, so
its launcher entry also gets `-noverifyfiles`. **The trade-off is real**: with
verification off, Steam no longer repairs a damaged installation by itself. That
is why Steam is a switch of its own rather than part of the general handling —
turn it off in the settings and Steam is left completely alone.
The script comes back on every client update. The watcher notices and puts the
patch back.
Starting Steam from a terminal without `-noverifyfiles` undoes it for that one
session; the next start from the menu has it again.
## Applications installed later
A systemd user path unit watches every directory a launcher can appear in —
`/usr/share/applications`, the Flatpak exports, `~/.local/share/applications`,
`~/.config/autostart` — plus Steam's helper script. Anything new is handled
within a second of being installed, whether it came from pacman, the AUR,
Flatpak or an AppImage manager. There is no hook to install per package manager.
## What it will not guess
An AppImage keeps its payload in a compressed filesystem, so there is no way to
tell from the outside whether Chromium is in there. Those show up as **cannot
tell** and are left alone until you switch them on from the applications screen.
Detection is deliberately conservative everywhere else too. A wrong "yes" would
append an unknown argument to something that is not Chromium, and plenty of
programs treat an unrecognised argument as a file name to open.
## Undoing it
```bash
middleclick-autoscroll disable
```
Every change is recorded in a ledger as it is made, and `disable` replays it
backwards: generated entries are deleted, edited files are restored from their
backups, flag files that only ever contained our line are removed, and a flag
file that was merged into loses exactly the one feature that was added to it.
Files that were not touched by this program are not touched by it now either.
Run this before uninstalling the package.
As a fallback, for the case where that isn't possible, `-noverifyfiles` goes on
everything that starts Steam: its launcher, its autostart entry, and the
shortcuts it writes for single games. That one means Steam won't auto-repair
damaged files on its own just so you're aware of that.
## Commands
| | |
| Command | |
|---|---|
| `middleclick-autoscroll` | the menu above |
| `… enable` | turn it on, apply, start watching |
| `… disable` | turn it off and put everything back |
| `… apply` | apply to anything new (this is what the watcher calls) |
| `… apply --rebuild` | take everything back and apply it again, to repair a mess |
| `… status` | what is covered |
| `… list` | every application that was found and how it is handled |
See `man middleclick-autoscroll` for the details.
## Requirements
Arch or an Arch derivative (CachyOS, EndeavourOS, Manjaro), bash, systemd for
the watcher. Nothing outside your home directory is ever written to, and running
it as root is refused.
| `middleclick-autoscroll` | Interactive menu |
| `… enable` | Turn on, apply, start watching |
| `… disable` | Undo everything |
| `… apply` | Apply to new apps |
| `… apply --rebuild` | Redo from scratch |
| `… status` | What's covered |
| `… list` | All apps and how they're handled |
## Building from source
@@ -189,7 +149,12 @@ make
sudo make install
```
`make check` runs `bash -n` and, if installed, `shellcheck` over every script.
Optionally needs `msgfmt` (gettext) for translations and `scdoc` for the man
page. Supports `PREFIX` and `DESTDIR`. `make check` runs syntax checks and
shellcheck.
See [packaging/README.md](packaging/README.md) for release builds and repo
signing.
## License
+83 -19
View File
@@ -20,6 +20,19 @@ on the command line.
and puts that argument somewhere the application will actually read it, then
keeps doing so for anything installed later.
A browser is given *--enable-features=MiddleClickAutoscroll* instead. It asks
for the same feature - Blink generates a feature of that name for each of its
runtime flags - but it is not on the list of flags Chromium warns about, so the
browser does not put a bar reading "You are using an unsupported command-line
flag" above every page. That spelling only works from Chromium 124 onwards,
which is why everything else keeps the first one: an application that embeds an
older Chromium, such as Steam's CEF, has no such bar to show anyway. Helium
knows the feature under a name of its own and is asked for
*HeliumMiddleClickAutoscroll* as well; a name a browser does not know is
ignored. It works on any distribution:
which of the routes below an application takes is read off its launcher, not
assumed from where the launcher came from.
Run without a command it shows an interactive menu. Everything it can be told
is reachable from there; the configuration file behind it does not need to be
edited by hand.
@@ -66,20 +79,30 @@ is refused.
Two ways, chosen per application.
*Flag file*
Arch's Electron and Chromium wrappers read extra arguments from
Where the launcher reads extra arguments from
_$XDG_CONFIG_HOME/<name>-flags.conf_. This is the preferred route: it is
the supported way to pass arguments, it survives package upgrades, and it
applies to a launch from a terminal as much as one from the menu. An
*--enable-blink-features* line that is already in the file is extended
rather than duplicated - Chromium keeps only the last occurrence of that
option, so a second one would switch the first one off.
applies to a launch from a terminal as much as one from the menu. A
feature list that is already in the file is extended rather than
duplicated - Chromium keeps only the last occurrence of such an option, so
a second one would switch the first one off.
Arch's Electron and Chromium packages all wrap their binaries this way, and
so do a number of individual vendors' launchers elsewhere. Whether a given
launcher does is read off the launcher itself, never assumed from the
distribution: only one that really names such a file takes this route.
*Desktop entry*
For applications that ship their own binary with no wrapper, and for
Flatpaks, a copy of the desktop entry with the argument appended is written
to _~/.local/share/applications_, where it shadows the system one. Entries
that already live there - AppImages, web app shortcuts - are edited in
place, with the original kept.
everything inside a Flatpak or a snap, a copy of the desktop entry with the
argument appended is written to _~/.local/share/applications_, where it
shadows the system one. Entries that already live there - AppImages, web
app shortcuts - are edited in place, with the original kept.
This is the route everything takes on the distributions whose Chromium
wrappers keep their equivalent file under _/etc_, where it is the system's
to write and not the user's: Debian, Ubuntu, Fedora and openSUSE among
them.
A generated entry is marked *X-MCA-Generated* and an entry edited in place
*X-MCA-Patched*. The two are never confused: the first is deleted when
@@ -90,6 +113,13 @@ Programs that start themselves at login write their own entry into
_~/.config/autostart_ pointing straight at their binary, bypassing the menu
entry entirely. Those are patched in place as well.
Shortcuts on the desktop itself are patched in place too. Nothing in the XDG
search path looks at that folder, so a shortcut that lives only there would
otherwise be invisible - and Steam puts one there for every game somebody asks
for a shortcut to. The folder's name is translated, and the name in use is read
from _~/.config/user-dirs.dirs_ rather than guessed; the watcher is told about
it in a drop-in written when autoscroll is turned on.
# STEAM
Steam's interface is CEF and supports the feature, but Steam builds the command
@@ -98,22 +128,50 @@ helper, inside Steam's own installation:
~/.local/share/Steam/ubuntu12_64/steamwebhelper_sniper_wrap.sh
Steam checksums that script at every start and restores it when it differs, so
its launcher entry also gets *-noverifyfiles*. The trade-off is that Steam no
longer repairs a damaged installation on its own; that is why Steam is a switch
of its own in the settings.
Where that installation is depends on how Steam was installed:
_~/.local/share/Steam_ for Valve's own package and Arch's,
_~/.steam/debian-installation_ for Debian's, and the private tree of the
sandbox for the Flatpak and the snap. All of them are looked at.
Starting Steam from a terminal without *-noverifyfiles* undoes the patch for
that session. The watcher notices and puts it back, so the next start has it
again.
Steam compares the installed files against its manifest at every start - by
size and timestamp rather than by content - and restores whatever differs. So
the patch is written to look untouched: the bytes the argument costs are taken
back out of the script's own comments and the timestamp is put back afterwards,
leaving a file exactly as long and exactly as old as Steam left it. A client
that checks its files finds nothing to repair, and the argument survives
however Steam was started - from the menu, from a game shortcut, from a
launcher like Heroic or Lutris, from a terminal.
That is what has to work, because there is no way to make every possible way of
starting Steam carry an argument. *-noverifyfiles* is the second line rather
than the first: it covers the case where the script has no comments left to pay
for the argument and the patch has to grow the file. It goes on Steam's
launcher entry, on its entry in _~/.config/autostart_, which Steam writes as
soon as it is set to run at login, and on the shortcuts Steam writes for single
games, on the desktop and in the menu alike. A game is not an application this
program has anything to offer and none of them is listed under *Applications*,
but starting one is a Steam start like any other. The trade-off of the switch
is that Steam no longer repairs a damaged installation on its own; that is why
Steam is a switch of its own in the settings.
A patch that does change the size is still held back while the client is
running: Steam puts its own copy back, the two would only undo each other, and
the helper is started once, at the start, so patching again would not help that
session anyway. It goes in at the next apply with Steam closed. A patch that
keeps the size has nothing to wait for and goes in either way.
A client update brings a new version of the script. The watcher notices and
patches it again, and the copy kept for undoing is replaced with the new
version at the same time, so undoing puts back the script Steam last shipped
rather than the one from before the update.
# SPOTIFY
The official client is CEF rather than Electron. Installed through
*spotify-launcher*, it is started by a program that builds its own command line
and has a configuration file with a slot for extra arguments; that slot is
where the flag goes. Installed as a plain package, it is an ordinary desktop
entry and needs nothing special.
where the flag goes. Installed as a plain package, a Flatpak or a snap, it is
an ordinary desktop entry and needs nothing special.
# WHAT CANNOT BE DETECTED
@@ -142,9 +200,15 @@ _~/.cache/middleclick-autoscroll/detect_
*NO_COLOR*
Disables colour.
# REQUIREMENTS
Bash 4.2 or newer and GNU coreutils. The watcher needs a systemd user session;
without one everything else works and applications installed later are picked
up at the next *apply* rather than on their own.
# SEE ALSO
*systemctl*(1), *flatpak*(1)
*systemctl*(1), *flatpak*(1), *snap*(8)
# AUTHORS
+88
View File
@@ -0,0 +1,88 @@
# Packaging and releases
The Makefile installs everything; these only wrap what it produced. That is
deliberate — a packaging script that lists the files again is a second
description of the layout, and two descriptions drift.
| | |
|---|---|
| `deb/control`, `deb/copyright` | metadata for the Debian binary package |
| `rpm/middleclick-autoscroll.spec` | the RPM spec |
| `build-deb.sh`, `build-rpm.sh` | build one package into `dist/` |
| `check-version.sh` | refuses a tag that disagrees with the Makefile |
| `publish-repos.sh` | regenerates the APT and RPM repositories |
| `pages/` | the landing page and the `.repo` file served from GitHub Pages |
Neither package carries a maintainer script. The units are enabled per user by
the program itself, and there is nothing to do as root at install time.
## Building one by hand
```bash
packaging/build-deb.sh # needs dpkg-dev, gettext, scdoc
packaging/build-rpm.sh # needs rpm-build, gettext, scdoc, systemd-rpm-macros
```
Both take the version from `make version` unless one is passed as the first
argument.
## Making a release
1. Bump `VERSION` in the Makefile.
2. Commit, then `git tag vX.Y.Z && git push --tags`.
The `release` workflow builds both packages in a Debian and a Fedora container,
refuses the tag if it disagrees with the Makefile, attaches the packages to a
GitHub release, and adds them to the APT and RPM repositories on the `gh-pages`
branch. Nothing else has to be done by hand.
## Trying the release path first
```bash
gh workflow run release.yml -f dry_run=true
```
Builds both packages, builds both repositories with a key generated on the
spot, checks the three signatures it wrote, and then installs the packages back
out of the repositories — apt on the runner, dnf in a Fedora container. Nothing
is pushed and no release is made. This is worth running after any change to the
packaging, because the alternative is finding out from a tag.
## Setting up the signing, once
The repositories are signed, so this needs a key. Make one that exists for
nothing else — not a personal key — and give it no passphrase: it lives as an
encrypted repository secret, and `rpmsign` cannot be handed a passphrase
unattended.
```bash
gpg --batch --passphrase '' --quick-generate-key \
'middleclick-autoscroll repository <felitendoyt@gmail.com>' rsa4096 sign never
gpg --armor --export-secret-keys 'middleclick-autoscroll repository' \
| gh secret set GPG_PRIVATE_KEY
```
Without the secret the workflow still builds both packages and attaches them to
the release; it says so in the log and leaves the repositories alone.
## Pointing Pages at it, once — and in this order
The `gh-pages` branch does not exist until a release has put something on it,
and a branch that does not exist cannot be picked in the Pages settings. So:
1. Set the secret, above.
2. Tag a release. The workflow creates the branch and fills it.
3. *Then* set **Pages** to deploy from a branch and pick `gh-pages` at the
root.
Doing it the other way round is a wall, and leaving Pages pointed at `main`
serves the source tree at the address the install instructions name — the key
and the indexes are 404 and nothing installs.
## What users end up with
The public key is published as `KEY.gpg` beside the repositories, and the
landing page carries the setup lines for each distribution. After that, a new
version arrives with `apt upgrade`, `dnf upgrade` or `zypper up` like anything
else.
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
#
# Builds the binary package for Debian, Ubuntu and their derivatives into
# dist/.
#
# Everything the package contains comes out of `make install`. This only wraps
# what that produced, so there is exactly one description of where a file goes
# and it is the Makefile - a packaging script that lists the files again is a
# second description, and the two drift.
#
# Needs: make, dpkg-deb, msgfmt (gettext), scdoc.
set -euo pipefail
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
version="${1:-$(make -s -C "$here" version)}"
name=middleclick-autoscroll
# A package without its man page or its translations is not a package this
# should be quietly willing to produce: the Makefile skips both when the tools
# are missing, and the result would look like a successful build.
for tool in msgfmt scdoc dpkg-deb; do
command -v "$tool" > /dev/null || { echo "$0: $tool is not installed" >&2; exit 1; }
done
root="$(mktemp -d)"
trap 'rm -rf -- "$root"' EXIT
make -C "$here" install \
DESTDIR="$root" \
PREFIX=/usr \
VERSION="$version" \
USERUNITDIR=/usr/lib/systemd/user
install -d "$root/DEBIAN"
sed "s|@VERSION@|$version|g" "$here/packaging/deb/control" > "$root/DEBIAN/control"
install -Dm644 "$here/packaging/deb/copyright" \
"$root/usr/share/doc/$name/copyright"
# Relative to the package root, and DEBIAN/ itself is not part of the contents.
# Sorted in the C locale so the file comes out the same whatever the locale of
# the machine that built it.
( cd "$root" && find . -type f ! -path './DEBIAN/*' -printf '%P\0' \
| LC_ALL=C sort -z | xargs -0 md5sum > DEBIAN/md5sums )
mkdir -p "$here/dist"
out="$here/dist/${name}_${version}_all.deb"
dpkg-deb --root-owner-group --build "$root" "$out" > /dev/null
echo "$out"
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
#
# Builds the binary package for Fedora, RHEL, openSUSE and their derivatives
# into dist/.
#
# The spec takes the version as a macro rather than carrying one of its own,
# for the same reason the Debian control file has a placeholder: the Makefile
# is where the version is written down.
#
# Needs: rpmbuild, make, msgfmt (gettext), scdoc, systemd-rpm-macros.
set -euo pipefail
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
version="${1:-$(make -s -C "$here" version)}"
name=middleclick-autoscroll
command -v rpmbuild > /dev/null || { echo "$0: rpmbuild is not installed" >&2; exit 1; }
top="$(mktemp -d)"
trap 'rm -rf -- "$top"' EXIT
mkdir -p "$top"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS}
# The working tree as it is, not as it was committed: a package built from a
# checkout has to contain what is in that checkout.
tar czf "$top/SOURCES/$name-$version.tar.gz" \
--transform "s,^\\.,$name-$version," \
--exclude=./.git --exclude=./dist --exclude=./po/'*.mo' \
-C "$here" .
rpmbuild \
--define "_topdir $top" \
--define "_version $version" \
-bb "$here/packaging/rpm/$name.spec" > /dev/null
mkdir -p "$here/dist"
cp "$top"/RPMS/noarch/*.rpm "$here/dist/"
ls "$here/dist/$name-$version"*.rpm
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
#
# Refuses a release whose tag and Makefile disagree.
#
# The version is baked into the program at install time from the Makefile, and
# the packages take theirs from the same place - but the tag is what people see
# and what the release is named after. A tag that says something else produces
# a package called 1.0.4 containing a program that reports 1.0.3, and nothing
# would have complained.
#
# Anything that is not a v-tag - a run started by hand from a branch - is not a
# release and has nothing to check.
set -euo pipefail
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
ref="${1:-}"
case "$ref" in
v[0-9]*) ;;
*)
echo "not a release tag (${ref:-none}) - nothing to check against"
exit 0
;;
esac
tag_version="${ref#v}"
make_version="$(make -s -C "$here" version)"
if [[ $tag_version != "$make_version" ]]; then
echo "tag $ref says $tag_version, the Makefile says $make_version" >&2
echo "Bump VERSION in the Makefile to match the tag, or retag." >&2
exit 1
fi
echo "$ref matches the Makefile"
+24
View File
@@ -0,0 +1,24 @@
Package: middleclick-autoscroll
Version: @VERSION@
Section: utils
Priority: optional
Architecture: all
Maintainer: Felitendo <felitendoyt@gmail.com>
Depends: bash (>= 4.2), coreutils, findutils, grep, sed, mawk | gawk | original-awk
Recommends: gettext-base, systemd, desktop-file-utils
Homepage: https://github.com/Felitendo/middleclick-autoscroll
Description: middle-click autoscroll for Chromium-based applications
Blink - the engine inside Chromium, Electron and CEF - has had Windows-style
autoscroll for years: hold the middle mouse button, move the pointer, the page
scrolls. On Linux it is switched off, because middle click is already taken by
primary-selection paste.
.
This turns it back on for every application on the system that can do it, and
for anything installed later. There is no list of supported applications to
keep up to date: every launcher is examined, the ones running on Chromium
underneath are identified by what they ship rather than by their name, and the
argument goes wherever that particular application will actually read it.
.
Nothing outside the user's home directory is ever written to. Run
"middleclick-autoscroll disable" before removing this package, so that
everything it changed is put back.
+20
View File
@@ -0,0 +1,20 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: middleclick-autoscroll
Source: https://github.com/Felitendo/middleclick-autoscroll
Files: *
Copyright: 2026 Felitendo
License: GPL-3+
License: GPL-3+
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later
version.
.
This program is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE. See the GNU General Public License for more details.
.
On Debian systems the full text of the GNU General Public License version 3 can
be found in /usr/share/common-licenses/GPL-3.
+115
View File
@@ -0,0 +1,115 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>middleclick-autoscroll</title>
<style>
:root {
--bg: #ffffff;
--fg: #1b1f23;
--muted: #57606a;
--rule: #d8dee4;
--code-bg: #f6f8fa;
--accent: #1793d1;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0d1117;
--fg: #e6edf3;
--muted: #9198a1;
--rule: #30363d;
--code-bg: #161b22;
--accent: #58a6ff;
}
}
* { box-sizing: border-box; }
body {
margin: 0 auto;
padding: 3rem 1.25rem 5rem;
max-width: 46rem;
background: var(--bg);
color: var(--fg);
font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
}
h1 { font-size: 1.6rem; margin: 0 0 .4rem; }
h2 {
font-size: 1.05rem; margin: 2.5rem 0 .6rem;
padding-bottom: .3rem; border-bottom: 1px solid var(--rule);
}
p.lead { color: var(--muted); margin: 0 0 2rem; }
p { margin: 0 0 1rem; }
a { color: var(--accent); }
code {
background: var(--code-bg); padding: .12em .35em;
border-radius: 4px; font-size: .9em;
}
pre {
background: var(--code-bg);
border: 1px solid var(--rule);
border-radius: 6px;
padding: .9rem 1rem;
overflow-x: auto;
}
pre code { background: none; padding: 0; font-size: .85rem; }
footer {
margin-top: 3.5rem; padding-top: 1rem;
border-top: 1px solid var(--rule);
color: var(--muted); font-size: .9rem;
}
</style>
</head>
<body>
<h1>middleclick-autoscroll</h1>
<p class="lead">
Middle-click autoscroll — hold the middle mouse button, move the pointer, the
page scrolls — in every Chromium-based application on the system, and in
anything installed later.
</p>
<p>
This page is the package repository. Set it up once and every new version
arrives with the rest of your system updates. The
<a href="https://github.com/Felitendo/middleclick-autoscroll">source and the
documentation</a> are on GitHub.
</p>
<h2>Debian, Ubuntu, Linux Mint, Pop!_OS</h2>
<pre><code>sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL @BASEURL@/KEY.gpg \
| sudo gpg --dearmor -o /etc/apt/keyrings/middleclick-autoscroll.gpg
echo "deb [signed-by=/etc/apt/keyrings/middleclick-autoscroll.gpg] @BASEURL@/deb ./" \
| sudo tee /etc/apt/sources.list.d/middleclick-autoscroll.list
sudo apt update
sudo apt install middleclick-autoscroll</code></pre>
<h2>Fedora, RHEL, CentOS Stream</h2>
<pre><code>sudo curl -fsSL -o /etc/yum.repos.d/middleclick-autoscroll.repo \
@BASEURL@/middleclick-autoscroll.repo
sudo dnf install middleclick-autoscroll</code></pre>
<h2>openSUSE</h2>
<pre><code>sudo rpm --import @BASEURL@/KEY.gpg
sudo zypper addrepo --gpgcheck --refresh @BASEURL@/rpm middleclick-autoscroll
sudo zypper install middleclick-autoscroll</code></pre>
<h2>Arch, CachyOS, EndeavourOS, Manjaro</h2>
<pre><code>paru -S middleclick-autoscroll</code></pre>
<h2>Then, once</h2>
<pre><code>middleclick-autoscroll enable</code></pre>
<p>
That is the whole setup. Nothing else has to be configured and no file has to
be edited. Run <code>middleclick-autoscroll disable</code> before removing the
package — it puts back everything that was changed.
</p>
<footer>
GPL-3.0-or-later. Packages are signed; the public key is
<a href="@BASEURL@/KEY.gpg">KEY.gpg</a>.
</footer>
</body>
</html>
@@ -0,0 +1,7 @@
[middleclick-autoscroll]
name=middleclick-autoscroll
baseurl=@BASEURL@/rpm
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=@BASEURL@/KEY.gpg
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
#
# Puts the packages that were just built into the APT and RPM repositories on
# the gh-pages branch, and regenerates the indexes over everything that is
# there.
#
# Old versions are kept rather than replaced. An index built over all of them
# is what lets somebody pin a version or go back to one, and it costs a few
# hundred kilobytes.
#
# Usage: publish-repos.sh <gh-pages checkout> <directory of new packages>
#
# Needs: dpkg-dev, apt-utils, createrepo-c, gpg, and a secret key already
# imported - its id is taken from the keyring.
set -euo pipefail
pages="$(cd -- "$1" && pwd)"
incoming="$(cd -- "$2" && pwd)"
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
base_url="${MCA_REPO_URL:-https://felitendo.github.io/middleclick-autoscroll}"
keyid="$(gpg --list-secret-keys --with-colons | awk -F: '/^sec:/ { print $5; exit }')"
[[ -n $keyid ]] || { echo "$0: no secret key in the keyring" >&2; exit 1; }
mkdir -p "$pages/deb" "$pages/rpm"
cp -- "$incoming"/*.deb "$pages/deb/"
cp -- "$incoming"/*.rpm "$pages/rpm/"
# ---------------------------------------------------------------------------
# APT
# ---------------------------------------------------------------------------
# A flat repository: the packages and their index sit in one directory and the
# sources line ends in "./". There is one distribution here and it is the same
# package for all of them, so the suite and component machinery of a pool
# layout would describe nothing.
(
cd "$pages/deb"
# The old index must be gone before the new one is written: apt-ftparchive
# hashes every file in the directory, and a Release that hashes the
# previous Release is a Release that cannot be verified.
rm -f Packages Packages.gz Release Release.gpg InRelease
dpkg-scanpackages --multiversion . > Packages
gzip -9kf Packages
apt-ftparchive \
-o APT::FTPArchive::Release::Origin=middleclick-autoscroll \
-o APT::FTPArchive::Release::Label=middleclick-autoscroll \
-o APT::FTPArchive::Release::Suite=stable \
-o APT::FTPArchive::Release::Codename=stable \
-o APT::FTPArchive::Release::Architectures=all \
-o APT::FTPArchive::Release::Components=main \
release . > Release
# Both signatures: InRelease is what current apt fetches, Release.gpg is
# what an older one falls back to.
gpg --batch --yes --local-user "$keyid" --clearsign --output InRelease Release
gpg --batch --yes --local-user "$keyid" --detach-sign --armor --output Release.gpg Release
)
# ---------------------------------------------------------------------------
# RPM
# ---------------------------------------------------------------------------
(
cd "$pages/rpm"
createrepo_c --quiet --update .
rm -f repodata/repomd.xml.asc
gpg --batch --yes --local-user "$keyid" --detach-sign --armor repodata/repomd.xml
)
# ---------------------------------------------------------------------------
# The key and the landing page
# ---------------------------------------------------------------------------
gpg --armor --export "$keyid" > "$pages/KEY.gpg"
sed "s|@BASEURL@|$base_url|g" "$here/packaging/pages/index.html" > "$pages/index.html"
sed "s|@BASEURL@|$base_url|g" "$here/packaging/pages/middleclick-autoscroll.repo" \
> "$pages/middleclick-autoscroll.repo"
# Pages would otherwise hand the whole directory to Jekyll, which drops every
# file whose name starts with an underscore and can rewrite the rest.
touch "$pages/.nojekyll"
echo "signed with $keyid"
ls -1 "$pages/deb" "$pages/rpm"
+72
View File
@@ -0,0 +1,72 @@
# Built with `packaging/build-rpm.sh`, which passes the version in rather than
# editing this file: the Makefile is where the version is written down, and two
# places that have to agree are one place too many.
%global upstream_version %{?_version}%{!?_version:1.0.3}
Name: middleclick-autoscroll
Version: %{upstream_version}
Release: 1%{?dist}
Summary: Middle-click autoscroll for Chromium-based applications
License: GPL-3.0-or-later
URL: https://github.com/Felitendo/middleclick-autoscroll
Source0: %{name}-%{version}.tar.gz
BuildArch: noarch
BuildRequires: make
BuildRequires: gettext
BuildRequires: scdoc
# For %{_userunitdir}, which is where the watcher's user units belong.
BuildRequires: systemd-rpm-macros
Requires: bash >= 4.2
Requires: coreutils
Requires: findutils
Requires: grep
Requires: sed
Requires: gawk
# The interface falls back to English without gettext and picks up new
# applications at the next `apply` without systemd, so neither is required.
Recommends: /usr/bin/gettext
Recommends: systemd
Recommends: desktop-file-utils
%description
Blink - the engine inside Chromium, Electron and CEF - has had Windows-style
autoscroll for years: hold the middle mouse button, move the pointer, the page
scrolls. On Linux it is switched off, because middle click is already taken by
primary-selection paste.
This turns it back on for every application on the system that can do it, and
for anything installed later. There is no list of supported applications to keep
up to date: every launcher is examined, the ones running on Chromium underneath
are identified by what they ship rather than by their name, and the argument goes
wherever that particular application will actually read it.
Nothing outside the user's home directory is ever written to. Run
"middleclick-autoscroll disable" before removing this package, so that everything
it changed is put back.
%prep
%autosetup -n %{name}-%{version}
%build
%make_build VERSION=%{upstream_version}
%install
%make_install PREFIX=%{_prefix} VERSION=%{upstream_version} \
USERUNITDIR=%{_userunitdir}
%find_lang %{name}
%files -f %{name}.lang
%license LICENSE
%doc %{_datadir}/doc/%{name}/README.md
%{_bindir}/%{name}
%{_datadir}/%{name}/
%{_userunitdir}/%{name}.path
%{_userunitdir}/%{name}.service
%{_mandir}/man1/%{name}.1*
%changelog
+9
View File
@@ -43,6 +43,9 @@ msgstr "nicht erkennbar"
msgid "not patched yet"
msgstr "noch nicht gepatcht"
msgid "waiting until Steam is closed"
msgstr "wartet, bis Steam geschlossen ist"
#. Status block
msgid "Autoscroll"
@@ -103,6 +106,9 @@ msgstr "Chromium-basierte Browser"
msgid "Flatpak applications"
msgstr "Flatpak-Anwendungen"
msgid "Snap applications"
msgstr "Snap-Anwendungen"
msgid "Programs that start themselves at login"
msgstr "Programme, die sich beim Anmelden selbst starten"
@@ -176,6 +182,9 @@ msgstr "Bereits auf %d Anwendungen angewendet."
msgid "Steam starts its interface in a way this version does not recognise; leaving it alone."
msgstr "Steam startet seine Oberfläche auf eine Art, die diese Version nicht kennt; Steam bleibt unangetastet."
msgid "Steam is running and has put its own file back; the change waits until Steam is closed."
msgstr "Steam läuft und hat seine eigene Datei wiederhergestellt; die Änderung wird nachgeholt, sobald Steam geschlossen ist."
#. Help
msgid "Usage: middleclick-autoscroll [command]"
+9
View File
@@ -43,6 +43,9 @@ msgstr ""
msgid "not patched yet"
msgstr ""
msgid "waiting until Steam is closed"
msgstr ""
#. Status block
msgid "Autoscroll"
@@ -103,6 +106,9 @@ msgstr ""
msgid "Flatpak applications"
msgstr ""
msgid "Snap applications"
msgstr ""
msgid "Programs that start themselves at login"
msgstr ""
@@ -176,6 +182,9 @@ msgstr ""
msgid "Steam starts its interface in a way this version does not recognise; leaving it alone."
msgstr ""
msgid "Steam is running and has put its own file back; the change waits until Steam is closed."
msgstr ""
#. Help
msgid "Usage: middleclick-autoscroll [command]"
+22 -4
View File
@@ -3,21 +3,39 @@ Description=Watch for newly installed Chromium-based applications
Documentation=man:middleclick-autoscroll(1)
[Path]
# Every way an application can turn up: a package, a Flatpak, something
# Every way an application can turn up: a package, a Flatpak, a snap, something
# installed by hand. Watching where the launchers land means there is no hook
# to write per package manager - pacman, flatpak and an AppImage manager all
# end up here.
# to write per package manager - pacman, apt, dnf, zypper, flatpak, snapd and
# an AppImage manager all end up here.
#
# A directory that does not exist on this system costs nothing: systemd watches
# the nearest parent that does and picks the path up when it appears.
PathModified=/usr/share/applications
PathModified=/usr/local/share/applications
PathModified=%h/.local/share/applications
PathModified=%h/.config/autostart
PathModified=/var/lib/flatpak/exports/share/applications
PathModified=%h/.local/share/flatpak/exports/share/applications
PathModified=/var/lib/snapd/desktop/applications
# NixOS and Guix build the search path out of profiles rather than /usr.
PathModified=/run/current-system/sw/share/applications
PathModified=%h/.nix-profile/share/applications
PathModified=%h/.guix-profile/share/applications
# The desktop folder is watched too - Steam writes a shortcut there for every
# game somebody asks for one, and starting a game is a Steam start - but its
# name is translated and cannot be written down here. `enable` adds it in a
# drop-in, from the name this system uses.
# Steam replaces the script that starts its interface on every client update,
# which takes the patch with it.
# which takes the patch with it. Where that installation lives depends on how
# Steam was installed.
PathChanged=%h/.local/share/Steam/ubuntu12_64/steamwebhelper_sniper_wrap.sh
PathChanged=%h/.steam/debian-installation/ubuntu12_64/steamwebhelper_sniper_wrap.sh
PathChanged=%h/.var/app/com.valvesoftware.Steam/.local/share/Steam/ubuntu12_64/steamwebhelper_sniper_wrap.sh
PathChanged=%h/.var/app/com.valvesoftware.Steam/data/Steam/ubuntu12_64/steamwebhelper_sniper_wrap.sh
PathChanged=%h/snap/steam/common/.local/share/Steam/ubuntu12_64/steamwebhelper_sniper_wrap.sh
Unit=middleclick-autoscroll.service
+99 -16
View File
@@ -10,7 +10,7 @@
MCA_CHANGES=0
MCA_ROUTES=()
# mca_route <kind> <id> <program>
# mca_route <kind> <id> <program> [packaging]
# What happens to one application, left in MCA_ROUTE:
# flags - the launcher reads a flag file; write it there
# desktop - no flag file; shadow or edit the desktop entry
@@ -24,7 +24,7 @@ MCA_ROUTES=()
MCA_ROUTE=''
mca_route() {
local kind="$1" id="$2" prog="$3"
local kind="$1" id="$2" prog="$3" packaging="${4:-native}"
if [[ $kind == steam ]]; then
if mca_config_list_has Skip "$id" || [[ $CFG_STEAM != yes ]]; then
@@ -35,14 +35,14 @@ mca_route() {
return
fi
if ! mca_kind_wanted "$kind" "$id"; then
if ! mca_kind_wanted "$kind" "$id" "$packaging"; then
[[ $kind == unknown ]] && MCA_ROUTE=unknown || MCA_ROUTE=off
return
fi
# A Flatpak has its own copy of everything and none of the host's wrappers,
# so the desktop entry is the only way in.
if [[ $kind == flatpak ]]; then
# A Flatpak or a snap carries its own copy of everything and sees none of
# the host's wrappers, so the desktop entry is the only way in.
if [[ $packaging != native ]]; then
MCA_ROUTE=desktop
return
fi
@@ -59,13 +59,22 @@ mca_route() {
# as it likes to be - it writes only what differs, which is what keeps the
# watcher from chasing its own changes.
mca_apply() {
local i id file prog kind route steam_done=0
local i id file prog kind packaging route steam_done=0
MCA_CHANGES=0
MCA_ROUTES=()
MCA_N_ON=0; MCA_N_OFF=0; MCA_N_UNKNOWN=0
mca_config_load
# An installation that was set up by a version whose answer was a different
# flag still has that flag where it put it, and nothing below would touch it
# again: a file that is already marked as patched is left alone. Taking
# everything back once, here, is what carries such an installation over.
if [[ "$(mca_state_read flag_scheme 1)" != "$MCA_FLAG_SCHEME" ]]; then
mca_revert_all
fi
mca_scan
for i in "${!MCA_IDS[@]}"; do
@@ -73,8 +82,9 @@ mca_apply() {
file="${MCA_FILES[i]}"
prog="${MCA_PROGS[i]}"
kind="${MCA_KINDS[i]}"
packaging="${MCA_PACKAGING[i]}"
mca_route "$kind" "$id" "$prog"
mca_route "$kind" "$id" "$prog" "$packaging"
route="$MCA_ROUTE"
MCA_ROUTES[i]="$route"
@@ -86,13 +96,14 @@ mca_apply() {
case "$route" in
flags)
mca_flags_apply "$prog" || mca_desktop_apply "$id" "$file"
mca_flags_apply "$prog" "$kind" \
|| mca_desktop_apply "$id" "$file" "$(mca_flags "$kind")"
;;
desktop)
mca_desktop_apply "$id" "$file"
mca_desktop_apply "$id" "$file" "$(mca_flags "$kind")"
;;
steam)
mca_steam_desktop_apply "$id" "$file"
mca_steam_desktop_apply "$id" "$file" "$packaging"
if (( ! steam_done )); then
mca_steam_apply
steam_done=1
@@ -107,7 +118,32 @@ mca_apply() {
mca_steam_apply
fi
[[ $CFG_AUTOSTART == yes ]] && mca_autostart_apply
# The shortcuts Steam writes for single games. They are not applications
# and are not offered as ones, but starting a game with Steam closed is a
# Steam start like any other: without the switch the client finds the
# patched helper script, puts its own back, and the interface loses
# autoscroll for the rest of the session.
if [[ $CFG_STEAM == yes ]]; then
for i in "${!MCA_STEAM_LINKS[@]}"; do
mca_steam_desktop_apply "${MCA_STEAM_LINKS[i]}" \
"${MCA_STEAM_LINK_FILES[i]}" "${MCA_STEAM_LINK_PACK[i]}"
done
fi
# Steam's autostart entry carries Steam's own switch and follows the Steam
# setting, not this one - leaving it out while Steam is patched is what puts
# the client in an update loop - so both are checked inside.
if [[ $CFG_AUTOSTART == yes || $CFG_STEAM == yes ]]; then
mca_autostart_apply
fi
# Shortcuts on the desktop itself. Nothing above has seen them - the XDG
# search path does not go there - and a game started from one starts Steam
# without its switch, which is the difference between autoscroll working
# and autoscroll working most of the time. Each entry is gated on its own,
# so there is nothing to check out here.
mca_shortcuts_apply
[[ $CFG_SPOTIFY == yes ]] && mca_spotify_apply
mca_prune_orphans
@@ -117,6 +153,7 @@ mca_apply() {
fi
mca_state_write last_apply "$(date +%s)"
mca_state_write flag_scheme "$MCA_FLAG_SCHEME"
return 0
}
@@ -137,13 +174,19 @@ mca_revert() {
# The watcher
# ---------------------------------------------------------------------------
# A systemd user path unit watching every directory a desktop entry can appear
# in. That covers a package installed with pacman, a Flatpak, an AppImage
# registered by hand and a Steam client update, without a hook per package
# manager.
# in. That covers a package from whatever the distribution's package manager
# is, a Flatpak, a snap, an AppImage registered by hand and a Steam client
# update, without a hook per package manager.
MCA_UNIT_PATH="middleclick-autoscroll.path"
MCA_UNIT_SERVICE="middleclick-autoscroll.service"
# The desktop folder is watched like every other directory an entry can turn up
# in, but its name is translated and the unit that ships with the package
# cannot know it. So that one path is written here, into a drop-in, from the
# name this system actually uses.
MCA_UNIT_DROPIN="${MCA_XDG_CONFIG}/systemd/user/${MCA_UNIT_PATH}.d"
mca_watch_available() {
mca_have systemctl && [[ -n ${XDG_RUNTIME_DIR:-} || -S "/run/user/$(id -u)/bus" ]]
}
@@ -153,17 +196,56 @@ mca_watch_enabled() {
systemctl --user is-enabled --quiet "$MCA_UNIT_PATH" 2>/dev/null
}
# _mca_watch_dropin_write
# Returns 0 when the drop-in changed and systemd has to be told, 1 when it was
# already right - the same contract as mca_write_if_changed, and for the same
# reason: the watcher runs after every desktop entry that appears anywhere on
# the system, and a daemon-reload each time would be absurd.
_mca_watch_dropin_write() {
local dir file="$MCA_UNIT_DROPIN/desktop.conf" content
dir="$(mca_desktop_folder)"
[[ -d $dir ]] || { _mca_watch_dropin_remove; return $?; }
# A per cent sign starts a specifier in a unit file and has to be doubled
# to mean itself. Rare in a folder name, fatal when it happens.
content="[Path]"$'\n'"PathModified=${dir//%/%%}"$'\n'
mca_write_if_changed "$file" "$content"
}
_mca_watch_dropin_remove() {
[[ -e "$MCA_UNIT_DROPIN/desktop.conf" ]] || return 1
rm -f -- "$MCA_UNIT_DROPIN/desktop.conf"
rmdir "$MCA_UNIT_DROPIN" 2>/dev/null || true
return 0
}
mca_watch_enable() {
mca_watch_available || return 1
_mca_watch_dropin_write
systemctl --user daemon-reload > /dev/null 2>&1 || true
systemctl --user enable --now "$MCA_UNIT_PATH" > /dev/null 2>&1 || return 1
systemctl --user enable "$MCA_UNIT_SERVICE" > /dev/null 2>&1 || true
return 0
}
# mca_watch_refresh
# Keeps a running watcher in step with a desktop folder that has been renamed,
# which is what a change of session language does to it.
mca_watch_refresh() {
mca_watch_available || return 0
_mca_watch_dropin_write || return 0
systemctl --user daemon-reload > /dev/null 2>&1 || true
systemctl --user restart "$MCA_UNIT_PATH" > /dev/null 2>&1 || true
return 0
}
mca_watch_disable() {
mca_watch_available || return 0
systemctl --user disable --now "$MCA_UNIT_PATH" > /dev/null 2>&1 || true
systemctl --user disable "$MCA_UNIT_SERVICE" > /dev/null 2>&1 || true
_mca_watch_dropin_remove && systemctl --user daemon-reload > /dev/null 2>&1
return 0
}
@@ -185,7 +267,8 @@ mca_count_routes() {
MCA_ROUTES=()
for i in "${!MCA_IDS[@]}"; do
mca_route "${MCA_KINDS[i]}" "${MCA_IDS[i]}" "${MCA_PROGS[i]}"
mca_route "${MCA_KINDS[i]}" "${MCA_IDS[i]}" "${MCA_PROGS[i]}" \
"${MCA_PACKAGING[i]}"
route="$MCA_ROUTE"
MCA_ROUTES[i]="$route"
case "$route" in
+75 -6
View File
@@ -13,11 +13,42 @@ 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"
# The Blink feature name on its own, for merging into an --enable-blink-features
# list that an app (or the user) already carries.
# Helium ships the feature under its own name and does not answer to the
# Chromium one, so browsers are asked for both. 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"
# 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"
# Bumped when the flags above change. 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.
MCA_FLAG_SCHEME=2
MCA_LIBDIR="${MCA_LIBDIR:-@LIBDIR@}"
MCA_LOCALEDIR="${MCA_LOCALEDIR:-@LOCALEDIR@}"
@@ -34,7 +65,7 @@ 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 anything pacman owns.
# 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.
@@ -44,6 +75,29 @@ MCA_BACKUPDIR="${MCA_STATEDIR}/backup"
# 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 - and 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
# ---------------------------------------------------------------------------
@@ -60,8 +114,15 @@ mca_ui_locale() {
[[ -z $l ]] && l="${LANG:-}"
fi
if [[ -z $l && -r /etc/locale.conf ]]; then
l="$(sed -n 's/^LANG=//p' /etc/locale.conf | tr -d '"' | head -n1)"
# 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}"
@@ -239,6 +300,14 @@ mca_ledger_forget() {
}
# 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
@@ -246,7 +315,7 @@ mca_ledger_forget() {
# patched version.
mca_backup() {
local file="$1" name
name="$(printf '%s' "$file" | sed 's|/|%|g')"
name="$(mca_backup_name "$file")"
mkdir -p "$MCA_BACKUPDIR" 2>/dev/null || return 1
if [[ ! -e "$MCA_BACKUPDIR/$name" ]]; then
+9
View File
@@ -139,6 +139,7 @@ mca_config_load() {
CFG_APPS=no; mca_config_bool PatchApps yes && CFG_APPS=yes
CFG_BROWSERS=no; mca_config_bool PatchBrowsers yes && CFG_BROWSERS=yes
CFG_FLATPAK=no; mca_config_bool PatchFlatpak yes && CFG_FLATPAK=yes
CFG_SNAP=no; mca_config_bool PatchSnap yes && CFG_SNAP=yes
CFG_AUTOSTART=no; mca_config_bool PatchAutostart yes && CFG_AUTOSTART=yes
CFG_STEAM=no; mca_config_bool PatchSteam yes && CFG_STEAM=yes
CFG_SPOTIFY=no; mca_config_bool PatchSpotify yes && CFG_SPOTIFY=yes
@@ -153,10 +154,18 @@ mca_config_load() {
CFG_SKIP="$(mca_config_get Skip '')"
}
# mca_flags [kind]
# The full argument string that gets injected. Kept in one place so the flag
# file writer and the desktop entry writer cannot drift apart.
#
# A browser gets the flag that leaves it without a warning bar; everything else
# gets the one that works in every Chromium there is. See MCA_FLAG.
mca_flags() {
if [[ ${1:-app} == browser ]]; then
printf '%s' "$MCA_BROWSER_FLAG"
else
printf '%s' "$MCA_FLAG"
fi
[[ -n ${CFG_EXTRA_FLAGS:-} ]] && printf ' %s' "$CFG_EXTRA_FLAGS"
printf '\n'
}
+327 -38
View File
@@ -27,9 +27,23 @@ MCA_MARKERS=(
MCA_SYSTEM_DIRS=(
/ /bin /lib /lib32 /lib64 /sbin /usr /usr/bin /usr/lib /usr/lib32
/usr/lib64 /usr/libexec /usr/sbin /usr/local /usr/local/bin
/usr/local/lib /opt
/usr/local/lib /usr/local/libexec /opt
)
# The same thing for the layouts that put a machine triplet in the path.
# Debian and Ubuntu keep the shared libraries in /usr/lib/x86_64-linux-gnu
# rather than /usr/lib, so that directory is every bit as shared as /usr/lib is
# elsewhere and a marker sitting in it belongs to nobody in particular.
MCA_SYSTEM_DIR_GLOBS=(
'/usr/lib/*-linux-gnu*' '/usr/lib32/*-linux-gnu*'
'/usr/lib64/*-linux-gnu*' '/usr/local/lib/*-linux-gnu*'
)
# Where snapd mounts the installed snaps. /snap is the usual place and the one
# the shims point into; distributions that keep /snap free of a top-level
# directory use the second.
MCA_SNAP_DIRS=(/snap /var/lib/snapd/snap)
# Strings in a launcher script that mean it starts a Chromium or Electron
# process, for the wrappers whose command line is assembled out of variables and
# cannot be followed from the outside.
@@ -38,34 +52,51 @@ MCA_SYSTEM_DIRS=(
# "chromium" on its own is not one of them: /usr/bin/xdg-open lists every
# browser it knows how to start, and that is not a browser.
#
# The flag file convention is an Arch packaging habit and says nothing about the
# engine either, so it is not in here.
MCA_SCRIPT_HINTS='ELECTRON_|app\.asar|chrome-sandbox|libcef|enable-blink-features|ozone-platform-hint'
# The flag file convention is one distribution's packaging habit and says
# nothing about the engine either, so it is not in here.
#
# CHROMIUM_FLAGS and CHROME_WRAPPER earn their place: they are the variables
# the Debian, Fedora and openSUSE Chromium wrappers and Google's own Chrome
# wrapper build their command line out of, and nothing else sets them.
MCA_SCRIPT_HINTS='ELECTRON_|app\.asar|chrome-sandbox|libcef|enable-blink-features|ozone-platform-hint|CHROMIUM_FLAGS|CHROME_WRAPPER|CHROME_VERSION_EXTRA'
# ---------------------------------------------------------------------------
# Desktop entries
# ---------------------------------------------------------------------------
# Where Flatpak and snapd put the launchers they export. Both add these to
# XDG_DATA_DIRS themselves, through a file in /etc/profile.d - but only for a
# session that was started after they were installed, and only for a session
# manager that reads it at all. They are appended, after everything XDG names,
# so a directory that is already in the search path keeps its own position and
# the ones that were missing are still scanned.
mca_extra_desktop_dirs() {
printf '%s\n' \
"$MCA_XDG_DATA/flatpak/exports/share/applications" \
/var/lib/flatpak/exports/share/applications \
/var/lib/snapd/desktop/applications
}
# The directories a desktop entry can come from, most specific first - which is
# also XDG lookup order, so the first file found for an id is the one that is
# actually used.
mca_desktop_dirs() {
local dirs="${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" d
local -A seen=()
while IFS= read -r d; do
[[ -n $d ]] || continue
d="${d%/}"
[[ -n ${seen[$d]+set} ]] && continue
seen[$d]=1
printf '%s\n' "$d"
done < <(
printf '%s\n' "$MCA_APPDIR"
while IFS= read -r -d: d; do
[[ -n $d ]] && printf '%s/applications\n' "${d%/}"
done <<< "${dirs}:"
}
# mca_desktop_get <file> <key>
# A single value from the [Desktop Entry] group. Desktop Action groups repeat
# the same keys, and reading past the first group would pick up the wrong one.
mca_desktop_get() {
local file="$1" key="$2"
awk -v key="$key" '
/^[[:space:]]*\[/ { inentry = ($0 ~ /^[[:space:]]*\[Desktop Entry\][[:space:]]*$/); next }
inentry && index($0, key "=") == 1 { print substr($0, length(key) + 2); exit }
' "$file" 2>/dev/null
mca_extra_desktop_dirs
)
}
# _mca_desktop_read <file>
@@ -180,6 +211,35 @@ mca_exec_flatpak_id() {
return 1
}
# mca_exec_is_steam_link <exec line> <program>
# Whether an entry starts something inside Steam rather than starting Steam
# itself: it carries a steam:// address of its own. Steam writes one of those
# for every game somebody asks for a shortcut to, and the client's own entry
# never has one - it takes an address from the outside, through %U.
mca_exec_is_steam_link() {
local line="$1" prog="$2"
[[ $line == *steam://* ]] || return 1
mca_prog_is_steam "$prog"
}
# mca_prog_is_steam <program>
# Whether running this program starts the Steam client. Every packaging is in
# here and every name Valve and the distributions give the launcher, because
# the answer decides whether an entry gets Steam's own switch - and an entry
# that starts Steam without it undoes the web helper patch on the way up.
#
# The program is what a scan leaves behind: an absolute path for a native
# install, flatpak:<id> or snap:<name> for the other two.
mca_prog_is_steam() {
local prog="$1"
case "${prog##*/}" in
steam|steam-runtime|steam-native|steam-jupiter) return 0 ;;
esac
[[ $prog == flatpak:com.valvesoftware.Steam || $prog == snap:steam ]]
}
# ---------------------------------------------------------------------------
# Is this Chromium?
# ---------------------------------------------------------------------------
@@ -250,6 +310,11 @@ _mca_has_markers() {
for s in "${MCA_SYSTEM_DIRS[@]}"; do
[[ $dir == "$s" ]] && return 1
done
for s in "${MCA_SYSTEM_DIR_GLOBS[@]}"; do
# Unquoted on purpose - these are patterns, not names.
# shellcheck disable=SC2053
[[ $dir == $s ]] && return 1
done
for m in "${MCA_MARKERS[@]}"; do
[[ -e "$dir/$m" ]] && return 0
@@ -258,23 +323,118 @@ _mca_has_markers() {
return 1
}
# The plain assignments the script made before it handed over, for
# _mca_script_subst to read. A variable of its own rather than something passed
# around: every caller of _mca_script_target reads it through a command
# substitution, so each call already works on a copy and there is nothing here
# that two of them could collide over.
declare -A MCA_SCRIPT_VARS=()
# _mca_script_subst <text>
# The text with $NAME and ${NAME} replaced by what the script assigned to them,
# left in MCA_SUBST.
#
# Fails as soon as something turns up that only a running shell could work out
# - a positional parameter, a name the script never set, a default value. That
# is the point: an unresolvable path has to come out as no path at all, never
# as a wrong one.
MCA_SUBST=''
_mca_script_subst() {
local text="$1" out='' rest name
while [[ $text == *'$'* ]]; do
out+="${text%%\$*}"
rest="${text#*\$}"
if [[ $rest == '{'* ]]; then
[[ $rest == *'}'* ]] || return 1
name="${rest%%\}*}"; name="${name#\{}"
rest="${rest#*\}}"
else
name="${rest%%[!A-Za-z0-9_]*}"
rest="${rest:${#name}}"
fi
[[ $name =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || return 1
[[ -n ${MCA_SCRIPT_VARS[$name]+set} ]] || return 1
out+="${MCA_SCRIPT_VARS[$name]}"
text="$rest"
done
MCA_SUBST="$out$text"
return 0
}
# _mca_script_target <script>
# The program a wrapper script hands over to, so a chain like
# heroic -> electron43 -> /usr/lib/electron43/electron can be followed.
#
# The assignments above the exec line are followed as well, because that is the
# shape the Chromium wrappers outside Arch have: Debian, Ubuntu, Fedora and
# openSUSE all set the directory and the program name into variables at the top
# of the script and end on `exec -a "$APPNAME" "$LIBDIR/$APPNAME"`. Without
# resolving those there is nothing to follow, and the answer would have to come
# from the hint scan - a weaker kind of evidence than finding the binary and
# its markers.
_mca_script_target() {
local script="$1" line tok
local script="$1" line tok name val skip=0
local -a tokens
local re_assign='^[[:space:]]*(export[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*)=([^[:space:];|&()`]*)[[:space:]]*$'
MCA_SCRIPT_VARS=()
while IFS= read -r line; do
if [[ $line =~ $re_assign ]]; then
name="${BASH_REMATCH[2]}"
val="${BASH_REMATCH[3]}"
# One pair of quotes around the whole value is ordinary and means
# nothing here. Single quotes also mean the value is literal, so
# there is nothing left to expand.
if [[ $val == \'*\' ]]; then
MCA_SCRIPT_VARS[$name]="${val:1:${#val}-2}"
continue
fi
[[ $val == \"*\" ]] && val="${val:1:${#val}-2}"
if _mca_script_subst "$val"; then
MCA_SCRIPT_VARS[$name]="$MCA_SUBST"
else
# Not resolvable, so anything built from it must not be either.
unset "MCA_SCRIPT_VARS[$name]"
fi
continue
fi
[[ $line =~ ^[[:space:]]*exec[[:space:]]+(.*)$ ]] || continue
read -r -a tokens <<< "${BASH_REMATCH[1]}"
skip=0
for tok in "${tokens[@]}"; do
[[ $tok == env ]] && continue
(( skip )) && { skip=0; continue; }
case "$tok" in
env) continue ;;
# `exec -a NAME PROG` renames the process. NAME is not a
# program, and it is usually the wrapper's own name, so
# following it would lead straight back here.
-a|--argv0) skip=1; continue ;;
-*) continue ;;
esac
[[ $tok == *=* && $tok != /* ]] && continue
[[ $tok == -* ]] && continue
# Anything still carrying a shell variable cannot be resolved from
# the outside; the hint scan has to answer for those.
[[ $tok == *'$'* ]] && return 1
tok="${tok%\"}"; tok="${tok#\"}"
tok="${tok%\'}"; tok="${tok#\'}"
# Anything still carrying a shell variable is resolved from the
# assignments above, or not at all; the hint scan answers for the
# wrappers that build their command line some other way.
if [[ $tok == *'$'* ]]; then
_mca_script_subst "$tok" || return 1
tok="$MCA_SUBST"
fi
[[ -n $tok ]] || return 1
if [[ $tok == /* ]]; then
printf '%s\n' "$tok"
else
@@ -287,13 +447,15 @@ _mca_script_target() {
}
# mca_flags_candidates <program>
# The flag files a launcher reads, in the order it reads them. Arch's Electron
# and Chromium wrappers all take extra arguments from
# $XDG_CONFIG_HOME/<name>-flags.conf, which is a far better place to inject than
# a desktop entry: it survives package updates and it applies to a launch from
# the terminal too.
# The flag files a launcher reads, in the order it reads them, taken from the
# launcher itself rather than assumed from the distribution. Where the wrappers
# follow that convention - Arch's Electron and Chromium packages do, and take
# extra arguments from $XDG_CONFIG_HOME/<name>-flags.conf - it is a far better
# place to inject than a desktop entry: it survives package updates and it
# applies to a launch from the terminal too. Where they do not, this finds
# nothing and the caller falls back to the desktop entry.
mca_flags_candidates() {
local prog="$1" depth="${2:-0}" target
local prog="$1" depth="${2:-0}" target inherited
(( depth > 3 )) && return 0
[[ -r $prog ]] || return 0
head -c2 -- "$prog" 2>/dev/null | grep -q '#!' || return 0
@@ -301,10 +463,22 @@ mca_flags_candidates() {
grep -oE '[A-Za-z0-9_.+-]+-flags\.conf' -- "$prog" 2>/dev/null
# A wrapper that only execs another wrapper (heroic -> electron43) inherits
# that one's flag files, plus the specific name it builds from a variable.
# that one's flag files, plus the specific name it builds from a variable
# at runtime and therefore never writes down - which is why that one is
# derived from the target's own name rather than found.
#
# It is derived only once the target has shown that it reads a flag file at
# all. Every launcher that can be followed is not a launcher that reads
# one: the Chromium wrappers outside Arch do not, and neither does an
# ordinary program that simply execs its own binary. Naming a file for
# those would put the flag somewhere nothing ever looks and skip the
# desktop entry that would have worked.
if target="$(_mca_script_target "$prog")" && [[ -n $target ]]; then
inherited="$(mca_flags_candidates "$target" $(( depth + 1 )))"
if [[ -n $inherited ]]; then
printf '%s-flags.conf\n' "${target##*/}"
mca_flags_candidates "$target" $(( depth + 1 ))
printf '%s\n' "$inherited"
fi
fi
}
@@ -398,8 +572,19 @@ _mca_detect_uncached() {
MCA_IDS=() # desktop file id, without the .desktop suffix
MCA_FILES=() # the desktop entry that is in effect for that id
MCA_NAMES=() # display name
MCA_PROGS=() # resolved program, or a flatpak app id
MCA_KINDS=() # app | browser | flatpak | steam | unknown | no
MCA_PROGS=() # resolved program, or a Flatpak app id or a snap name
MCA_KINDS=() # app | browser | steam | unknown | no
MCA_PACKAGING=() # native | flatpak | snap
# The shortcuts Steam writes for single games. Not applications of their own - a
# game is whatever engine it was built with, and none of those reads a Chromium
# argument - so they are kept apart from the list rather than listed as
# something that got switched on. They do start Steam, which is why they are
# kept at all: the Steam module gives them Steam's own switch.
MCA_STEAM_LINKS=() # desktop file id
MCA_STEAM_LINK_FILES=() # the entry that is in effect for it
MCA_STEAM_LINK_PACK=() # native | flatpak | snap, which decides where the
# switch goes on the command line
# A scan reads every desktop entry on the system, so the menu does it once and
# then redraws from what it found. Applying rescans on its own, so nothing else
@@ -412,11 +597,13 @@ mca_scan_once() {
}
mca_scan() {
local dir file id name exec_line prog kind i
local dir file id name exec_line prog kind packaging i
local -A seen=()
local -a c_ids=() c_files=() c_names=() c_progs=() c_browser=() c_stat=()
MCA_IDS=(); MCA_FILES=(); MCA_NAMES=(); MCA_PROGS=(); MCA_KINDS=()
MCA_PACKAGING=()
MCA_STEAM_LINKS=(); MCA_STEAM_LINK_FILES=(); MCA_STEAM_LINK_PACK=()
# Pass one: read the entries and work out what each of them starts. No
# detection yet - that needs a stat per program, and those are collected so
@@ -452,6 +639,21 @@ mca_scan() {
if [[ ${prog##*/} == flatpak ]]; then
mca_exec_flatpak_id "$exec_line" || continue
prog="flatpak:$MCA_PROG"
elif mca_snap_name "$prog"; then
prog="snap:$MCA_PROG"
fi
if mca_exec_is_steam_link "$exec_line" "$prog"; then
MCA_STEAM_LINKS+=("$id")
MCA_STEAM_LINK_FILES+=("$file")
if [[ $prog == flatpak:* ]]; then
MCA_STEAM_LINK_PACK+=(flatpak)
elif [[ $prog == snap:* ]]; then
MCA_STEAM_LINK_PACK+=(snap)
else
MCA_STEAM_LINK_PACK+=(native)
fi
continue
fi
c_ids+=("$id"); c_files+=("$file"); c_names+=("$name")
@@ -464,15 +666,32 @@ mca_scan() {
MCA_STAT=()
_mca_stat_batch "${c_stat[@]}"
# Pass two: decide what each one is.
# Pass two: decide what each one is. What it does - an application or a
# browser - and how it was packaged are two separate questions: a Chromium
# installed as a snap is still a browser, and somebody who has turned
# browsers off means that one too.
for i in "${!c_ids[@]}"; do
prog="${c_progs[i]}"
kind=no
packaging=native
if [[ $prog == flatpak:* ]]; then
packaging=flatpak
if mca_prog_is_steam "$prog"; then
kind=steam
elif mca_flatpak_is_chromium "${prog#flatpak:}"; then
(( c_browser[i] )) && kind=browser || kind=app
fi
prog="${prog#flatpak:}"
mca_flatpak_is_chromium "$prog" && kind=flatpak
elif [[ ${prog##*/} == steam || ${prog##*/} == steam-runtime ]]; then
elif [[ $prog == snap:* ]]; then
prog="${prog#snap:}"
packaging=snap
if [[ $prog == steam ]]; then
kind=steam
elif mca_snap_is_chromium "$prog"; then
(( c_browser[i] )) && kind=browser || kind=app
fi
elif mca_prog_is_steam "$prog"; then
# Steam is Chromium inside, but nothing about it can be changed
# from a command line argument; it has its own module.
kind=steam
@@ -489,6 +708,7 @@ mca_scan() {
MCA_NAMES+=("${c_names[i]}")
MCA_PROGS+=("$prog")
MCA_KINDS+=("$kind")
MCA_PACKAGING+=("$packaging")
done
mca_cache_flush
@@ -527,6 +747,66 @@ mca_desktop_is_browser() {
return 1
}
# mca_snap_name <program>
# The snap an executable belongs to, left in MCA_PROG - assigned rather than
# printed for the same reason mca_exec_program is: the scan asks this about
# every desktop entry on the system, and a command substitution per entry is a
# fork per entry.
#
# /snap/bin/<name> is the shim snapd puts in PATH and is a symlink to snapd
# itself, so following it lands on /usr/bin/snap and says nothing whatever
# about the application. The name is the only thing that carries information,
# and it is what leads to the mounted tree below.
mca_snap_name() {
local prog="$1" rest d
for d in "${MCA_SNAP_DIRS[@]}"; do
if [[ $prog == "$d/bin/"* ]]; then
rest="${prog#"$d/bin/"}"
# A snap that ships several programs names them <snap>.<app>.
MCA_PROG="${rest%%.*}"
return 0
fi
if [[ $prog == "$d/"* ]]; then
rest="${prog#"$d/"}"
MCA_PROG="${rest%%/*}"
return 0
fi
done
return 1
}
# mca_snap_is_chromium <snap name>
# A snap keeps everything it ships inside its own mounted revision, so the
# marker check works the same way there as anywhere else once that tree has
# been located. "current" is the symlink snapd keeps pointing at the revision
# that will actually be started.
mca_snap_is_chromium() {
local name="$1" d root
if [[ -n ${MCA_DETECT_MEMO[snap:$name]+set} ]]; then
[[ ${MCA_DETECT_MEMO[snap:$name]} == yes ]]
return $?
fi
for d in "${MCA_SNAP_DIRS[@]}"; do
root="$d/$name/current"
[[ -d $root ]] || continue
if [[ -n "$(find "$root" -maxdepth 5 \
\( -name 'chrome_crashpad_handler' -o -name 'app.asar' \
-o -name 'libcef.so' -o -name 'v8_context_snapshot.bin' \
-o -name 'chrome-sandbox' \) \
-print -quit 2>/dev/null)" ]]
then
MCA_DETECT_MEMO[snap:$name]=yes
return 0
fi
done
MCA_DETECT_MEMO[snap:$name]=no
return 1
}
# mca_flatpak_is_chromium <app id>
# Flatpak keeps every application in its own tree, so the marker check works the
# same way once that tree has been located.
@@ -554,19 +834,28 @@ mca_flatpak_is_chromium() {
return 1
}
# mca_kind_wanted <kind> <id>
# mca_kind_wanted <kind> <id> [packaging]
# Whether the current settings say this entry should be patched. Skip beats
# everything, an explicit include beats detection, and detection beats nothing.
#
# Packaging is a gate in front of the category rather than a category of its
# own: a Flatpak or a snap sees none of the host's configuration and is worth
# switching off as a group, but it is still an application or a browser and
# whichever of those the user turned off applies to it too.
mca_kind_wanted() {
local kind="$1" id="$2"
local kind="$1" id="$2" packaging="${3:-native}"
mca_config_list_has Skip "$id" && return 1
mca_config_list_has Include "$id" && return 0
case "$packaging" in
flatpak) [[ $CFG_FLATPAK == yes ]] || return 1 ;;
snap) [[ $CFG_SNAP == yes ]] || return 1 ;;
esac
case "$kind" in
app) [[ $CFG_APPS == yes ]] ;;
browser) [[ $CFG_BROWSERS == yes ]] ;;
flatpak) [[ $CFG_FLATPAK == yes && $CFG_APPS == yes ]] ;;
*) return 1 ;;
esac
}
+4
View File
@@ -178,6 +178,9 @@ mca_ui_status() {
if mca_steam_installed; then
if mca_steam_patched; then
_mca_row "$(mca_msg "Steam")" "$(_mca_onoff yes)"
elif [[ $CFG_STEAM == yes && $CFG_ENABLED == yes ]] && mca_steam_waiting; then
_mca_row "$(mca_msg "Steam")" \
"${MCA_C_DIM}$(mca_msg "waiting until Steam is closed")${MCA_C_RESET}"
elif [[ $CFG_STEAM == yes && $CFG_ENABLED == yes ]]; then
_mca_row "$(mca_msg "Steam")" \
"${MCA_C_YELLOW}$(mca_msg "not patched yet")${MCA_C_RESET}"
@@ -212,6 +215,7 @@ MCA_SETTINGS=(
"PatchApps|bool|yes|Electron and CEF applications"
"PatchBrowsers|bool|yes|Chromium-based browsers"
"PatchFlatpak|bool|yes|Flatpak applications"
"PatchSnap|bool|yes|Snap applications"
"PatchAutostart|bool|yes|Programs that start themselves at login"
"PatchSteam|bool|yes|Steam"
"PatchSpotify|bool|yes|Spotify"
+216 -64
View File
@@ -4,21 +4,65 @@
#
# 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.
# 1. A flag file. Where the distribution wraps Electron and Chromium in a
# launcher script that reads extra arguments from
# $XDG_CONFIG_HOME/<name>-flags.conf - Arch and its derivatives do, and it
# is the convention their packages follow - 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.
#
# 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. Elsewhere - Debian, Ubuntu, Fedora, openSUSE, where
# the equivalent file lives under /etc and is the system's to write -
# there is no flag file to use and route 2 answers for everything.
#
# 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.
# 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
# ---------------------------------------------------------------------------
@@ -60,28 +104,32 @@ _mca_exec_after_program() {
# 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.
# 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 value existing at changed=0
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
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}"
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
fi
;;
esac
[[ " $line " == *" $flag "* ]] && continue
@@ -107,12 +155,12 @@ mca_exec_inject() {
# Flag files
# ---------------------------------------------------------------------------
# mca_flags_apply <program>
# 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" name target='' existing_target=''
local prog="$1" kind="${2:-app}" name target='' existing_target=''
local -a candidates=()
mapfile -t candidates < <(mca_flags_candidates "$prog" | awk '!seen[$0]++')
@@ -123,7 +171,7 @@ mca_flags_apply() {
for name in "${candidates[@]}"; do
if [[ -f "$MCA_XDG_CONFIG/$name" ]]; then
existing_target="$name"
_mca_flags_write "$MCA_XDG_CONFIG/$name"
_mca_flags_write "$MCA_XDG_CONFIG/$name" "$kind"
fi
done
[[ -n $existing_target ]] && return 0
@@ -133,19 +181,21 @@ mca_flags_apply() {
# creating that one also covers every other application using the same
# shared Electron.
target="${candidates[-1]}"
_mca_flags_write "$MCA_XDG_CONFIG/$target"
_mca_flags_write "$MCA_XDG_CONFIG/$target" "$kind"
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 <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" content='' line flag value existing merged=0 block=''
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)"
read -r -a flags <<< "$(mca_flags "$kind")"
[[ -f $file ]] || created=1
if (( ! created )); then
@@ -156,24 +206,31 @@ _mca_flags_write() {
fi
fi
# Merge into a blink-features line the user (or a distribution) put there.
if [[ $content == *--enable-blink-features=* ]]; then
# 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:]]*--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
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
(( merged )) && [[ $flag == --enable-blink-features=* ]] && continue
[[ -n ${merged_opts["${flag%%=*}"]:-} ]] && continue
block+="$flag"$'\n'
done
@@ -208,7 +265,7 @@ _mca_flags_drop_block() {
# mca_flags_revert <file> <detail>
mca_flags_revert() {
local file="$1" detail="$2" content line out='' existing new
local file="$1" detail="$2" content line out='' opt existing new
[[ -f $file ]] || return 0
@@ -228,12 +285,13 @@ mca_flags_revert() {
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%,}"
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="--enable-blink-features=$new"
line="$opt=$new"
fi
out+="$line"$'\n'
done <<< "$content"
@@ -339,38 +397,119 @@ mca_desktop_apply() {
}
# ---------------------------------------------------------------------------
# Autostart entries
# Entries outside the search path
# ---------------------------------------------------------------------------
# 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.
# 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.
#
# Steam is in both, even though it is not a Chromium process itself: its
# entries need -noverifyfiles exactly like the menu one. The autostart entry is
# the one most likely to exist, because Steam writes it as soon as "run at
# startup" is ticked; without it a Steam started at login restores the patched
# web helper script and the watcher patches it back, over and over. The desktop
# ones are the gap behind "autoscroll works, except sometimes": starting a game
# from the desktop is a Steam start like any other, and a Steam start without
# the switch costs the interface its autoscroll for the rest of the session.
#
# Neither can be shadowed from anywhere, so both are edited where they stand,
# with the original kept.
# _mca_entry_patch_inplace <file> <gate: autostart|apps>
# One desktop entry that lives outside the XDG search path, edited where it is
# because there is nowhere to shadow it from.
#
# Anything that starts Steam gets Steam's own switch; a Chromium application
# gets the flag. What decides whether a Chromium application here is in scope
# differs by where the entry came from, which is what the gate says: an
# autostart entry follows the autostart setting, a shortcut follows the same
# rules as the application it is a shortcut to.
_mca_entry_patch_inplace() {
local file="$1" gate="$2"
local id prog packaging=native content backup kind
grep -q "^$MCA_MARK_INPLACE=" "$file" 2>/dev/null && return 0
_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
if mca_prog_is_steam "$prog"; then
[[ $CFG_STEAM == yes ]] || return 0
content="$(_mca_desktop_transform "$file" "$MCA_MARK_INPLACE" \
"$MCA_STEAM_LAUNCH_FLAG" "$(mca_steam_flag_position "$packaging")")"
else
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
if [[ $gate == autostart ]]; then
[[ $CFG_AUTOSTART == yes ]] || return 0
else
id="${file##*/}"; id="${id%.desktop}"
mca_kind_wanted "$kind" "$id" "$packaging" || return 0
fi
content="$(_mca_desktop_transform "$file" "$MCA_MARK_INPLACE" \
"$(mca_flags "$kind")")"
fi
[[ -n $content ]] || return 0
[[ "$content" == "$(< "$file")" ]] && return 0
backup="$(mca_backup "$file")" || return 0
if mca_write_if_changed "$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 exec_line prog content backup
local dir="$MCA_XDG_CONFIG/autostart" file
[[ -d $dir ]] || return 0
for file in "$dir"/*.desktop; do
[[ -f $file ]] || continue
grep -q "^$MCA_MARK_INPLACE=" "$file" 2>/dev/null && continue
_mca_entry_patch_inplace "$file" autostart
done
}
exec_line="$(mca_desktop_get "$file" Exec)"
[[ -n $exec_line ]] || continue
mca_shortcuts_apply() {
local dir file
dir="$(mca_desktop_folder)"
mca_exec_program "$exec_line" || continue
prog="$MCA_PROG"
mca_is_chromium "$prog" || continue
[[ -d $dir ]] || return 0
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"
for file in "$dir"/*.desktop; do
[[ -f $file ]] || continue
_mca_entry_patch_inplace "$file" apps
done
}
@@ -423,13 +562,15 @@ mca_spotify_apply() {
# ---------------------------------------------------------------------------
# mca_prune_orphans
# Removes generated entries whose application is gone.
# 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.
# 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=()
@@ -439,6 +580,17 @@ mca_prune_orphans() {
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
+223 -23
View File
@@ -9,11 +9,28 @@
#
# ~/.local/share/Steam/ubuntu12_64/steamwebhelper_sniper_wrap.sh
#
# Steam checksums that script at every start and restores it when it differs,
# which is why the launcher gets -noverifyfiles as well. The trade-off is real
# and belongs to the user, so this is a switch of its own rather than part of
# the general application handling: with verification off, Steam no longer
# repairs a damaged installation by itself.
# Steam compares the installed files against its manifest at every start - by
# size and timestamp rather than by content - and restores whatever differs.
# So the patch is written to look untouched: the bytes the flag costs are taken
# back out of the script's comments and the timestamp is put back afterwards,
# which leaves a file that is byte-for-byte the size Steam recorded and as old
# as Steam left it. A client that verifies its files finds nothing to repair
# and the flag survives however Steam was started - from the menu, from a game
# shortcut, from a launcher like Heroic, from a terminal.
#
# That is the part that has to work, because there is no way to make every
# possible way of starting Steam carry an argument. -noverifyfiles on the
# launcher entries is the second line rather than the first: it covers the case
# where the script has no comments left to pay for the flag and the patch has
# to grow the file. The trade-off is real and belongs to the user, so it is a
# switch of its own rather than part of the general application handling: with
# verification off, Steam no longer repairs a damaged installation by itself.
#
# A patch that does change the size still has to stay out of the way of a
# client that is already running, or the two programs spend the session undoing
# each other: Steam restores the script, the watcher patches it again, Steam
# restores it again, and the client never gets past its update dialog. A patch
# that keeps the size is invisible to that check and goes in either way.
#
# The file comes back on every client update, and the watcher re-applies the
# patch when that happens.
@@ -22,6 +39,11 @@ MCA_STEAM_LAUNCH_FLAG='-noverifyfiles'
# Every place a Steam installation is known to live, resolved and de-duplicated
# because ~/.steam/steam is normally a symlink into ~/.local/share.
#
# Which of these is the real one depends on how Steam was installed: Valve's
# own package and the Arch one use ~/.local/share/Steam, Debian's puts it in
# ~/.steam/debian-installation, and the Flatpak and the snap each keep it
# inside the private tree their sandbox gives them.
mca_steam_roots() {
local candidates=(
"$MCA_XDG_DATA/Steam"
@@ -29,6 +51,8 @@ mca_steam_roots() {
"$HOME/.steam/root"
"$HOME/.steam/debian-installation"
"$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam"
"$HOME/.var/app/com.valvesoftware.Steam/data/Steam"
"$HOME/snap/steam/common/.local/share/Steam"
)
local dir real
local -A seen=()
@@ -64,6 +88,24 @@ mca_steam_installed() {
return 1
}
# mca_steam_running
# Steam records its own process id beside its installation while it runs. The
# file outlives a crash, so the id is checked rather than believed.
mca_steam_running() {
local f pid
for f in \
"$HOME/.steam/steam.pid" \
"$HOME/.var/app/com.valvesoftware.Steam/.steam/steam.pid" \
"$HOME/snap/steam/common/.steam/steam.pid"
do
[[ -r $f ]] || continue
pid="$(< "$f")"
[[ $pid =~ ^[0-9]+$ ]] || continue
kill -0 "$pid" 2>/dev/null && return 0
done
return 1
}
# mca_steam_script_patched <script>
mca_steam_script_patched() {
grep -q -- "$MCA_FEATURE" "$1" 2>/dev/null
@@ -78,6 +120,26 @@ mca_steam_patched() {
return 1
}
# mca_steam_deferred <script>
# Whether the patch is being held back rather than simply missing: the script
# was patched before, its own copy is back, and Steam is still running. See
# mca_steam_apply for why that is left alone.
mca_steam_deferred() {
[[ -e "$MCA_BACKUPDIR/$(mca_backup_name "$1")" ]] && mca_steam_running
}
# mca_steam_waiting
# The same question for the status screen, which has no script in hand.
mca_steam_waiting() {
local root script
while IFS= read -r root; do
script="$(mca_steam_script "$root")" || continue
mca_steam_script_patched "$script" && continue
mca_steam_deferred "$script" && return 0
done < <(mca_steam_roots)
return 1
}
# _mca_steam_exec_line <script>
# The line number of the command that starts the web helper: the last
# uncommented line that both names steamwebhelper and forwards "$@". Both the
@@ -92,12 +154,112 @@ _mca_steam_exec_line() {
' "$1" 2>/dev/null
}
# _mca_steam_build <script> <flags> <reclaim: 0|1>
# The patched script on stdout: the flags appended to the line that starts the
# web helper, and - when asked for - the same number of bytes taken back out of
# the script's comments, so that the file Steam finds is the length Steam wrote
# down. Everything else - setting up the container runtime, deciding whether
# the sandbox can be used - is left alone.
#
# Comments are eaten from the bottom up, so the header that says what the
# script is for is the last thing to lose anything, and the '#' itself always
# stays: a line that loses it stops being a comment and starts being a command.
# Fails when the comments are too short to pay for the flags.
_mca_steam_build() {
local script="$1" flags="$2" reclaim="$3" lineno need=0
lineno="$(_mca_steam_exec_line "$script")"
[[ -n $lineno ]] || return 1
if (( reclaim )); then
need="$(printf '%s' " $flags" | wc -c)"
need="${need//[^0-9]/}"
fi
# LC_ALL=C so awk counts bytes: a comment with an accent in it is shorter
# in characters than it is on disk, and it is the disk that Steam measures.
LC_ALL=C awk -v n="$lineno" -v f=" $flags" -v need="$need" '
{ lines[NR] = (NR == n ? $0 f : $0) }
END {
for (i = NR; i >= 2 && need > 0; i--) {
if (i == n) continue
p = index(lines[i], "#")
if (p == 0) continue
# Only a line that is nothing but a comment; a trailing one
# sits behind a command that has to stay intact.
if (substr(lines[i], 1, p - 1) ~ /[^ \t]/) continue
can = length(lines[i]) - p
if (can <= 0) continue
if (can > need) can = need
lines[i] = substr(lines[i], 1, length(lines[i]) - can)
need -= can
}
if (need > 0) exit 1
for (i = 1; i <= NR; i++) print lines[i]
}
' "$script"
}
# _mca_steam_same_size <script> <content>
# Whether writing that content leaves the file at the size it has now. Measured
# rather than worked out: a script without a final newline gains one on the way
# through awk, and one byte is all it takes for Steam to notice.
_mca_steam_same_size() {
local script="$1" content="$2"
(( $(wc -c < "$script") == $(printf '%s\n' "$content" | wc -c) ))
}
# _mca_steam_refresh_backup <script>
# A client update brings a new version of the script, and the copy kept from
# before it is no longer what putting it back means. Only ever done while the
# script is unpatched, so the copy that replaces it is Steam's own.
_mca_steam_refresh_backup() {
local script="$1" name
name="$(mca_backup_name "$script")"
[[ -e "$MCA_BACKUPDIR/$name" ]] || return 0
cmp -s -- "$script" "$MCA_BACKUPDIR/$name" && return 0
cp -p -- "$script" "$MCA_BACKUPDIR/$name" 2>/dev/null || true
}
# _mca_steam_reneutralise <script> <flags>
# An earlier version of this program patched by appending and left the script
# longer than Steam expects, which is a patch that only survives because
# -noverifyfiles is there to stop Steam looking. If the same flags can be made
# to fit, they are: the file goes back to the length and the timestamp Steam
# wrote down and stops depending on that switch.
#
# The new copy is built from the untouched original that was kept when the
# script was first patched, never from the patched file - patching a patched
# file is how a flag ends up in there twice.
_mca_steam_reneutralise() {
local script="$1" flags="$2" name copy out
name="$(mca_backup_name "$script")"
copy="$MCA_BACKUPDIR/$name"
[[ -f $copy ]] || return 1
# Already the length Steam expects: there is nothing to gain.
(( $(wc -c < "$script") == $(wc -c < "$copy") )) && return 1
out="$(_mca_steam_build "$copy" "$flags" 1)" || return 1
_mca_steam_same_size "$copy" "$out" || return 1
if mca_write_if_changed "$script" "$out"$'\n'; then
MCA_CHANGES=$(( MCA_CHANGES + 1 ))
fi
chmod +x -- "$script" 2>/dev/null || true
touch -r "$copy" -- "$script" 2>/dev/null || true
mca_ledger_add steam "$script" "$name"
return 0
}
# mca_steam_apply
# Appends the flags to the line that starts the web helper. Everything else in
# the script - setting up the container runtime, deciding whether the sandbox
# can be used - is left alone.
# Puts the flags into every Steam installation that has the script, preferring
# the patch that keeps the file's size and falling back to the one that does
# not. Returns 1 when there is no Steam to patch at all.
mca_steam_apply() {
local root script backup flags lineno found=0 out
local root script backup flags found=0 out neutral
flags="$(mca_flags)"
@@ -105,24 +267,51 @@ mca_steam_apply() {
script="$(mca_steam_script "$root")" || continue
found=1
mca_steam_script_patched "$script" && continue
lineno="$(_mca_steam_exec_line "$script")"
if [[ -z $lineno ]]; then
# A client update changed how the helper is started. Leaving the
# file alone is the only safe answer: this script is what starts
# Steam's entire interface.
mca_note "$(mca_msg "Steam starts its interface in a way this version does not recognise; leaving it alone.")"
if mca_steam_script_patched "$script"; then
_mca_steam_reneutralise "$script" "$flags" || true
continue
fi
out="$(awk -v n="$lineno" -v f=" $flags" 'NR == n { $0 = $0 f } { print }' "$script")" || continue
_mca_steam_refresh_backup "$script"
neutral=1
if ! { out="$(_mca_steam_build "$script" "$flags" 1)" \
&& _mca_steam_same_size "$script" "$out"; }
then
neutral=0
out="$(_mca_steam_build "$script" "$flags" 0)" || {
# A client update changed how the helper is started. Leaving
# the file alone is the only safe answer: this script is what
# starts Steam's entire interface.
mca_note "$(mca_msg "Steam starts its interface in a way this version does not recognise; leaving it alone.")"
continue
}
fi
# The patch has to grow the file, and it is not patched now but was
# before: Steam has just put its own copy back. Patching it again
# while the client watches is what turns one size mismatch into an
# endless update dialog, and it would not help this session anyway -
# the helper is started once, at the start. The patch waits for the
# next apply with Steam closed. A patch that keeps the size has
# nothing to wait for.
if (( ! neutral )) && mca_steam_deferred "$script"; then
mca_note "$(mca_msg "Steam is running and has put its own file back; the change waits until Steam is closed.")"
continue
fi
backup="$(mca_backup "$script")" || continue
if mca_write_if_changed "$script" "$out"$'\n'; then
MCA_CHANGES=$(( MCA_CHANGES + 1 ))
fi
chmod +x -- "$script" 2>/dev/null || true
# Steam looks at when the file was last written as well as at how big
# it is, and the copy taken before the edit still carries the original
# timestamp. Putting it back costs nothing and removes the other half
# of what the client would notice.
touch -r "$MCA_BACKUPDIR/$backup" -- "$script" 2>/dev/null || true
mca_ledger_add steam "$script" "$backup"
done < <(mca_steam_roots)
@@ -151,11 +340,22 @@ mca_steam_revert() {
return 0
}
# mca_steam_desktop_apply <id> <source entry>
# mca_steam_flag_position <packaging>
# Where Steam's own switch goes on a command line. Straight after the program
# for a native or snap entry, because Steam reads its options before the
# steam:// argument that the right-click actions pass. For a Flatpak the first
# token is flatpak itself, which would take the switch for one of its own and
# refuse to start, so there it goes at the end of the arguments - after the
# application id, where flatpak hands everything on to Steam.
mca_steam_flag_position() {
[[ ${1:-native} == flatpak ]] && { printf 'before-fields\n'; return; }
printf 'after-program\n'
}
# mca_steam_desktop_apply <id> <source entry> [packaging]
# Steam restores its own files at every start unless it is told not to verify
# them, so the launcher entry carries that switch. It goes directly after the
# program because Steam reads its options before the steam:// argument that the
# right-click actions pass.
# them, so the launcher entry carries that switch.
mca_steam_desktop_apply() {
mca_desktop_apply "$1" "$2" "$MCA_STEAM_LAUNCH_FLAG" after-program
mca_desktop_apply "$1" "$2" "$MCA_STEAM_LAUNCH_FLAG" \
"$(mca_steam_flag_position "${3:-native}")"
}
+5 -1
View File
@@ -119,8 +119,12 @@ mca_do_apply() {
mca_ok "$(mca_msg "Already applied to %d applications." "$MCA_N_ON")"
fi
if [[ $CFG_WATCH == yes ]] && mca_watch_available && ! mca_watch_enabled; then
if [[ $CFG_WATCH == yes ]] && mca_watch_available; then
if mca_watch_enabled; then
mca_watch_refresh
else
mca_watch_enable || true
fi
elif [[ $CFG_WATCH != yes ]] && mca_watch_enabled; then
mca_watch_disable
fi