Replace --exec-arg with an ergonomic --exec that consumes the rest of the line as the click command. The caller's shell tokenizes the words into discrete arguments before the tool sees them, and the shell runs them as positional parameters (never a re-parsed string), so safety is identical to the argv form while the call sites read naturally: `--exec omarchy toggle something`. Crucially the tool never splits a string itself — a single quoted whole-command argument is rejected and points at the unquoted form, because whitespace- splitting a string hands argument boundaries to whoever controls its content (the injection we are avoiding). --exec must come last; migrate every caller.
197 lines
6.9 KiB
Bash
Executable File
197 lines
6.9 KiB
Bash
Executable File
#!/bin/bash
|
|
|
|
# omarchy:summary=Native messaging host: download the URL sent by the yt-dlp Chromium extension
|
|
# omarchy:hidden=true
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_PATH="${BASH_SOURCE[0]}"
|
|
|
|
# The browser launches us without Omarchy's environment, so locate the repo from
|
|
# our own path when OMARCHY_PATH isn't already set. Export it — omarchy-shell (used
|
|
# by omarchy-osd) needs it to find the running shell, and silently no-ops without it.
|
|
export OMARCHY_PATH="${OMARCHY_PATH:-$(cd -- "$(dirname -- "$SCRIPT_PATH")/.." && pwd)}"
|
|
|
|
# Make sure the Omarchy bin and yt-dlp are reachable when launched by the browser.
|
|
export PATH="$OMARCHY_PATH/bin:/usr/local/bin:/usr/bin:$PATH"
|
|
|
|
DOWNLOAD_DIR="${OMARCHY_YTDLP_DIR:-$HOME/Videos}"
|
|
|
|
parse_url() {
|
|
jq -r '.url // empty' 2>/dev/null <<<"$1" || true
|
|
}
|
|
|
|
valid_url() {
|
|
[[ $1 =~ ^https?:// ]]
|
|
}
|
|
|
|
# A printed path is only usable if it is a regular file inside DOWNLOAD_DIR.
|
|
# Forged records (leading-dash mpv options, paths with control chars, or
|
|
# anything that escaped the download directory) must not reach the click command.
|
|
resolve_download_file() {
|
|
local candidate=$1 file_real dir_real
|
|
|
|
[[ -n $candidate ]] || return 1
|
|
[[ $candidate != *$'\n'* && $candidate != *$'\r'* && $candidate != *$'\t'* ]] || return 1
|
|
[[ -f $candidate ]] || return 1
|
|
|
|
# Read to a NUL: command substitution strips trailing newlines, which would
|
|
# resolve a name ending in one to a different file that may well exist.
|
|
IFS= read -r -d '' file_real < <(realpath -ze -- "$candidate") || return 1
|
|
IFS= read -r -d '' dir_real < <(realpath -ze -- "$DOWNLOAD_DIR") || return 1
|
|
|
|
[[ $file_real != *$'\n'* && $file_real != *$'\r'* && $file_real != *$'\t'* ]] || return 1
|
|
# Trim the slash so a download directory of "/" still leaves a usable prefix.
|
|
[[ $file_real == "${dir_real%/}"/* ]] || return 1
|
|
|
|
printf '%s' "$file_real"
|
|
}
|
|
|
|
# yt-dlp prints the title JSON-encoded, so a newline or tab in page metadata is an
|
|
# escape sequence rather than a record boundary. This is toast text, never a command.
|
|
decode_title() {
|
|
local decoded
|
|
|
|
decoded=$(jq -r 'if type == "string" then . else empty end' <<<"$1" 2>/dev/null) || return 1
|
|
decoded=${decoded%%[[:cntrl:]]*} # keep what a person would read, drop the forgery
|
|
[[ -n $decoded && $decoded != -* ]] || return 1
|
|
|
|
printf '%s' "$decoded"
|
|
}
|
|
|
|
title_from_file() {
|
|
local name=${1##*/}
|
|
name=${name%.*}
|
|
name=${name//[$'\n\r\t']/}
|
|
if [[ -z $name || $name == -* ]]; then
|
|
printf '%s' "Video"
|
|
else
|
|
printf '%s' "$name"
|
|
fi
|
|
}
|
|
|
|
# Drive the Quickshell OSD — a single overlay that updates in place (like the
|
|
# volume/brightness bar), so download progress never stacks like notifications.
|
|
osd_progress() {
|
|
omarchy-osd -i -p "$1" -d 8000 >/dev/null 2>&1 || true
|
|
}
|
|
|
|
osd_close() {
|
|
omarchy-shell -q osd close >/dev/null 2>&1 || true
|
|
}
|
|
|
|
download_url() {
|
|
local url="$1"
|
|
|
|
mkdir -p "$DOWNLOAD_DIR"
|
|
|
|
# Don't show anything until yt-dlp confirms there's actually a video to grab.
|
|
if ! yt-dlp --no-playlist --simulate --quiet --no-warnings --no-exec --no-exec-before-download -- "$url" >/dev/null 2>&1; then
|
|
omarchy-notification-send -u critical -g "No video found for download" "$url"
|
|
exit 0
|
|
fi
|
|
|
|
osd_progress 0
|
|
|
|
# Stream the download: OMARCHY_PROG carries the percent (drives the OSD), and
|
|
# OMARCHY_FILE and OMARCHY_TITLE (printed only after a successful move) carry the
|
|
# path and the title. The title is JSON-encoded so metadata cannot forge a record,
|
|
# and the file is named after it: yt-dlp strips control characters from a filename
|
|
# with or without --restrict-filenames, so a record is still only ever one line.
|
|
local line pct intpct last="" er nowms lastms=0 title="" filepath="" resolved
|
|
while IFS= read -r line; do
|
|
case $line in
|
|
OMARCHY_PROG*)
|
|
pct=${line#OMARCHY_PROG$'\t'}
|
|
intpct=${pct%%.*}
|
|
intpct=${intpct//[^0-9]/}
|
|
[[ -n $intpct && $intpct != "$last" ]] || continue # skip no-op repeats
|
|
# Throttle to ~4 redraws/sec so fast downloads don't spawn a flurry of processes.
|
|
er=$EPOCHREALTIME
|
|
nowms=$((${er%[.,]*} * 1000 + 10#${er##*[.,]} / 1000))
|
|
((nowms - lastms >= 250)) || continue
|
|
last=$intpct
|
|
lastms=$nowms
|
|
osd_progress "$intpct"
|
|
;;
|
|
OMARCHY_FILE*)
|
|
resolved=$(resolve_download_file "${line#OMARCHY_FILE$'\t'}") || continue
|
|
filepath=$resolved
|
|
;;
|
|
OMARCHY_TITLE*)
|
|
title=$(decode_title "${line#OMARCHY_TITLE$'\t'}") || title=""
|
|
;;
|
|
esac
|
|
done < <(PYTHONUNBUFFERED=1 yt-dlp --no-playlist --no-simulate \
|
|
--quiet --no-warnings --no-exec --no-exec-before-download --progress --newline \
|
|
--progress-template $'download:OMARCHY_PROG\t%(progress._percent_str)s' \
|
|
--paths "$DOWNLOAD_DIR" -o '%(title)s.%(ext)s' \
|
|
--print $'after_move:OMARCHY_FILE\t%(filepath)s' \
|
|
--print $'after_move:OMARCHY_TITLE\t%(title)j' \
|
|
-- "$url" 2>&1)
|
|
|
|
osd_close
|
|
|
|
# after_move only prints on a successful download+move, so a captured path == success.
|
|
if [[ -n $filepath ]]; then
|
|
[[ -n $title ]] || title=$(title_from_file "$filepath")
|
|
((${#title} > 50)) && title="${title:0:50}…" # keep the toast compact
|
|
|
|
# Square, center-cropped thumbnail so the notification preview isn't stretched.
|
|
local preview
|
|
preview="$(mktemp --suffix=.jpg)"
|
|
ffmpeg -y -i "$filepath" -ss 00:00:00.1 -vframes 1 \
|
|
-vf "crop='min(iw,ih)':'min(iw,ih)',scale=256:256" -q:v 2 \
|
|
"$preview" -loglevel quiet 2>/dev/null || true
|
|
|
|
# Best-effort: the download already succeeded, and under `set -e` a failed
|
|
# toast would exit before the thumbnail cleanup below is ever scheduled.
|
|
# `--` keeps mpv from parsing a leading-dash filename as an option; the path
|
|
# is one discrete argument, so it never reaches a shell.
|
|
omarchy-notification-send -g "Download complete" "$title" \
|
|
-t 10000 --image "${preview:-$filepath}" \
|
|
--exec mpv -- "$filepath" || true
|
|
|
|
# The shell loads the thumbnail into memory when the toast appears and never
|
|
# re-reads the file, so the preview only has to outlive that load, not the
|
|
# toast.
|
|
(
|
|
sleep 2
|
|
rm -f "$preview"
|
|
) &
|
|
else
|
|
omarchy-notification-send -u critical -g "Download failed" "$url"
|
|
fi
|
|
|
|
exit 0
|
|
}
|
|
|
|
main() {
|
|
local length payload url
|
|
|
|
# Detached worker: this is what actually runs yt-dlp and fires notifications.
|
|
if [[ ${1:-} == "--download" ]]; then
|
|
download_url "$2"
|
|
fi
|
|
|
|
# Native messaging frame: 4-byte little-endian length prefix, then UTF-8 JSON.
|
|
length=$(head -c4 | od -An -v -tu4 --endian=little | tr -d ' ')
|
|
[[ -n ${length:-} ]] && ((length > 0)) || exit 0
|
|
|
|
payload=$(head -c "$length")
|
|
|
|
# Ack with an empty message so the extension's sendNativeMessage callback resolves cleanly.
|
|
printf '\x02\x00\x00\x00{}'
|
|
|
|
url=$(parse_url "$payload")
|
|
[[ -n $url ]] || exit 0
|
|
valid_url "$url" || exit 0
|
|
|
|
# Detach the download so this host exits promptly and frees the browser's port.
|
|
setsid -f "$SCRIPT_PATH" --download "$url" </dev/null >/dev/null 2>&1
|
|
}
|
|
|
|
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
|
|
main "$@"
|
|
fi
|