diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1753855..1b4828f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,24 +60,8 @@ jobs: exit 1 fi - - name: Install dependencies - run: brew install aria2 dylibbundler - - name: Fetch media engines - run: | - mkdir -p Sources/Firelink - - # Download the one-file macOS build. PyInstaller on macOS now properly supports code-signing without corrupting the archive. - curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos -o Sources/Firelink/yt-dlp - chmod +x Sources/Firelink/yt-dlp - - # Download latest FFmpeg release build for macOS ARM64 from Martin Riedl's build server - curl -fsSL "https://ffmpeg.martin-riedl.de/redirect/latest/macos/arm64/release/ffmpeg.zip" -o ffmpeg.zip - unzip -q -o ffmpeg.zip -d Sources/Firelink - rm ffmpeg.zip - chmod +x Sources/Firelink/ffmpeg - test -x Sources/Firelink/yt-dlp - test -x Sources/Firelink/ffmpeg + run: Scripts/fetch_media_engines.sh - name: Build app bundle env: @@ -89,7 +73,25 @@ jobs: run: | file build/Firelink.app/Contents/MacOS/Firelink lipo -archs build/Firelink.app/Contents/MacOS/Firelink | grep -qx arm64 - codesign --verify --deep build/Firelink.app + codesign --verify --deep --strict build/Firelink.app + + RESOURCES="build/Firelink.app/Contents/Resources" + test -d "$RESOURCES/_internal" + test -x "$RESOURCES/yt-dlp" + test -x "$RESOURCES/deno" + test -x "$RESOURCES/ffmpeg" + test -x "$RESOURCES/aria2c" + test -f "$RESOURCES/aria2-cacert.pem" + test -d "$RESOURCES/aria2-libs" + test "$(tr -d '[:space:]' < "$RESOURCES/aria2-version.txt")" = "1.37.0-2-arm64-sonoma" + test "$("$RESOURCES/yt-dlp" --version)" = "$(tr -d '[:space:]' < "$RESOURCES/yt-dlp-version.txt")" + "$RESOURCES/deno" --version + "$RESOURCES/ffmpeg" -version + "$RESOURCES/aria2c" --version | grep -q '^aria2 version 1.37.0$' + for library in "$RESOURCES"/aria2-libs/*.dylib; do + lipo -archs "$library" | grep -qx arm64 + done + ! otool -L "$RESOURCES/aria2c" "$RESOURCES"/aria2-libs/*.dylib | grep -Eq '(@@HOMEBREW|/opt/homebrew|/usr/local)' - name: Create DMG env: diff --git a/.gitignore b/.gitignore index e02dde7..157943f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,8 +23,14 @@ SparklePrivateKey* private-key* private_key* yt-dlp +deno ffmpeg +aria2c +Sources/Firelink/*-version.txt Sources/Firelink/_internal/ +Sources/Firelink/aria2-libs/ +Sources/Firelink/aria2-licenses/ +Sources/Firelink/aria2-cacert.pem !Sources/Firelink/_internal/ Sources/Firelink/_internal/* !Sources/Firelink/_internal/.gitkeep diff --git a/CHANGELOG.md b/CHANGELOG.md index 42a88cc..c6fc9de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed +- Prevented yt-dlp and JavaScript child processes from keeping metadata fetches or canceled downloads alive indefinitely. +- Replaced the repeatedly extracted one-file yt-dlp build with a stable prewarmed runtime cache. +- Bundled Deno so YouTube JavaScript challenges and formats above 720p do not depend on system-installed tools. +- Stopped masking empty-format extraction failures and removed brittle forced YouTube client selection. + +### Changed +- Pinned and checksum-verified yt-dlp, Deno, FFmpeg, aria2, and aria2's libraries for matching local and GitHub Actions builds. +- Removed aria2's runtime dependency on Homebrew and configured its bundled CA certificate for direct and yt-dlp-delegated HTTPS downloads. +- Added bounded network retries and optional aria2c acceleration for large direct media downloads. + ## [0.7.1] - 2026-06-11 ### Fixes diff --git a/Makefile b/Makefile index afd7dc0..c95c14f 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,9 @@ -.PHONY: build app dmg release run verify clean +.PHONY: engines build app dmg release run verify clean -build: +engines: + Scripts/fetch_media_engines.sh + +build: engines swift build -c release app: @@ -13,7 +16,7 @@ release: Scripts/create_app_bundle.sh Scripts/create_dmg.sh -run: +run: engines swift run Firelink verify: diff --git a/Package.swift b/Package.swift index 1200cf9..b97dff4 100644 --- a/Package.swift +++ b/Package.swift @@ -17,12 +17,21 @@ let package = Package( dependencies: [], path: "Sources/Firelink", exclude: [ - "_internal" + "deno-version.txt", + "ffmpeg-version.txt" ], resources: [ .process("Assets.xcassets"), .copy("yt-dlp"), - .copy("ffmpeg") + .copy("yt-dlp-version.txt"), + .copy("_internal"), + .copy("deno"), + .copy("ffmpeg"), + .copy("aria2c"), + .copy("aria2-libs"), + .copy("aria2-cacert.pem"), + .copy("aria2-version.txt"), + .copy("aria2-licenses") ] ) ] diff --git a/Scripts/create_app_bundle.sh b/Scripts/create_app_bundle.sh index 7f1dd70..e9a4f4c 100755 --- a/Scripts/create_app_bundle.sh +++ b/Scripts/create_app_bundle.sh @@ -17,36 +17,7 @@ ICON_NAME="AppIcon" cd "$ROOT_DIR" -is_valid_mach_o() { - local path="$1" - if ! file "$path" | grep -q 'Mach-O'; then - return 1 - fi - - lipo -archs "$path" >/dev/null 2>&1 -} - -ensure_ytdlp() { - local ytdlp_path="$ROOT_DIR/Sources/Firelink/yt-dlp" - - if [[ -x "$ytdlp_path" ]] && is_valid_mach_o "$ytdlp_path"; then - return - fi - - if ! command -v curl >/dev/null; then - echo "WARNING: yt-dlp is missing, and curl is not available to refresh it." >&2 - return - fi - - echo "Refreshing bundled yt-dlp runtime..." - curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos -o "$ytdlp_path" - chmod +x "$ytdlp_path" - - # Remove legacy _internal directory if it exists - rm -rf "$ROOT_DIR/Sources/Firelink/_internal" -} - -ensure_ytdlp +"$ROOT_DIR/Scripts/fetch_media_engines.sh" swift build -c "$CONFIGURATION" @@ -57,7 +28,7 @@ cp "$ROOT_DIR/Resources/$ICON_NAME.icns" "$RESOURCES_DIR/$ICON_NAME.icns" cp "$ROOT_DIR/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png" "$RESOURCES_DIR/MenuBarIconTemplate.png" cp "$ROOT_DIR/Resources/GitHubTemplate.png" "$RESOURCES_DIR/GitHubTemplate.png" -for media_engine in yt-dlp ffmpeg; do +for media_engine in yt-dlp deno ffmpeg aria2c; do media_engine_path="$ROOT_DIR/Sources/Firelink/$media_engine" if [[ -x "$media_engine_path" ]]; then cp "$media_engine_path" "$RESOURCES_DIR/$media_engine" @@ -67,9 +38,23 @@ for media_engine in yt-dlp ffmpeg; do fi done -if [[ -d "$ROOT_DIR/Sources/Firelink/_internal" ]]; then - cp -R "$ROOT_DIR/Sources/Firelink/_internal" "$RESOURCES_DIR/_internal" -fi +for resource_directory in _internal aria2-libs aria2-licenses; do + source_path="$ROOT_DIR/Sources/Firelink/$resource_directory" + if [[ ! -d "$source_path" ]]; then + echo "Required runtime directory is missing: $source_path" >&2 + exit 1 + fi + cp -R "$source_path" "$RESOURCES_DIR/$resource_directory" +done + +for resource_file in yt-dlp-version.txt aria2-version.txt aria2-cacert.pem; do + source_path="$ROOT_DIR/Sources/Firelink/$resource_file" + if [[ ! -f "$source_path" ]]; then + echo "Required runtime file is missing: $source_path" >&2 + exit 1 + fi + cp "$source_path" "$RESOURCES_DIR/$resource_file" +done echo "Packaging Firefox extension..." mkdir -p "$RESOURCES_DIR/FirefoxExtension" @@ -80,23 +65,6 @@ cp -R "$ROOT_DIR/Extensions/Firefox/icons" "$RESOURCES_DIR/FirefoxExtension/icon cp -R "$ROOT_DIR/Extensions/Firefox/popup" "$RESOURCES_DIR/FirefoxExtension/popup" -ARIA2C_PATH=$(which aria2c || true) -if [[ -n "$ARIA2C_PATH" && -x "$ARIA2C_PATH" ]]; then - echo "Bundling aria2c from $ARIA2C_PATH..." - cp "$ARIA2C_PATH" "$RESOURCES_DIR/aria2c" - - if ! command -v dylibbundler &> /dev/null; then - echo "Installing dylibbundler..." - brew install dylibbundler - fi - - FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks" - mkdir -p "$FRAMEWORKS_DIR" - dylibbundler -od -b -x "$RESOURCES_DIR/aria2c" -d "$FRAMEWORKS_DIR" -p "@executable_path/../Frameworks/" -else - echo "WARNING: aria2c not found! It will not be bundled. Please install it first." -fi - FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks" mkdir -p "$FRAMEWORKS_DIR" @@ -165,6 +133,11 @@ if command -v codesign &> /dev/null; then sign_mach_o_file() { local path="$1" + case "$path" in + */Python.framework/Python|*/Python.framework/Versions/Current/*) + return + ;; + esac if file "$path" | grep -q 'Mach-O'; then if ! sign_path "$path"; then @@ -189,7 +162,7 @@ if command -v codesign &> /dev/null; then sign_path "$MACOS_DIR/$APP_NAME" sign_path "$APP_DIR" - codesign --verify --deep --verbose=2 "$APP_DIR" || true + codesign --verify --deep --strict --verbose=2 "$APP_DIR" fi echo "Created $APP_DIR" diff --git a/Scripts/fetch_media_engines.sh b/Scripts/fetch_media_engines.sh new file mode 100755 index 0000000..08178ed --- /dev/null +++ b/Scripts/fetch_media_engines.sh @@ -0,0 +1,337 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SOURCE_DIR="$ROOT_DIR/Sources/Firelink" + +YTDLP_VERSION="2026.06.09" +YTDLP_MACOS_ZIP_SHA256="62a3108d7c37090107f0bb9a2369b953b35e43f4bc76ab0ea87e4ab593c23ec7" + +DENO_VERSION="2.8.2" +DENO_ARM64_ZIP_SHA256="02e5eb795c9f763772dfd081429cead9029e0a4a6aaff6d4e5f3ed6d2e94d361" +DENO_X86_64_ZIP_SHA256="77cf27f835f1921e49434449675c57432c6314d54edc725e2474cc825546e206" + +FFMPEG_VERSION="8.1.1" +FFMPEG_ARM64_URL="https://ffmpeg.martin-riedl.de/download/macos/arm64/1778761665_8.1.1/ffmpeg.zip" +FFMPEG_ARM64_ZIP_SHA256="a05b1a47bb3ac89a95a55eec713f8bbb347051bb07015f3b7d08fb62ed81a21e" + +ARIA2_VERSION="1.37.0" +ARIA2_BOTTLE_REVISION="2" +ARIA2_RUNTIME_ID="$ARIA2_VERSION-$ARIA2_BOTTLE_REVISION-arm64-sonoma" +ARIA2_BOTTLE_SHA256="8815b6b79395235863349628dc0d753bbee9069e99d94257b7646ffd85615623" +CARES_VERSION="1.34.6" +CARES_BOTTLE_SHA256="17f44048d8003b88231d69bac0408cf22be2f712ef8588d4933ff0811b92342c" +LIBSSH2_VERSION="1.11.1_1" +LIBSSH2_BOTTLE_SHA256="34927ad08cd265d32f1390a92d84451f85ab5b2f28101ca951da3d3e9df12047" +OPENSSL_VERSION="3.6.2" +OPENSSL_BOTTLE_SHA256="aaa5f4f3d87868ecd5f5fd6967da0c305eb335a58171faba193e9c7e39fbf35c" +SQLITE_VERSION="3.53.0" +SQLITE_BOTTLE_SHA256="36080e3273614fe3d606ff0bd5bb090ad33c19f186ba44c35807b8f97afa15be" +GETTEXT_VERSION="1.0" +GETTEXT_BOTTLE_SHA256="f9ea4eed738746ea4150a4f83e8dd11ca21ca3de5bb113995c25eec409bb5749" + +ARIA2_RUNTIME_SHA256="111b2f5ed760f1e1a2ec06117c4e8094fcde336ba16122dda1c5e7209bf1862d" +CARES_RUNTIME_SHA256="86ceec6264753bfffb1562df22e81cbbed72d370105936ee083f3152c9dc1673" +LIBCRYPTO_RUNTIME_SHA256="a13e280563c6eb85058f590f6f558fb20f54e171024f3b9b3637df140add1714" +LIBINTL_RUNTIME_SHA256="7e6628118b26b58b57346f3f088b1f87b263c677736ade678f0aced5579ea357" +LIBSQLITE_RUNTIME_SHA256="27da39c4cc96e7f43c8ed0134d2de6dd2fd36008dcb044c03aeee3eec9edc545" +LIBSSH2_RUNTIME_SHA256="67cbce90dca26590a8a7627af8f4abccfd94f41cae48f7fa67a5f0cb98efc85b" +LIBSSL_RUNTIME_SHA256="f5676ffe68757ea2629898c29bcee5f15982e06fe878bec4f70d159dd1b70452" + +mkdir -p "$SOURCE_DIR" + +sha256() { + shasum -a 256 "$1" | awk '{print $1}' +} + +verify_sha256() { + local path="$1" + local expected="$2" + local actual + actual="$(sha256 "$path")" + if [[ "$actual" != "$expected" ]]; then + echo "Checksum mismatch for $path" >&2 + echo "Expected: $expected" >&2 + echo "Actual: $actual" >&2 + exit 1 + fi +} + +version_matches() { + local marker_path="$1" + local expected="$2" + [[ -f "$marker_path" ]] && [[ "$(tr -d '[:space:]' < "$marker_path")" == "$expected" ]] +} + +fetch_homebrew_bottle() { + local repository="$1" + local digest="$2" + local output_path="$3" + local token + + token="$( + curl -fsSL "https://ghcr.io/token?service=ghcr.io&scope=repository:homebrew/core/$repository:pull" | + python3 -c 'import json, sys; print(json.load(sys.stdin)["token"])' + )" + curl -fsSL \ + -H "Authorization: Bearer $token" \ + "https://ghcr.io/v2/homebrew/core/$repository/blobs/sha256:$digest" \ + -o "$output_path" + verify_sha256 "$output_path" "$digest" +} + +fetch_ytdlp() { + local executable="$SOURCE_DIR/yt-dlp" + local runtime="$SOURCE_DIR/_internal" + local marker="$SOURCE_DIR/yt-dlp-version.txt" + + if [[ -x "$executable" ]] && [[ -d "$runtime" ]] && version_matches "$marker" "$YTDLP_VERSION"; then + return + fi + + echo "Fetching yt-dlp $YTDLP_VERSION one-folder runtime..." + local temp_dir + temp_dir="$(mktemp -d)" + curl -fsSL \ + "https://github.com/yt-dlp/yt-dlp/releases/download/$YTDLP_VERSION/yt-dlp_macos.zip" \ + -o "$temp_dir/yt-dlp.zip" + verify_sha256 "$temp_dir/yt-dlp.zip" "$YTDLP_MACOS_ZIP_SHA256" + unzip -q -o "$temp_dir/yt-dlp.zip" -d "$temp_dir/runtime" + + rm -rf "$runtime" + cp "$temp_dir/runtime/yt-dlp_macos" "$executable" + cp -R "$temp_dir/runtime/_internal" "$runtime" + touch "$runtime/.gitkeep" + chmod +x "$executable" + printf '%s\n' "$YTDLP_VERSION" > "$marker" + rm -rf "$temp_dir" +} + +fetch_deno() { + local machine_arch + machine_arch="$(uname -m)" + + local asset_arch + local expected_sha + case "$machine_arch" in + arm64) + asset_arch="aarch64" + expected_sha="$DENO_ARM64_ZIP_SHA256" + ;; + x86_64) + asset_arch="x86_64" + expected_sha="$DENO_X86_64_ZIP_SHA256" + ;; + *) + echo "Unsupported architecture for bundled Deno: $machine_arch" >&2 + exit 1 + ;; + esac + + local executable="$SOURCE_DIR/deno" + local marker="$SOURCE_DIR/deno-version.txt" + if [[ -x "$executable" ]] && version_matches "$marker" "$DENO_VERSION-$machine_arch"; then + return + fi + + echo "Fetching Deno $DENO_VERSION for $machine_arch..." + local temp_dir + temp_dir="$(mktemp -d)" + curl -fsSL \ + "https://github.com/denoland/deno/releases/download/v$DENO_VERSION/deno-$asset_arch-apple-darwin.zip" \ + -o "$temp_dir/deno.zip" + verify_sha256 "$temp_dir/deno.zip" "$expected_sha" + unzip -q -o "$temp_dir/deno.zip" -d "$temp_dir/runtime" + cp "$temp_dir/runtime/deno" "$executable" + chmod +x "$executable" + printf '%s\n' "$DENO_VERSION-$machine_arch" > "$marker" + rm -rf "$temp_dir" +} + +fetch_ffmpeg() { + local executable="$SOURCE_DIR/ffmpeg" + local marker="$SOURCE_DIR/ffmpeg-version.txt" + + if [[ "$(uname -m)" != "arm64" ]]; then + if [[ ! -x "$executable" ]]; then + echo "A local ffmpeg executable is required on non-ARM64 development hosts." >&2 + exit 1 + fi + return + fi + + if [[ -x "$executable" ]] && version_matches "$marker" "$FFMPEG_VERSION-arm64"; then + return + fi + + echo "Fetching FFmpeg $FFMPEG_VERSION for arm64..." + local temp_dir + temp_dir="$(mktemp -d)" + curl -fsSL "$FFMPEG_ARM64_URL" -o "$temp_dir/ffmpeg.zip" + verify_sha256 "$temp_dir/ffmpeg.zip" "$FFMPEG_ARM64_ZIP_SHA256" + unzip -q -o "$temp_dir/ffmpeg.zip" -d "$temp_dir/runtime" + cp "$temp_dir/runtime/ffmpeg" "$executable" + chmod +x "$executable" + printf '%s\n' "$FFMPEG_VERSION-arm64" > "$marker" + rm -rf "$temp_dir" +} + +aria2_runtime_is_ready() { + local required_paths=( + "$SOURCE_DIR/aria2c" + "$SOURCE_DIR/aria2-cacert.pem" + "$SOURCE_DIR/aria2-libs/libcares.2.dylib" + "$SOURCE_DIR/aria2-libs/libcrypto.3.dylib" + "$SOURCE_DIR/aria2-libs/libintl.8.dylib" + "$SOURCE_DIR/aria2-libs/libsqlite3.dylib" + "$SOURCE_DIR/aria2-libs/libssh2.1.dylib" + "$SOURCE_DIR/aria2-libs/libssl.3.dylib" + ) + + version_matches "$SOURCE_DIR/aria2-version.txt" "$ARIA2_RUNTIME_ID" || return 1 + [[ -x "$SOURCE_DIR/aria2c" ]] || return 1 + [[ -d "$SOURCE_DIR/aria2-licenses" ]] || return 1 + + local path + for path in "${required_paths[@]}"; do + [[ -e "$path" ]] || return 1 + done + + [[ "$(sha256 "$SOURCE_DIR/aria2c")" == "$ARIA2_RUNTIME_SHA256" ]] || return 1 + [[ "$(sha256 "$SOURCE_DIR/aria2-libs/libcares.2.dylib")" == "$CARES_RUNTIME_SHA256" ]] || return 1 + [[ "$(sha256 "$SOURCE_DIR/aria2-libs/libcrypto.3.dylib")" == "$LIBCRYPTO_RUNTIME_SHA256" ]] || return 1 + [[ "$(sha256 "$SOURCE_DIR/aria2-libs/libintl.8.dylib")" == "$LIBINTL_RUNTIME_SHA256" ]] || return 1 + [[ "$(sha256 "$SOURCE_DIR/aria2-libs/libsqlite3.dylib")" == "$LIBSQLITE_RUNTIME_SHA256" ]] || return 1 + [[ "$(sha256 "$SOURCE_DIR/aria2-libs/libssh2.1.dylib")" == "$LIBSSH2_RUNTIME_SHA256" ]] || return 1 + [[ "$(sha256 "$SOURCE_DIR/aria2-libs/libssl.3.dylib")" == "$LIBSSL_RUNTIME_SHA256" ]] || return 1 +} + +fetch_aria2() { + if [[ "$(uname -m)" != "arm64" ]]; then + echo "The pinned aria2 runtime is currently available only for ARM64 macOS." >&2 + exit 1 + fi + + if aria2_runtime_is_ready; then + return + fi + + echo "Fetching aria2 $ARIA2_VERSION Homebrew ARM64 Sonoma runtime..." + local temp_dir + temp_dir="$(mktemp -d)" + local bottles_dir="$temp_dir/bottles" + local extracted_dir="$temp_dir/extracted" + local runtime_dir="$temp_dir/runtime" + local libraries_dir="$runtime_dir/aria2-libs" + local licenses_dir="$runtime_dir/aria2-licenses" + mkdir -p "$bottles_dir" "$extracted_dir" "$libraries_dir" "$licenses_dir" + + fetch_homebrew_bottle "aria2" "$ARIA2_BOTTLE_SHA256" "$bottles_dir/aria2.tar.gz" + fetch_homebrew_bottle "c-ares" "$CARES_BOTTLE_SHA256" "$bottles_dir/c-ares.tar.gz" + fetch_homebrew_bottle "libssh2" "$LIBSSH2_BOTTLE_SHA256" "$bottles_dir/libssh2.tar.gz" + fetch_homebrew_bottle "openssl/3" "$OPENSSL_BOTTLE_SHA256" "$bottles_dir/openssl.tar.gz" + fetch_homebrew_bottle "sqlite" "$SQLITE_BOTTLE_SHA256" "$bottles_dir/sqlite.tar.gz" + fetch_homebrew_bottle "gettext" "$GETTEXT_BOTTLE_SHA256" "$bottles_dir/gettext.tar.gz" + + local bottle + for bottle in "$bottles_dir"/*.tar.gz; do + tar -xzf "$bottle" -C "$extracted_dir" + done + + cp "$extracted_dir/aria2/$ARIA2_VERSION"_"$ARIA2_BOTTLE_REVISION/bin/aria2c" "$runtime_dir/aria2c" + cp "$extracted_dir/c-ares/$CARES_VERSION/lib/libcares.2.19.5.dylib" "$libraries_dir/libcares.2.dylib" + cp "$extracted_dir/libssh2/$LIBSSH2_VERSION/lib/libssh2.1.dylib" "$libraries_dir/libssh2.1.dylib" + cp "$extracted_dir/openssl@3/$OPENSSL_VERSION/lib/libssl.3.dylib" "$libraries_dir/libssl.3.dylib" + cp "$extracted_dir/openssl@3/$OPENSSL_VERSION/lib/libcrypto.3.dylib" "$libraries_dir/libcrypto.3.dylib" + cp "$extracted_dir/sqlite/$SQLITE_VERSION/lib/libsqlite3.3.53.0.dylib" "$libraries_dir/libsqlite3.dylib" + cp "$extracted_dir/gettext/$GETTEXT_VERSION/lib/libintl.8.dylib" "$libraries_dir/libintl.8.dylib" + cp "$SOURCE_DIR/_internal/certifi/cacert.pem" "$runtime_dir/aria2-cacert.pem" + + cp "$extracted_dir/aria2/$ARIA2_VERSION"_"$ARIA2_BOTTLE_REVISION/COPYING" "$licenses_dir/aria2-COPYING" + cp "$extracted_dir/c-ares/$CARES_VERSION/LICENSE.md" "$licenses_dir/c-ares-LICENSE.md" + cp "$extracted_dir/libssh2/$LIBSSH2_VERSION/COPYING" "$licenses_dir/libssh2-COPYING" + cp "$extracted_dir/openssl@3/$OPENSSL_VERSION/LICENSE.txt" "$licenses_dir/openssl-LICENSE.txt" + cp "$extracted_dir/gettext/$GETTEXT_VERSION/COPYING" "$licenses_dir/gettext-COPYING" + cp "$extracted_dir/sqlite/$SQLITE_VERSION/sbom.spdx.json" "$licenses_dir/sqlite-sbom.spdx.json" + + install_name_tool \ + -change "@@HOMEBREW_PREFIX@@/opt/sqlite/lib/libsqlite3.dylib" "@loader_path/aria2-libs/libsqlite3.dylib" \ + -change "@@HOMEBREW_PREFIX@@/opt/openssl@3/lib/libssl.3.dylib" "@loader_path/aria2-libs/libssl.3.dylib" \ + -change "@@HOMEBREW_PREFIX@@/opt/openssl@3/lib/libcrypto.3.dylib" "@loader_path/aria2-libs/libcrypto.3.dylib" \ + -change "@@HOMEBREW_PREFIX@@/opt/libssh2/lib/libssh2.1.dylib" "@loader_path/aria2-libs/libssh2.1.dylib" \ + -change "@@HOMEBREW_PREFIX@@/opt/c-ares/lib/libcares.2.dylib" "@loader_path/aria2-libs/libcares.2.dylib" \ + -change "@@HOMEBREW_PREFIX@@/opt/gettext/lib/libintl.8.dylib" "@loader_path/aria2-libs/libintl.8.dylib" \ + "$runtime_dir/aria2c" + + local library + for library in "$libraries_dir"/*.dylib; do + install_name_tool -id "@loader_path/$(basename "$library")" "$library" + done + + install_name_tool \ + -change "@@HOMEBREW_PREFIX@@/opt/openssl@3/lib/libssl.3.dylib" "@loader_path/libssl.3.dylib" \ + -change "@@HOMEBREW_PREFIX@@/opt/openssl@3/lib/libcrypto.3.dylib" "@loader_path/libcrypto.3.dylib" \ + "$libraries_dir/libssh2.1.dylib" + install_name_tool \ + -change "@@HOMEBREW_CELLAR@@/openssl@3/$OPENSSL_VERSION/lib/libcrypto.3.dylib" "@loader_path/libcrypto.3.dylib" \ + "$libraries_dir/libssl.3.dylib" + + chmod +x "$runtime_dir/aria2c" + for library in "$libraries_dir"/*.dylib; do + codesign --force --sign - "$library" + done + codesign --force --sign - "$runtime_dir/aria2c" + + verify_sha256 "$runtime_dir/aria2c" "$ARIA2_RUNTIME_SHA256" + verify_sha256 "$libraries_dir/libcares.2.dylib" "$CARES_RUNTIME_SHA256" + verify_sha256 "$libraries_dir/libcrypto.3.dylib" "$LIBCRYPTO_RUNTIME_SHA256" + verify_sha256 "$libraries_dir/libintl.8.dylib" "$LIBINTL_RUNTIME_SHA256" + verify_sha256 "$libraries_dir/libsqlite3.dylib" "$LIBSQLITE_RUNTIME_SHA256" + verify_sha256 "$libraries_dir/libssh2.1.dylib" "$LIBSSH2_RUNTIME_SHA256" + verify_sha256 "$libraries_dir/libssl.3.dylib" "$LIBSSL_RUNTIME_SHA256" + + if otool -L "$runtime_dir/aria2c" "$libraries_dir"/*.dylib | grep -Eq '(@@HOMEBREW|/opt/homebrew|/usr/local)'; then + echo "The prepared aria2 runtime still contains a Homebrew path." >&2 + rm -rf "$temp_dir" + exit 1 + fi + if ! vtool -show-build "$runtime_dir/aria2c" | grep -q 'minos 14.0'; then + echo "The pinned aria2 runtime no longer supports the app's macOS 14 deployment target." >&2 + rm -rf "$temp_dir" + exit 1 + fi + if [[ "$("$runtime_dir/aria2c" --version | head -n 1)" != "aria2 version $ARIA2_VERSION" ]]; then + echo "The prepared aria2 runtime failed its version check." >&2 + rm -rf "$temp_dir" + exit 1 + fi + + rm -rf "$SOURCE_DIR/aria2-libs" "$SOURCE_DIR/aria2-licenses" + cp "$runtime_dir/aria2c" "$SOURCE_DIR/aria2c" + cp "$runtime_dir/aria2-cacert.pem" "$SOURCE_DIR/aria2-cacert.pem" + cp -R "$libraries_dir" "$SOURCE_DIR/aria2-libs" + cp -R "$licenses_dir" "$SOURCE_DIR/aria2-licenses" + printf '%s\n' "$ARIA2_RUNTIME_ID" > "$SOURCE_DIR/aria2-version.txt" + rm -rf "$temp_dir" +} + +fetch_ytdlp +fetch_deno +fetch_ffmpeg +fetch_aria2 + +if command -v xattr >/dev/null; then + xattr -cr \ + "$SOURCE_DIR/yt-dlp" \ + "$SOURCE_DIR/_internal" \ + "$SOURCE_DIR/deno" \ + "$SOURCE_DIR/ffmpeg" \ + "$SOURCE_DIR/aria2c" \ + "$SOURCE_DIR/aria2-libs" 2>/dev/null || true +fi + +echo "Media engines are ready:" +echo " yt-dlp $YTDLP_VERSION" +echo " Deno $DENO_VERSION" +echo " FFmpeg $FFMPEG_VERSION" +echo " aria2 $ARIA2_VERSION (Homebrew bottle revision $ARIA2_BOTTLE_REVISION)" diff --git a/Scripts/verify.sh b/Scripts/verify.sh index 2edbf8a..ed163e0 100755 --- a/Scripts/verify.sh +++ b/Scripts/verify.sh @@ -4,6 +4,25 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" +"$ROOT_DIR/Scripts/fetch_media_engines.sh" + +ARIA2C="$ROOT_DIR/Sources/Firelink/aria2c" +ARIA2_LIBS="$ROOT_DIR/Sources/Firelink/aria2-libs" +test -x "$ARIA2C" +test -f "$ROOT_DIR/Sources/Firelink/aria2-cacert.pem" +test -d "$ARIA2_LIBS" +"$ARIA2C" --version | grep -q '^aria2 version 1.37.0$' +test "$(tr -d '[:space:]' < "$ROOT_DIR/Sources/Firelink/aria2-version.txt")" = "1.37.0-2-arm64-sonoma" +lipo -archs "$ARIA2C" | grep -qx arm64 +vtool -show-build "$ARIA2C" | grep -q 'minos 14.0' +for library in "$ARIA2_LIBS"/*.dylib; do + lipo -archs "$library" | grep -qx arm64 +done +if otool -L "$ARIA2C" "$ARIA2_LIBS"/*.dylib | grep -Eq '(@@HOMEBREW|/opt/homebrew|/usr/local)'; then + echo "Bundled aria2 runtime contains a non-portable dependency path." >&2 + exit 1 +fi + swift build git diff --check python3 -m json.tool Extensions/Firefox/manifest.json >/dev/null diff --git a/Sources/Firelink/Aria2DownloadEngine.swift b/Sources/Firelink/Aria2DownloadEngine.swift index 34fa6b1..eb62824 100644 --- a/Sources/Firelink/Aria2DownloadEngine.swift +++ b/Sources/Firelink/Aria2DownloadEngine.swift @@ -57,7 +57,7 @@ final class Aria2DownloadEngine: Sendable { var errorDescription: String? { switch self { case .executableNotFound: - "aria2c was not found. Install it with `brew install aria2`, or bundle aria2c inside the app resources." + "The bundled aria2c runtime is missing. Reinstall Firelink or rebuild its media engines." case .launchFailed(let details): "Could not start aria2c: \(details)" case .unsupportedProxy(let details): @@ -73,20 +73,30 @@ final class Aria2DownloadEngine: Sendable { } static func findExecutable() -> URL? { - if let bundled = Bundle.main.url(forResource: "aria2c", withExtension: nil), - FileManager.default.isExecutableFile(atPath: bundled.path) { + bundledResource(named: "aria2c", executable: true) + } + + static func certificateBundleURL() -> URL? { + bundledResource(named: "aria2-cacert.pem", executable: false) + } + + private static func bundledResource(named name: String, executable: Bool) -> URL? { + func validResource(in bundle: Bundle) -> URL? { + guard let url = bundle.resourceURL?.appendingPathComponent(name) else { return nil } + let isValid = executable + ? FileManager.default.isExecutableFile(atPath: url.path) + : FileManager.default.fileExists(atPath: url.path) + return isValid ? url : nil + } + + if let bundled = validResource(in: .main) { return bundled } - let candidates = [ - "/opt/homebrew/bin/aria2c", - "/usr/local/bin/aria2c", - "/usr/bin/aria2c", - "/opt/local/bin/aria2c" - ] - - if let found = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) { - return URL(fileURLWithPath: found) + if Bundle.main.bundleURL.pathExtension.lowercased() != "app" { + #if SWIFT_PACKAGE + return validResource(in: .module) + #endif } return nil @@ -303,7 +313,7 @@ final class Aria2DownloadEngine: Sendable { return Handle(processIdentifier: process.processIdentifier, rpcPort: rpcPort, rpcSecret: rpcSecret) { completionMonitor.cancel() if process.isRunning { - process.terminate() + ProcessTreeTerminator.terminate(process) } cleanupTempDir() } @@ -323,7 +333,7 @@ final class Aria2DownloadEngine: Sendable { if await completedDownloadStatus(rpcPort: rpcPort, rpcSecret: rpcSecret) { completionGate.complete(.success(())) if process.isRunning { - process.terminate() + ProcessTreeTerminator.terminate(process) } return } @@ -449,6 +459,10 @@ final class Aria2DownloadEngine: Sendable { arguments.append("--max-overall-download-limit=\(speedLimitKiBPerSecond)K") } + if let certificateBundleURL = Self.certificateBundleURL() { + arguments.append("--ca-certificate=\(certificateBundleURL.path)") + } + arguments.append(contentsOf: try proxyArguments(for: item, configuration: proxyConfiguration)) return arguments } diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index 979fc52..6e11d85 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -472,7 +472,7 @@ final class DownloadController: ObservableObject { } guard hasRunnableQueuedDownload else { - engineMessage = "aria2c is not installed. Run `brew install aria2` to enable downloads." + engineMessage = "The bundled aria2c runtime is missing. Reinstall Firelink or rebuild its media engines." return } diff --git a/Sources/Firelink/FirelinkApp.swift b/Sources/Firelink/FirelinkApp.swift index ae4c45a..86622b7 100644 --- a/Sources/Firelink/FirelinkApp.swift +++ b/Sources/Firelink/FirelinkApp.swift @@ -38,6 +38,7 @@ struct FirelinkApp: App { .modifier(AppFontSizeModifier(fontSize: settings.appFontSize)) .task { updateChecker.checkAutomaticallyIfNeeded() + _ = await MediaEngineManager.shared.preparedBinaryPath(for: .ytDlp) } .onOpenURL { url in let now = Date() diff --git a/Sources/Firelink/MediaDownloadEngine.swift b/Sources/Firelink/MediaDownloadEngine.swift index bcb304b..15faaa8 100644 --- a/Sources/Firelink/MediaDownloadEngine.swift +++ b/Sources/Firelink/MediaDownloadEngine.swift @@ -26,7 +26,7 @@ final class MediaDownloadEngine: @unchecked Sendable { messageUpdate: @escaping @Sendable (String) -> Void, completion: @escaping @Sendable (Result) -> Void ) async throws -> Handle { - let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp) + let ytDlpURL = await MediaEngineManager.shared.preparedBinaryPath(for: .ytDlp) let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg) guard let ytDlpURL, FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else { @@ -45,11 +45,12 @@ final class MediaDownloadEngine: @unchecked Sendable { "--newline", "--ffmpeg-location", ffmpegURL.path, "--no-check-formats", + "--socket-timeout", "20", + "--retries", "3", + "--extractor-retries", "3", "--fragment-retries", "10", "--retry-sleep", "0", "--skip-unavailable-fragments", - "--extractor-args", "youtube:player_client=tv,web", - "--extractor-args", "youtube:skip=webpage", "--compat-options", "no-youtube-unavailable-videos", "-o", item.destinationPath ] @@ -71,7 +72,8 @@ final class MediaDownloadEngine: @unchecked Sendable { to: &arguments, cookieSource: cookieSource, credentials: item.credentials, - transferOptions: item.transferOptions + transferOptions: item.transferOptions, + preferredDenoURL: ytDlpURL.deletingLastPathComponent().appendingPathComponent("deno") ) if let proxyURI = proxyConfiguration.customProxyURI, proxyConfiguration.mode == .custom { @@ -82,7 +84,11 @@ final class MediaDownloadEngine: @unchecked Sendable { arguments.append(contentsOf: ["--limit-rate", "\(speedLimitKiBPerSecond)K"]) } - appendParallelDownloadArguments(to: &arguments, connectionsPerServer: item.connectionsPerServer) + appendParallelDownloadArguments( + to: &arguments, + item: item, + speedLimitKiBPerSecond: speedLimitKiBPerSecond + ) arguments.append(item.url.absoluteString) process.arguments = arguments @@ -134,7 +140,7 @@ final class MediaDownloadEngine: @unchecked Sendable { if let tempConfigDir { try? FileManager.default.removeItem(at: tempConfigDir) } - readGroup.notify(queue: .global()) { + let complete: @Sendable () -> Void = { if finishedProcess.terminationStatus == 0 { completionGate.complete(.success(Self.resolvedOutputURL(for: item, tracker: outputPathTracker))) } else { @@ -142,6 +148,12 @@ final class MediaDownloadEngine: @unchecked Sendable { completionGate.complete(.failure(EngineError.launchFailed(Self.cleanErrorMessage(errorString, status: finishedProcess.terminationStatus)))) } } + readGroup.notify(queue: .global(), execute: complete) + DispatchQueue.global().asyncAfter(deadline: .now() + 1) { + outputPipe.fileHandleForReading.readabilityHandler = nil + errorPipe.fileHandleForReading.readabilityHandler = nil + complete() + } } var didRun = false @@ -158,7 +170,7 @@ final class MediaDownloadEngine: @unchecked Sendable { return Handle(cancel: { if process.isRunning { - process.terminate() + ProcessTreeTerminator.terminate(process) } }) } @@ -212,12 +224,37 @@ final class MediaDownloadEngine: @unchecked Sendable { return message } - private func appendParallelDownloadArguments(to arguments: inout [String], connectionsPerServer: Int) { - let connections = min(max(connectionsPerServer, 1), 16) + private func appendParallelDownloadArguments( + to arguments: inout [String], + item: DownloadItem, + speedLimitKiBPerSecond: Int? + ) { + let connections = min(max(item.connectionsPerServer, 1), 16) guard connections > 1 else { return } arguments.append(contentsOf: ["--concurrent-fragments", "\(connections)"]) - // Use yt-dlp's native concurrent downloader instead of aria2c to ensure progress parsing works via stdout + let largeDirectDownloadThreshold: Int64 = 128 * 1024 * 1024 + guard item.isAudioOnlyMedia != true, + (item.sizeBytes ?? 0) >= largeDirectDownloadThreshold, + speedLimitKiBPerSecond == nil, + let aria2URL = Aria2DownloadEngine.findExecutable() else { + return + } + + let aria2Connections = min(connections, 8) + let certificateArgument = Aria2DownloadEngine.certificateBundleURL().map { + " --ca-certificate=\(Self.shellQuoted($0.path))" + } ?? "" + arguments.append(contentsOf: [ + "--downloader", aria2URL.path, + "--downloader", "dash,m3u8:native", + "--downloader-args", + "aria2c:-x\(aria2Connections) -s\(aria2Connections) -k1M --file-allocation=none --summary-interval=1\(certificateArgument)" + ]) + } + + private static func shellQuoted(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" } } diff --git a/Sources/Firelink/MediaEngineManager.swift b/Sources/Firelink/MediaEngineManager.swift index 68b8351..f307f91 100644 --- a/Sources/Firelink/MediaEngineManager.swift +++ b/Sources/Firelink/MediaEngineManager.swift @@ -1,5 +1,6 @@ import Foundation import Combine +import Darwin enum AddonState: Equatable, Sendable { case notInstalled @@ -25,23 +26,34 @@ final class MediaEngineManager: ObservableObject { @Published var ytDlpState: AddonState = .notInstalled @Published var ffmpegState: AddonState = .notInstalled + private var ytDlpPreparationTask: Task? private init() { checkLocalInstallation() - Task.detached { [weak self] in - await self?.prewarmYtDlp() + Task { [weak self] in + _ = await self?.preparedBinaryPath(for: .ytDlp) } } - nonisolated private func prewarmYtDlp() async { - guard let path = await MainActor.run(body: { binaryPath(for: .ytDlp) }) else { return } - let process = Process() - process.executableURL = path - process.arguments = ["--version"] - process.standardOutput = nil - process.standardError = nil - try? process.run() - process.waitUntilExit() + func preparedBinaryPath(for addon: AddonType) async -> URL? { + guard addon == .ytDlp else { return binaryPath(for: addon) } + + if let ytDlpPreparationTask { + return await ytDlpPreparationTask.value + } + + guard let bundledURL = binaryPath(for: .ytDlp) else { return nil } + let runtimeVersion = bundledRuntimeVersion(near: bundledURL) + let task = Task.detached(priority: .userInitiated) { + let executableURL = Self.installStableYtDlpRuntime( + bundledExecutableURL: bundledURL, + version: runtimeVersion + ) ?? bundledURL + Self.prewarm(executableURL) + return executableURL + } + ytDlpPreparationTask = task + return await task.value } func binaryPath(for addon: AddonType) -> URL? { @@ -62,6 +74,144 @@ final class MediaEngineManager: ObservableObject { return nil } + private func bundledRuntimeVersion(near executableURL: URL) -> String { + let versionURL = executableURL.deletingLastPathComponent() + .appendingPathComponent("yt-dlp-version.txt") + if let version = try? String(contentsOf: versionURL, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines), + !version.isEmpty { + return version + } + + return Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown" + } + + nonisolated private static func installStableYtDlpRuntime( + bundledExecutableURL: URL, + version: String + ) -> URL? { + let fileManager = FileManager.default + let bundledDirectory = bundledExecutableURL.deletingLastPathComponent() + let bundledInternalURL = bundledDirectory.appendingPathComponent("_internal", isDirectory: true) + let bundledDenoURL = bundledDirectory.appendingPathComponent("deno") + let hasBundledDeno = fileManager.isExecutableFile(atPath: bundledDenoURL.path) + + guard fileManager.fileExists(atPath: bundledInternalURL.path) else { + return bundledExecutableURL + } + + guard let applicationSupportURL = fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first else { + return bundledExecutableURL + } + + let safeVersion = version.replacingOccurrences( + of: #"[^A-Za-z0-9._-]"#, + with: "_", + options: .regularExpression + ) + let enginesURL = applicationSupportURL + .appendingPathComponent("Firelink", isDirectory: true) + .appendingPathComponent("MediaEngines", isDirectory: true) + .appendingPathComponent("yt-dlp", isDirectory: true) + let runtimeURL = enginesURL.appendingPathComponent(safeVersion, isDirectory: true) + let executableURL = runtimeURL.appendingPathComponent("yt-dlp") + let internalURL = runtimeURL.appendingPathComponent("_internal", isDirectory: true) + let denoURL = runtimeURL.appendingPathComponent("deno") + + if fileManager.isExecutableFile(atPath: executableURL.path), + fileManager.fileExists(atPath: internalURL.path), + !hasBundledDeno || fileManager.isExecutableFile(atPath: denoURL.path) { + return executableURL + } + + let temporaryURL = enginesURL.appendingPathComponent( + ".install-\(UUID().uuidString)", + isDirectory: true + ) + + do { + try fileManager.createDirectory(at: enginesURL, withIntermediateDirectories: true) + try fileManager.createDirectory(at: temporaryURL, withIntermediateDirectories: true) + try fileManager.copyItem( + at: bundledExecutableURL, + to: temporaryURL.appendingPathComponent("yt-dlp") + ) + try fileManager.copyItem( + at: bundledInternalURL, + to: temporaryURL.appendingPathComponent("_internal", isDirectory: true) + ) + if hasBundledDeno { + try fileManager.copyItem( + at: bundledDenoURL, + to: temporaryURL.appendingPathComponent("deno") + ) + } + removeTransportAttributesRecursively(at: temporaryURL) + + if fileManager.fileExists(atPath: runtimeURL.path) { + try fileManager.removeItem(at: runtimeURL) + } + try fileManager.moveItem(at: temporaryURL, to: runtimeURL) + return executableURL + } catch { + try? fileManager.removeItem(at: temporaryURL) + return bundledExecutableURL + } + } + + nonisolated private static func removeTransportAttributesRecursively(at rootURL: URL) { + removeTransportAttributes(at: rootURL) + + guard let enumerator = FileManager.default.enumerator( + at: rootURL, + includingPropertiesForKeys: nil, + options: [], + errorHandler: nil + ) else { + return + } + + for case let itemURL as URL in enumerator { + removeTransportAttributes(at: itemURL) + } + } + + nonisolated private static func removeTransportAttributes(at url: URL) { + url.withUnsafeFileSystemRepresentation { path in + guard let path else { return } + removexattr(path, "com.apple.quarantine", 0) + removexattr(path, "com.apple.provenance", 0) + } + } + + nonisolated private static func prewarm(_ executableURL: URL) { + runVersionCommand(executableURL) + + let denoURL = executableURL.deletingLastPathComponent().appendingPathComponent("deno") + if FileManager.default.isExecutableFile(atPath: denoURL.path) { + runVersionCommand(denoURL) + } + } + + nonisolated private static func runVersionCommand(_ executableURL: URL) { + let process = Process() + process.executableURL = executableURL + process.arguments = ["--version"] + process.standardOutput = nil + process.standardError = nil + process.standardInput = nil + + do { + try process.run() + process.waitUntilExit() + } catch { + return + } + } + func checkLocalInstallation() { for addon in AddonType.allCases { if binaryPath(for: addon) != nil { diff --git a/Sources/Firelink/MediaExtractionEngine.swift b/Sources/Firelink/MediaExtractionEngine.swift index d61fb53..a6a1f76 100644 --- a/Sources/Firelink/MediaExtractionEngine.swift +++ b/Sources/Firelink/MediaExtractionEngine.swift @@ -87,20 +87,20 @@ enum MediaExtractionEngine { if let cached = metadataCache.object(forKey: url as NSURL), Date().timeIntervalSince(cached.date) < 300 { return (cached.metadata, cached.options) } - guard let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp), + guard let ytDlpURL = await MediaEngineManager.shared.preparedBinaryPath(for: .ytDlp), FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else { throw ExtractionError.processFailed("yt-dlp binary not found.") } let ytDlpPath = ytDlpURL.path var args = [ - "-J", - "--no-warnings", - "--ignore-no-formats-error", - "--no-playlist", + "-J", + "--no-warnings", + "--no-playlist", "--no-check-formats", - "--extractor-args", "youtube:player_client=tv,web", - "--extractor-args", "youtube:skip=webpage", + "--socket-timeout", "20", + "--retries", "3", + "--extractor-retries", "3", "--compat-options", "no-youtube-unavailable-videos" ] @@ -113,7 +113,13 @@ enum MediaExtractionEngine { args.append(contentsOf: ["--proxy", proxyURI]) } - let tempConfigDir = appendCommonArguments(to: &args, cookieSource: cookieSource, credentials: credentials, transferOptions: transferOptions) + let tempConfigDir = appendCommonArguments( + to: &args, + cookieSource: cookieSource, + credentials: credentials, + transferOptions: transferOptions, + preferredDenoURL: cachedDenoURL(near: ytDlpURL) + ) defer { if let tempConfigDir { try? FileManager.default.removeItem(at: tempConfigDir) @@ -144,13 +150,14 @@ enum MediaExtractionEngine { to args: inout [String], cookieSource: BrowserCookieSource, credentials: DownloadCredentials?, - transferOptions: DownloadTransferOptions + transferOptions: DownloadTransferOptions, + preferredDenoURL: URL? = nil ) -> URL? { if let browserName = cookieSource.ytDlpBrowserName { args.append(contentsOf: ["--cookies-from-browser", browserName]) } - appendJavaScriptRuntimeArguments(to: &args) + appendJavaScriptRuntimeArguments(to: &args, preferredDenoURL: preferredDenoURL) for header in transferOptions.requestHeaders.map(\.normalized) where !header.isEmpty { args.append(contentsOf: ["--add-header", header.headerLine]) @@ -175,9 +182,14 @@ enum MediaExtractionEngine { return tempConfigDir } - private static func appendJavaScriptRuntimeArguments(to args: inout [String]) { + private static func appendJavaScriptRuntimeArguments( + to args: inout [String], + preferredDenoURL: URL? + ) { var runtimes: [String] = [] - if let denoPath = executablePath(named: "deno", candidates: [ + if let denoPath = executablePath(at: preferredDenoURL) ?? + bundledExecutablePath(named: "deno") ?? + executablePath(named: "deno", candidates: [ "/opt/homebrew/bin/deno", "/usr/local/bin/deno" ]) { @@ -197,6 +209,36 @@ enum MediaExtractionEngine { } } + private static func cachedDenoURL(near ytDlpURL: URL) -> URL? { + let denoURL = ytDlpURL.deletingLastPathComponent().appendingPathComponent("deno") + return FileManager.default.isExecutableFile(atPath: denoURL.path) ? denoURL : nil + } + + private static func executablePath(at url: URL?) -> String? { + guard let url, FileManager.default.isExecutableFile(atPath: url.path) else { + return nil + } + return url.path + } + + private static func bundledExecutablePath(named name: String) -> String? { + if let bundled = Bundle.main.url(forResource: name, withExtension: nil), + FileManager.default.isExecutableFile(atPath: bundled.path) { + return bundled.path + } + + if Bundle.main.bundleURL.pathExtension.lowercased() != "app" { + #if SWIFT_PACKAGE + if let bundled = Bundle.module.url(forResource: name, withExtension: nil), + FileManager.default.isExecutableFile(atPath: bundled.path) { + return bundled.path + } + #endif + } + + return nil + } + private static func executablePath(named name: String, candidates: [String]) -> String? { var safeCandidates = candidates safeCandidates.append(contentsOf: [ @@ -501,6 +543,9 @@ private final class YTDLPMetadataProcess: @unchecked Sendable { do { try process.run() + if Task.isCancelled { + self.terminate() + } outputPipe.fileHandleForWriting.closeFile() errorPipe.fileHandleForWriting.closeFile() } catch { @@ -515,14 +560,6 @@ private final class YTDLPMetadataProcess: @unchecked Sendable { private func terminate() { let p = lock.withLock { self.process } guard let p, p.isRunning else { return } - - p.terminate() - - Task.detached { - try? await Task.sleep(nanoseconds: 500_000_000) - if p.isRunning { - kill(p.processIdentifier, SIGKILL) - } - } + ProcessTreeTerminator.terminate(p) } } diff --git a/Sources/Firelink/ProcessTreeTerminator.swift b/Sources/Firelink/ProcessTreeTerminator.swift new file mode 100644 index 0000000..312598c --- /dev/null +++ b/Sources/Firelink/ProcessTreeTerminator.swift @@ -0,0 +1,53 @@ +import Foundation +import Darwin + +enum ProcessTreeTerminator { + static func terminate(_ process: Process, forceAfter delay: TimeInterval = 0.5) { + let rootPID = process.processIdentifier + guard rootPID > 0 else { return } + + let processIDs = descendants(of: rootPID) + [rootPID] + signal(processIDs.reversed(), with: SIGTERM) + + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { + signal(processIDs.reversed(), with: SIGKILL) + } + } + + private static func descendants(of rootPID: pid_t) -> [pid_t] { + var result: [pid_t] = [] + var pending = directChildren(of: rootPID) + + while let processID = pending.popLast() { + result.append(processID) + pending.append(contentsOf: directChildren(of: processID)) + } + + return result + } + + private static func directChildren(of processID: pid_t) -> [pid_t] { + var capacity = 32 + + while capacity <= 4096 { + var processIDs = [pid_t](repeating: 0, count: capacity) + let count = processIDs.withUnsafeMutableBytes { buffer in + proc_listchildpids(processID, buffer.baseAddress, Int32(buffer.count)) + } + + guard count > 0 else { return [] } + if count < capacity { + return Array(processIDs.prefix(Int(count))).filter { $0 > 0 } + } + capacity *= 2 + } + + return [] + } + + private static func signal(_ processIDs: S, with signal: Int32) where S.Element == pid_t { + for processID in processIDs where processID > 0 { + kill(processID, signal) + } + } +} diff --git a/Sources/Firelink/Settings/EngineSettingsPane.swift b/Sources/Firelink/Settings/EngineSettingsPane.swift index abdf5b6..c3983db 100644 --- a/Sources/Firelink/Settings/EngineSettingsPane.swift +++ b/Sources/Firelink/Settings/EngineSettingsPane.swift @@ -34,7 +34,7 @@ struct EngineSettingsPane: View { Text("Core Downloader (Aria2)") } footer: { if executableURL == nil { - Text("Install aria2 with Homebrew or ensure it is bundled inside the app resources.") + Text("The bundled aria2 runtime is missing. Reinstall Firelink or rebuild its media engines.") .foregroundStyle(.red) } else { Text("Handles core HTTP/FTP and BitTorrent downloads.")