mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-03 06:27:57 +00:00
fix(media): stabilize bundled download engines
This commit is contained in:
@@ -60,24 +60,8 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: brew install aria2 dylibbundler
|
|
||||||
|
|
||||||
- name: Fetch media engines
|
- name: Fetch media engines
|
||||||
run: |
|
run: Scripts/fetch_media_engines.sh
|
||||||
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
|
|
||||||
|
|
||||||
- name: Build app bundle
|
- name: Build app bundle
|
||||||
env:
|
env:
|
||||||
@@ -89,7 +73,25 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
file build/Firelink.app/Contents/MacOS/Firelink
|
file build/Firelink.app/Contents/MacOS/Firelink
|
||||||
lipo -archs build/Firelink.app/Contents/MacOS/Firelink | grep -qx arm64
|
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
|
- name: Create DMG
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -23,8 +23,14 @@ SparklePrivateKey*
|
|||||||
private-key*
|
private-key*
|
||||||
private_key*
|
private_key*
|
||||||
yt-dlp
|
yt-dlp
|
||||||
|
deno
|
||||||
ffmpeg
|
ffmpeg
|
||||||
|
aria2c
|
||||||
|
Sources/Firelink/*-version.txt
|
||||||
Sources/Firelink/_internal/
|
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/*
|
Sources/Firelink/_internal/*
|
||||||
!Sources/Firelink/_internal/.gitkeep
|
!Sources/Firelink/_internal/.gitkeep
|
||||||
|
|||||||
@@ -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/),
|
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).
|
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
|
## [0.7.1] - 2026-06-11
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|||||||
@@ -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
|
swift build -c release
|
||||||
|
|
||||||
app:
|
app:
|
||||||
@@ -13,7 +16,7 @@ release:
|
|||||||
Scripts/create_app_bundle.sh
|
Scripts/create_app_bundle.sh
|
||||||
Scripts/create_dmg.sh
|
Scripts/create_dmg.sh
|
||||||
|
|
||||||
run:
|
run: engines
|
||||||
swift run Firelink
|
swift run Firelink
|
||||||
|
|
||||||
verify:
|
verify:
|
||||||
|
|||||||
+11
-2
@@ -17,12 +17,21 @@ let package = Package(
|
|||||||
dependencies: [],
|
dependencies: [],
|
||||||
path: "Sources/Firelink",
|
path: "Sources/Firelink",
|
||||||
exclude: [
|
exclude: [
|
||||||
"_internal"
|
"deno-version.txt",
|
||||||
|
"ffmpeg-version.txt"
|
||||||
],
|
],
|
||||||
resources: [
|
resources: [
|
||||||
.process("Assets.xcassets"),
|
.process("Assets.xcassets"),
|
||||||
.copy("yt-dlp"),
|
.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")
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -17,36 +17,7 @@ ICON_NAME="AppIcon"
|
|||||||
|
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
is_valid_mach_o() {
|
"$ROOT_DIR/Scripts/fetch_media_engines.sh"
|
||||||
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
|
|
||||||
|
|
||||||
swift build -c "$CONFIGURATION"
|
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/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png" "$RESOURCES_DIR/MenuBarIconTemplate.png"
|
||||||
cp "$ROOT_DIR/Resources/GitHubTemplate.png" "$RESOURCES_DIR/GitHubTemplate.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"
|
media_engine_path="$ROOT_DIR/Sources/Firelink/$media_engine"
|
||||||
if [[ -x "$media_engine_path" ]]; then
|
if [[ -x "$media_engine_path" ]]; then
|
||||||
cp "$media_engine_path" "$RESOURCES_DIR/$media_engine"
|
cp "$media_engine_path" "$RESOURCES_DIR/$media_engine"
|
||||||
@@ -67,9 +38,23 @@ for media_engine in yt-dlp ffmpeg; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -d "$ROOT_DIR/Sources/Firelink/_internal" ]]; then
|
for resource_directory in _internal aria2-libs aria2-licenses; do
|
||||||
cp -R "$ROOT_DIR/Sources/Firelink/_internal" "$RESOURCES_DIR/_internal"
|
source_path="$ROOT_DIR/Sources/Firelink/$resource_directory"
|
||||||
fi
|
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..."
|
echo "Packaging Firefox extension..."
|
||||||
mkdir -p "$RESOURCES_DIR/FirefoxExtension"
|
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"
|
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"
|
FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks"
|
||||||
mkdir -p "$FRAMEWORKS_DIR"
|
mkdir -p "$FRAMEWORKS_DIR"
|
||||||
|
|
||||||
@@ -165,6 +133,11 @@ if command -v codesign &> /dev/null; then
|
|||||||
|
|
||||||
sign_mach_o_file() {
|
sign_mach_o_file() {
|
||||||
local path="$1"
|
local path="$1"
|
||||||
|
case "$path" in
|
||||||
|
*/Python.framework/Python|*/Python.framework/Versions/Current/*)
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
if file "$path" | grep -q 'Mach-O'; then
|
if file "$path" | grep -q 'Mach-O'; then
|
||||||
if ! sign_path "$path"; 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 "$MACOS_DIR/$APP_NAME"
|
||||||
sign_path "$APP_DIR"
|
sign_path "$APP_DIR"
|
||||||
codesign --verify --deep --verbose=2 "$APP_DIR" || true
|
codesign --verify --deep --strict --verbose=2 "$APP_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Created $APP_DIR"
|
echo "Created $APP_DIR"
|
||||||
|
|||||||
Executable
+337
@@ -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)"
|
||||||
@@ -4,6 +4,25 @@ set -euo pipefail
|
|||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT_DIR"
|
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
|
swift build
|
||||||
git diff --check
|
git diff --check
|
||||||
python3 -m json.tool Extensions/Firefox/manifest.json >/dev/null
|
python3 -m json.tool Extensions/Firefox/manifest.json >/dev/null
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ final class Aria2DownloadEngine: Sendable {
|
|||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .executableNotFound:
|
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):
|
case .launchFailed(let details):
|
||||||
"Could not start aria2c: \(details)"
|
"Could not start aria2c: \(details)"
|
||||||
case .unsupportedProxy(let details):
|
case .unsupportedProxy(let details):
|
||||||
@@ -73,20 +73,30 @@ final class Aria2DownloadEngine: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func findExecutable() -> URL? {
|
static func findExecutable() -> URL? {
|
||||||
if let bundled = Bundle.main.url(forResource: "aria2c", withExtension: nil),
|
bundledResource(named: "aria2c", executable: true)
|
||||||
FileManager.default.isExecutableFile(atPath: bundled.path) {
|
}
|
||||||
|
|
||||||
|
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
|
return bundled
|
||||||
}
|
}
|
||||||
|
|
||||||
let candidates = [
|
if Bundle.main.bundleURL.pathExtension.lowercased() != "app" {
|
||||||
"/opt/homebrew/bin/aria2c",
|
#if SWIFT_PACKAGE
|
||||||
"/usr/local/bin/aria2c",
|
return validResource(in: .module)
|
||||||
"/usr/bin/aria2c",
|
#endif
|
||||||
"/opt/local/bin/aria2c"
|
|
||||||
]
|
|
||||||
|
|
||||||
if let found = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) {
|
|
||||||
return URL(fileURLWithPath: found)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -303,7 +313,7 @@ final class Aria2DownloadEngine: Sendable {
|
|||||||
return Handle(processIdentifier: process.processIdentifier, rpcPort: rpcPort, rpcSecret: rpcSecret) {
|
return Handle(processIdentifier: process.processIdentifier, rpcPort: rpcPort, rpcSecret: rpcSecret) {
|
||||||
completionMonitor.cancel()
|
completionMonitor.cancel()
|
||||||
if process.isRunning {
|
if process.isRunning {
|
||||||
process.terminate()
|
ProcessTreeTerminator.terminate(process)
|
||||||
}
|
}
|
||||||
cleanupTempDir()
|
cleanupTempDir()
|
||||||
}
|
}
|
||||||
@@ -323,7 +333,7 @@ final class Aria2DownloadEngine: Sendable {
|
|||||||
if await completedDownloadStatus(rpcPort: rpcPort, rpcSecret: rpcSecret) {
|
if await completedDownloadStatus(rpcPort: rpcPort, rpcSecret: rpcSecret) {
|
||||||
completionGate.complete(.success(()))
|
completionGate.complete(.success(()))
|
||||||
if process.isRunning {
|
if process.isRunning {
|
||||||
process.terminate()
|
ProcessTreeTerminator.terminate(process)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -449,6 +459,10 @@ final class Aria2DownloadEngine: Sendable {
|
|||||||
arguments.append("--max-overall-download-limit=\(speedLimitKiBPerSecond)K")
|
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))
|
arguments.append(contentsOf: try proxyArguments(for: item, configuration: proxyConfiguration))
|
||||||
return arguments
|
return arguments
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -472,7 +472,7 @@ final class DownloadController: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard hasRunnableQueuedDownload else {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ struct FirelinkApp: App {
|
|||||||
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
|
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
|
||||||
.task {
|
.task {
|
||||||
updateChecker.checkAutomaticallyIfNeeded()
|
updateChecker.checkAutomaticallyIfNeeded()
|
||||||
|
_ = await MediaEngineManager.shared.preparedBinaryPath(for: .ytDlp)
|
||||||
}
|
}
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
let now = Date()
|
let now = Date()
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
|||||||
messageUpdate: @escaping @Sendable (String) -> Void,
|
messageUpdate: @escaping @Sendable (String) -> Void,
|
||||||
completion: @escaping @Sendable (Result<URL, Error>) -> Void
|
completion: @escaping @Sendable (Result<URL, Error>) -> Void
|
||||||
) async throws -> Handle {
|
) 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)
|
let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg)
|
||||||
|
|
||||||
guard let ytDlpURL, FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
|
guard let ytDlpURL, FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
|
||||||
@@ -45,11 +45,12 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
|||||||
"--newline",
|
"--newline",
|
||||||
"--ffmpeg-location", ffmpegURL.path,
|
"--ffmpeg-location", ffmpegURL.path,
|
||||||
"--no-check-formats",
|
"--no-check-formats",
|
||||||
|
"--socket-timeout", "20",
|
||||||
|
"--retries", "3",
|
||||||
|
"--extractor-retries", "3",
|
||||||
"--fragment-retries", "10",
|
"--fragment-retries", "10",
|
||||||
"--retry-sleep", "0",
|
"--retry-sleep", "0",
|
||||||
"--skip-unavailable-fragments",
|
"--skip-unavailable-fragments",
|
||||||
"--extractor-args", "youtube:player_client=tv,web",
|
|
||||||
"--extractor-args", "youtube:skip=webpage",
|
|
||||||
"--compat-options", "no-youtube-unavailable-videos",
|
"--compat-options", "no-youtube-unavailable-videos",
|
||||||
"-o", item.destinationPath
|
"-o", item.destinationPath
|
||||||
]
|
]
|
||||||
@@ -71,7 +72,8 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
|||||||
to: &arguments,
|
to: &arguments,
|
||||||
cookieSource: cookieSource,
|
cookieSource: cookieSource,
|
||||||
credentials: item.credentials,
|
credentials: item.credentials,
|
||||||
transferOptions: item.transferOptions
|
transferOptions: item.transferOptions,
|
||||||
|
preferredDenoURL: ytDlpURL.deletingLastPathComponent().appendingPathComponent("deno")
|
||||||
)
|
)
|
||||||
|
|
||||||
if let proxyURI = proxyConfiguration.customProxyURI, proxyConfiguration.mode == .custom {
|
if let proxyURI = proxyConfiguration.customProxyURI, proxyConfiguration.mode == .custom {
|
||||||
@@ -82,7 +84,11 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
|||||||
arguments.append(contentsOf: ["--limit-rate", "\(speedLimitKiBPerSecond)K"])
|
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)
|
arguments.append(item.url.absoluteString)
|
||||||
process.arguments = arguments
|
process.arguments = arguments
|
||||||
@@ -134,7 +140,7 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
|||||||
if let tempConfigDir {
|
if let tempConfigDir {
|
||||||
try? FileManager.default.removeItem(at: tempConfigDir)
|
try? FileManager.default.removeItem(at: tempConfigDir)
|
||||||
}
|
}
|
||||||
readGroup.notify(queue: .global()) {
|
let complete: @Sendable () -> Void = {
|
||||||
if finishedProcess.terminationStatus == 0 {
|
if finishedProcess.terminationStatus == 0 {
|
||||||
completionGate.complete(.success(Self.resolvedOutputURL(for: item, tracker: outputPathTracker)))
|
completionGate.complete(.success(Self.resolvedOutputURL(for: item, tracker: outputPathTracker)))
|
||||||
} else {
|
} else {
|
||||||
@@ -142,6 +148,12 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
|||||||
completionGate.complete(.failure(EngineError.launchFailed(Self.cleanErrorMessage(errorString, status: finishedProcess.terminationStatus))))
|
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
|
var didRun = false
|
||||||
@@ -158,7 +170,7 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
|||||||
|
|
||||||
return Handle(cancel: {
|
return Handle(cancel: {
|
||||||
if process.isRunning {
|
if process.isRunning {
|
||||||
process.terminate()
|
ProcessTreeTerminator.terminate(process)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -212,12 +224,37 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
|||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
|
|
||||||
private func appendParallelDownloadArguments(to arguments: inout [String], connectionsPerServer: Int) {
|
private func appendParallelDownloadArguments(
|
||||||
let connections = min(max(connectionsPerServer, 1), 16)
|
to arguments: inout [String],
|
||||||
|
item: DownloadItem,
|
||||||
|
speedLimitKiBPerSecond: Int?
|
||||||
|
) {
|
||||||
|
let connections = min(max(item.connectionsPerServer, 1), 16)
|
||||||
guard connections > 1 else { return }
|
guard connections > 1 else { return }
|
||||||
|
|
||||||
arguments.append(contentsOf: ["--concurrent-fragments", "\(connections)"])
|
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: "'\"'\"'"))'"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
import Darwin
|
||||||
|
|
||||||
enum AddonState: Equatable, Sendable {
|
enum AddonState: Equatable, Sendable {
|
||||||
case notInstalled
|
case notInstalled
|
||||||
@@ -25,23 +26,34 @@ final class MediaEngineManager: ObservableObject {
|
|||||||
|
|
||||||
@Published var ytDlpState: AddonState = .notInstalled
|
@Published var ytDlpState: AddonState = .notInstalled
|
||||||
@Published var ffmpegState: AddonState = .notInstalled
|
@Published var ffmpegState: AddonState = .notInstalled
|
||||||
|
private var ytDlpPreparationTask: Task<URL?, Never>?
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
checkLocalInstallation()
|
checkLocalInstallation()
|
||||||
Task.detached { [weak self] in
|
Task { [weak self] in
|
||||||
await self?.prewarmYtDlp()
|
_ = await self?.preparedBinaryPath(for: .ytDlp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private func prewarmYtDlp() async {
|
func preparedBinaryPath(for addon: AddonType) async -> URL? {
|
||||||
guard let path = await MainActor.run(body: { binaryPath(for: .ytDlp) }) else { return }
|
guard addon == .ytDlp else { return binaryPath(for: addon) }
|
||||||
let process = Process()
|
|
||||||
process.executableURL = path
|
if let ytDlpPreparationTask {
|
||||||
process.arguments = ["--version"]
|
return await ytDlpPreparationTask.value
|
||||||
process.standardOutput = nil
|
}
|
||||||
process.standardError = nil
|
|
||||||
try? process.run()
|
guard let bundledURL = binaryPath(for: .ytDlp) else { return nil }
|
||||||
process.waitUntilExit()
|
let runtimeVersion = bundledRuntimeVersion(near: bundledURL)
|
||||||
|
let task = Task<URL?, Never>.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? {
|
func binaryPath(for addon: AddonType) -> URL? {
|
||||||
@@ -62,6 +74,144 @@ final class MediaEngineManager: ObservableObject {
|
|||||||
return nil
|
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() {
|
func checkLocalInstallation() {
|
||||||
for addon in AddonType.allCases {
|
for addon in AddonType.allCases {
|
||||||
if binaryPath(for: addon) != nil {
|
if binaryPath(for: addon) != nil {
|
||||||
|
|||||||
@@ -87,20 +87,20 @@ enum MediaExtractionEngine {
|
|||||||
if let cached = metadataCache.object(forKey: url as NSURL), Date().timeIntervalSince(cached.date) < 300 {
|
if let cached = metadataCache.object(forKey: url as NSURL), Date().timeIntervalSince(cached.date) < 300 {
|
||||||
return (cached.metadata, cached.options)
|
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 {
|
FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
|
||||||
throw ExtractionError.processFailed("yt-dlp binary not found.")
|
throw ExtractionError.processFailed("yt-dlp binary not found.")
|
||||||
}
|
}
|
||||||
let ytDlpPath = ytDlpURL.path
|
let ytDlpPath = ytDlpURL.path
|
||||||
|
|
||||||
var args = [
|
var args = [
|
||||||
"-J",
|
"-J",
|
||||||
"--no-warnings",
|
"--no-warnings",
|
||||||
"--ignore-no-formats-error",
|
"--no-playlist",
|
||||||
"--no-playlist",
|
|
||||||
"--no-check-formats",
|
"--no-check-formats",
|
||||||
"--extractor-args", "youtube:player_client=tv,web",
|
"--socket-timeout", "20",
|
||||||
"--extractor-args", "youtube:skip=webpage",
|
"--retries", "3",
|
||||||
|
"--extractor-retries", "3",
|
||||||
"--compat-options", "no-youtube-unavailable-videos"
|
"--compat-options", "no-youtube-unavailable-videos"
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -113,7 +113,13 @@ enum MediaExtractionEngine {
|
|||||||
args.append(contentsOf: ["--proxy", proxyURI])
|
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 {
|
defer {
|
||||||
if let tempConfigDir {
|
if let tempConfigDir {
|
||||||
try? FileManager.default.removeItem(at: tempConfigDir)
|
try? FileManager.default.removeItem(at: tempConfigDir)
|
||||||
@@ -144,13 +150,14 @@ enum MediaExtractionEngine {
|
|||||||
to args: inout [String],
|
to args: inout [String],
|
||||||
cookieSource: BrowserCookieSource,
|
cookieSource: BrowserCookieSource,
|
||||||
credentials: DownloadCredentials?,
|
credentials: DownloadCredentials?,
|
||||||
transferOptions: DownloadTransferOptions
|
transferOptions: DownloadTransferOptions,
|
||||||
|
preferredDenoURL: URL? = nil
|
||||||
) -> URL? {
|
) -> URL? {
|
||||||
if let browserName = cookieSource.ytDlpBrowserName {
|
if let browserName = cookieSource.ytDlpBrowserName {
|
||||||
args.append(contentsOf: ["--cookies-from-browser", browserName])
|
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 {
|
for header in transferOptions.requestHeaders.map(\.normalized) where !header.isEmpty {
|
||||||
args.append(contentsOf: ["--add-header", header.headerLine])
|
args.append(contentsOf: ["--add-header", header.headerLine])
|
||||||
@@ -175,9 +182,14 @@ enum MediaExtractionEngine {
|
|||||||
return tempConfigDir
|
return tempConfigDir
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func appendJavaScriptRuntimeArguments(to args: inout [String]) {
|
private static func appendJavaScriptRuntimeArguments(
|
||||||
|
to args: inout [String],
|
||||||
|
preferredDenoURL: URL?
|
||||||
|
) {
|
||||||
var runtimes: [String] = []
|
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",
|
"/opt/homebrew/bin/deno",
|
||||||
"/usr/local/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? {
|
private static func executablePath(named name: String, candidates: [String]) -> String? {
|
||||||
var safeCandidates = candidates
|
var safeCandidates = candidates
|
||||||
safeCandidates.append(contentsOf: [
|
safeCandidates.append(contentsOf: [
|
||||||
@@ -501,6 +543,9 @@ private final class YTDLPMetadataProcess: @unchecked Sendable {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
try process.run()
|
try process.run()
|
||||||
|
if Task.isCancelled {
|
||||||
|
self.terminate()
|
||||||
|
}
|
||||||
outputPipe.fileHandleForWriting.closeFile()
|
outputPipe.fileHandleForWriting.closeFile()
|
||||||
errorPipe.fileHandleForWriting.closeFile()
|
errorPipe.fileHandleForWriting.closeFile()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -515,14 +560,6 @@ private final class YTDLPMetadataProcess: @unchecked Sendable {
|
|||||||
private func terminate() {
|
private func terminate() {
|
||||||
let p = lock.withLock { self.process }
|
let p = lock.withLock { self.process }
|
||||||
guard let p, p.isRunning else { return }
|
guard let p, p.isRunning else { return }
|
||||||
|
ProcessTreeTerminator.terminate(p)
|
||||||
p.terminate()
|
|
||||||
|
|
||||||
Task.detached {
|
|
||||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
|
||||||
if p.isRunning {
|
|
||||||
kill(p.processIdentifier, SIGKILL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<S: Sequence>(_ processIDs: S, with signal: Int32) where S.Element == pid_t {
|
||||||
|
for processID in processIDs where processID > 0 {
|
||||||
|
kill(processID, signal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ struct EngineSettingsPane: View {
|
|||||||
Text("Core Downloader (Aria2)")
|
Text("Core Downloader (Aria2)")
|
||||||
} footer: {
|
} footer: {
|
||||||
if executableURL == nil {
|
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)
|
.foregroundStyle(.red)
|
||||||
} else {
|
} else {
|
||||||
Text("Handles core HTTP/FTP and BitTorrent downloads.")
|
Text("Handles core HTTP/FTP and BitTorrent downloads.")
|
||||||
|
|||||||
Reference in New Issue
Block a user