diff --git a/README.md b/README.md index bc34aa6..5696d72 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ docs/ Settings reference, and the design specs behind the work ## 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 panama test # everything diff --git a/bin/panama-transcode b/bin/panama-transcode new file mode 100755 index 0000000..5e8c905 --- /dev/null +++ b/bin/panama-transcode @@ -0,0 +1,118 @@ +#!/usr/bin/env bash + +# Make a video smaller, or turn a picture into another format. +# +# panama-transcode video 1080p|720p|480p +# panama-transcode image 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" [1080p|720p|480p] + panama-transcode image [jpg|png|webp] + +Writes beside the input, never over it, and never over an existing output. +USAGE + ;; + *) err "unknown command: $1"; exit 2 ;; +esac diff --git a/config/local/share/nautilus-python/extensions/panama-share.py b/config/local/share/nautilus-python/extensions/panama-share.py new file mode 100644 index 0000000..d4bf893 --- /dev/null +++ b/config/local/share/nautilus-python/extensions/panama-share.py @@ -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 diff --git a/config/local/share/nautilus-python/extensions/panama-transcode.py b/config/local/share/nautilus-python/extensions/panama-transcode.py new file mode 100644 index 0000000..de6304f --- /dev/null +++ b/config/local/share/nautilus-python/extensions/panama-transcode.py @@ -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 diff --git a/setup/packages/desktop-packages b/setup/packages/desktop-packages index cc0419c..ed6c059 100644 --- a/setup/packages/desktop-packages +++ b/setup/packages/desktop-packages @@ -61,6 +61,8 @@ mozilla-openh264 mscore-fonts nautilus 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 # 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 diff --git a/setup/scripts/link-dotfiles b/setup/scripts/link-dotfiles index 2722fec..7020794 100755 --- a/setup/scripts/link-dotfiles +++ b/setup/scripts/link-dotfiles @@ -434,6 +434,28 @@ if [ -d "$PANAMA_QUADLET_DIR" ]; then systemctl --user daemon-reload 2>/dev/null || true 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" 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 diff --git a/tests/setup/nautilus-extensions-contract b/tests/setup/nautilus-extensions-contract new file mode 100755 index 0000000..7bf4100 --- /dev/null +++ b/tests/setup/nautilus-extensions-contract @@ -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'