import argparse
import gzip
import hashlib
import io
import os
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
import textwrap
import tomllib
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PACKAGING = ROOT / "packaging"
TARGETS = {
"x86_64-unknown-linux-gnu": ("x86_64", "amd64"),
"aarch64-unknown-linux-gnu": ("aarch64", "arm64"),
}
STUB_LIBS = (("mpv", "libmpv.so"), ("gbm", "libgbm.so"))
DEB_PACKAGES = {
"libc.so.6": "libc6",
"libm.so.6": "libc6",
"libdl.so.2": "libc6",
"libpthread.so.0": "libc6",
"librt.so.1": "libc6",
"ld-linux-x86-64.so.2": "libc6",
"ld-linux-aarch64.so.1": "libc6",
"libgcc_s.so.1": "libgcc-s1",
"libmpv.so.2": "libmpv2",
"libgbm.so.1": "libgbm1",
}
DEB_EXTRA_DEPENDS = ["libegl1"]
DEB_RECOMMENDS = ["libvulkan1", "mesa-vulkan-drivers", "yt-dlp", "kbd"]
DEB_REVISION = "1"
def run(cmd, **kw):
print("+", " ".join(map(str, cmd)), flush=True)
return subprocess.run(cmd, check=True, **kw)
def output(cmd):
return subprocess.run(cmd, check=True, capture_output=True, text=True).stdout
def need(tool, hint):
if shutil.which(tool) is None:
sys.exit(f"dist: {tool} not found ({hint})")
def cargo_meta():
data = tomllib.loads((ROOT / "Cargo.toml").read_text())["package"]
return data["name"], data["version"], data["description"]
def source_date_epoch():
if "SOURCE_DATE_EPOCH" in os.environ:
return int(os.environ["SOURCE_DATE_EPOCH"])
try:
return int(output(["git", "-C", str(ROOT), "log", "-1", "--format=%ct"]).strip())
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
return 0
def maintainer():
name = os.environ.get("DEBFULLNAME")
email = os.environ.get("DEBEMAIL")
try:
name = name or output(["git", "-C", str(ROOT), "config", "user.name"]).strip()
email = email or output(["git", "-C", str(ROOT), "config", "user.email"]).strip()
except (subprocess.CalledProcessError, FileNotFoundError):
pass
if not name or not email:
sys.exit("dist: set DEBFULLNAME and DEBEMAIL (or git user.name/user.email)")
return f"{name} <{email}>"
def host_library(filename):
dirs = []
try:
dirs.append(output(["pkg-config", "--variable=libdir", "mpv"]).strip())
except (subprocess.CalledProcessError, FileNotFoundError):
pass
dirs += ["/usr/lib64", "/usr/lib/x86_64-linux-gnu", "/usr/lib/aarch64-linux-gnu",
"/usr/lib", "/lib64", "/lib", "/usr/local/lib"]
for d in dirs:
path = Path(d) / filename
if path.exists():
return path.resolve()
sys.exit(f"dist: host {filename} not found; it lists the symbols for the stub library")
def soname(path):
match = re.search(r"\(SONAME\)\s+Library soname: \[([^\]]+)\]", output(["readelf", "-d", str(path)]))
if not match:
sys.exit(f"dist: {path} has no SONAME")
return match.group(1)
def exported_functions(path):
names = set()
for line in output(["nm", "-D", "--defined-only", str(path)]).splitlines():
fields = line.split()
if len(fields) == 3 and fields[1] in ("T", "W", "i"):
names.add(fields[2].split("@")[0])
return sorted(names)
def build_stubs(zig_target, stub_dir):
stub_dir.mkdir(parents=True, exist_ok=True)
for name, filename in STUB_LIBS:
host = host_library(filename)
source = stub_dir / f"{name}.c"
source.write_text("".join(f"void {f}(void) {{}}\n" for f in exported_functions(host)))
run(["zig", "cc", "-target", zig_target, "-shared", "-fPIC", "-nostdlib",
f"-Wl,-soname,{soname(host)}", "-o", str(stub_dir / f"lib{name}.so"), str(source)])
def build(triple, glibc, features):
arch = triple.split("-")[0]
stub_dir = ROOT / "target" / "dist-stubs" / triple
build_stubs(f"{arch}-linux-gnu.{glibc}", stub_dir)
env = dict(os.environ)
env["RUSTFLAGS"] = (env.get("RUSTFLAGS", "") + f" -L native={stub_dir}").strip()
env["CARGO_PROFILE_RELEASE_STRIP"] = "symbols"
cmd = ["cargo", "zigbuild", "--release", "--target", f"{triple}.{glibc}"]
if features:
cmd += ["--features", features]
run(cmd, cwd=ROOT, env=env)
binary = ROOT / "target" / triple / "release" / "medias"
check_binary(binary, arch)
return binary
ELF_MACHINE = {"x86_64": "Advanced Micro Devices X86-64", "aarch64": "AArch64"}
def check_binary(binary, arch):
header = output(["readelf", "-h", str(binary)])
if ELF_MACHINE[arch] not in header:
sys.exit(f"dist: {binary} is not {arch}")
def needed_libraries(binary):
return re.findall(r"\(NEEDED\)\s+Shared library: \[([^\]]+)\]", output(["readelf", "-d", str(binary)]))
def max_glibc(binary):
versions = re.findall(r"GLIBC_(\d+)\.(\d+)(?:\.(\d+))?", output(["readelf", "-V", str(binary)]))
best = max((tuple(int(v or 0) for v in ver) for ver in versions), default=(2, 17, 0))
return ".".join(map(str, best[: 3 if best[2] else 2]))
def deb_depends(binary):
packages = []
for lib in needed_libraries(binary):
if lib not in DEB_PACKAGES:
sys.exit(f"dist: no Debian package known for {lib} (add it to DEB_PACKAGES)")
packages.append(DEB_PACKAGES[lib])
deps = []
for pkg in packages + DEB_EXTRA_DEPENDS:
entry = f"libc6 (>= {max_glibc(binary)})" if pkg == "libc6" else pkg
if entry not in deps:
deps.append(entry)
return deps
def tar_info(name, size, mode, mtime, kind=tarfile.REGTYPE):
info = tarfile.TarInfo(name)
info.size, info.mode, info.mtime, info.type = size, mode, mtime, kind
info.uid = info.gid = 0
info.uname = info.gname = "root"
return info
def write_tar(path, compression, entries, mtime):
with tarfile.open(path, f"w:{compression}", format=tarfile.GNU_FORMAT) as tar:
for name, data, mode in entries:
if data is None:
tar.addfile(tar_info(name, 0, mode, mtime, tarfile.DIRTYPE))
else:
tar.addfile(tar_info(name, len(data), mode, mtime), io.BytesIO(data))
def with_parents(files):
dirs = set()
for path in files:
parts = path.split("/")[:-1]
for i in range(1, len(parts) + 1):
dirs.add("/".join(parts[:i]))
entries = [(d + "/", None, 0o755) for d in dirs]
entries += [(p, data, mode) for p, (data, mode) in files.items()]
return sorted(entries, key=lambda e: e[0])
def ar_archive(members, mtime):
out = bytearray(b"!<arch>\n")
for name, data in members:
out += f"{name:<16}{mtime:<12}{0:<6}{0:<6}{0o100644:<8o}{len(data):<10}`\n".encode()
out += data
if len(data) % 2:
out += b"\n"
return bytes(out)
def doc_files(prefix):
return {
f"{prefix}README.md": ((ROOT / "README.md").read_bytes(), 0o644),
f"{prefix}LiberationSans-OFL.txt": ((ROOT / "assets" / "LiberationSans-OFL.txt").read_bytes(), 0o644),
}
def build_tarball(binary, out_dir, name, version, suffix, mtime):
top = f"{name}-{version}-linux-{suffix}"
files = {
f"{top}/medias": (binary.read_bytes(), 0o755),
f"{top}/LICENSE": ((ROOT / "LICENSE").read_bytes(), 0o644),
f"{top}/medias@.service": ((PACKAGING / "systemd" / "medias@.service").read_bytes(), 0o644),
f"{top}/medias.default": ((PACKAGING / "systemd" / "medias.default").read_bytes(), 0o644),
**doc_files(f"{top}/"),
}
path = out_dir / f"{top}.tar.gz"
write_tar(path, "gz", with_parents(files), mtime)
return path
def control_text(fields):
return "".join(f"{k}: {v}\n" for k, v in fields).encode()
def build_deb(binary, out_dir, name, version, description, deb_arch, depends, maint, mtime):
doc = f"usr/share/doc/{name}/"
files = {
"usr/bin/medias": (binary.read_bytes(), 0o755),
"usr/lib/systemd/system/medias@.service": ((PACKAGING / "systemd" / "medias@.service").read_bytes(), 0o644),
"etc/default/medias": ((PACKAGING / "systemd" / "medias.default").read_bytes(), 0o644),
f"{doc}copyright": ((PACKAGING / "debian" / "copyright").read_bytes(), 0o644),
**doc_files(doc),
}
files[f"{doc}changelog.Debian.gz"] = (gzip.compress(changelog(name, version, maint, mtime), mtime=mtime), 0o644)
data = with_parents({f"./{p}": v for p, v in files.items()})
short, _, long = description.partition(": ")
installed_kib = (sum(len(d) for d, _ in files.values()) + 1023) // 1024
control = control_text([
("Package", name),
("Version", f"{version}-{DEB_REVISION}"),
("Architecture", deb_arch),
("Maintainer", maint),
("Installed-Size", installed_kib),
("Depends", ", ".join(depends)),
("Recommends", ", ".join(DEB_RECOMMENDS)),
("Section", "video"),
("Priority", "optional"),
("Description", short + "".join(f"\n {line}" for line in textwrap.wrap(long[:1].upper() + long[1:] + ".", 72))),
])
md5sums = "".join(f"{hashlib.md5(d).hexdigest()} {p}\n" for p, (d, _) in sorted(files.items())).encode()
control_files = {
"./control": (control, 0o644),
"./md5sums": (md5sums, 0o644),
"./conffiles": (b"/etc/default/medias\n", 0o644),
}
for script in ("postinst", "prerm", "postrm"):
control_files[f"./{script}"] = ((PACKAGING / "debian" / script).read_bytes(), 0o755)
with tempfile.TemporaryDirectory() as tmp:
control_tar, data_tar = Path(tmp) / "control.tar.xz", Path(tmp) / "data.tar.xz"
write_tar(control_tar, "xz", [("./", None, 0o755)] + sorted(
[(p, d, m) for p, (d, m) in control_files.items()]), mtime)
write_tar(data_tar, "xz", data, mtime)
deb = ar_archive([
("debian-binary", b"2.0\n"),
("control.tar.xz", control_tar.read_bytes()),
("data.tar.xz", data_tar.read_bytes()),
], mtime)
path = out_dir / f"{name}_{version}-{DEB_REVISION}_{deb_arch}.deb"
path.write_bytes(deb)
return path
def changelog(name, version, maint, mtime):
from email.utils import formatdate
return (f"{name} ({version}-{DEB_REVISION}) trixie; urgency=medium\n\n"
f" * Release {version}.\n\n"
f" -- {maint} {formatdate(mtime)}\n").encode()
def write_sha256(path):
digest = hashlib.sha256(path.read_bytes()).hexdigest()
Path(f"{path}.sha256").write_text(f"{digest} {path.name}\n")
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--target", action="append", choices=sorted(TARGETS),
help="Rust target triple (repeatable; default: all)")
parser.add_argument("--glibc", default="2.36", help="minimum glibc version (default: 2.36)")
parser.add_argument("--features", default="", help="cargo features, e.g. youtube-tv")
parser.add_argument("--no-deb", action="store_true", help="skip Debian packages")
parser.add_argument("--out", type=Path, default=ROOT / "dist", help="output directory")
args = parser.parse_args(argv)
for tool, hint in (("cargo-zigbuild", "cargo install cargo-zigbuild"),
("zig", "https://ziglang.org/download/"),
("readelf", "binutils"), ("nm", "binutils"), ("rustup", "https://rustup.rs")):
need(tool, hint)
name, version, description = cargo_meta()
mtime = source_date_epoch()
maint = None if args.no_deb else maintainer()
args.out.mkdir(parents=True, exist_ok=True)
targets = args.target or list(TARGETS)
run(["rustup", "target", "add", *targets])
written = []
for triple in targets:
suffix, deb_arch = TARGETS[triple]
print(f"--- {triple}", flush=True)
binary = build(triple, args.glibc, args.features)
written.append(build_tarball(binary, args.out, name, version, suffix, mtime))
if not args.no_deb:
depends = deb_depends(binary)
written.append(build_deb(binary, args.out, name, version, description,
deb_arch, depends, maint, mtime))
for path in written:
write_sha256(path)
print(f"wrote {path.relative_to(ROOT) if path.is_relative_to(ROOT) else path}")
if __name__ == "__main__":
main()