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:
2026-10-12 01:17:22 +02:00
parent 8eba97579f
commit 31c4759c1d
13 changed files with 911 additions and 113 deletions
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Stage private runtime dependencies, relocate Mach-O links, and sign the app."""
import argparse
from collections import deque
from pathlib import Path
import platform
import re
import shutil
import subprocess
SYSTEM_PATHS = ("/usr/lib/", "/System/Library/", "/Library/Apple/System/")
FFMPEG_LIBRARY = re.compile(r"lib(?:avformat|avcodec|avutil|swresample|swscale)(?:\.\d+)+\.dylib\Z")
def run(*arguments):
return subprocess.run(arguments, check=True, text=True, stdout=subprocess.PIPE).stdout
def dependencies(path):
lines = run("otool", "-arch", platform.machine(), "-L", str(path)).splitlines()[1:]
if path.suffix == ".dylib":
lines = lines[1:] # LC_ID_DYLIB is not a dependency.
return [line.strip().split(" (compatibility version ", 1)[0] for line in lines]
def bundle(app, root, identity):
frameworks = app / "Contents/Frameworks"
executables = app / "Contents/MacOS"
resources = app / "Contents/Resources"
player = frameworks / "libterebi-player.dylib"
search_paths = [*(root / name / "lib" for name in ("ffmpeg", "dav1d", "xz")), frameworks]
for name in ("yt-dlp", "deno"):
shutil.copy2(root / "bin" / name, executables / name)
for name in ("licenses", "sources"):
destination = resources / name
if destination.exists():
shutil.rmtree(destination)
shutil.copytree(root / name, destination)
def resolve(reference, owner):
if reference.startswith("@loader_path/"):
candidates = [owner.parent / reference[len("@loader_path/"):]]
elif reference.startswith("@executable_path/"):
candidates = [executables / reference[len("@executable_path/"):]]
elif reference.startswith("@rpath/"):
candidates = [directory / reference[len("@rpath/"):] for directory in search_paths]
elif reference.startswith("/"):
candidates = [Path(reference)]
else:
raise RuntimeError(f"Unsupported library reference {reference!r} in {owner}")
for candidate in candidates:
if candidate.is_file():
source = candidate.resolve()
if not any(source.is_relative_to(directory.resolve()) for directory in search_paths):
raise RuntimeError(f"Unpinned external library {source} required by {owner}")
return source
raise RuntimeError(f"Cannot resolve {reference!r} required by {owner}")
modules = [player, *(executables / name for name in ("terebi", "terebi-cli", "yt-dlp", "deno"))]
pending = deque(modules)
staged = {player.resolve(): player}
links = {}
claimed = {player.name: player.resolve()}
ffmpeg = []
while pending:
owner = pending.popleft()
owner_links = []
for reference in dependencies(owner):
if reference.startswith(SYSTEM_PATHS):
continue
source = resolve(reference, owner)
if source not in staged:
name = Path(reference).name
previous = claimed.get(name)
if previous is not None and previous != source:
raise RuntimeError(f"Conflicting private library filename: {name}")
claimed[name] = source
destination = frameworks / name
staged[source] = destination
pending.append(source)
destination = staged[source]
owner_links.append((reference, destination))
if owner == player and FFMPEG_LIBRARY.fullmatch(destination.name):
ffmpeg.append(destination.name)
links[owner] = owner_links
if len(ffmpeg) != 5 or len({name.split(".")[0] for name in ffmpeg}) != 5:
raise RuntimeError("Player must link exactly the five required FFmpeg library families")
# Discover the complete closure before copying or editing any source file.
for source, destination in staged.items():
if source != destination.resolve():
shutil.copy2(source, destination)
destination.chmod(0o755)
retained = {destination.name for destination in staged.values()}
for old in frameworks.glob("*.dylib"):
if old.name not in retained:
old.unlink()
for owner, owner_links in links.items():
destination = staged.get(owner.resolve(), owner)
changes = []
if destination.suffix == ".dylib":
changes += ["-id", "@loader_path/" + destination.name]
for reference, dependency in owner_links:
relative = ("@loader_path/" if destination.parent == frameworks else "@executable_path/../Frameworks/") + dependency.name
if reference != relative:
changes += ["-change", reference, relative]
if changes:
run("install_name_tool", *changes, str(destination))
resources.mkdir(parents=True, exist_ok=True)
(resources / "ffmpeg-libraries.txt").write_text("\n".join(sorted(ffmpeg)) + "\n")
# Fail the build, not the user's launch, if any link still needs a build machine.
for module in [*staged.values(), *modules[1:]]:
for reference in dependencies(module):
if not reference.startswith((*SYSTEM_PATHS, "@loader_path/", "@executable_path/../Frameworks/")):
raise RuntimeError(f"Nonportable reference {reference!r} remains in {module}")
run("codesign", "--force", "--sign", identity, str(module))
run("codesign", "--force", "--sign", identity, str(app))
run("codesign", "--verify", "--deep", "--strict", str(app))
print(f"Bundled {len(staged) - 1} private libraries, yt-dlp and Deno in {app}")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--app", type=Path, required=True)
parser.add_argument("--dependencies", type=Path, required=True)
parser.add_argument("--signing-identity", default="-")
args = parser.parse_args()
try:
bundle(args.app.resolve(), args.dependencies.resolve(), args.signing_identity)
except (OSError, RuntimeError, subprocess.CalledProcessError) as error:
parser.exit(1, f"Error: could not bundle Terebi: {error}\n")
if __name__ == "__main__":
main()
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env python3
"""Prepare pinned native macOS libraries or standalone helpers; no runtime package manager."""
import argparse
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import platform
import shlex
import shutil
import subprocess
import sys
import tarfile
import tempfile
import urllib.error
import urllib.request
import zipfile
REPOSITORY = Path(__file__).resolve().parent.parent
LOCK = REPOSITORY / "Dependencies.lock.json"
def sha256(path):
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def download(asset, directory):
directory.mkdir(parents=True, exist_ok=True)
target = directory / asset["filename"]
if target.is_file() and sha256(target) == asset["sha256"]:
return target
temporary = None
try:
with tempfile.NamedTemporaryFile(dir=directory, delete=False) as output:
temporary = Path(output.name)
request = urllib.request.Request(asset["url"], headers={"User-Agent": "Terebi-dependency-preparer"})
with urllib.request.urlopen(request, timeout=120) as response:
shutil.copyfileobj(response, output)
actual = sha256(temporary)
if actual != asset["sha256"]:
raise RuntimeError(f"SHA256 mismatch for {asset['url']}: expected {asset['sha256']}, got {actual}; no unverified download installed")
temporary.chmod(0o644)
os.replace(temporary, target)
return target
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def archive_path(name, destination):
# Reject links altogether: the pinned source archives need none. This also
# prevents a preceding archive member from redirecting subsequent writes.
relative = PurePosixPath(name)
if relative.is_absolute() or ".." in relative.parts or "\\" in name:
raise RuntimeError(f"Unsafe archive member: {name!r}")
target = destination.joinpath(*relative.parts)
if not target.resolve().is_relative_to(destination.resolve()):
raise RuntimeError(f"Archive member escapes extraction directory: {name!r}")
return target
def extract(archive, destination):
destination.mkdir(parents=True, exist_ok=True)
if zipfile.is_zipfile(archive):
with zipfile.ZipFile(archive) as source:
for member in source.infolist():
target = archive_path(member.filename, destination)
mode = member.external_attr >> 16
if mode & 0o170000 not in (0, 0o100000, 0o040000):
raise RuntimeError(f"Unsupported archive member: {member.filename}")
if member.is_dir():
target.mkdir(parents=True, exist_ok=True)
else:
target.parent.mkdir(parents=True, exist_ok=True)
with source.open(member) as data, target.open("wb") as output:
shutil.copyfileobj(data, output)
target.chmod(0o755 if mode & 0o111 else 0o644)
else:
with tarfile.open(archive) as source:
for member in source:
target = archive_path(member.name, destination)
if member.isdir():
target.mkdir(parents=True, exist_ok=True)
elif member.isfile():
target.parent.mkdir(parents=True, exist_ok=True)
with source.extractfile(member) as data, target.open("wb") as output:
shutil.copyfileobj(data, output)
target.chmod(0o755 if member.mode & 0o111 else 0o644)
else:
raise RuntimeError(f"Unsupported archive member: {member.name}")
children = list(destination.iterdir())
return children[0] if len(children) == 1 and children[0].is_dir() else destination
def run(command, cwd=None, env=None):
print("+ " + shlex.join(map(str, command)), flush=True)
subprocess.run(list(map(str, command)), cwd=cwd, env=env, check=True)
def native(root, arch, lock):
for tool in ("xcrun", "make", "meson", "ninja", "pkg-config") + (("nasm",) if arch == "x86_64" else ()):
if shutil.which(tool) is None:
raise RuntimeError(f"Missing build prerequisite {tool}; install it before preparing native dependencies")
sdk = subprocess.check_output(["xcrun", "--sdk", "macosx", "--show-sdk-path"], text=True).strip()
clang = subprocess.check_output(["xcrun", "--find", "clang"], text=True).strip()
clangxx = subprocess.check_output(["xcrun", "--find", "clang++"], text=True).strip()
flags = shlex.join(["-arch", arch, "-isysroot", sdk, "-mmacosx-version-min=15.0"])
environment = dict(os.environ)
# Host packages must never leak into dependency discovery or compiler flags.
for name in ("CPATH", "C_INCLUDE_PATH", "CPLUS_INCLUDE_PATH", "LIBRARY_PATH", "SDKROOT", "ARCHFLAGS", "CPPFLAGS", "PKG_CONFIG_PATH", "PKG_CONFIG_SYSROOT_DIR", "DYLD_LIBRARY_PATH"):
environment.pop(name, None)
environment.update(CC=clang, CXX=clangxx, CFLAGS=flags, CXXFLAGS=flags,
OBJCFLAGS=flags, LDFLAGS=flags, MACOSX_DEPLOYMENT_TARGET="15.0",
PKG_CONFIG_LIBDIR=os.pathsep.join(str(root / name / "lib/pkgconfig") for name in ("dav1d", "xz")), PKG_CONFIG_PATH="")
work = root / "work/native"
shutil.rmtree(work, ignore_errors=True)
work.mkdir(parents=True)
for component in ("dav1d", "xz", "ffmpeg"):
shutil.rmtree(root / component, ignore_errors=True)
sources = root / "sources/native"
sources.mkdir(parents=True, exist_ok=True)
licenses = root / "licenses/native"
licenses.mkdir(parents=True, exist_ok=True)
trees = {}
for component in ("dav1d", "xz", "ffmpeg"):
archive = download(lock[component]["source"], root / "downloads")
shutil.copy2(archive, sources / archive.name)
trees[component] = extract(archive, work / component)
dav1d_command = ["meson", "setup", work / "dav1d-build", trees["dav1d"],
"--prefix=" + str(root / "dav1d"), "--libdir=lib", "--buildtype=release",
"--default-library=shared", "--wrap-mode=nodownload", "-Denable_tools=false",
"-Denable_tests=false", "-Denable_examples=false", "-Denable_docs=false"]
xz_command = [trees["xz"] / "configure", "--prefix=" + str(root / "xz"),
"--enable-shared", "--disable-static", "--disable-xz", "--disable-xzdec",
"--disable-lzmadec", "--disable-lzmainfo", "--disable-scripts", "--disable-doc", "--disable-nls"]
ffmpeg_command = [trees["ffmpeg"] / "configure", "--prefix=" + str(root / "ffmpeg"),
"--arch=" + arch, "--target-os=darwin", "--cc=" + clang, "--cxx=" + clangxx,
"--host-cc=" + clang, "--host-cflags=" + flags, "--host-ldflags=" + flags,
"--extra-cflags=" + flags + " " + shlex.quote("-I" + str(root / "xz/include")),
"--extra-ldflags=" + flags + " " + shlex.quote("-L" + str(root / "xz/lib")),
"--enable-shared", "--disable-static", "--disable-programs", "--disable-doc",
"--disable-encoders", "--disable-muxers", "--disable-avdevice", "--disable-avfilter",
"--disable-gpl", "--disable-nonfree", "--disable-version3", "--disable-autodetect",
"--enable-libdav1d", "--enable-securetransport", "--enable-videotoolbox",
"--enable-audiotoolbox", "--enable-zlib", "--enable-bzlib", "--enable-lzma"]
configuration = {"arch": arch, "deployment_target": "15.0", "sdk": sdk,
"environment": {key: environment[key] for key in ("CC", "CXX", "CFLAGS", "CXXFLAGS", "LDFLAGS", "MACOSX_DEPLOYMENT_TARGET", "PKG_CONFIG_LIBDIR", "PKG_CONFIG_PATH")},
"dav1d": list(map(str, dav1d_command)), "xz": list(map(str, xz_command)),
"ffmpeg": list(map(str, ffmpeg_command))}
(licenses / "build-configuration.json").write_text(json.dumps(configuration, indent=2) + "\n")
run(dav1d_command, env=environment)
run(["ninja", "-C", work / "dav1d-build"], env=environment)
run(["ninja", "-C", work / "dav1d-build", "install"], env=environment)
xz_build = work / "xz-build"
xz_build.mkdir()
run(xz_command, cwd=xz_build, env=environment)
run(["make", "-j", str(os.cpu_count() or 1)], cwd=xz_build, env=environment)
run(["make", "install"], cwd=xz_build, env=environment)
build = work / "ffmpeg-build"
build.mkdir()
run(ffmpeg_command, cwd=build, env=environment)
run(["make", "-j", str(os.cpu_count() or 1)], cwd=build, env=environment)
run(["make", "install"], cwd=build, env=environment)
for filename in ("COPYING.LGPLv2.1", "LICENSE.md"):
shutil.copy2(trees["ffmpeg"] / filename, licenses / ("FFmpeg-" + filename))
shutil.copy2(trees["dav1d"] / "COPYING", licenses / "dav1d-COPYING")
shutil.copy2(trees["xz"] / "COPYING", licenses / "liblzma-COPYING")
for filename in ("config.h", "ffbuild/config.mak"):
shutil.copy2(build / filename, licenses / ("FFmpeg-" + Path(filename).name))
(licenses / "NOTICE.txt").write_text(
"FFmpeg 9.0.1: LGPL-2.1-or-later; dav1d 1.5.4: BSD-2-Clause; liblzma 5.8.4: 0BSD/public domain.\n"
"Exact unmodified source archives accompany this distribution under sources/native.\n"
"Build commands and flags are in build-configuration.json; source changes: none.\n"
"This software is based in part on the work of the Independent JPEG Group.\n"
"FFmpeg JPEG source files were not modified. Apple system libraries are supplied by macOS.\n"
"You may replace these shared libraries with compatible modified versions; no static linking is used.\n")
def helpers(root, arch, lock):
binaries = root / "bin"
binaries.mkdir(parents=True, exist_ok=True)
sources = root / "sources/helpers"
sources.mkdir(parents=True, exist_ok=True)
licenses = root / "licenses/helpers"
licenses.mkdir(parents=True, exist_ok=True)
for name in ("yt-dlp", "deno"):
component = lock[name]
asset = component["binary"] if name == "yt-dlp" else component["binaries"][arch]
archive = download(asset, root / "downloads")
with tempfile.TemporaryDirectory(dir=root) as temporary:
stage = Path(temporary)
executable = archive if name == "yt-dlp" else extract(archive, stage) / "deno"
if not executable.is_file():
raise RuntimeError(f"Pinned {name} archive does not contain its expected executable")
target = stage / "installed-executable"
shutil.copyfile(executable, target)
target.chmod(0o755)
os.replace(target, binaries / name)
for material in component["materials"]:
path = download(material, root / "downloads")
shutil.copy2(path, (sources if material.get("kind") == "source" else licenses) / path.name)
(licenses / (name + "-provenance.json")).write_text(json.dumps(component, indent=2) + "\n")
(licenses / (name + "-NOTICE.txt")).write_text(component["notice"] + "\n")
def outputs_exist(root, part, lock):
if part == "helpers":
return (all((root / "bin" / name).is_file() and os.access(root / "bin" / name, os.X_OK)
and all(((root / "sources/helpers" if material.get("kind") == "source" else root / "licenses/helpers") / material["filename"]).is_file()
for material in lock[name]["materials"])
and (root / "licenses/helpers" / (name + "-NOTICE.txt")).is_file()
and (root / "licenses/helpers" / (name + "-provenance.json")).is_file()
for name in ("yt-dlp", "deno")))
return ((root / "ffmpeg/include/libavcodec/avcodec.h").is_file()
and all((root / "ffmpeg/lib/pkgconfig" / (name + ".pc")).is_file()
and any((root / "ffmpeg/lib").glob(name + ".*.dylib"))
for name in ("libavcodec", "libavformat", "libavutil", "libswscale", "libswresample"))
and any((root / "dav1d/lib").glob("libdav1d.*.dylib"))
and any((root / "xz/lib").glob("liblzma.*.dylib"))
and (root / "licenses/native/build-configuration.json").is_file()
and all((root / "sources/native" / lock[name]["source"]["filename"]).is_file()
for name in ("ffmpeg", "dav1d", "xz")))
def self_check():
"""Offline archive-boundary check: python3 -c 'from Scripts.dependencies import self_check; self_check()'."""
build = REPOSITORY / ".build"
build.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(dir=build) as temporary:
root = Path(temporary)
for name in ("../escape", "/absolute", "safe/../../escape", "safe\\escape"):
try:
archive_path(name, root)
except RuntimeError:
pass
else:
raise AssertionError(f"Unsafe path accepted: {name}")
assert archive_path("safe/file", root) == root / "safe/file"
archive = root / "links.tar"
with tarfile.open(archive, "w") as output:
link = tarfile.TarInfo("link")
link.type = tarfile.SYMTYPE
link.linkname = "../outside"
output.addfile(link)
try:
extract(archive, root / "extracted")
except RuntimeError:
pass
else:
raise AssertionError("Archive symlink accepted")
archive = root / "traversal.zip"
with zipfile.ZipFile(archive, "w") as output:
output.writestr("../escape", "unsafe")
try:
extract(archive, root / "zip-extracted")
except RuntimeError:
pass
else:
raise AssertionError("ZIP traversal accepted")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", required=True, type=Path)
parser.add_argument("--arch", required=True, choices=("arm64", "x86_64"))
parser.add_argument("--part", required=True, choices=("native", "helpers"))
args = parser.parse_args()
if platform.system() != "Darwin" or platform.machine() != args.arch:
parser.error(f"Native macOS {args.arch} host required; running {platform.system()} {platform.machine()}. Cross-architecture builds are unsupported (do not run under Rosetta).")
translated = subprocess.run(["/usr/sbin/sysctl", "-in", "sysctl.proc_translated"],
capture_output=True, text=True, check=False)
if translated.stdout.strip() == "1":
parser.error("Rosetta builds are unsupported; use native arm64 Python and build tools.")
if not args.root.is_absolute():
parser.error("--root must be an absolute path beneath the repository's ignored .build directory")
root = args.root.resolve()
if not root.is_relative_to((REPOSITORY / ".build").resolve()):
parser.error("--root must be beneath the repository's ignored .build directory")
lock = json.loads(LOCK.read_text())
fingerprint = hashlib.sha256(LOCK.read_bytes() + Path(__file__).read_bytes() + args.arch.encode() + args.part.encode()).hexdigest()
root.mkdir(parents=True, exist_ok=True)
stamp = root / (args.part + ".stamp")
if stamp.is_file() and stamp.read_text().strip() == fingerprint and outputs_exist(root, args.part, lock):
return 0
stamp.unlink(missing_ok=True)
try:
(native if args.part == "native" else helpers)(root, args.arch, lock)
if not outputs_exist(root, args.part, lock):
raise RuntimeError(f"{args.part} preparation completed without all required outputs")
shutil.copy2(LOCK, root / "licenses/Dependencies.lock.json")
shutil.copy2(Path(__file__), root / "licenses/dependencies.py")
with tempfile.NamedTemporaryFile(mode="w", dir=root, delete=False) as output:
output.write(fingerprint + "\n")
temporary = Path(output.name)
os.replace(temporary, stamp)
except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError, tarfile.TarError, zipfile.BadZipFile) as error:
print(f"Dependency preparation failed: {error}. Fix the prerequisite/network issue and rerun the same command; no readiness stamp was published.", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())