laussyMenu
Fabrice P. Lauss𝕪's Web

laussyMenu

laussyMenu is my own service menu for KDE: a submenu that appears when right-clicking files in Dolphin, holding the handful of operations I actually perform every day. It is the spiritual successor of KIM (KDE Image Menu), the classic right-click image toolbox that has not survived the move to Plasma 6.

The point is not the operations themselves—any of them is a one-line shell command—it is having them under the ring finger, on the files already selected, without opening a terminal, an editor or GIMP.

Built with Claude Opus 5 on 1 August (2026) for Plasma 6.3.2 / Dolphin 24.12.2. Version 1.1.0.

The catalogue of everything currently in the menu, entry by entry, with the tunables and the recipe for adding one, is on LaussyMenu services.

Why this needed doing at all

I had a compress_image.desktop lying around since the KDE 4 days. It had stopped working and nothing said why. It turned out to be wrong in three separate ways at once, and that is the whole story of why service menus have a reputation for being nightmarish to maintain:

  • The header read [DesktopEntry] instead of [Desktop Entry]. One missing space. The file is then simply not a desktop file, and no error is reported anywhere.
  • It lived in ~/.local/share/kservices5/ServiceMenus/. That was correct for Plasma 5; Plasma 6 does not read that directory at all. The new home is ~/.local/share/kio/servicemenus/.
  • It was not executable. Since KDE Frameworks 6, a service menu .desktop file must have the executable bit set or Dolphin ignores it silently.

Three failure modes, zero diagnostics, and each one of them makes the entry vanish completely rather than misbehave visibly. Multiply by every Plasma upgrade and you see why people give up. So the actual deliverable here is not "an image compressor", it is a generator: I never write a .desktop file again.

Architecture

There are three pieces, and only the middle one is ever edited.

Piece Where What it is
the driver ~/bin/laussymenu one bash script: installer, generator, dispatcher and doctor
the actions ~/.local/share/laussymenu/actions/ one executable script per menu entry, carrying its own metadata
the generated menu ~/.local/share/kio/servicemenus/laussymenu-*.desktop written by laussymenu install; never edited by hand

An action declares itself in comment lines at the top of its own file:

#!/usr/bin/env bash
# LM-Name: Shrink for upload (2000 px, q85)
# LM-Icon: image-x-generic
# LM-Mime: image/jpeg;image/png;image/tiff;image/bmp;image/webp
# LM-Order: 10

laussymenu install reads those headers, groups the actions by MIME set (KDE attaches MimeType to the file, not to the individual action, so one file per distinct set), writes the .desktop, sets the executable bit, and refreshes the KDE service cache. Adding an entry to the menu is therefore: drop a script in actions/, chmod +x, run laussymenu install. Nothing else, ever.

The generated Exec line is laussymenu run <action> %F rather than the action's own path, so the menu keeps working if I move the action files, and so every entry goes through one place that I can instrument.

The X-KDE-Priority=TopLevel line is what puts laussyMenu directly in the right-click menu instead of burying it two levels down under Actions—that is the ring-finger requirement.

The first action: shrinking images for upload

The recurring job: a photo comes off the camera at 6960×4640 and 25 MB, and it needs to become something a wiki, a mail or a form will accept. The target is 2000 px on the longest side at quality 85.

Right-click → laussyMenuShrink for upload (2000 px, q85) and a copy called photo-web.jpg appears next to photo.jpg, followed by a notification saying how much was saved.

What the recipe actually does

The naive version is convert in.jpg -resize 2000x2000 -quality 85 out.jpg. Every refinement below came from a specific way that naive version misbehaves.

Ingredient Why
-resize 2000x2000> the > means shrink only. Without it a 800×600 thumbnail gets upscaled to 2000 px: bigger file, blurrier picture, no gain whatsoever.
-auto-orient before stripping phone photos record their rotation in the EXIF Orientation tag. Strip the EXIF first and the picture comes out on its side. Baking the rotation into the pixels first is the fix.
-strip removes EXIF, ICC junk and thumbnails. Typically 30–60 kB, but the real reason is privacy: camera serial number and GPS coordinates of my house otherwise ride along with every uploaded holiday photo. LM_KEEP_EXIF=1 turns it off when the metadata is the point.
-colorspace sRGB an AdobeRGB or ProPhoto original looks washed-out in browsers, which assume sRGB and ignore the profile that -strip just removed.
-filter Lanczos sharper downscaling than the default for large reductions, at no cost worth measuring.
-interlace Plane progressive JPEG. Usually a few percent smaller, and it renders coarse-to-fine while loading instead of top-to-bottom.
-sampling-factor 4:2:0 standard chroma subsampling; the eye's colour resolution is lower than its luminance resolution, so this is nearly free. (Not for screenshots of coloured text, where it smears—use the custom entry with a higher quality there.)
-quality 85, capped by the source 85 is the sweet spot where artefacts stop being visible at 100% zoom. But re-encoding a quality-55 image at 85 makes a bigger file that faithfully preserves the original's artefacts. So the script reads identify -format %Q and never encodes above the source's own quality.
PNG stays PNG if transparency is used flattening an alpha channel onto JPEG gives black or white fringes. The script checks whether the alpha channel is genuinely non-opaque (-alpha extract, minimum < 1); if it is, it stays PNG, otherwise the "PNG" is really a photo and becomes a JPEG.
output alongside, never in place the original is never touched. The copy is name-web.jpg; a second run gives name-web-2.jpg rather than overwriting; and a file already ending in -web does not become -web-web.

Measured

Numbers from the acceptance run:

Input Output Note
Canon EOS 90D photo 6960×4640, 25.2 MB 2000×1333, 1.2 MB 95% smaller; EXIF (make, model, GPS) gone
opaque PNG 2600×1500, 20.6 MB 2000×1154, 323 kB JPEG re-containered, since nothing was transparent
PNG with alpha 3000×1800, 26 kB 2000×1200, 12 kB PNG stayed PNG, transparency intact
quality-55 JPEG 4000×2000, 398 kB 2000×1000, 113 kB at q55 not re-encoded up to 85
800×600 thumbnail 800×600 not enlarged

