31c4759c1d
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.
138 lines
6.1 KiB
Python
Executable File
138 lines
6.1 KiB
Python
Executable File
#!/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()
|