#!/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()