Tuning

Defaults live in ~/.config/laussymenu.conf and are read at every run, so changing them needs no reinstall:

LM_MAX=2000        # longest side in pixels (never enlarges)
LM_QUALITY=85      # JPEG quality ceiling
LM_SUFFIX=-web     # copy is named  foo-web.jpg  next to  foo.jpg
LM_KEEP_EXIF=0     # 1 keeps camera metadata / GPS in the copy

The second menu entry, Shrink for upload (choose size / quality…), pops a kdialog box asking for the two numbers and then calls the same script with them—so there is exactly one implementation of the recipe.

The second action: sending a PDF to the iPad

I read papers on the iPad, in Notability, and annotate them there. Getting the annotated copy back out is already automatic—Notability keeps its own backup—but getting a paper in was still done by hand, and the obvious ways of doing it are all wrong for me: Dropbox, Google Drive, mailing it to myself, all of them send the document on a trip through somebody else's computer. Papers, drafts and referee reports have no business transiting through the web or any other public place. So this entry hands the file to the iPad over the local network, straight off this machine, and nothing ever leaves the house.

Notability has no letterbox

The first thing to establish was the least welcome one: Notability has no watch folder and no import inbox. Nothing on the Linux side can put a file into its library. Every route—cloud, mail, AirDrop, KDE Connect—ends in the same place, a share-sheet tap on the iPad. The Notability folder that was already sitting in my Dropbox turned out to be the backup end of the pipe, annotated PDFs flowing out of the iPad, and not a way in.

That is worth finding out before writing anything, because it reframes the job. The last tap is irreducible, so everything before it has to cost nothing: no address to type, no hunting through the Files app, no cloud round-trip to wait for.

How it works

Right-click a PDF, Send to iPad, and two things happen. The file is hard-linked into a spool directory—a link and not a copy, so a 56 MB thesis costs no extra disk—and a small server is started, if one is not already listening. On the iPad I open a bookmark, the paper is already on the screen, and Share → Notability finishes it.

The server is a single Python file using nothing outside the standard library. It listens on port 8642, serves only what is in the spool, and refuses any client that is not on a private address, so anything reaching the port from outside gets a flat 403.

Because avahi-daemon is running, this machine answers to azag.local over Bonjour, which iOS speaks natively. That is what makes the bookmark durable, and what removed the need for a QR code: the address is short enough to type once, and it survives the router handing out a different lease. Added to the Home Screen, it is one tap.

The two things that silently break it

Both need root, and both were true here:

  • ufw was active, so the port was dropped before anything reached the server. The cure is ufw allow from 192.168.1.0/24 to any port 8642 proto tcp.
  • azag.local resolved to 192.168.122.1, the libvirt bridge, instead of the Wi-Fi address. Avahi was advertising the virtual interface and the iPad was chasing a dead address. The cure is deny-interfaces=virbr0 in /etc/avahi/avahi-daemon.conf. This was a general mDNS fault on the machine, not something this entry introduced—anything at all asking for azag.local was being misdirected.

laussymenu doctor now checks both. The server itself never asks the system for its own address, because gethostbyname() would have answered virbr0 just as happily; it opens a UDP socket towards the gateway and reads back which interface the kernel picked.

Details that turned out to matter

  • Range requests. Safari asks for byte ranges when rendering a PDF. A server that only ever answers 200 makes it re-fetch the whole file, repeatedly, on anything large. This one answers 206 with a proper Content-Range, and 416 when the range is nonsense.
  • Content-Disposition: inline. With attachment the iPad downloads the file and I have to go and find it again; with inline Safari renders it and the share sheet is right there.
  • Going straight to the paper. One file waiting is the normal case, so the index redirects to it instead of showing a list of one. But only while it is unread: once fetched, the bookmark falls back to the list, or there would be no way of ever reaching the remove buttons.
  • Reading a file does not consume it. Files stay in the spool until I clear them, and clearing drops the hard link only—the original is untouched.
Command Effect
ipad-server --status whether it is running, on which address, and how many files are waiting
ipad-server --ensure start it in the background unless it is already listening—what the entry itself calls
ipad-server --stop stop the background instance

Commands

Command Effect
laussymenu install regenerate the .desktop files from actions/ and refresh KDE
laussymenu list show the installed entries with their order and MIME types
laussymenu doctor diagnose a menu that is not showing up
laussymenu run <action> <files…> run an entry from the terminal, exactly as Dolphin calls it
laussymenu uninstall remove the generated files

doctor exists precisely because of the three silent failures above: it checks that the generated file is where Plasma 6 looks, that it is executable, that the external tools are present, and it warns if stale KF5 menus are still sitting in kservices5/ServiceMenus/ pretending to be installed.

Notes and gotchas

  • Restart Dolphin after the first install. New menus are usually picked up on the next right-click, but a Dolphin that has already cached its actions will not show them until it is restarted.
  • desktop-file-validate reports two errors on the generated file, complaining that MimeType and Actions are only valid for Type=Application. These are expected and must be ignored: Type=Service is a KDE extension that the freedesktop validator does not know about, and this is exactly the shape KDE's own service menus have.
  • Ubuntu ships ImageMagick 6, so the command is convert, not magick. On ImageMagick 7 the geometry argument 2000x2000> needs quoting against the shell in interactive use—inside the script it already is.
  • Installing pngquant is worth it: the script uses it when present, and for screenshots and flat-colour PNGs it beats optipng by a wide margin (lossy palette quantisation, typically 60–70% off, visually indistinguishable). sudo apt install pngquant.
  • Notifications come through notify-send under the application name laussyMenu, so they can be muted or styled separately in System Settings → Notifications.

