Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/build_ipa.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,22 @@ jobs:
"$python_test_env/bin/python" -m pip install yt-dlp==2026.8.19
PYTHONDONTWRITEBYTECODE=1 "$python_test_env/bin/python" -m unittest scripts.test_format_resolution

- name: Install QuickJS-NG sources
run: |
set -euo pipefail
archive="$RUNNER_TEMP/quickjs-0.16.2.tar.gz"
curl -fL https://github.com/quickjs-ng/quickjs/archive/refs/tags/v0.16.2.tar.gz -o "$archive"
expected="97c80625b26775a4c7ca618c004d4ea24cf99cbf867e4eba78bd927a8b23d106"
actual=$(shasum -a 256 "$archive" | cut -d ' ' -f 1)
test "$actual" = "$expected"
mkdir -p Frameworks/QuickJS
tar -xzf "$archive" --strip-components=1 -C Frameworks/QuickJS

- name: Test embedded QuickJS and EJS
run: |
"$RUNNER_TEMP/palladium-python-tests/bin/python" -m pip install "yt-dlp[default]==2026.8.19" yt-dlp-apple-webkit-jsi==0.1.1
PYTHONDONTWRITEBYTECODE=1 "$RUNNER_TEMP/palladium-python-tests/bin/python" -m unittest scripts.test_quickjs_runtime

