diff --git a/bin/omarchy-chromium-ytdlp-host b/bin/omarchy-chromium-ytdlp-host new file mode 100755 index 00000000..5fc5842d --- /dev/null +++ b/bin/omarchy-chromium-ytdlp-host @@ -0,0 +1,136 @@ +#!/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?:// ]] +} + +# 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 "$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), + # OMARCHY_FILE (printed only after a successful move) carries title + path. + local line rest pct intpct last="" er nowms lastms=0 title="" filepath="" + 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*) + rest=${line#OMARCHY_FILE$'\t'} + title=${rest%%$'\t'*} + filepath=${rest#*$'\t'} + ;; + esac + done < <(PYTHONUNBUFFERED=1 yt-dlp --no-playlist --restrict-filenames --no-simulate \ + --quiet --no-warnings --progress --newline \ + --progress-template $'download:OMARCHY_PROG\t%(progress._percent_str)s' \ + --paths "$DOWNLOAD_DIR" -o '%(title)s [%(id)s].%(ext)s' \ + --print $'after_move:OMARCHY_FILE\t%(title)s\t%(filepath)s' \ + "$url" 2>&1) + + osd_close + + # after_move only prints on a successful download+move, so a captured path == success. + if [[ -n $filepath ]]; then + ((${#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 + + # Clicking the toast opens the video in mpv (-a blocks until clicked or timed out). + ( + if [[ -n $(omarchy-notification-send -g 󰄬 "Download complete" "$title" -t 10000 --image "${preview:-$filepath}" -a) ]]; then + mpv "$filepath" + fi + 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 2>&1 +} + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + main "$@" +fi diff --git a/bin/omarchy-install-browser b/bin/omarchy-install-browser index 3359d922..f5a187df 100755 --- a/bin/omarchy-install-browser +++ b/bin/omarchy-install-browser @@ -17,6 +17,7 @@ announce_browser_installed() { copy_chromium_flags() { mkdir -p ~/.config cp -f "$OMARCHY_PATH/config/chromium-flags.conf" "$1" + omarchy-install-chromium-ytdlp } setup_firefox_preferences() { @@ -66,7 +67,8 @@ brave-origin) setup_policy_directory /etc/brave/policies/managed mkdir -p ~/.config # FIXME: Use normal chromium flags when Brave Origin wrapper has been fixed - echo "--load-extension=~/.local/share/omarchy/default/chromium/extensions/copy-url" > ~/.config/brave-origin-beta-flags.conf + echo "--load-extension=~/.local/share/omarchy/default/chromium/extensions/copy-url,~/.local/share/omarchy/default/chromium/extensions/yt-dlp" > ~/.config/brave-origin-beta-flags.conf + omarchy-install-chromium-ytdlp omarchy-theme-set-browser announce_browser_installed "Brave Origin" ;; diff --git a/bin/omarchy-install-chromium-ytdlp b/bin/omarchy-install-chromium-ytdlp new file mode 100755 index 00000000..c700578c --- /dev/null +++ b/bin/omarchy-install-chromium-ytdlp @@ -0,0 +1,29 @@ +#!/bin/bash + +# omarchy:summary=Install the native messaging host for the yt-dlp Chromium extension + +set -euo pipefail + +HOST_NAME="com.omarchy.ytdlp" +HOST_PATH="$OMARCHY_PATH/bin/omarchy-chromium-ytdlp-host" +TEMPLATE="$OMARCHY_PATH/default/chromium/native-messaging-hosts/$HOST_NAME.json" + +# Chromium-based browser profile roots that use the NativeMessagingHosts layout. +browser_dirs=( + "$HOME/.config/chromium" + "$HOME/.config/google-chrome" + "$HOME/.config/google-chrome-beta" + "$HOME/.config/google-chrome-unstable" + "$HOME/.config/BraveSoftware/Brave-Browser" + "$HOME/.config/BraveSoftware/Brave-Browser-Beta" + "$HOME/.config/BraveSoftware/Brave-Browser-Nightly" + "$HOME/.config/microsoft-edge" + "$HOME/.config/microsoft-edge-dev" +) + +manifest=$(sed "s|__HOST_PATH__|$HOST_PATH|g" "$TEMPLATE") + +for dir in "${browser_dirs[@]}"; do + mkdir -p "$dir/NativeMessagingHosts" + printf '%s\n' "$manifest" >"$dir/NativeMessagingHosts/$HOST_NAME.json" +done diff --git a/bin/omarchy-menu-keybindings b/bin/omarchy-menu-keybindings index 5ca6ca88..f2d82d1b 100755 --- a/bin/omarchy-menu-keybindings +++ b/bin/omarchy-menu-keybindings @@ -379,6 +379,7 @@ dynamic_bindings() { # Hardcoded bindings, like the copy-url extension and such static_bindings() { echo "SHIFT ALT,L,Copy URL from Web App,sendshortcut,SHIFT ALT,L," + echo "SHIFT ALT,D,Download Video from Web App,sendshortcut,SHIFT ALT,D," } # Parse and format keybindings diff --git a/bin/omarchy-osd b/bin/omarchy-osd index 95af756f..99442989 100755 --- a/bin/omarchy-osd +++ b/bin/omarchy-osd @@ -1,7 +1,7 @@ #!/bin/bash # omarchy:summary=Show the Omarchy Quickshell on-screen display -# omarchy:args=[-i|--icon ] [-m|--message ] [-p|--progress <0-100>] +# omarchy:args=[-i|--icon ] [-m|--message ] [-p|--progress <0-100>] [-d|--duration ] # omarchy:examples=omarchy osd -i brightness -p 50 | omarchy osd -m "Hello" set -euo pipefail @@ -11,12 +11,14 @@ message="" progress="" progress_text="" max="100" +duration="" while (($#)); do case $1 in -i|--icon) icon="${2:-}"; shift 2 ;; -m|--message) message="${2:-}"; shift 2 ;; -p|--progress) progress="${2:-}"; shift 2 ;; + -d|--duration) duration="${2:-}"; shift 2 ;; -h|--help) omarchy osd --help; exit 0 ;; *) echo "Unknown OSD option: $1" >&2; exit 1 ;; esac @@ -32,6 +34,7 @@ payload=$(jq -cn \ --arg value "$progress" \ --arg progressText "$progress_text" \ --arg max "$max" \ - '{icon:$icon,message:$message,value:$value,progressText:$progressText,max:$max}') + --arg duration "$duration" \ + '{icon:$icon,message:$message,value:$value,progressText:$progressText,max:$max,duration:$duration}') omarchy-shell -q osd show "$payload" diff --git a/bin/omarchy-refresh-chromium b/bin/omarchy-refresh-chromium index f8d13f73..77165837 100755 --- a/bin/omarchy-refresh-chromium +++ b/bin/omarchy-refresh-chromium @@ -15,6 +15,9 @@ fi # Refresh the Chromium configuration omarchy-refresh-config chromium-flags.conf +# Install/refresh the native messaging host used by the yt-dlp extension +omarchy-install-chromium-ytdlp + # Re-install Google accounts if previously configured if [[ $INSTALL_GOOGLE_ACCOUNTS == "true" ]]; then omarchy-install-chromium-google-account diff --git a/config/chromium-flags.conf b/config/chromium-flags.conf index 88c8082b..78c39070 100644 --- a/config/chromium-flags.conf +++ b/config/chromium-flags.conf @@ -1,4 +1,4 @@ --ozone-platform=wayland --ozone-platform-hint=wayland --enable-features=TouchpadOverscrollHistoryNavigation ---load-extension=~/.local/share/omarchy/default/chromium/extensions/copy-url +--load-extension=~/.local/share/omarchy/default/chromium/extensions/copy-url,~/.local/share/omarchy/default/chromium/extensions/yt-dlp diff --git a/default/chromium/extensions/yt-dlp/background.js b/default/chromium/extensions/yt-dlp/background.js new file mode 100644 index 00000000..07006501 --- /dev/null +++ b/default/chromium/extensions/yt-dlp/background.js @@ -0,0 +1,41 @@ +function sendUrl(url) { + if (!url || !/^https?:/i.test(url)) return; + + // The native messaging host runs yt-dlp and owns all the desktop + // notifications, so we just hand off the URL and ignore the reply. + chrome.runtime.sendNativeMessage('com.omarchy.ytdlp', { url }, () => { + void chrome.runtime.lastError; + }); +} + +function triggerDownload(tab) { + if (!tab) return; + + // The activeTab permission exposes tab.url whenever the user invokes the + // extension — both via the toolbar click and the keyboard shortcut. + if (tab.url) { + sendUrl(tab.url); + return; + } + + // Fallback: read the URL straight from the page. + if (tab.id === undefined) return; + chrome.scripting + .executeScript({ target: { tabId: tab.id }, func: () => location.href }) + .then((results) => sendUrl(results && results[0] && results[0].result)) + .catch(() => {}); +} + +// Keyboard shortcut (Alt+Shift+D). +chrome.commands.onCommand.addListener((command) => { + if (command === 'download-video') { + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + triggerDownload(tabs[0]); + }); + } +}); + +// Clicking the extension's toolbar icon. +chrome.action.onClicked.addListener((tab) => { + triggerDownload(tab); +}); diff --git a/default/chromium/extensions/yt-dlp/download-video.svg b/default/chromium/extensions/yt-dlp/download-video.svg new file mode 100644 index 00000000..d3731d19 --- /dev/null +++ b/default/chromium/extensions/yt-dlp/download-video.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/default/chromium/extensions/yt-dlp/icon.png b/default/chromium/extensions/yt-dlp/icon.png new file mode 100644 index 00000000..838c9e77 Binary files /dev/null and b/default/chromium/extensions/yt-dlp/icon.png differ diff --git a/default/chromium/extensions/yt-dlp/manifest.json b/default/chromium/extensions/yt-dlp/manifest.json new file mode 100644 index 00000000..26e14fa7 --- /dev/null +++ b/default/chromium/extensions/yt-dlp/manifest.json @@ -0,0 +1,23 @@ +{ + "manifest_version": 3, + "name": "Download Video", + "version": "1.0", + "description": "Download the current page's video, this extension is installed by Omarchy", + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4fww2jejSazrS0dpOBYyx8IfWRkRmek213PnIOaqzAuLK1jE1T+rR+SqBaKnC9j311O6sGrQ+MZJHotJfZyXoKBvSlfiay2z3NU0+Qv1Xa2VaRDuFoHzpfc4OcPvN1B/dGutLCxcLjvSBWEomGVSV1RpK+fgcOeJPFOjKLVrajedv9RnV3JE/Mz3VqBIpkzCUBtsM2eEpZZYWOWuyyk+h6RlbGkHXwudzizf7uJ7bA6RzZKF92wqnbgKDvGji0TwolzAJKALFTul44JzYNiU3Cy7FJXv/aXAKXwAf1VOXXMKie6udXv/M0E1vZAIWHv1sGhkxZZFxZ3C33svlXfg7wIDAQAB", + "permissions": ["activeTab", "scripting", "nativeMessaging"], + "icons": { + "16": "icon.png", + "48": "icon.png", + "128": "icon.png" + }, + "action": { + "default_title": "Download Video" + }, + "commands": { + "download-video": { + "suggested_key": {"default": "Alt+Shift+D"}, + "description": "Download Video" + } + }, + "background": {"service_worker": "background.js"} +} diff --git a/default/chromium/native-messaging-hosts/com.omarchy.ytdlp.json b/default/chromium/native-messaging-hosts/com.omarchy.ytdlp.json new file mode 100644 index 00000000..963a8211 --- /dev/null +++ b/default/chromium/native-messaging-hosts/com.omarchy.ytdlp.json @@ -0,0 +1,9 @@ +{ + "name": "com.omarchy.ytdlp", + "description": "Omarchy yt-dlp download host", + "path": "__HOST_PATH__", + "type": "stdio", + "allowed_origins": [ + "chrome-extension://dedjgknigfeelejglamclffonmophnfl/" + ] +} diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 85b6aa7a..ad4b7db7 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -139,4 +139,5 @@ xmlstarlet xournalpp yaru-icon-theme yay +yt-dlp zoxide diff --git a/migrations/1780517689.sh b/migrations/1780517689.sh new file mode 100644 index 00000000..1992f265 --- /dev/null +++ b/migrations/1780517689.sh @@ -0,0 +1,25 @@ +echo "Add yt-dlp download extension (Alt+Shift+D) to Chromium-based browsers" + +YTDLP_EXT="$OMARCHY_PATH/default/chromium/extensions/yt-dlp" + +add_ytdlp_extension() { + local file=$1 + + [[ -f $file ]] || return 0 + grep -q "extensions/yt-dlp" "$file" && return 0 + + if grep -q "^--load-extension=" "$file"; then + sed -i --follow-symlinks "s|^--load-extension=\(.*\)$|--load-extension=\1,$YTDLP_EXT|" "$file" + else + echo "--load-extension=$YTDLP_EXT" >>"$file" + fi +} + +for conf in chromium chrome google-chrome brave brave-beta brave-nightly brave-origin-beta microsoft-edge-stable; do + add_ytdlp_extension "$HOME/.config/$conf-flags.conf" +done + +omarchy-pkg-add yt-dlp + +# Register the native messaging host that runs yt-dlp for the extension. +omarchy-install-chromium-ytdlp || true diff --git a/test/shell.d/chromium-ytdlp-test.sh b/test/shell.d/chromium-ytdlp-test.sh new file mode 100755 index 00000000..3d33494f --- /dev/null +++ b/test/shell.d/chromium-ytdlp-test.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +TMPDIR="" + +export PATH="$ROOT/bin:$PATH" + +cleanup() { + [[ -n $TMPDIR && -d $TMPDIR ]] && rm -rf "$TMPDIR" +} +trap cleanup EXIT + +require_command jq + +TMPDIR=$(mktemp -d) +test_home="$TMPDIR/home" +manifest_path="$test_home/.config/chromium/NativeMessagingHosts/com.omarchy.ytdlp.json" + +HOME="$test_home" OMARCHY_PATH="$ROOT" omarchy-install-chromium-ytdlp + +[[ -f $manifest_path ]] || fail "yt-dlp native host installer creates fresh Chromium profile root" +pass "yt-dlp native host installer creates fresh Chromium profile root" + +jq -e --arg path "$ROOT/bin/omarchy-chromium-ytdlp-host" ' + .name == "com.omarchy.ytdlp" and + .path == $path and + (.allowed_origins | index("chrome-extension://dedjgknigfeelejglamclffonmophnfl/")) +' "$manifest_path" >/dev/null +pass "yt-dlp native host manifest uses Omarchy host path and extension id" + +parse_result=$(bash -c ' + OMARCHY_PATH="$3" + source "$1" + parse_url "$2" +' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" '{"url":"https://example.test/watch?v=\"quoted\"&name=a\\b"}' "$ROOT") + +[[ $parse_result == "https://example.test/watch?v=\"quoted\"&name=a\\b" ]] || + fail "yt-dlp native host parses escaped JSON URLs" "$parse_result" +pass "yt-dlp native host parses escaped JSON URLs" + +bash -c ' + OMARCHY_PATH="$3" + source "$1" + valid_url "$2" +' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "javascript:alert(1)" "$ROOT" && + fail "yt-dlp native host rejects non-web URLs" +pass "yt-dlp native host rejects non-web URLs"