Versions

  1. v°1.0 on 1 August (2026) — first version: the generator, and image shrinking
  2. v°1.0.1 on 1 August (2026) — action identifiers use - rather than _, which desktop-file-validate wants, and a Name key in the main group
  3. v°1.0.2 on 1 August (2026) — dense one-liners unfolded, so that the listings below fit the width of this page
  4. v°1.1.0 on 3 August (2026) — a second entry: hand a PDF to the iPad over the local network, for Notability

Source

The driver

~/bin/laussymenu. This is the only piece that knows anything about the .desktop format, and the whole point is that it is the only piece that ever will.

#!/usr/bin/env bash
# laussymenu — a KIM-like service-menu framework for KDE Plasma 6 (Dolphin)
# Version: 1.0.2
#
# Usage:
#   laussymenu install        regenerate the .desktop files from actions/ and refresh KDE
#   laussymenu uninstall      remove the generated .desktop files
#   laussymenu list           show the installed actions
#   laussymenu doctor         diagnose why the menu is not showing up
#   laussymenu run <action> [files...]     run an action directly (what Dolphin calls)
#
# Adding a new entry = drop an executable script in ~/.local/share/laussymenu/actions/
# with an LM- metadata header, then run `laussymenu install`. That is the whole
# maintenance story.
#
# Metadata header keys (read from '# LM-Key: value' comment lines):
#   LM-Name   menu label                         (required)
#   LM-Mime   ';'-separated MIME types           (required)
#   LM-Icon   freedesktop icon name              (default: application-x-executable)
#   LM-Order  sort key inside the submenu        (default: 50)

set -uo pipefail

VERSION=1.1.0
LM_HOME="${LM_HOME:-$HOME/.local/share/laussymenu}"
ACTION_DIR="$LM_HOME/actions"
MENU_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/kio/servicemenus"
SUBMENU="laussyMenu"
PREFIX="laussymenu"

die() { printf 'laussymenu: %s\n' "$*" >&2; exit 1; }

meta() { # meta <file> <key> [default]
	local v
	v=$(sed -n "s/^#[[:space:]]*$2:[[:space:]]*//p" "$1" | head -1)
	printf '%s' "${v:-${3-}}"
}

actions() { find "$ACTION_DIR" -maxdepth 1 -type f -perm -u+x -printf '%f\n' 2>/dev/null | sort; }

