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()