- name: Download Python Apple Support
uses: robinraju/release-downloader@v1
with:
Expand Down
27 changes: 27 additions & 0 deletions Palladium.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
objects = {

/* Begin PBXBuildFile section */
D22000042F80000100D20001 /* QuickJS LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = D21000042F80000100D20001 /* QuickJS LICENSE */; };
D22000032F80000100D20001 /* libunicode.c in Sources */ = {isa = PBXBuildFile; fileRef = D21000032F80000100D20001 /* libunicode.c */; settings = {COMPILER_FLAGS = "-D_GNU_SOURCE -DQUICKJS_NG_BUILD"; }; };
D22000022F80000100D20001 /* libregexp.c in Sources */ = {isa = PBXBuildFile; fileRef = D21000022F80000100D20001 /* libregexp.c */; settings = {COMPILER_FLAGS = "-D_GNU_SOURCE -DQUICKJS_NG_BUILD"; }; };
D22000012F80000100D20001 /* dtoa.c in Sources */ = {isa = PBXBuildFile; fileRef = D21000012F80000100D20001 /* dtoa.c */; settings = {COMPILER_FLAGS = "-D_GNU_SOURCE -DQUICKJS_NG_BUILD"; }; };
D22000002F80000100D20001 /* quickjs.c in Sources */ = {isa = PBXBuildFile; fileRef = D21000002F80000100D20001 /* quickjs.c */; settings = {COMPILER_FLAGS = "-D_GNU_SOURCE -DQUICKJS_NG_BUILD"; }; };
8256E3B62F57A8970067DF6E /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8256E3AC2F57A8960067DF6E /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
82BF06CE2F5762BC00133331 /* Frameworks/Python.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 82BF06CD2F5762BC00133331 /* Frameworks/Python.xcframework */; };
82BF06CF2F5762BC00133331 /* Frameworks/Python.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 82BF06CD2F5762BC00133331 /* Frameworks/Python.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
Expand Down Expand Up @@ -58,6 +63,11 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
D21000042F80000100D20001 /* QuickJS LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; name = "QuickJS LICENSE"; path = Frameworks/QuickJS/LICENSE; sourceTree = SOURCE_ROOT; };
D21000032F80000100D20001 /* libunicode.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = Frameworks/QuickJS/libunicode.c; sourceTree = SOURCE_ROOT; };
D21000022F80000100D20001 /* libregexp.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = Frameworks/QuickJS/libregexp.c; sourceTree = SOURCE_ROOT; };
D21000012F80000100D20001 /* dtoa.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = Frameworks/QuickJS/dtoa.c; sourceTree = SOURCE_ROOT; };
D21000002F80000100D20001 /* quickjs.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = Frameworks/QuickJS/quickjs.c; sourceTree = SOURCE_ROOT; };
8256E3AC2F57A8960067DF6E /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
82BF06BE2F5761E800133331 /* Palladium.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Palladium.app; sourceTree = BUILT_PRODUCTS_DIR; };
82BF06CD2F5762BC00133331 /* Frameworks/Python.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = Frameworks/Python.xcframework; sourceTree = "<group>"; };
Expand Down Expand Up @@ -306,6 +316,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D22000042F80000100D20001 /* QuickJS LICENSE in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -374,6 +385,10 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D22000032F80000100D20001 /* libunicode.c in Sources */,
D22000022F80000100D20001 /* libregexp.c in Sources */,
D22000012F80000100D20001 /* dtoa.c in Sources */,
D22000002F80000100D20001 /* quickjs.c in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -630,10 +645,16 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = "$(APP_VERSION)";
HEADER_SEARCH_PATHS = (
"$(inherited)",
"$(SRCROOT)/Frameworks/QuickJS",
);
OTHER_LDFLAGS = (
"$(inherited)",
"-Wl,-u,_palladium_ffmpeg_bridge_run",
"-Wl,-u,_palladium_ffmpeg_bridge_free",
"-Wl,-u,_palladium_quickjs_bridge_run",
"-Wl,-u,_palladium_quickjs_bridge_free",
);
PRODUCT_BUNDLE_IDENTIFIER = com.tfourj.Palladium;
PRODUCT_NAME = "$(TARGET_NAME)";
Expand Down Expand Up @@ -681,10 +702,16 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = "$(APP_VERSION)";
HEADER_SEARCH_PATHS = (
"$(inherited)",
"$(SRCROOT)/Frameworks/QuickJS",
);
OTHER_LDFLAGS = (
"$(inherited)",
"-Wl,-u,_palladium_ffmpeg_bridge_run",
"-Wl,-u,_palladium_ffmpeg_bridge_free",
"-Wl,-u,_palladium_quickjs_bridge_run",
"-Wl,-u,_palladium_quickjs_bridge_free",
);
PRODUCT_BUNDLE_IDENTIFIER = com.tfourj.Palladium;
PRODUCT_NAME = "$(TARGET_NAME)";
Expand Down
24 changes: 24 additions & 0 deletions Palladium/Models/JavaScriptRuntime.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Foundation

enum JavaScriptRuntime: String, CaseIterable, Identifiable {
case webkit
case quickjs

static let defaultsKey = "palladium.javascriptRuntime"
static let defaultValue = JavaScriptRuntime.webkit

var id: String { rawValue }

var title: String {
switch self {
case .webkit:
return String(localized: "settings.advanced.javascript_runtime.webkit", bundle: .app)
case .quickjs:
return String(localized: "settings.advanced.javascript_runtime.quickjs", bundle: .app)
}
}

static var selected: JavaScriptRuntime {
UserDefaults.standard.string(forKey: defaultsKey).flatMap(Self.init(rawValue:)) ?? defaultValue
}
}
40 changes: 40 additions & 0 deletions Palladium/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -5416,6 +5416,46 @@
}
}
},
"settings.advanced.javascript_runtime" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "JavaScript runtime"
}
}
}
},
"settings.advanced.javascript_runtime.help" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Choose the JavaScript runtime for video downloads. Apple WebKit is the default."
}
}
}
},
"settings.advanced.javascript_runtime.quickjs" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickJS"
}
}
}
},
"settings.advanced.javascript_runtime.webkit" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Apple WebKit"
}
}
}
},
"settings.advanced.youtube_patch_mode" : {
"localizations" : {
"en" : {
Expand Down
7 changes: 7 additions & 0 deletions Palladium/Services/Python/PythonFlowRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,13 @@ enum PythonFlowRunner {
}

private static func loadYtDlpModule() throws -> PythonObject {
let os = try Python.attemptImport("os")
let environment = try pythonMember(os, named: "environ")
let setEnvironment = try pythonMember(environment, named: "__setitem__")
_ = try setEnvironment.throwing.dynamicallyCall(withArguments: [
"PALLADIUM_JS_RUNTIME", JavaScriptRuntime.selected.rawValue
])

let scriptURL = PythonScripts.ytDlpScriptURL
let scriptPath = scriptURL.path
let scriptDirectoryPath = scriptURL.deletingLastPathComponent().path
Expand Down
1 change: 1 addition & 0 deletions Palladium/Services/Python/PythonScripts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ enum PythonScripts {
"palladium_ytdlp/maintenance.py",
"palladium_ytdlp/packages.py",
"palladium_ytdlp/patching.py",
"palladium_ytdlp/quickjs_bridge.py",
"palladium_ytdlp/runtime.py",
"palladium_ytdlp/shared.py",
"palladium_ytdlp/webkit_jsi.py",
Expand Down
166 changes: 166 additions & 0 deletions Palladium/Services/Python/palladium_ytdlp/quickjs_bridge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""Adapt yt-dlp's QuickJS provider to the engine linked into the iOS app."""

import contextlib
import ctypes
import json
import os


QUICKJS_DEFAULT_ARGS = ("--no-js-runtimes", "--js-runtimes", "quickjs")


def runtime_default_args():
if os.environ.get("PALLADIUM_JS_RUNTIME") == "quickjs":
return QUICKJS_DEFAULT_ARGS
return ("--no-js-runtimes",)


def runtime_default_options():
options = quickjs_default_options()
if os.environ.get("PALLADIUM_JS_RUNTIME") != "quickjs":
options["js_runtimes"] = {}
return options


def quickjs_default_options():
options = {"js_runtimes": {"quickjs": {}}, "remote_components": {"ejs:github"}}
cache_dir = os.environ.get("PALLADIUM_CACHE_DIR", "").strip()
if cache_dir:
options["cachedir"] = cache_dir
return options


class QuickJSBridge:
def __init__(self):
candidates = [None]
executable = os.environ.get("PALLADIUM_EXECUTABLE_PATH", "").strip()
if executable:
candidates.append(executable)
last_error = None
for path in candidates:
try:
self._library = ctypes.CDLL(path)
self._run = self._library.palladium_quickjs_bridge_run
self._run.argtypes = [ctypes.c_char_p]
self._run.restype = ctypes.c_void_p
self._free = self._library.palladium_quickjs_bridge_free
self._free.argtypes = [ctypes.c_void_p]
self._free.restype = None
self.info = self._invoke({"operation": "info"})
if not self.info.get("ok") or self.info.get("name") != "quickjs-ng":
raise RuntimeError("unexpected QuickJS bridge metadata")
self.version = str(self.info["version"])
self.version_tuple = tuple(int(part) for part in self.version.split("."))
if self.version_tuple < (0, 12, 0):
raise RuntimeError("QuickJS-NG 0.12.0 or newer is required")
return
except Exception as error:
last_error = error
raise RuntimeError(f"unable to load embedded QuickJS: {last_error}")

def _invoke(self, request):
pointer = self._run(json.dumps(request, ensure_ascii=True).encode("utf-8"))
if not pointer:
raise RuntimeError("QuickJS bridge returned no response")
try:
response = json.loads(ctypes.string_at(pointer).decode("utf-8"))
finally:
self._free(pointer)
if not isinstance(response, dict):
raise RuntimeError("invalid QuickJS bridge response")
return response

def evaluate(self, source, cancel_file=""):
if cancel_file and os.path.exists(cancel_file):
raise KeyboardInterrupt("cancel requested before JavaScript evaluation")
response = self._invoke({"operation": "evaluate", "source": source, "cancel_file": cancel_file})
if response.get("status") == "cancelled" or (cancel_file and os.path.exists(cancel_file)):
raise KeyboardInterrupt("cancel requested during JavaScript evaluation")
if not response.get("ok"):
raise RuntimeError(response.get("error") or response.get("status") or "QuickJS evaluation failed")
output = response.get("output")
if not isinstance(output, str):
raise RuntimeError("QuickJS returned invalid output")
return output


@contextlib.contextmanager
def embedded_quickjs(cancel_file=None):
"""Install reversible, per-invocation hooks against the current yt-dlp modules.

The EJS/runtime hooks are private upstream APIs. If their shape changes, keep
WebKit available and leave the original modules untouched. Do not catch errors
from the operation inside this context as if they were setup failures.
"""
if cancel_file is None:
cancel_file = os.environ.get("PALLADIUM_CANCEL_FILE", "").strip()
try:
from yt_dlp.extractor.youtube.jsc._builtin.quickjs import QuickJSJCP
from yt_dlp.extractor.youtube.jsc._registry import _jsc_preferences
from yt_dlp.extractor.youtube.jsc.provider import JsChallengeProviderError, register_preference
from yt_dlp.utils._jsruntime import JsRuntimeInfo, QuickJsRuntime

original_info = QuickJsRuntime._info
original_run = QuickJSJCP._run_js_runtime
preferences = _jsc_preferences.value
if not callable(original_info) or not callable(original_run) or not isinstance(preferences, set):
raise RuntimeError("unsupported yt-dlp QuickJS provider API")
except Exception as error:
print(f"[palladium][quickjs] integration unavailable; retaining WebKit: {error}")
yield
return

bridge = None
runtime_info = None
try:
bridge = QuickJSBridge()
runtime_info = JsRuntimeInfo(
name="quickjs-ng", path="embedded", version=bridge.version,
version_tuple=bridge.version_tuple, supported=True,
)
print(f"[palladium][quickjs] embedded QuickJS-NG {bridge.version} available")
except Exception as error:
print(f"[palladium][quickjs] engine unavailable; retaining WebKit: {error}")

def embedded_info(runtime):
# There is no external qjs executable on iOS, including when the native
# bridge is missing. Never fall back to a subprocess version probe.
if runtime._path not in (None, "", "qjs", "embedded"):
print("[palladium][quickjs] external executable paths are unavailable on iOS")
return None
return runtime_info

def run_script(provider, source):
if bridge is None:
raise JsChallengeProviderError("embedded QuickJS is unavailable", expected=True)
try:
return bridge.evaluate(source, cancel_file)
except Exception as error:
provider._available = False
print(f"[palladium][quickjs] evaluation failed; allowing WebKit fallback: {error}")
raise JsChallengeProviderError(str(error), expected=True) from error

def prefer_quickjs(provider, requests):
return 10000

previous_preferences = set(preferences)
added_preferences = set()
try:
register_preference(QuickJSJCP)(prefer_quickjs)
added_preferences = preferences - previous_preferences
QuickJsRuntime._info = embedded_info
QuickJSJCP._run_js_runtime = run_script
except Exception as error:
preferences.difference_update(preferences - previous_preferences)
QuickJsRuntime._info = original_info
QuickJSJCP._run_js_runtime = original_run
print(f"[palladium][quickjs] adapter setup failed; retaining WebKit: {error}")
yield
return

try:
yield
finally:
QuickJsRuntime._info = original_info
QuickJSJCP._run_js_runtime = original_run
preferences.difference_update(added_preferences)
Loading
Loading