cmd_install() {
	[ -d "$ACTION_DIR" ] || die "no action directory at $ACTION_DIR"
	mkdir -p "$MENU_DIR"
	rm -f "$MENU_DIR/$PREFIX"-*.desktop

	# Group actions by their MIME set: KDE applies MimeType per file, not per action.
	local -A group_actions=() group_names=()
	local a f mime key
	while read -r a; do
		[ -n "$a" ] || continue
		f="$ACTION_DIR/$a"
		mime=$(meta "$f" LM-Mime)
		[ -n "$mime" ] || { printf 'skipping %s: no LM-Mime header\n' "$a" >&2; continue; }
		key=$(printf '%s' "$mime" | md5sum | cut -c1-8)
		group_actions[$key]+="$a "
		group_names[$key]="$mime"
	done < <(actions)

	[ ${#group_actions[@]} -gt 0 ] || die "no executable actions found in $ACTION_DIR"

	local n=0 out ids id
	for key in "${!group_actions[@]}"; do
		n=$((n + 1))
		out="$MENU_DIR/$PREFIX-$key.desktop"
		ids=""
		# order actions by LM-Order then name
		while read -r _ a; do
			ids+="lm-${a//[^a-zA-Z0-9]/-};"
		done < <(for a in ${group_actions[$key]}; do
			printf '%s %s\n' "$(meta "$ACTION_DIR/$a" LM-Order 50)" "$a"
		done | sort -k1,1n -k2,2)

		{
			printf '# Generated by laussymenu %s — do not edit; edit the action scripts instead.\n' "$VERSION"
			printf '[Desktop Entry]\n'
			printf 'Name=laussyMenu\n'; printf 'Type=Service\n'
			printf 'MimeType=%s\n' "${group_names[$key]}"
			printf 'Actions=%s\n' "$ids"
			printf 'X-KDE-Submenu=%s\n' "$SUBMENU"
			printf 'X-KDE-Priority=TopLevel\n'
			printf 'X-KDE-StartupNotify=false\n'
			for a in ${group_actions[$key]}; do
				id="lm-${a//[^a-zA-Z0-9]/-}"
				printf '\n[Desktop Action %s]\n' "$id"
				printf 'Name=%s\n' "$(meta "$ACTION_DIR/$a" LM-Name "$a")"
				printf 'Icon=%s\n' "$(meta "$ACTION_DIR/$a" LM-Icon application-x-executable)"
				printf 'Exec=%s run %s %%F\n' "$(command -v laussymenu || printf '%s/bin/laussymenu' "$HOME")" "$a"
			done
		} >"$out"
		chmod +x "$out"   # KF6 requires this, or Dolphin silently ignores the file
		printf 'wrote %s\n' "$out"
	done

	command -v kbuildsycoca6 >/dev/null && kbuildsycoca6 --noincremental >/dev/null 2>&1
	printf 'laussyMenu %s installed (%d file(s), %d action(s)). Restart Dolphin to see it.\n' \
		"$VERSION" "$n" "$(actions | wc -l)"
}

cmd_uninstall() {
	rm -fv "$MENU_DIR/$PREFIX"-*.desktop
	command -v kbuildsycoca6 >/dev/null && kbuildsycoca6 --noincremental >/dev/null 2>&1
	echo "laussyMenu removed."
}

cmd_list() {
	printf '%-10s %-34s %s\n' ORDER NAME MIME
	while read -r a; do
		[ -n "$a" ] || continue
		printf '%-10s %-34s %s\n' "$(meta "$ACTION_DIR/$a" LM-Order 50)" \
			"$(meta "$ACTION_DIR/$a" LM-Name "$a")" "$(meta "$ACTION_DIR/$a" LM-Mime)"
	done < <(actions)
}

cmd_doctor() {
	printf 'laussymenu %s\n' "$VERSION"
	printf 'Plasma:      %s\n' "$(plasmashell --version 2>/dev/null || echo '?')"
	printf 'Dolphin:     %s\n' "$(dolphin --version 2>/dev/null || echo '?')"
	printf 'action dir:  %s (%s executable action(s))\n' "$ACTION_DIR" "$(actions | wc -l)"
	printf 'menu dir:    %s\n' "$MENU_DIR"
	local f ok=1
	for f in "$MENU_DIR/$PREFIX"-*.desktop; do
		[ -e "$f" ] || { echo '  !! no generated .desktop — run: laussymenu install'; ok=0; break; }
		[ -x "$f" ] && printf '  ok  %s (executable)\n' "${f##*/}" \
			|| { printf '  !!  %s is NOT executable — KF6 will ignore it\n' "${f##*/}"; ok=0; }
	done
	if [ -e "$HOME/.local/share/kservices5/ServiceMenus" ] && \
	   [ -n "$(ls -A "$HOME/.local/share/kservices5/ServiceMenus" 2>/dev/null)" ]; then
		printf '  note: stale KF5 menus in ~/.local/share/kservices5/ServiceMenus/ — Plasma 6 ignores them.\n'
	fi
	for f in convert identify notify-send python3; do
		command -v "$f" >/dev/null || { printf '  !!  missing tool: %s\n' "$f"; ok=0; }
	done

	# Send to iPad: three things silently break the hand-off.
	if [ -x "$LM_HOME/ipad-server" ]; then
		printf 'iPad server: %s\n' "$("$LM_HOME/ipad-server" --status 2>/dev/null | head -1)"
		local port=8642 lanip mdns
		port=$(sed -n 's/^LM_IPAD_PORT=\([0-9]*\).*/\1/p' "$HOME/.config/laussymenu.conf" \
			2>/dev/null | head -1); port=${port:-8642}
		if command -v ufw >/dev/null && ! ufw status 2>/dev/null | grep -q "$port"; then
			printf '  note: cannot confirm ufw allows port %s — check with:\n' "$port"
			printf '        sudo ufw status | grep %s\n' "$port"
		fi
		lanip=$(ip -4 route get 192.168.1.1 2>/dev/null | grep -oP 'src \K[\d.]+' | head -1)
		mdns=$(avahi-resolve -4 -n "$(hostname).local" 2>/dev/null | awk '{print $2}' | head -1)
		if [ -n "$mdns" ] && [ -n "$lanip" ] && [ "$mdns" != "$lanip" ]; then
			printf '  !!  %s.local resolves to %s but the LAN address is %s\n' \
				"$(hostname)" "$mdns" "$lanip"
			printf '      (avahi is advertising a virtual interface; the iPad will not reach it)\n'
			ok=0
		fi
	fi
	[ "$ok" = 1 ] && echo 'All good.' || echo 'Problems found (see !! above).'
}

cmd_run() {
	local a=${1-}; shift || true
	[ -n "$a" ] || die "run: no action given"
	[ -x "$ACTION_DIR/$a" ] || die "run: no such action: $a"
	exec "$ACTION_DIR/$a" "$@"
}

case "${1-}" in
	install)          shift; cmd_install "$@" ;;
	uninstall)        shift; cmd_uninstall "$@" ;;
	list)             shift; cmd_list "$@" ;;
	doctor)           shift; cmd_doctor "$@" ;;
	run)              shift; cmd_run "$@" ;;
	-V|--version)     printf 'laussymenu %s\n' "$VERSION" ;;
	""|-h|--help)     sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' ;;
	*)                die "unknown command: $1 (try --help)" ;;
esac

An action

~/.local/share/laussymenu/actions/image-shrink. Note the LM- header: that is the entire interface between an action and the menu.

#!/usr/bin/env bash
# LM-Name: Shrink for upload (2000 px, q85)
# LM-Icon: image-x-generic
# LM-Mime: image/jpeg;image/png;image/tiff;image/bmp;image/webp;image/heif;image/x-portable-pixmap
# LM-Order: 10
#
# laussymenu action — version 1.0.2
# Makes an upload-sized copy NEXT TO the original. The original is never touched.
#
# Tunables (env, or ~/.config/laussymenu.conf):
#   LM_MAX=2000      longest-side cap in pixels; images already smaller are not enlarged
#   LM_QUALITY=85    JPEG quality; never raised above the source's own quality
#   LM_SUFFIX=-web   appended to the basename of the copy
#   LM_KEEP_EXIF=0   1 to keep camera metadata (GPS, serial numbers…) in the copy

set -uo pipefail

# shellcheck source=/dev/null
[ -r "$HOME/.config/laussymenu.conf" ] && . "$HOME/.config/laussymenu.conf"

MAX=${LM_MAX:-2000}
Q=${LM_QUALITY:-85}
SUFFIX=${LM_SUFFIX:--web}
KEEP_EXIF=${LM_KEEP_EXIF:-0}

notify() { # notify <urgency> <title> <body>
	notify-send -a laussyMenu -u "$1" -i image-x-generic "$2" "$3" 2>/dev/null
}
hsize() { numfmt --to=iec --suffix=B --format='%.1f' "$1" 2>/dev/null || printf '%sB' "$1"; }

