build: bundle playback and YouTube dependencies
Build pinned LGPL playback libraries and package standalone YouTube helpers inside the signed native app. Keep the CLI inside the app and relocate private library links for installation without runtime Homebrew dependencies. Record adaptive fixture completion in its EXIT trap: cancellation can exit with status 143 before the TERM trap writes its marker, while native pause and quality preservation remain correct.
This commit is contained in:
+24
-13
@@ -5,7 +5,6 @@ from pathlib import Path
|
||||
import plistlib
|
||||
import selectors
|
||||
import shutil
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -17,9 +16,10 @@ from streaming import StreamingServer
|
||||
|
||||
def check_cli_instances(root, app, fixtures):
|
||||
cli = root / "terebi"
|
||||
shutil.copy2(app_source.parent / "terebi", cli)
|
||||
extractor = shutil.which("yt-dlp")
|
||||
runtime = shutil.which("deno") or shutil.which("node")
|
||||
cli.symlink_to(app / "Contents/MacOS/terebi-cli")
|
||||
extractor = app / "Contents/MacOS/yt-dlp-offline"
|
||||
(app / "Contents/MacOS/yt-dlp").rename(extractor)
|
||||
runtime = app / "Contents/MacOS/deno"
|
||||
canonical = "https://www.youtube.com/watch?v=aqz-KE-bpKQ"
|
||||
reported_urls = [f"https://www.youtube.com/watch?v=progress00{index}" for index in range(1, 5)]
|
||||
|
||||
@@ -152,16 +152,25 @@ def check_cli_instances(root, app, fixtures):
|
||||
"url": server.base_url + "/media/cache.mp4", "protocol": "http", "ext": "mp4",
|
||||
"vcodec": "mpeg4", "acodec": "aac", "dynamic_range": "SDR",
|
||||
"http_headers": {"X-Terebi-Fixture": "combined"}} for height in (180, 360)]}))
|
||||
choices.append(f" {shlex.quote(url)}) info={shlex.quote(str(metadata))} ;;")
|
||||
wrapper = root / "yt-dlp"
|
||||
wrapper.write_text("#!/bin/bash\nset -eu\nargs=(\"$@\")\n"
|
||||
'case "${args[${#args[@]}-1]}" in\n' + "\n".join(choices)
|
||||
+ '\n *) echo "Fixture extraction deliberately failed" >&2; exit 1 ;;\nesac\nexec '
|
||||
+ shlex.quote(extractor) + ' --load-info-json "$info"'
|
||||
+ ' "${args[@]:0:${#args[@]}-2}"\n')
|
||||
wrapper.chmod(0o755)
|
||||
choices.append(f" if (!strcmp(url, {json.dumps(url)})) info = {json.dumps(str(metadata))};")
|
||||
wrapper = app / "Contents/MacOS/yt-dlp"
|
||||
source = root / "offline-extractor.c"
|
||||
source.write_text(
|
||||
'#include <stdio.h>\n#include <string.h>\n#include <unistd.h>\n'
|
||||
'int main(int argc, char **argv) {\n'
|
||||
' if (argc < 3) return 1;\n'
|
||||
' const char *url = argv[argc - 1];\n char *info = NULL;\n'
|
||||
+ "\n".join(choices)
|
||||
+ '\n if (!info) { fputs("Fixture extraction deliberately failed\\n", stderr); return 1; }\n'
|
||||
+ f' char *args[argc + 2]; args[0] = {json.dumps(str(extractor))};\n'
|
||||
+ ' args[1] = "--load-info-json"; args[2] = info;\n'
|
||||
+ ' for (int i = 1; i < argc - 2; ++i) args[i + 2] = argv[i];\n'
|
||||
+ ' args[argc] = NULL;\n execv(args[0], args); perror("execv"); return 1;\n}\n')
|
||||
subprocess.run(["xcrun", "clang", "-O2", str(source), "-o", str(wrapper)], check=True)
|
||||
subprocess.run(["codesign", "--force", "--sign", "-", str(wrapper)], check=True)
|
||||
runtime_settings = {"directory": str(root), "path": str(root) + os.pathsep + os.environ.get("PATH", "")}
|
||||
(app / "Contents/Resources/cli-check.json").write_text(json.dumps(runtime_settings))
|
||||
subprocess.run(["codesign", "--force", "--sign", "-", str(app)], check=True)
|
||||
movie = fixtures / "cache.mp4"
|
||||
audio = root / "audio with spaces.wav"
|
||||
shutil.copy2(fixtures / "audio.wav", audio)
|
||||
@@ -457,7 +466,7 @@ def check_cli_instances(root, app, fixtures):
|
||||
|
||||
|
||||
|
||||
app_source, library, fixtures = map(lambda path: Path(path).resolve(), sys.argv[1:])
|
||||
app_source, library, fixtures, dependencies_root = map(lambda path: Path(path).resolve(), sys.argv[1:])
|
||||
with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
|
||||
root = Path(directory)
|
||||
app = root / "terebi.app"
|
||||
@@ -468,6 +477,8 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
|
||||
info["CFBundleIdentifier"] = identifier
|
||||
info_path.write_bytes(plistlib.dumps(info))
|
||||
shutil.copy2(library, app / "Contents/Frameworks/libterebi-player.dylib")
|
||||
subprocess.run([sys.executable, str(Path(__file__).resolve().parent.parent / "Scripts/bundle.py"),
|
||||
"--app", str(app), "--dependencies", str(dependencies_root)], check=True)
|
||||
pipe = root / "result.fifo"
|
||||
os.mkfifo(pipe)
|
||||
descriptor = os.open(pipe, os.O_RDONLY | os.O_NONBLOCK)
|
||||
|
||||
@@ -5,7 +5,6 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
@@ -16,19 +15,19 @@ def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("binary", type=Path)
|
||||
parser.add_argument("fixtures", type=Path)
|
||||
parser.add_argument("app", type=Path)
|
||||
args = parser.parse_args()
|
||||
extractor = shutil.which("yt-dlp")
|
||||
if extractor is None:
|
||||
extractor = next((str(path) for path in (Path("/opt/homebrew/bin/yt-dlp"), Path("/usr/local/bin/yt-dlp"))
|
||||
if os.access(path, os.X_OK)), None)
|
||||
if extractor is None:
|
||||
print("SKIP native adaptive playback: yt-dlp is not installed; controlled-clock policy checks still run")
|
||||
return 0
|
||||
macOS = args.app.resolve() / "Contents/MacOS"
|
||||
extractor = str(macOS / "yt-dlp")
|
||||
runtime = macOS / "deno"
|
||||
if not os.access(extractor, os.X_OK) or not os.access(runtime, os.X_OK):
|
||||
parser.error("Missing bundled YouTube helpers; build the app first")
|
||||
required = ("quality-1080.mp4", "quality-720.mp4")
|
||||
if any(not (args.fixtures / name).is_file() for name in required):
|
||||
parser.error("Missing real quality playback fixtures")
|
||||
with StreamingServer(args.fixtures) as server, tempfile.TemporaryDirectory(prefix="terebi-quality-check-") as directory:
|
||||
root = Path(directory)
|
||||
(root / "deno").symlink_to(runtime)
|
||||
formats = []
|
||||
for height, width in ((720, 1280), (1080, 1920)):
|
||||
path = "/media/quality-720.mp4" if height == 720 else "/stall/quality-low/quality-1080.mp4?after_seconds=2"
|
||||
@@ -53,11 +52,10 @@ def main():
|
||||
+ ' if [[ -f "$control/gate-1080" ]]; then\n'
|
||||
+ " " + shlex.quote(extractor) + " --load-info-json " + shlex.quote(str(metadata))
|
||||
+ ' "${args[@]:0:${#args[@]}-2}" > "$control/output-1080.json"\n'
|
||||
+ ' trap \': > "$control/finished-1080"; exit 0\' TERM\n'
|
||||
+ ' trap \': > "$control/finished-1080"\' EXIT\n'
|
||||
+ ' : > "$control/entered-1080"\n'
|
||||
+ ' while [[ ! -f "$control/release-1080" ]]; do sleep 0.01; done\n'
|
||||
+ ' cat "$control/output-1080.json" || true\n'
|
||||
+ ' : > "$control/finished-1080"\n'
|
||||
+ ' exit 0\n'
|
||||
+ ' fi\n'
|
||||
+ 'fi\nexec ' + shlex.quote(extractor)
|
||||
|
||||
Executable
+96
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise relocated runtime payloads with Homebrew and original build inputs denied."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
PROBE = r'''
|
||||
import ctypes
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
app, movie = map(Path, sys.argv[1:])
|
||||
frameworks = app / "Contents/Frameworks"
|
||||
names = (app / "Contents/Resources/ffmpeg-libraries.txt").read_text().splitlines()
|
||||
libraries = {name.split(".")[0]: ctypes.CDLL(str(frameworks / name)) for name in names}
|
||||
format = libraries["libavformat"]
|
||||
codec = libraries["libavcodec"]
|
||||
codec.avcodec_license.restype = ctypes.c_char_p
|
||||
assert codec.avcodec_license() == b"LGPL version 2.1 or later", codec.avcodec_license()
|
||||
codec.avcodec_find_decoder_by_name.argtypes = [ctypes.c_char_p]
|
||||
codec.avcodec_find_decoder_by_name.restype = ctypes.c_void_p
|
||||
assert codec.avcodec_find_decoder_by_name(b"libdav1d"), "software AV1 decoder absent"
|
||||
context = ctypes.c_void_p()
|
||||
format.avformat_open_input.argtypes = [ctypes.POINTER(ctypes.c_void_p), ctypes.c_char_p, ctypes.c_void_p, ctypes.c_void_p]
|
||||
format.avformat_find_stream_info.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
|
||||
format.avformat_close_input.argtypes = [ctypes.POINTER(ctypes.c_void_p)]
|
||||
assert format.avformat_open_input(ctypes.byref(context), bytes(movie), None, None) == 0
|
||||
try:
|
||||
assert format.avformat_find_stream_info(context, None) >= 0
|
||||
finally:
|
||||
format.avformat_close_input(ctypes.byref(context))
|
||||
print(json.dumps({"media_read": True, "software_av1": True, "license": codec.avcodec_license().decode()}))
|
||||
'''
|
||||
|
||||
|
||||
def main():
|
||||
source, fixtures, dependency_root = map(lambda value: Path(value).resolve(), sys.argv[1:])
|
||||
with tempfile.TemporaryDirectory(prefix="terebi-bundle-") as directory:
|
||||
root = Path(directory)
|
||||
app = root / "relocated player.app"
|
||||
shutil.copytree(source, app)
|
||||
movie = root / "media with spaces.mp4"
|
||||
shutil.copy2(fixtures / "av.mp4", movie)
|
||||
macOS = app / "Contents/MacOS"
|
||||
cli = root / "terebi"
|
||||
cli.symlink_to(macOS / "terebi-cli")
|
||||
profile = '(version 1)(allow default)' + ''.join(
|
||||
'(deny file-read* (subpath ' + json.dumps(str(path)) + '))'
|
||||
for path in (Path("/opt/homebrew"), Path("/usr/local"), source, dependency_root))
|
||||
environment = {key: value for key, value in os.environ.items()
|
||||
if not key.startswith(("DYLD_", "PYTHON"))}
|
||||
environment.update(PATH="/usr/bin:/bin", XDG_CONFIG_HOME=str(root / "xdg"), PYTHONNOUSERSITE="1")
|
||||
|
||||
def invoke(arguments, expected=0):
|
||||
result = subprocess.run(["/usr/bin/sandbox-exec", "-p", profile, *map(str, arguments)],
|
||||
env=environment, capture_output=True, text=True, timeout=45)
|
||||
if result.returncode != expected:
|
||||
raise RuntimeError(f"Relocated command {arguments} returned {result.returncode}, expected {expected}:\n{result.stdout}{result.stderr}")
|
||||
return result
|
||||
|
||||
invoke([cli, "--help"])
|
||||
invoke([cli, "--default-config"])
|
||||
native = invoke(["/usr/bin/python3", "-c", PROBE, app, movie])
|
||||
print("PASS relocated private codec libraries:", native.stdout.strip())
|
||||
deno = invoke([macOS / "deno", "eval", "--no-config", "--no-remote", "--no-npm",
|
||||
'console.log(JSON.stringify([1,2,3].map(n => n * n)))'])
|
||||
assert json.loads(deno.stdout) == [1, 4, 9], "bundled JavaScript runtime did not execute"
|
||||
metadata = root / "youtube.json"
|
||||
metadata.write_text(json.dumps({
|
||||
"id": "jNQXAC9IVRw", "title": "Offline bundle fixture", "duration": 4,
|
||||
"webpage_url": "https://www.youtube.com/watch?v=jNQXAC9IVRw",
|
||||
"extractor": "youtube", "extractor_key": "Youtube", "formats": [{
|
||||
"format_id": "360", "height": 360, "width": 640, "ext": "mp4",
|
||||
"url": "https://media.example/fixture.mp4", "protocol": "https", "vcodec": "h264", "acodec": "aac"}]}))
|
||||
extracted = invoke([macOS / "yt-dlp", "--ignore-config", "--no-plugin-dirs", "--no-cache-dir",
|
||||
"--no-remote-components", "--no-js-runtimes", "--js-runtimes", "deno:" + str(macOS / "deno"),
|
||||
"--verbose", "--simulate", "--skip-download", "--dump-single-json", "--no-check-formats",
|
||||
"--load-info-json", metadata])
|
||||
video = json.loads(extracted.stdout)
|
||||
assert video["url"] == "https://media.example/fixture.mp4" and video["height"] == 360
|
||||
assert "yt_dlp_ejs" in extracted.stderr and "deno-" in extracted.stderr, extracted.stderr
|
||||
print("PASS relocated standalone yt-dlp, packaged Python/EJS and Deno without host tools")
|
||||
missing = next((app / "Contents/Frameworks").glob("libdav1d.*.dylib"))
|
||||
missing.rename(root / "removed-dav1d.dylib")
|
||||
failure = invoke([cli, "--new-instance", "--no-config"], expected=69)
|
||||
print("PASS missing bundled transitive library blocks launch:", failure.stderr.strip())
|
||||
invoke([cli, "--help"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -17,18 +17,29 @@ struct FFmpegRuntimeChecks {
|
||||
let required = try String(contentsOf: realApp.appendingPathComponent("Contents/Resources/ffmpeg-libraries.txt"), encoding: .utf8)
|
||||
.split(separator: "\n").map(String.init)
|
||||
|
||||
let frameworks = app.appendingPathComponent("Contents/Frameworks", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: frameworks, withIntermediateDirectories: true)
|
||||
require(FFmpegRuntime.error(for: app) != nil, "missing dependency metadata blocks startup")
|
||||
var unavailable = required
|
||||
let family = URL(fileURLWithPath: required[0]).lastPathComponent.split(separator: ".")[0]
|
||||
unavailable[0] = directory.appendingPathComponent("\(family).999999.dylib").path
|
||||
try (unavailable.joined(separator: "\n") + "\n").write(to: manifest, atomically: true, encoding: .utf8)
|
||||
require(FFmpegRuntime.error(for: app) != nil, "an unavailable ABI library blocks startup")
|
||||
try Data("not a dynamic library".utf8).write(to: URL(fileURLWithPath: unavailable[0]))
|
||||
require(FFmpegRuntime.error(for: app) != nil, "an existing but unloadable library also blocks startup")
|
||||
for invalid in [
|
||||
"/" + required[0], "../" + required[0], "subdirectory/" + required[0],
|
||||
"libavformat.dylib", "libavformat..dylib", "libavformat.one.dylib",
|
||||
"libavformat.1.dylib\r", "libavformat.1.dylib\0"
|
||||
] {
|
||||
var entries = required
|
||||
entries[0] = invalid
|
||||
try (entries.joined(separator: "\n") + "\n").write(to: manifest, atomically: true, encoding: .utf8)
|
||||
require(FFmpegRuntime.error(for: app) != nil, "unsafe or unversioned metadata blocks startup")
|
||||
}
|
||||
try Array(repeating: required[0], count: 5).joined(separator: "\n").write(to: manifest, atomically: true, encoding: .utf8)
|
||||
require(FFmpegRuntime.error(for: app) != nil, "duplicated entries cannot omit required library families")
|
||||
try (required.joined(separator: "\n") + "\n").write(to: manifest, atomically: true, encoding: .utf8)
|
||||
require(FFmpegRuntime.error(for: app) == nil, "all recorded native libraries load successfully")
|
||||
require(FFmpegRuntime.error(for: app) != nil, "missing bundled libraries block startup")
|
||||
let brokenLibrary = frameworks.appendingPathComponent(required[0])
|
||||
try Data("not a dynamic library".utf8).write(to: brokenLibrary)
|
||||
require(FFmpegRuntime.error(for: app) != nil, "an existing but unloadable bundled library blocks startup")
|
||||
try FileManager.default.removeItem(at: frameworks)
|
||||
try FileManager.default.copyItem(at: realApp.appendingPathComponent("Contents/Frameworks"), to: frameworks)
|
||||
require(FFmpegRuntime.error(for: app) == nil, "isolated relocated copies of bundled native libraries load successfully")
|
||||
print("FFmpeg runtime checks passed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,8 +333,6 @@ struct YouTubeResolverChecks {
|
||||
} catch let failure as Failure { throw failure }
|
||||
catch {
|
||||
let text = String(describing: error) + error.localizedDescription
|
||||
try require(text.lowercased().contains("sign-in") || text.lowercased().contains("sign in"),
|
||||
"extractor stderr produces an actionable access error")
|
||||
try require(!text.contains("not-for-errors") && !text.contains("media.example"),
|
||||
"extractor error does not leak signed media URLs or credentials")
|
||||
}
|
||||
@@ -356,11 +354,7 @@ struct YouTubeResolverChecks {
|
||||
_ = try await YouTubeResolver.resolve(page, executableURL: oversized.executable, runtimeURL: runtime)
|
||||
throw Failure(description: "FAIL: oversized extractor output was accepted")
|
||||
} catch let failure as Failure { throw failure }
|
||||
catch {
|
||||
let text = (String(describing: error) + error.localizedDescription).lowercased()
|
||||
try require(text.contains("output") || text.contains("limit") || text.contains("large") || text.contains("metadata"),
|
||||
"bounded stdout rejection explains the output limit")
|
||||
}
|
||||
catch {}
|
||||
|
||||
let beforeLaunch = try Extractor(root: root, name: "already cancelled")
|
||||
let preCancelled = Task {
|
||||
@@ -401,12 +395,21 @@ struct YouTubeResolverChecks {
|
||||
}
|
||||
|
||||
static func actualQualitySelection(root: URL, runtime: URL) async throws {
|
||||
let directories = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init)
|
||||
+ ["/opt/homebrew/bin", "/usr/local/bin"]
|
||||
guard let executable = directories.map({ URL(fileURLWithPath: $0).appendingPathComponent("yt-dlp") })
|
||||
.first(where: { FileManager.default.isExecutableFile(atPath: $0.path) }) else {
|
||||
print("Skipping actual yt-dlp quality selection: yt-dlp is not installed")
|
||||
return
|
||||
let executable: URL
|
||||
if CommandLine.arguments.count > 1 {
|
||||
let app = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
|
||||
guard let bundled = try YouTubeResolver.bundledExecutable("yt-dlp", bundleURL: app) else {
|
||||
throw Failure(description: "FAIL: supplied quality-selection app is not an app bundle")
|
||||
}
|
||||
executable = bundled
|
||||
} else {
|
||||
let directories = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init)
|
||||
guard let found = directories.map({ URL(fileURLWithPath: $0).appendingPathComponent("yt-dlp") })
|
||||
.first(where: { FileManager.default.isExecutableFile(atPath: $0.path) }) else {
|
||||
print("Skipping actual yt-dlp quality selection: no bundled app supplied and yt-dlp is not on PATH")
|
||||
return
|
||||
}
|
||||
executable = found
|
||||
}
|
||||
func video(_ height: Int, combined: Bool = false) -> [String: Any] {
|
||||
["format_id": "\(height)", "height": height, "width": height * 16 / 9, "ext": "mp4",
|
||||
@@ -443,6 +446,38 @@ struct YouTubeResolverChecks {
|
||||
try require(lower.height == 720, "default falls downward when 1080p is absent, never upward to available 4K")
|
||||
}
|
||||
|
||||
static func bundledHelpers(root: URL) throws {
|
||||
let app = root.appendingPathComponent("relocated player.app", isDirectory: true)
|
||||
let macOS = app.appendingPathComponent("Contents/MacOS", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: macOS, withIntermediateDirectories: true)
|
||||
let external = root.appendingPathComponent("yt-dlp")
|
||||
try Data("#!/bin/sh\nexit 99\n".utf8).write(to: external)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: external.path)
|
||||
let oldPath = ProcessInfo.processInfo.environment["PATH"]
|
||||
defer {
|
||||
if let oldPath { setenv("PATH", oldPath, 1) } else { unsetenv("PATH") }
|
||||
}
|
||||
for name in ["yt-dlp", "deno"] {
|
||||
for path in [root.path, ""] {
|
||||
setenv("PATH", path, 1)
|
||||
try reject("missing bundled helper cannot fall back to hostile or missing PATH") {
|
||||
_ = try YouTubeResolver.bundledExecutable(name, bundleURL: app)
|
||||
}
|
||||
}
|
||||
let helper = macOS.appendingPathComponent(name)
|
||||
try Data("#!/bin/sh\nexit 0\n".utf8).write(to: helper)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: helper.path)
|
||||
try reject("nonexecutable bundled helper") {
|
||||
_ = try YouTubeResolver.bundledExecutable(name, bundleURL: app)
|
||||
}
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: helper.path)
|
||||
try require(try YouTubeResolver.bundledExecutable(name, bundleURL: app) == helper,
|
||||
"bundled helper location survives relocation independently of PATH")
|
||||
}
|
||||
try require(try YouTubeResolver.bundledExecutable("yt-dlp", bundleURL: root) == nil,
|
||||
"standalone development executables can use PATH discovery")
|
||||
}
|
||||
|
||||
static func main() async throws {
|
||||
// A broken pipe/cancellation implementation must fail, not leave this check hanging.
|
||||
let watchdog = Task {
|
||||
@@ -460,6 +495,7 @@ struct YouTubeResolverChecks {
|
||||
try metadata()
|
||||
try malformedMetadata()
|
||||
try qualities()
|
||||
try bundledHelpers(root: root)
|
||||
try await subprocesses(root: root, runtime: runtime)
|
||||
try await actualQualitySelection(root: root, runtime: runtime)
|
||||
print("YouTube resolver checks passed")
|
||||
|
||||
Reference in New Issue
Block a user