mirror of
https://github.com/suyu-emu/drippu-rep.git
synced 2026-09-21 14:44:58 -07:00
The macOS packager treated each library install name as a load command, so @executable_path self-ids failed the portability check. FreeBSD configure requires Qt6 Charts and Svg. Android libretro could not link the prebuilt OpenSSL archive until Poly1305 SVE2 binds locally, matching yuzu-android.
442 lines
18 KiB
Python
Executable File
442 lines
18 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Build a portable, signed macOS release directory from CMake outputs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import plistlib
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from collections import deque
|
|
from pathlib import Path
|
|
|
|
|
|
SYSTEM_PREFIXES = ("/System/Library/", "/usr/lib/")
|
|
FORBIDDEN_PREFIXES = ("/opt/homebrew/", "/usr/local/")
|
|
|
|
|
|
def run(*args: str | Path, capture: bool = False) -> str:
|
|
command = [str(arg) for arg in args]
|
|
if not capture:
|
|
print("+", " ".join(command))
|
|
result = subprocess.run(
|
|
command,
|
|
check=True,
|
|
text=True,
|
|
stdout=subprocess.PIPE if capture else None,
|
|
)
|
|
return result.stdout if capture else ""
|
|
|
|
|
|
def is_macho(path: Path) -> bool:
|
|
if not path.is_file() or path.is_symlink():
|
|
return False
|
|
result = subprocess.run(
|
|
["file", "-b", str(path)], check=True, text=True, stdout=subprocess.PIPE
|
|
)
|
|
return "Mach-O" in result.stdout
|
|
|
|
|
|
def macho_files(root: Path) -> list[Path]:
|
|
return sorted((path for path in root.rglob("*") if is_macho(path)), key=str)
|
|
|
|
|
|
def dependent_libraries(load_entries: list[str], install_name: str | None) -> list[str]:
|
|
"""Drop a Mach-O install name from an ``otool -L`` listing.
|
|
|
|
``otool -L`` prints a dylib's own LC_ID_DYLIB before its real dependents.
|
|
That install name is not a load command dyld resolves. Homebrew and
|
|
macdeployqt set it to ``@executable_path/../Frameworks/<self>``, which is
|
|
valid for the bundled binary and must not be treated as a CLI dependency.
|
|
"""
|
|
if install_name and load_entries and load_entries[0] == install_name:
|
|
return load_entries[1:]
|
|
return list(load_entries)
|
|
|
|
|
|
def otool_entries(binary: Path, flag: str) -> list[str]:
|
|
lines = run("otool", flag, binary, capture=True).splitlines()[1:]
|
|
return [line.strip().split(" (", 1)[0] for line in lines if line.strip()]
|
|
|
|
|
|
def install_id(binary: Path) -> str | None:
|
|
entries = otool_entries(binary, "-D")
|
|
return entries[0] if entries else None
|
|
|
|
|
|
def dependencies(binary: Path) -> list[str]:
|
|
return dependent_libraries(otool_entries(binary, "-L"), install_id(binary))
|
|
|
|
|
|
def rpaths(binary: Path) -> list[str]:
|
|
lines = run("otool", "-l", binary, capture=True).splitlines()
|
|
paths: list[str] = []
|
|
for index, line in enumerate(lines):
|
|
if line.strip() == "cmd LC_RPATH" and index + 2 < len(lines):
|
|
value = lines[index + 2].strip()
|
|
if value.startswith("path "):
|
|
paths.append(value[5:].split(" (offset", 1)[0])
|
|
return paths
|
|
|
|
|
|
def external_dependency(dependency: str) -> bool:
|
|
return dependency.startswith("/") and not dependency.startswith(SYSTEM_PREFIXES)
|
|
|
|
|
|
def framework_parts(source: Path) -> tuple[Path, Path] | None:
|
|
parts = source.parts
|
|
for index, part in enumerate(parts):
|
|
if part.endswith(".framework"):
|
|
root = Path(*parts[: index + 1])
|
|
relative_binary = Path(*parts[index:])
|
|
return root, relative_binary
|
|
return None
|
|
|
|
|
|
def copy_dependency(source: Path, frameworks: Path) -> Path:
|
|
framework = framework_parts(source)
|
|
if framework:
|
|
framework_root, relative_binary = framework
|
|
destination_root = frameworks / framework_root.name
|
|
if not destination_root.exists():
|
|
print(f"Bundling framework {framework_root}")
|
|
shutil.copytree(framework_root, destination_root, symlinks=True)
|
|
return frameworks / relative_binary
|
|
|
|
destination = frameworks / source.name
|
|
if not destination.exists():
|
|
print(f"Bundling library {source}")
|
|
shutil.copy2(source, destination)
|
|
destination.chmod(destination.stat().st_mode | 0o200)
|
|
return destination
|
|
|
|
|
|
def loader_reference(consumer: Path, bundled: Path) -> str:
|
|
relative = os.path.relpath(bundled, consumer.parent)
|
|
return "@loader_path/" + relative
|
|
|
|
|
|
def _executable_path_candidates(
|
|
dependency: str, consumer: Path, app: Path, cli: Path, frameworks: Path
|
|
) -> list[Path]:
|
|
"""Candidate source files for an @executable_path/ dependency.
|
|
|
|
A dylib inside Frameworks can be loaded by either the .app executable
|
|
(Contents/MacOS) or the sibling CLI (output-dir/drippu-cmd), so
|
|
@executable_path resolves differently per loader. The bundled copy in
|
|
Frameworks may also not exist yet when its referrer is processed
|
|
(order-dependent), so fall back to the Homebrew original by basename.
|
|
"""
|
|
suffix = dependency.removeprefix("@executable_path/")
|
|
candidates: list[Path] = []
|
|
# Resolved against the .app executable (valid for in-bundle loads).
|
|
candidates.append((app / "Contents" / "MacOS" / suffix).resolve())
|
|
# Resolved against the sibling CLI (output-dir/<cli>).
|
|
candidates.append((cli.parent / suffix).resolve())
|
|
# The common macdeployqt leftover: @executable_path/../Frameworks/<lib>
|
|
# inside a Frameworks dylib means "my sibling in Frameworks".
|
|
if suffix.startswith("../Frameworks/"):
|
|
candidates.append((frameworks / suffix.removeprefix("../Frameworks/")).resolve())
|
|
# Fall back to the development install by basename so bundling does not
|
|
# depend on processing order (the Frameworks copy may not exist yet).
|
|
basename = Path(suffix).name
|
|
for root in (Path("/opt/homebrew/lib"), Path("/usr/local/lib")):
|
|
candidates.append(root / basename)
|
|
return candidates
|
|
|
|
|
|
def dependency_source(
|
|
dependency: str, consumer: Path, app: Path, cli: Path, frameworks: Path
|
|
) -> Path | None:
|
|
if external_dependency(dependency):
|
|
return Path(dependency)
|
|
if dependency.startswith("@executable_path/"):
|
|
for candidate in _executable_path_candidates(dependency, consumer, app, cli, frameworks):
|
|
if candidate.exists() and candidate.is_file():
|
|
return candidate
|
|
if dependency.startswith("@loader_path/"):
|
|
# Already loader-relative (correct for Frameworks-internal refs).
|
|
# Resolve to verify it exists; if it does, nothing needs copying.
|
|
candidate = (consumer.parent / dependency.removeprefix("@loader_path/")).resolve()
|
|
if candidate.exists():
|
|
return None
|
|
# Broken @loader_path: try the bundled Frameworks copy / Homebrew by basename.
|
|
basename = Path(dependency.removeprefix("@loader_path/")).name
|
|
for fallback in (frameworks / basename, Path("/opt/homebrew/lib") / basename,
|
|
Path("/usr/local/lib") / basename):
|
|
if fallback.exists():
|
|
return fallback
|
|
return None
|
|
if dependency.startswith("@rpath/"):
|
|
relative = Path(dependency.removeprefix("@rpath/"))
|
|
for root in (frameworks, Path("/opt/homebrew/lib"), Path("/usr/local/lib")):
|
|
candidate = root / relative
|
|
if candidate.exists():
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def bundle_non_system_libraries(app: Path, cli: Path) -> None:
|
|
frameworks = app / "Contents" / "Frameworks"
|
|
frameworks.mkdir(parents=True, exist_ok=True)
|
|
queue = deque(macho_files(app) + [cli])
|
|
processed: set[Path] = set()
|
|
|
|
while queue:
|
|
binary = queue.popleft()
|
|
if binary in processed or not is_macho(binary):
|
|
continue
|
|
processed.add(binary)
|
|
|
|
for dependency in dependencies(binary):
|
|
source = dependency_source(dependency, binary, app, cli, frameworks)
|
|
if source is None:
|
|
continue
|
|
if not source.exists():
|
|
raise RuntimeError(f"missing dependency for {binary}: {dependency}")
|
|
destination = copy_dependency(source, frameworks)
|
|
replacement = loader_reference(binary, destination)
|
|
run("install_name_tool", "-change", dependency, replacement, binary)
|
|
if is_macho(destination):
|
|
queue.append(destination)
|
|
framework = framework_parts(destination)
|
|
if framework:
|
|
queue.extend(macho_files(frameworks / framework[0].name))
|
|
|
|
if frameworks in binary.parents:
|
|
install_ids = run("otool", "-D", binary, capture=True).splitlines()[1:]
|
|
if any(external_dependency(install_id.strip()) for install_id in install_ids):
|
|
run("install_name_tool", "-id", loader_reference(binary, binary), binary)
|
|
|
|
# Second pass: macdeployqt (and some Homebrew builds) leave
|
|
# @executable_path/../Frameworks/<lib> inside Frameworks dylibs. That
|
|
# resolves for the .app executable but NOT for the sibling CLI
|
|
# (output-dir/drippu-cmd), whose @executable_path is output-dir/ — this
|
|
# caused dyld "Library not loaded: @executable_path/../Frameworks/..."
|
|
# crashes at launch. Rewrite leftovers to @loader_path siblings, which
|
|
# resolve identically no matter which executable loads them.
|
|
for binary in macho_files(frameworks):
|
|
for dependency in dependencies(binary):
|
|
if not dependency.startswith("@executable_path/"):
|
|
continue
|
|
suffix = dependency.removeprefix("@executable_path/")
|
|
target_name = Path(suffix).name
|
|
sibling = (binary.parent / target_name).resolve()
|
|
frameworks_root_hit = frameworks / target_name
|
|
if sibling.exists() or frameworks_root_hit.exists():
|
|
run("install_name_tool", "-change", dependency,
|
|
f"@loader_path/{target_name}", binary)
|
|
else:
|
|
# Try to bundle the missing sibling from Homebrew, then rewrite.
|
|
for root in (Path("/opt/homebrew/lib"), Path("/usr/local/lib")):
|
|
source = root / target_name
|
|
if source.exists():
|
|
destination = copy_dependency(source, frameworks)
|
|
run("install_name_tool", "-change", dependency,
|
|
loader_reference(binary, destination), binary)
|
|
break
|
|
|
|
for binary in macho_files(app) + [cli]:
|
|
for path in rpaths(binary):
|
|
if path.startswith(FORBIDDEN_PREFIXES):
|
|
run("install_name_tool", "-delete_rpath", path, binary)
|
|
|
|
|
|
def reference_exists(dependency: str, consumer: Path, app: Path, cli: Path) -> bool:
|
|
frameworks = app / "Contents" / "Frameworks"
|
|
if dependency.startswith("@loader_path/"):
|
|
return (consumer.parent / dependency.removeprefix("@loader_path/")).resolve().exists()
|
|
if dependency.startswith("@executable_path/"):
|
|
# Frameworks dylibs are loaded by BOTH the .app executable and the
|
|
# sibling CLI, which have different @executable_path values. Require
|
|
# the reference to resolve for both loaders; otherwise it is a latent
|
|
# dyld crash for one of them (and should have been rewritten to
|
|
# @loader_path by bundle_non_system_libraries).
|
|
suffix = dependency.removeprefix("@executable_path/")
|
|
app_hit = (app / "Contents" / "MacOS" / suffix).resolve().exists()
|
|
cli_hit = (cli.parent / suffix).resolve().exists()
|
|
if frameworks in consumer.parents:
|
|
return app_hit and cli_hit
|
|
executable_dir = cli.parent if consumer == cli else app / "Contents" / "MacOS"
|
|
return (executable_dir / suffix).resolve().exists()
|
|
if dependency.startswith("@rpath/"):
|
|
relative = dependency.removeprefix("@rpath/")
|
|
return (frameworks / relative).exists()
|
|
return True
|
|
|
|
|
|
def validate_portability(app: Path, cli: Path, expected_arch: str) -> None:
|
|
failures: list[str] = []
|
|
for binary in macho_files(app) + [cli]:
|
|
archs = run("lipo", "-archs", binary, capture=True).strip().split()
|
|
if expected_arch and expected_arch not in archs:
|
|
failures.append(f"{binary}: expected architecture {expected_arch}, got {' '.join(archs)}")
|
|
ident = install_id(binary)
|
|
if ident and external_dependency(ident):
|
|
failures.append(f"{binary}: non-portable install id {ident}")
|
|
for dependency in dependencies(binary):
|
|
if external_dependency(dependency):
|
|
failures.append(f"{binary}: external dependency {dependency}")
|
|
elif dependency.startswith("@") and not reference_exists(dependency, binary, app, cli):
|
|
failures.append(f"{binary}: unresolved bundled dependency {dependency}")
|
|
for path in rpaths(binary):
|
|
if path.startswith(FORBIDDEN_PREFIXES):
|
|
failures.append(f"{binary}: development rpath {path}")
|
|
if failures:
|
|
raise RuntimeError("macOS package is not portable:\n" + "\n".join(failures))
|
|
|
|
|
|
def validate_bundle_metadata(app: Path) -> None:
|
|
with (app / "Contents" / "Info.plist").open("rb") as plist_file:
|
|
info = plistlib.load(plist_file)
|
|
expected = {
|
|
"CFBundleDisplayName": "drippu",
|
|
"CFBundleExecutable": "suyu",
|
|
"CFBundleIdentifier": "dev.drippu.emulator",
|
|
"CFBundleName": "drippu",
|
|
}
|
|
failures = [
|
|
f"{key}: expected {value!r}, got {info.get(key)!r}"
|
|
for key, value in expected.items()
|
|
if info.get(key) != value
|
|
]
|
|
for key in ("CFBundleShortVersionString", "CFBundleVersion", "LSMinimumSystemVersion"):
|
|
if not info.get(key):
|
|
failures.append(f"{key}: missing or empty")
|
|
if failures:
|
|
raise RuntimeError("invalid macOS bundle metadata:\n" + "\n".join(failures))
|
|
|
|
|
|
def sign_path(path: Path, identity: str, entitlements: Path | None = None) -> None:
|
|
args: list[str | Path] = ["codesign", "--force", "--sign", identity]
|
|
if identity != "-":
|
|
args.extend(["--options", "runtime", "--timestamp"])
|
|
if entitlements is not None:
|
|
args.extend(["--entitlements", entitlements])
|
|
args.append(path)
|
|
run(*args)
|
|
|
|
|
|
def sign_package(app: Path, cli: Path, identity: str, entitlements: Path) -> None:
|
|
main_executable = app / "Contents" / "MacOS" / "suyu"
|
|
frameworks = sorted(app.rglob("*.framework"), key=lambda path: len(path.parts), reverse=True)
|
|
framework_members = tuple(framework.resolve() for framework in frameworks)
|
|
|
|
nested = []
|
|
for binary in macho_files(app):
|
|
if binary == main_executable:
|
|
continue
|
|
resolved = binary.resolve()
|
|
if any(framework in resolved.parents for framework in framework_members):
|
|
continue
|
|
nested.append(binary)
|
|
|
|
for binary in sorted(nested, key=lambda path: len(path.parts), reverse=True):
|
|
sign_path(binary, identity)
|
|
for framework in frameworks:
|
|
sign_path(framework, identity)
|
|
sign_path(main_executable, identity, entitlements)
|
|
# Signing the bundle re-signs its main executable, so the final bundle
|
|
# signature must carry the JIT entitlements as well.
|
|
sign_path(app, identity, entitlements)
|
|
sign_path(cli, identity, entitlements)
|
|
|
|
run("codesign", "--verify", "--deep", "--strict", "--verbose=2", app)
|
|
run("codesign", "--verify", "--strict", "--verbose=2", cli)
|
|
for executable in (main_executable, cli):
|
|
result = subprocess.run(
|
|
["codesign", "-d", "--entitlements", "-", str(executable)],
|
|
check=True,
|
|
text=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
displayed = result.stdout + result.stderr
|
|
for required in (
|
|
"com.apple.security.cs.allow-jit",
|
|
"com.apple.security.cs.allow-unsigned-executable-memory",
|
|
):
|
|
if required not in displayed:
|
|
raise RuntimeError(f"{executable}: signed without required entitlement {required}")
|
|
|
|
|
|
def locate_macdeployqt() -> str:
|
|
executable = shutil.which("macdeployqt")
|
|
if executable:
|
|
return executable
|
|
for candidate in (
|
|
Path("/opt/homebrew/opt/qt/bin/macdeployqt"),
|
|
Path("/usr/local/opt/qt/bin/macdeployqt"),
|
|
Path("/opt/homebrew/opt/qt@6/bin/macdeployqt"),
|
|
Path("/usr/local/opt/qt@6/bin/macdeployqt"),
|
|
):
|
|
if candidate.exists():
|
|
return str(candidate)
|
|
raise RuntimeError("macdeployqt was not found; install Qt 6 or add it to PATH")
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--app", type=Path, required=True)
|
|
parser.add_argument("--cli", type=Path, required=True)
|
|
parser.add_argument("--output-dir", type=Path, required=True)
|
|
parser.add_argument("--archive", type=Path)
|
|
parser.add_argument("--expected-arch", default="arm64")
|
|
parser.add_argument(
|
|
"--entitlements",
|
|
type=Path,
|
|
default=Path("dist/macos/drippu.entitlements"),
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
if sys.platform != "darwin":
|
|
raise RuntimeError("package_macos.py must run on macOS")
|
|
if not args.app.is_dir() or not args.cli.is_file():
|
|
raise RuntimeError("both --app and --cli must point to built release outputs")
|
|
|
|
if args.output_dir.exists():
|
|
shutil.rmtree(args.output_dir)
|
|
args.output_dir.mkdir(parents=True)
|
|
app = args.output_dir / "drippu.app"
|
|
cli = args.output_dir / "drippu-cmd"
|
|
shutil.copytree(args.app, app, symlinks=True)
|
|
shutil.copy2(args.cli, cli)
|
|
|
|
smoke_binary = app / "Contents" / "MacOS" / "macos_moltenvk_smoke"
|
|
smoke_binary.unlink(missing_ok=True)
|
|
|
|
deploy_args: list[str | Path] = [
|
|
locate_macdeployqt(),
|
|
app,
|
|
"-always-overwrite",
|
|
"-no-strip",
|
|
"-no-codesign",
|
|
]
|
|
for library_root in (Path("/opt/homebrew/lib"), Path("/usr/local/lib")):
|
|
if library_root.is_dir():
|
|
deploy_args.append(f"-libpath={library_root}")
|
|
run(*deploy_args)
|
|
bundle_non_system_libraries(app, cli)
|
|
validate_bundle_metadata(app)
|
|
validate_portability(app, cli, args.expected_arch)
|
|
|
|
identity = os.environ.get("MACOS_SIGNING_IDENTITY", "").strip() or "-"
|
|
sign_package(app, cli, identity, args.entitlements)
|
|
|
|
if args.archive:
|
|
args.archive.parent.mkdir(parents=True, exist_ok=True)
|
|
args.archive.unlink(missing_ok=True)
|
|
run("tar", "-czf", args.archive, "-C", args.output_dir, ".")
|
|
|
|
print(f"macOS release package ready at {args.output_dir}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|