[ $# -gt 0 ] || { notify critical "laussyMenu" "No file given."; exit 1; }

total_in=0 total_out=0 done_n=0 fail_n=0 last_out="" report=""

for f in "$@"; do
	[ -f "$f" ] || { fail_n=$((fail_n + 1)); continue; }

	geom=$(identify -quiet -format '%w %h %Q %A' "$f[0]" 2>/dev/null) || {
		report+=$'\n'"✗ ${f##*/}: not a readable image"; fail_n=$((fail_n + 1)); continue; }
	read -r w h srcq alpha <<<"$geom"

	dir=${f%/*}; [ "$dir" = "$f" ] && dir=.
	base=${f##*/}; base=${base%.*}
	base=${base%"$SUFFIX"}                       # don't build foo-web-web.jpg chains

	# Transparency actually in use? Then stay PNG, else go JPEG.
	ext=jpg
	case "$alpha" in
		True|Blend|Associated)
			[ "$(convert "$f[0]" -alpha extract -format '%[fx:minima]' info: 2>/dev/null)" = "1" ] || ext=png ;;
	esac

	out="$dir/$base$SUFFIX.$ext"
	i=2; while [ -e "$out" ]; do out="$dir/$base$SUFFIX-$i.$ext"; i=$((i + 1)); done

	# Never re-encode *up*: a q60 source stays q60.
	q=$Q
	case "$srcq" in ''|*[!0-9]*) : ;; *) [ "$srcq" -gt 0 ] && [ "$srcq" -lt "$q" ] && q=$srcq ;; esac

	strip=(-strip)
	[ "$KEEP_EXIF" = 1 ] && strip=()

	if [ "$ext" = jpg ]; then
		convert "$f[0]" -auto-orient -colorspace sRGB \
			-filter Lanczos -resize "${MAX}x${MAX}>" \
			"${strip[@]}" -interlace Plane -sampling-factor 4:2:0 \
			-define jpeg:dct-method=float -quality "$q" "$out" 2>/dev/null
	else
		convert "$f[0]" -auto-orient \
			-filter Lanczos -resize "${MAX}x${MAX}>" \
			"${strip[@]}" -define png:compression-level=9 "$out" 2>/dev/null
		if [ -s "$out" ] && command -v pngquant >/dev/null; then
			pngquant --force --skip-if-larger --quality=65-90 \
				--output "$out" -- "$out" 2>/dev/null
		fi
		if [ -s "$out" ] && command -v optipng >/dev/null; then
			optipng -quiet -o2 "$out" 2>/dev/null
		fi
	fi

	if [ ! -s "$out" ]; then
		rm -f "$out"
		report+=$'\n'"✗ ${f##*/}: conversion failed"; fail_n=$((fail_n + 1)); continue
	fi

	in_b=$(stat -c%s "$f"); out_b=$(stat -c%s "$out")
	read -r nw nh <<<"$(identify -quiet -format '%w %h' "$out[0]" 2>/dev/null)"
	total_in=$((total_in + in_b)); total_out=$((total_out + out_b))
	done_n=$((done_n + 1)); last_out=$out
	report+=$'\n'"${out##*/}  ${w}×${h}${nw}×${nh}, $(hsize "$in_b")$(hsize "$out_b")"
done

if [ "$done_n" = 0 ]; then
	notify critical "laussyMenu — nothing written" "${report:-No image could be processed.}"
	exit 1
fi

pct=$(( total_in > 0 ? 100 - 100 * total_out / total_in : 0 ))
if [ "$done_n" = 1 ]; then
	title="Shrunk ${last_out##*/}${pct}% smaller"
else
	title="Shrunk $done_n images — ${pct}% smaller ($(hsize "$total_in")$(hsize "$total_out"))"
fi
[ "$fail_n" -gt 0 ] && title+=" ($fail_n failed)"
notify normal "$title" "${report#$'\n'}"

The variant that asks

~/.local/share/laussymenu/actions/image-shrink-custom, which is how cheap a second entry is once the first one exists: ask for the two numbers, then hand over to the real implementation.

#!/usr/bin/env bash
# LM-Name: Shrink for upload (choose size / quality…)
# LM-Icon: image-resize-symbolic
# LM-Mime: image/jpeg;image/png;image/tiff;image/bmp;image/webp;image/heif;image/x-portable-pixmap
# LM-Order: 20
#
# laussymenu action — version 1.0.2
# Same as image-shrink, but asks for the two numbers first.

set -uo pipefail
[ -r "$HOME/.config/laussymenu.conf" ] && . "$HOME/.config/laussymenu.conf"

ans=$(kdialog --title "laussyMenu" \
	--inputbox "Longest side (px) and JPEG quality (%):" \
	"${LM_MAX:-2000} ${LM_QUALITY:-85}") || exit 0

read -r max q _ <<<"$ans"

case "$max$q" in
	''|*[!0-9]*)
		kdialog --title "laussyMenu" --error "Expected two numbers, e.g. “1600 80”."
		exit 1 ;;
esac

if [ "$q" -lt 1 ] || [ "$q" -gt 100 ]; then
	kdialog --title "laussyMenu" --error "Quality must be 1–100."
	exit 1
fi

export LM_MAX="$max" LM_QUALITY="$q"
exec "${0%/*}/image-shrink" "$@"

The iPad entry

~/.local/share/laussymenu/actions/pdf-to-ipad. It spools and starts the server; it deliberately knows nothing about HTTP.

#!/usr/bin/env bash
# LM-Name: Send to iPad (Notability)
# LM-Icon: document-send
# LM-Mime: application/pdf;application/epub+zip
# LM-Order: 30
#
# laussymenu action — version 1.0.0
# Puts the file in the local hand-off spool and makes sure the LAN server is up.
# Nothing is uploaded anywhere: the iPad fetches it straight off this machine.
#
# On the iPad: open the bookmark, the file is there, Share -> Notability.
#
# Tunables (env, or ~/.config/laussymenu.conf):
#   LM_IPAD_PORT=8642   port the hand-off server listens on
#   LM_IPAD_SPOOL=...   where files wait to be collected

set -uo pipefail

