Teach the file manager to send and to shrink

Right-click a file to send it to your phone, or a video to make it
smaller. macOS has both behind the Share sheet and Quick Actions;
Windows has "Send to"; a stock Linux file manager has neither, and the
usual answer for the second one is a web uploader or an ffmpeg
incantation looked up again every time.

Neither adds machinery. Sending reuses panama-kdeconnect, the same
helper the Home & Phone page and quick settings already drive, so
there is one way to talk to a phone rather than two. The entry appears
only when a phone is actually reachable: an item that is present and
fails is worse than one that is absent, because the absence explains
itself.

The transcoder's two rules are both about not losing work. It never
writes to its input, and it never writes over an earlier output -- a
second run produces -2 rather than eating the first result. Verified
against a real encode: 1920x1080 became 854x480, with an even width
because H.264 rejects an odd one at the very end of a long encode,
which is the worst possible moment to find out.

Menus decide by mime type rather than extension, act on one file at a
time, and refuse anything that is not a local path. nautilus-python
turned out to be declared already; it now says it carries Panama's own
extensions too.
This commit is contained in:
Gabriel Brown
2026-08-22 06:52:04 -04:00
parent 05fd5346db
commit 68cbf892e9
7 changed files with 447 additions and 1 deletions
+1 -1
View File
@@ -114,7 +114,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests ## Tests
146 of them, under `tests/`. Run the lot, or a subset by pattern: 147 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh ```sh
panama test # everything panama test # everything
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# Make a video smaller, or turn a picture into another format.
#
# panama-transcode video <file> 1080p|720p|480p
# panama-transcode image <file> jpg|png|webp
#
# The two things people actually right-click a media file to do. macOS has
# Quick Actions for both; Linux file managers have neither, and the usual
# answer is a web uploader or a forgotten ffmpeg incantation.
#
# Two rules, both about not losing work:
#
# * The input is never written to. Output goes beside it with a suffix.
# * An existing output is never overwritten. The suffix gains a number
# rather than replacing something somebody made earlier.
#
# ffmpeg does the work and is already a declared dependency; this only decides
# the arguments, which is the part worth writing down once.
set -uo pipefail
err() { printf 'panama-transcode: %s\n' "$*" >&2; }
command -v ffmpeg >/dev/null 2>&1 || { err 'ffmpeg is not installed'; exit 1; }
# A path that does not exist yet, beside the input.
free_path() {
local dir="$1" stem="$2" suffix="$3" ext="$4"
local candidate="$dir/$stem-$suffix.$ext"
local counter=2
while [[ -e "$candidate" ]]; do
candidate="$dir/$stem-$suffix-$counter.$ext"
counter=$(( counter + 1 ))
done
printf '%s' "$candidate"
}
notify() {
command -v notify-send >/dev/null 2>&1 || return 0
notify-send --icon="${3:-video-x-generic}" "$1" "$2" 2>/dev/null || true
}
cmd_video() {
local input="${1:-}" preset="${2:-1080p}"
[[ -f "$input" ]] || { err 'that file does not exist'; return 2; }
local height
case "$preset" in
1080p) height=1080 ;;
720p) height=720 ;;
480p) height=480 ;;
*) err "unknown size: $preset"; return 2 ;;
esac
local dir stem output
dir="$(dirname "$input")"
stem="$(basename "${input%.*}")"
output="$(free_path "$dir" "$stem" "$preset" mp4)"
notify "Transcoding" "$(basename "$input") → $preset" video-x-generic
# -2 rather than -1 on width: H.264 needs even dimensions, and an odd one
# fails at the very end of a long encode.
if ffmpeg -nostdin -loglevel error -i "$input" \
-vf "scale=-2:'min($height,ih)'" \
-c:v libx264 -crf 23 -preset medium \
-c:a aac -b:a 128k \
"$output" </dev/null; then
notify "Transcoded" "$(basename "$output")" video-x-generic
printf '%s\n' "$output"
else
rm -f "$output"
notify "Transcode failed" "$(basename "$input")" dialog-error-symbolic
return 1
fi
}
cmd_image() {
local input="${1:-}" format="${2:-jpg}"
[[ -f "$input" ]] || { err 'that file does not exist'; return 2; }
case "$format" in
jpg|png|webp) ;;
*) err "unknown format: $format"; return 2 ;;
esac
local dir stem output
dir="$(dirname "$input")"
stem="$(basename "${input%.*}")"
output="$(free_path "$dir" "$stem" converted "$format")"
local -a quality=()
[[ "$format" == "jpg" ]] && quality=(-q:v 3)
[[ "$format" == "webp" ]] && quality=(-quality 82)
if ffmpeg -nostdin -loglevel error -i "$input" "${quality[@]}" "$output" </dev/null; then
notify "Converted" "$(basename "$output")" image-x-generic
printf '%s\n' "$output"
else
rm -f "$output"
notify "Conversion failed" "$(basename "$input")" dialog-error-symbolic
return 1
fi
}
case "${1:-}" in
video) shift; cmd_video "$@" ;;
image) shift; cmd_image "$@" ;;
-h|--help|"")
cat <<'USAGE'
usage: panama-transcode video <file> [1080p|720p|480p]
panama-transcode image <file> [jpg|png|webp]
Writes beside the input, never over it, and never over an existing output.
USAGE
;;
*) err "unknown command: $1"; exit 2 ;;
esac
@@ -0,0 +1,106 @@
# Right-click a file, send it to your phone.
#
# macOS has a Share sheet in Finder and Windows has "Send to". Both are among
# the few context-menu entries people genuinely reach for, and neither exists
# on a stock Linux file manager.
#
# This adds no new machinery. Panama already talks to KDE Connect through
# scripts/panama-kdeconnect, which the Home & Phone settings page and the quick
# settings panel both use; this is the same `send-file` verb from the file
# manager instead of from a panel.
#
# The entry appears only when a phone is actually reachable. A menu item that
# is present and fails is worse than one that is absent, because the absence
# is self-explaining: no phone, no send.
import json
import os
import subprocess
from urllib.parse import unquote, urlparse
from gi.repository import GObject, Nautilus, Notify
HELPER = os.path.expanduser("~/.config/quickshell/scripts/panama-kdeconnect")
def _status():
"""What panama-kdeconnect reports, or None when it cannot be asked."""
try:
result = subprocess.run(
[HELPER, "status"], capture_output=True, text=True, timeout=4, check=False
)
return json.loads(result.stdout) if result.returncode == 0 else None
except (OSError, ValueError, subprocess.SubprocessError):
return None
def _local_path(file_info):
"""A real filesystem path, or None for anything that is not one.
Nautilus hands out URIs, and a remote or trashed file has one that no
amount of unquoting turns into something sendable.
"""
uri = file_info.get_uri()
parsed = urlparse(uri)
if parsed.scheme != "file":
return None
path = unquote(parsed.path)
return path if os.path.isfile(path) else None
class PanamaShareExtension(GObject.GObject, Nautilus.MenuProvider):
def __init__(self):
super().__init__()
Notify.init("Panama")
def get_file_items(self, files):
# One file at a time. KDE Connect's --share takes a single path, and
# looping it for a selection of forty would be forty transfers and
# forty notifications on the phone.
if len(files) != 1:
return []
path = _local_path(files[0])
if path is None:
return []
status = _status()
if not status or not status.get("devices"):
return []
device = next(
(d for d in status["devices"] if d.get("reachable") and d.get("id")), None
)
if device is None:
return []
item = Nautilus.MenuItem(
name="Panama::SendToPhone",
label=f"Send to {device.get('name') or 'phone'}",
tip=f"Send this file to {device.get('name') or 'your phone'} with KDE Connect",
)
item.connect("activate", self._send, path, device["id"], device.get("name"))
return [item]
def _send(self, _menu, path, device_id, device_name):
try:
result = subprocess.run(
[HELPER, "send-file", device_id, path],
capture_output=True,
text=True,
timeout=30,
check=False,
)
ok = result.returncode == 0
except (OSError, subprocess.SubprocessError):
ok = False
name = os.path.basename(path)
target = device_name or "your phone"
notification = Notify.Notification.new(
"Sent" if ok else "Could not send",
f"{name}{target}" if ok else f"{name} did not reach {target}",
"phone-symbolic" if ok else "dialog-error-symbolic",
)
try:
notification.show()
except GObject.GError:
pass
@@ -0,0 +1,77 @@
# Right-click a video to shrink it, or a picture to convert it.
#
# macOS puts both behind Quick Actions in Finder. On Linux the usual answer is
# a web uploader or an ffmpeg incantation somebody looks up again every time.
#
# The menu only appears for files this can actually handle, decided by mime
# type rather than by extension, so a .mov named .txt does not get an entry it
# would fail on. The work itself is bin/panama-transcode, which writes beside
# the input and never over it.
import os
import subprocess
from urllib.parse import unquote, urlparse
from gi.repository import GObject, Nautilus
HELPER = os.path.expanduser("~/.local/share/Panama/bin/panama-transcode")
VIDEO_SIZES = [("1080p", "1080p"), ("720p", "720p"), ("480p", "480p")]
IMAGE_FORMATS = [("jpg", "JPEG"), ("png", "PNG"), ("webp", "WebP")]
def _local_path(file_info):
uri = file_info.get_uri()
parsed = urlparse(uri)
if parsed.scheme != "file":
return None
path = unquote(parsed.path)
return path if os.path.isfile(path) else None
class PanamaTranscodeExtension(GObject.GObject, Nautilus.MenuProvider):
def get_file_items(self, files):
# One at a time: a submenu that starts nine encodes from one click is
# a way to make a machine unusable by accident.
if len(files) != 1:
return []
info = files[0]
path = _local_path(info)
if path is None or not os.access(HELPER, os.X_OK):
return []
mime = info.get_mime_type() or ""
if mime.startswith("video/"):
return [self._submenu(path, "video", "Convert video", VIDEO_SIZES)]
if mime.startswith("image/"):
return [self._submenu(path, "image", "Convert image", IMAGE_FORMATS)]
return []
def _submenu(self, path, kind, label, options):
top = Nautilus.MenuItem(name=f"Panama::Transcode{kind}", label=label)
menu = Nautilus.Menu()
top.set_submenu(menu)
for value, shown in options:
item = Nautilus.MenuItem(
name=f"Panama::Transcode{kind}{value}",
label=shown,
tip=f"Write a {shown} copy beside this file",
)
item.connect("activate", self._run, kind, path, value)
menu.append_item(item)
return top
def _run(self, _menu, kind, path, value):
# Detached on purpose: a long encode must not block the file manager,
# and panama-transcode reports its own progress through notifications.
try:
subprocess.Popen(
[HELPER, kind, path, value],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
except OSError:
pass
+2
View File
@@ -61,6 +61,8 @@ mozilla-openh264
mscore-fonts mscore-fonts
nautilus nautilus
nautilus-extensions nautilus-extensions
# Also what lets Nautilus load Panama's own right-click extensions: send to
# phone, and convert a video or image. See config/local/share/nautilus-python.
nautilus-python nautilus-python
# Ships both the Nautilus "Open in Terminal" extension and its gsettings # Ships both the Nautilus "Open in Terminal" extension and its gsettings
# schema. Panama used to copy its own fork of the extension over the same # schema. Panama used to copy its own fork of the extension over the same
+22
View File
@@ -434,6 +434,28 @@ if [ -d "$PANAMA_QUADLET_DIR" ]; then
systemctl --user daemon-reload 2>/dev/null || true systemctl --user daemon-reload 2>/dev/null || true
fi fi
# Nautilus loads Python extensions from this directory. Linked per file rather
# than by symlinking the directory itself, the same way the quadlets and
# desktop entries are: Nautilus writes nothing here today, but a directory
# symlink into the repository is how machine state ends up in a tracked path.
PANAMA_NAUTILUS_DIR="$PANAMA_PATH/config/local/share/nautilus-python/extensions"
USER_NAUTILUS_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/nautilus-python/extensions"
if [ -d "$PANAMA_NAUTILUS_DIR" ]; then
mkdir -p "$USER_NAUTILUS_DIR"
for extension in "$PANAMA_NAUTILUS_DIR"/*.py; do
[ -e "$extension" ] || continue
extension_dst="$USER_NAUTILUS_DIR/$(basename "$extension")"
if [ -L "$extension_dst" ]; then
rm "$extension_dst"
elif [ -e "$extension_dst" ]; then
log "Keeping existing Nautilus extension at $extension_dst"
continue
fi
ln -s "$extension" "$extension_dst"
log "Linked Nautilus extension → $extension_dst"
done
fi
DEFAULT_APPS_HELPER="$PANAMA_PATH/config/dot/quickshell/scripts/panama-default-apps" DEFAULT_APPS_HELPER="$PANAMA_PATH/config/dot/quickshell/scripts/panama-default-apps"
if [[ -x "$DEFAULT_APPS_HELPER" ]] && command -v xdg-mime >/dev/null 2>&1; then if [[ -x "$DEFAULT_APPS_HELPER" ]] && command -v xdg-mime >/dev/null 2>&1; then
update-desktop-database "$USER_APPLICATION_DIR" >/dev/null 2>&1 || true update-desktop-database "$USER_APPLICATION_DIR" >/dev/null 2>&1 || true
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# Right-click a file and do something useful with it.
#
# macOS has a Share sheet and Quick Actions in Finder; Windows has "Send to".
# Both are among the few context-menu entries people genuinely reach for, and
# a stock Linux file manager has neither.
#
# What must hold:
#
# 1. The transcoder never writes over its input, and never over an earlier
# output. Somebody right-clicking a video to shrink it must not lose the
# original, and running it twice must not eat the first result.
# 2. It refuses what it cannot do rather than handing ffmpeg a guess.
# 3. The extensions only offer themselves for files they can act on, decided
# by mime type rather than extension, and only for a real local file.
# 4. Sending a file reuses panama-kdeconnect rather than inventing a second
# way to talk to a phone.
# 5. The menu entry for sending is absent when no phone is reachable. An
# entry that is present and fails is worse than one that is absent,
# because the absence explains itself.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
extensions="$repo_dir/config/local/share/nautilus-python/extensions"
share="$extensions/panama-share.py"
transcode_ext="$extensions/panama-transcode.py"
transcode="$repo_dir/bin/panama-transcode"
linker="$repo_dir/setup/scripts/link-dotfiles"
packages="$repo_dir/setup/packages/desktop-packages"
findings=()
note() { findings+=("$1"); }
for file in "$share" "$transcode_ext"; do
[[ -r "$file" ]] || note "$(basename "$file") is missing"
done
[[ -x "$transcode" ]] || { printf 'nautilus contract: %s is not executable\n' "$transcode" >&2; exit 1; }
# Both extensions must be importable Python, or Nautilus loads nothing and
# says nothing about why.
for file in "$share" "$transcode_ext"; do
[[ -r "$file" ]] || continue
python3 -c "import ast,sys; ast.parse(open(sys.argv[1]).read())" "$file" 2>/dev/null \
|| note "$(basename "$file") is not valid Python, so Nautilus would silently load nothing"
grep -q 'Nautilus.MenuProvider' "$file" \
|| note "$(basename "$file") does not implement MenuProvider, so it would never appear in a menu"
done
# ── 1 & 2. The transcoder protects the input and earlier outputs ────────────
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
if command -v ffmpeg >/dev/null 2>&1; then
ffmpeg -nostdin -loglevel error -f lavfi -i testsrc=duration=1:size=640x480:rate=10 \
-pix_fmt yuv420p "$work/clip.mp4" -y 2>/dev/null
before="$(sha256sum "$work/clip.mp4" | cut -d' ' -f1)"
"$transcode" video "$work/clip.mp4" 480p >/dev/null 2>&1 \
|| note 'transcoding a real video failed'
after="$(sha256sum "$work/clip.mp4" | cut -d' ' -f1)"
[[ "$before" == "$after" ]] || note 'the transcoder modified its own input'
[[ -f "$work/clip-480p.mp4" ]] || note 'the transcoder wrote no output beside the input'
first="$(sha256sum "$work/clip-480p.mp4" | cut -d' ' -f1)"
"$transcode" video "$work/clip.mp4" 480p >/dev/null 2>&1
[[ "$(sha256sum "$work/clip-480p.mp4" | cut -d' ' -f1)" == "$first" ]] \
|| note 'running the transcoder twice overwrote the first output'
[[ -f "$work/clip-480p-2.mp4" ]] \
|| note 'a second run did not write a numbered output, so one of the two was lost'
else
note 'ffmpeg is not installed, so the transcoder could not be exercised'
fi
# An unknown size or format is refused before ffmpeg is handed anything.
"$transcode" video "$work/clip.mp4" 9000p >/dev/null 2>&1 \
&& note 'an unknown video size was accepted'
"$transcode" image "$work/clip.mp4" bmp >/dev/null 2>&1 \
&& note 'an unknown image format was accepted'
"$transcode" video "$work/does-not-exist.mp4" 480p >/dev/null 2>&1 \
&& note 'a file that does not exist was accepted'
# ── 3. Only files they can act on ───────────────────────────────────────────
grep -q 'get_mime_type' "$transcode_ext" \
|| note 'the transcode menu decides by extension rather than mime type, so a mislabelled file gets an entry it fails on'
for file in "$share" "$transcode_ext"; do
[[ -r "$file" ]] || continue
grep -q "parsed.scheme != \"file\"" "$file" \
|| note "$(basename "$file") does not check that the file is local, so it would offer to act on a remote URI"
grep -q 'len(files) != 1' "$file" \
|| note "$(basename "$file") acts on a multiple selection, which would start one job per file from a single click"
done
# ── 4 & 5. Sending reuses the phone helper, and hides without a phone ───────
grep -q 'panama-kdeconnect' "$share" \
|| note 'the share extension does not use the existing phone helper'
grep -q 'send-file' "$share" \
|| note 'the share extension does not use the helper verb that sends a file'
grep -q 'reachable' "$share" \
|| note 'the share entry appears whether or not a phone is reachable, so it would be present and fail'
# ── Installed where Nautilus looks ──────────────────────────────────────────
grep -q 'nautilus-python/extensions' "$linker" \
|| note 'link-dotfiles does not publish the extensions, so Nautilus would never see them'
grep -qx 'nautilus-python' "$packages" \
|| note 'nautilus-python is not declared, so the extensions would not load on a fresh machine'
(( $(grep -cx 'nautilus-python' "$packages") == 1 )) \
|| note 'nautilus-python is declared more than once'
if (( ${#findings[@]} > 0 )); then
printf 'nautilus extensions contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'nautilus extensions contract: PASS\n'