# shellcheck source=/dev/null
[ -r "$HOME/.config/laussymenu.conf" ] && . "$HOME/.config/laussymenu.conf"

LM_HOME="${LM_HOME:-$HOME/.local/share/laussymenu}"
SPOOL="${LM_IPAD_SPOOL:-$LM_HOME/ipad}"
PORT="${LM_IPAD_PORT:-8642}"
SERVER="$LM_HOME/ipad-server"

notify() { # notify <urgency> <title> <body>
	notify-send -a laussyMenu -u "$1" -i document-send "$2" "$3" 2>/dev/null
}

[ $# -gt 0 ] || { notify critical "laussyMenu" "No file given."; exit 1; }
[ -x "$SERVER" ] || { notify critical "laussyMenu" "Missing $SERVER"; exit 1; }

mkdir -p "$SPOOL" || { notify critical "laussyMenu" "Cannot create $SPOOL"; exit 1; }

sent=0 fail=0 report=""
for f in "$@"; do
	if [ ! -f "$f" ]; then
		report+=$'\n'"✗ ${f##*/}: not a file"; fail=$((fail + 1)); continue
	fi
	dest="$SPOOL/${f##*/}"
	# Hard link when we can — a 56 MB thesis need not be copied to be served.
	if ! ln -f "$f" "$dest" 2>/dev/null && ! cp -f "$f" "$dest" 2>/dev/null; then
		report+=$'\n'"✗ ${f##*/}: could not spool"; fail=$((fail + 1)); continue
	fi
	sent=$((sent + 1))
	report+=$'\n'"${f##*/}"
done

if [ "$sent" = 0 ]; then
	notify critical "laussyMenu — nothing sent" "${report:-No file could be spooled.}"
	exit 1
fi

"$SERVER" --ensure 2>/dev/null

# The address the iPad should actually use: the LAN interface, not virbr0.
ip=$(ip -4 route get 192.168.1.1 2>/dev/null | grep -oP 'src \K[\d.]+' | head -1)
[ -n "$ip" ] || ip=$(hostname -I 2>/dev/null | awk '{print $1}')

waiting=$(find "$SPOOL" -maxdepth 1 -type f ! -name '.*' 2>/dev/null | wc -l)
if [ "$sent" = 1 ]; then
	title="Sent to iPad — ${report#$'\n'}"
else
	title="Sent $sent files to iPad"
fi
[ "$fail" -gt 0 ] && title+=" ($fail failed)"

notify normal "$title" "Open http://$ip:$PORT/ on the iPad — $waiting waiting.$(
	[ "$fail" -gt 0 ] && printf '%s' "$report")"

The hand-off server

~/.local/share/laussymenu/ipad-server, standard library only.

#!/usr/bin/env python3
"""laussyMenu — hand-off server for the iPad.

Serves whatever the 'Send to iPad' action has dropped in the spool directory,
over the local network only. Nothing ever leaves the house.

The iPad end is a Home Screen bookmark pointing at this server. Open it, the
pending PDF appears, Share -> Notability. Notability has no watch folder, so
that last tap is unavoidable; everything before it is not.

  ipad-server            run in the foreground (Ctrl-C to stop)
  ipad-server --ensure   start in the background unless already listening
  ipad-server --status   say whether it is running, and on which URL
  ipad-server --stop     stop the background instance

Tunables (env, or ~/.config/laussymenu.conf):
  LM_IPAD_PORT=8642      TCP port to listen on
  LM_IPAD_SPOOL=...      directory of files waiting to be picked up
  LM_IPAD_AUTOOPEN=1     with exactly one file pending, go straight to it
"""

import html
import os
import re
import socket
import subprocess
import sys
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

VERSION = "1.1.0"

HOME = os.path.expanduser("~")
LM_HOME = os.environ.get("LM_HOME", os.path.join(HOME, ".local/share/laussymenu"))


def conf(key, default):
    """Read a tunable from the environment, else ~/.config/laussymenu.conf."""
    if key in os.environ:
        return os.environ[key]
    path = os.path.join(HOME, ".config/laussymenu.conf")
    try:
        with open(path, encoding="utf-8") as fh:
            for line in fh:
                line = line.split("#", 1)[0].strip()
                if line.startswith(key + "="):
                    return line.split("=", 1)[1].strip().strip("\"'")
    except OSError:
        pass
    return default


SPOOL = os.path.expanduser(conf("LM_IPAD_SPOOL", os.path.join(LM_HOME, "ipad")))
PORT = int(conf("LM_IPAD_PORT", "8642"))
AUTOOPEN = conf("LM_IPAD_AUTOOPEN", "1") == "1"
PIDFILE = os.path.join(LM_HOME, "ipad-server.pid")

# Files the iPad has already fetched this session. Not persisted: a restart
# simply means the next visit auto-opens again, which is harmless.
SERVED = set()

# Content types we are willing to serve. Anything else is a download.
TYPES = {
    ".pdf": "application/pdf",
    ".epub": "application/epub+zip",
    ".png": "image/png",
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".txt": "text/plain; charset=utf-8",
}


def lan_ip():
    """The address of the interface that actually reaches the LAN.

    Deliberately not gethostbyname(): on a machine running libvirt that
    answers with the virbr0 address, which the iPad cannot reach.
    """
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        s.connect(("192.168.1.1", 9))  # no packet is sent for UDP connect
        return s.getsockname()[0]
    except OSError:
        return "127.0.0.1"
    finally:
        s.close()


def pending():
    """Spooled files, newest first."""
    try:
        names = [n for n in os.listdir(SPOOL) if not n.startswith(".")]
    except OSError:
        return []
    files = []
    for n in names:
        p = os.path.join(SPOOL, n)
        if os.path.isfile(p):
            files.append((n, os.path.getsize(p), os.path.getmtime(p)))
    files.sort(key=lambda t: t[2], reverse=True)
    return files


def hsize(n):
    for unit in ("B", "kB", "MB", "GB"):
        if n < 1024 or unit == "GB":
            return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
        n /= 1024.0


def safe(name):
    """Reject anything that is not a plain file name in the spool."""
    return name and "/" not in name and not name.startswith(".")


PAGE = """<!doctype html>
<html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex">
<title>Send to iPad</title>
<style>
 :root {{ color-scheme: light dark; }}
 body {{ font: 17px/1.5 -apple-system,system-ui,sans-serif; margin: 0;
        padding: 1.2rem; background: #fff1c5; color: #241f14; }}
 @media (prefers-color-scheme: dark) {{
   body {{ background: #1c1a15; color: #ece5d2; }}
   .card {{ background: #2a2720 !important; border-color: #4a4436 !important; }}
   .rm {{ border-left-color: #4a4436 !important; }} }}
 h1 {{ font-size: 1.15rem; font-weight: 600; margin: 0 0 1rem; opacity: .75; }}
 .card {{ display: flex; align-items: center; gap: .9rem;
        background: #fff; border: 1px solid #e0d6b4;
        border-radius: 12px; padding: .95rem 1.05rem; margin-bottom: .6rem; }}
 .card a {{ text-decoration: none; color: inherit; }}
 .open {{ flex: 1; min-width: 0; display: flex; align-items: center; gap: .9rem; }}
 .open:active {{ opacity: .6; }}
 .nm {{ flex: 1; min-width: 0; word-break: break-word; font-weight: 500; }}
 .sz {{ opacity: .55; font-size: .82rem; white-space: nowrap; }}
 .rm {{ color: #b3261e !important; font-size: 1.4rem; line-height: 1;
        padding: .2rem .1rem .2rem .7rem; opacity: .45;
        border-left: 1px solid #e0d6b4; }}
 .clear {{ display: inline-block; margin-top: .8rem; font-size: .85rem;
        color: #b3261e; text-decoration: none; opacity: .8; }}
 .empty {{ opacity: .55; padding: 2.5rem 0; text-align: center; }}
 footer {{ margin-top: 1.6rem; font-size: .78rem; opacity: .45; }}
</style></head><body>
<h1>{title}</h1>
{body}
<footer>laussyMenu {version} &middot; {host}</footer>
</body></html>"""


class Handler(BaseHTTPRequestHandler):
    server_version = f"laussyMenu-iPad/{VERSION}"
    protocol_version = "HTTP/1.1"

    def log_message(self, fmt, *args):  # keep the console quiet
        pass

    def _private(self):
        """Serve the LAN only, never a routed address that wandered in."""
        ip = self.client_address[0]
        return (
            ip.startswith("192.168.")
            or ip.startswith("10.")
            or ip.startswith("127.")
            or re.match(r"^172\.(1[6-9]|2\d|3[01])\.", ip)
            or ip in ("::1",)
            or ip.startswith("fe80:")
        )

    def _send(self, code, body=b"", ctype="text/html; charset=utf-8", extra=None):
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        for k, v in (extra or {}).items():
            self.send_header(k, v)
        self.end_headers()
        if self.command != "HEAD":
            self.wfile.write(body)

    def _redirect(self, to):
        self.send_response(303)
        self.send_header("Location", to)
        self.send_header("Content-Length", "0")
        self.end_headers()

    def do_HEAD(self):
        self.do_GET()

    def do_GET(self):
        if not self._private():
            self._send(403, b"Local network only.", "text/plain; charset=utf-8")
            return

        parsed = urllib.parse.urlparse(self.path)
        path = urllib.parse.unquote(parsed.path)
        query = urllib.parse.parse_qs(parsed.query)

        if path == "/":
            self.index(force_list="list" in query)
        elif path == "/rm-all":
            self.remove_all()
        elif path.startswith("/f/"):
            self.serve(path[3:])
        elif path.startswith("/rm/"):
            self.remove(path[4:])
        else:
            self._send(404, b"No.", "text/plain; charset=utf-8")

    def index(self, force_list=False):
        files = pending()

        # Sending one paper then picking up the iPad is the overwhelmingly
        # common case, so go straight to it. But only while it is still
        # unread: once fetched, the bookmark must fall back to the list, or
        # there would be no way to reach the remove buttons.
        fresh = [f for f in files if f[0] not in SERVED]
        if AUTOOPEN and len(fresh) == 1 and not force_list:
            self._redirect("/f/" + urllib.parse.quote(fresh[0][0]))
            return

        if not files:
            body = '<p class="empty">Nothing waiting.</p>'
            title = "Send to iPad"
        else:
            rows = []
            for name, size, _ in files:
                q = urllib.parse.quote(name)
                rows.append(
                    f'<div class="card">'
                    f'<a class="open" href="/f/{q}">'
                    f'<span class="nm">{html.escape(name)}</span>'
                    f'<span class="sz">{hsize(size)}</span></a>'
                    f'<a class="rm" href="/rm/{q}" title="remove">&times;</a>'
                    f"</div>"
                )
            rows.append('<a class="clear" href="/rm-all">clear all</a>')
            body = "\n".join(rows)
            title = f"{len(files)} waiting"

        page = PAGE.format(
            title=title, body=body, version=VERSION, host=socket.gethostname()
        )
        self._send(200, page.encode("utf-8"))

    def remove(self, name):
        if safe(name):
            try:
                os.unlink(os.path.join(SPOOL, name))
            except OSError:
                pass
            SERVED.discard(name)
        self._redirect("/?list=1")

    def remove_all(self):
        for name, _, _ in pending():
            try:
                os.unlink(os.path.join(SPOOL, name))
            except OSError:
                pass
        SERVED.clear()
        self._redirect("/?list=1")

    def serve(self, name):
        if not safe(name):
            self._send(404, b"No.", "text/plain; charset=utf-8")
            return
        path = os.path.join(SPOOL, name)
        if not os.path.isfile(path):
            self._send(404, b"Gone.", "text/plain; charset=utf-8")
            return

        SERVED.add(name)  # the iPad has seen it; stop auto-opening it
        ctype = TYPES.get(os.path.splitext(name)[1].lower(), "application/octet-stream")
        size = os.path.getsize(path)
        start, end = 0, size - 1

        # Safari asks for ranges when rendering a PDF; a 200-only server makes
        # it re-fetch the whole file repeatedly on anything large.
        rng = self.headers.get("Range")
        partial = False
        if rng:
            m = re.match(r"bytes=(\d*)-(\d*)$", rng.strip())
            if m:
                g1, g2 = m.group(1), m.group(2)
                if g1:
                    start = int(g1)
                    if g2:
                        end = min(int(g2), size - 1)
                elif g2:  # suffix range: last N bytes
                    start = max(0, size - int(g2))
                if start > end or start >= size:
                    self.send_response(416)
                    self.send_header("Content-Range", f"bytes */{size}")
                    self.send_header("Content-Length", "0")
                    self.end_headers()
                    return
                partial = True

        length = end - start + 1
        self.send_response(206 if partial else 200)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(length))
        self.send_header("Accept-Ranges", "bytes")
        # inline: Safari renders the PDF, so Share -> Notability is right there
        self.send_header(
            "Content-Disposition",
            "inline; filename*=UTF-8''" + urllib.parse.quote(name),
        )
        if partial:
            self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
        self.end_headers()

        if self.command == "HEAD":
            return
        with open(path, "rb") as fh:
            fh.seek(start)
            left = length
            while left > 0:
                chunk = fh.read(min(65536, left))
                if not chunk:
                    break
                try:
                    self.wfile.write(chunk)
                except (BrokenPipeError, ConnectionResetError):
                    return  # iPad closed the connection; perfectly normal
                left -= len(chunk)


def listening():
    """Is something already on our port?"""
    s = socket.socket()
    s.settimeout(0.4)
    try:
        s.connect(("127.0.0.1", PORT))
        return True
    except OSError:
        return False
    finally:
        s.close()


def url():
    return f"http://{lan_ip()}:{PORT}/"


def main():
    os.makedirs(SPOOL, exist_ok=True)
    arg = sys.argv[1] if len(sys.argv) > 1 else ""

    if arg == "--status":
        if listening():
            print(f"running on {url()}  (also http://{socket.gethostname()}.local:{PORT}/)")
            print(f"{len(pending())} file(s) waiting in {SPOOL}")
        else:
            print("not running")
        return 0

    if arg == "--stop":
        try:
            with open(PIDFILE) as fh:
                os.kill(int(fh.read().strip()), 15)
            os.unlink(PIDFILE)
            print("stopped")
        except (OSError, ValueError):
            print("not running (no pidfile)")
        return 0

    if arg == "--ensure":
        if listening():
            return 0
        # Re-exec ourselves, detached, so the caller (Dolphin) is not held.
        with open(os.devnull, "wb") as null:
            p = subprocess.Popen(
                [sys.executable, os.path.abspath(__file__)],
                stdout=null, stderr=null, stdin=null, start_new_session=True,
            )
        with open(PIDFILE, "w") as fh:
            fh.write(str(p.pid))
        return 0

    if arg in ("-V", "--version"):
        print(f"ipad-server {VERSION}")
        return 0

    srv = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
    srv.daemon_threads = True
    print(f"laussyMenu iPad server {VERSION} on {url()}")
    print(f"spool: {SPOOL}")
    try:
        srv.serve_forever()
    except KeyboardInterrupt:
        print("\nbye")
    return 0


if __name__ == "__main__":
    sys.exit(main())

The defaults

~/.config/laussymenu.conf, sourced at every run, so retuning needs no reinstall.

# laussyMenu defaults — edit freely, no reinstall needed.

# Shrink for upload
LM_MAX=2000        # longest side in pixels (never enlarges)
LM_QUALITY=85      # JPEG quality ceiling
LM_SUFFIX=-web     # copy is named  foo-web.jpg  next to  foo.jpg
LM_KEEP_EXIF=0     # 1 keeps camera metadata / GPS in the copy

# Send to iPad
LM_IPAD_PORT=8642      # LAN port the hand-off server listens on
LM_IPAD_AUTOOPEN=1     # one file waiting → open it instead of listing
# LM_IPAD_SPOOL=~/.local/share/laussymenu/ipad

What comes out

And this is the thing I no longer have to write, generated into ~/.local/share/kio/servicemenus/ and made executable. There are two such files since the iPad entry arrived—one per distinct MIME set, because KDE puts MimeType on the file and not on the action:

# Generated by laussymenu 1.1.0 — do not edit; edit the action scripts instead.
[Desktop Entry]
Name=laussyMenu
Type=Service
MimeType=image/jpeg;image/png;image/tiff;image/bmp;image/webp;image/heif;image/x-portable-pixmap
Actions=lm-image-shrink;lm-image-shrink-custom;
X-KDE-Submenu=laussyMenu
X-KDE-Priority=TopLevel
X-KDE-StartupNotify=false

[Desktop Action lm-image-shrink]
Name=Shrink for upload (2000 px, q85)
Icon=image-x-generic
Exec=/home/laussy/bin/laussymenu run image-shrink %F

[Desktop Action lm-image-shrink-custom]
Name=Shrink for upload (choose size / quality…)
Icon=image-resize-symbolic
Exec=/home/laussy/bin/laussymenu run image-shrink-custom %F

To do

Entries that belong in here next: convert to PDF and merge PDFs, "copy path to clipboard", checksum, extract audio from a video, and a send to the wiki entry that uploads the shrunk image straight to Special:Upload and puts the [[File:…]] markup on the clipboard.