Files
Qortal-Hub/electron/scripts/build-rnsd-frozen.py
2026-05-22 22:24:16 +03:00

260 lines
7.9 KiB
Python

#!/usr/bin/env python3
"""
Build standalone Reticulum executables with PyInstaller (no end-user Python required).
Must be run on each target OS/arch before packaging Electron (output is not portable).
Works on minimal Debian/Ubuntu without python3-venv by bootstrapping pip with
get-pip.py and installing rns + lxmf + pyinstaller into the user site-packages.
"""
from __future__ import annotations
import argparse
import datetime
import os
import shutil
import subprocess
import sys
import tempfile
import urllib.request
from pathlib import Path
GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py"
RETICULUM_PIP_PACKAGE = os.environ.get(
"QORTAL_RETICULUM_PIP_PACKAGE",
"git+https://github.com/Philreact/Reticulum.git@master",
)
PIP_ENV = {
"PIP_DISABLE_PIP_VERSION_CHECK": "1",
"PIP_BREAK_SYSTEM_PACKAGES": "1",
}
BUILD_TARGETS = (
{
"name": "rnsd",
"entry_resolver": lambda pyexe, electron_root: resolve_rnsd_entry(pyexe),
},
{
"name": "presence_bridge",
"entry_resolver": lambda pyexe, electron_root: str(
electron_root / "resources" / "presence_bridge.py"
),
},
)
def run(cmd: list[str], *, env_extra: dict[str, str] | None = None, cwd: Path | None = None) -> None:
env = {**os.environ, **(env_extra or {})}
subprocess.run(cmd, check=True, cwd=str(cwd) if cwd else None, env=env)
def has_module(pyexe: str, module_name: str) -> bool:
result = subprocess.run(
[pyexe, "-c", f"import {module_name}"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=os.environ,
)
return result.returncode == 0
def has_pip(pyexe: str) -> bool:
result = subprocess.run(
[pyexe, "-m", "pip", "--version"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env={**os.environ, **PIP_ENV},
)
return result.returncode == 0
def ensure_pip(pyexe: str) -> None:
if has_pip(pyexe):
return
print("pip not found; bootstrapping with get-pip.py …")
with tempfile.NamedTemporaryFile(suffix="-get-pip.py", delete=False) as fh:
tmp = Path(fh.name)
try:
with urllib.request.urlopen(GET_PIP_URL) as response:
tmp.write_bytes(response.read())
if os.name == "nt":
run([pyexe, str(tmp), "--user"], env_extra=PIP_ENV)
else:
try:
run([pyexe, str(tmp), "--user", "--break-system-packages"], env_extra=PIP_ENV)
except subprocess.CalledProcessError:
run([pyexe, str(tmp), "--user"], env_extra=PIP_ENV)
finally:
try:
tmp.unlink()
except FileNotFoundError:
pass
if not has_pip(pyexe):
sys.exit("Failed to bootstrap pip for the current Python.")
def pip_install(pyexe: str, packages: list[str], *, upgrade: bool = False, force_reinstall: bool = False) -> None:
flags = []
if upgrade:
flags.append("--upgrade")
if force_reinstall:
flags.append("--force-reinstall")
attempts = (
[["-m", "pip", "install", "--user", "--break-system-packages", *flags, *packages],
["-m", "pip", "install", "--user", *flags, *packages]]
if os.name != "nt"
else [["-m", "pip", "install", "--user", *flags, *packages]]
)
for args in attempts:
try:
run([pyexe, *args], env_extra=PIP_ENV)
return
except subprocess.CalledProcessError:
continue
sys.exit(f"Failed to install {' '.join(packages)} with pip.")
def resolve_rnsd_entry(pyexe: str) -> str:
proc = subprocess.run(
[pyexe, "-c", "import RNS.Utilities.rnsd as m; print(m.__file__)"],
capture_output=True,
text=True,
check=True,
env=os.environ,
)
rnsd_py = proc.stdout.strip()
if not rnsd_py or not Path(rnsd_py).is_file():
sys.exit(f"Could not resolve rnsd entry script (got: {rnsd_py!r})")
return rnsd_py
def freeze_target(
pyexe: str,
electron_root: Path,
build_root: Path,
output_dir: Path,
*,
name: str,
entry_script: str,
) -> None:
if not Path(entry_script).is_file():
sys.exit(f"Could not resolve {name} entry script (got: {entry_script!r})")
pi_work = build_root / name
pi_work.mkdir(parents=True)
dist_path = pi_work / "dist"
work_path = pi_work / "build"
cmd = [
pyexe,
"-m",
"PyInstaller",
"--onefile",
"--console",
"--clean",
"--noconfirm",
"--distpath",
str(dist_path),
"--workpath",
str(work_path),
"--specpath",
str(pi_work),
"--name",
name,
"--collect-all",
"RNS",
"--collect-all",
"cryptography",
"--collect-all",
"pyserial",
"--collect-all",
"LXMF",
"--hidden-import",
"RNS",
"--hidden-import",
"LXMF",
"--hidden-import",
"cryptography.hazmat.backends.openssl.backend",
entry_script,
]
print("Running:", " ".join(cmd))
run(cmd, env_extra=PIP_ENV, cwd=pi_work)
exe_name = f"{name}.exe" if os.name == "nt" else name
built = dist_path / exe_name
if not built.is_file():
sys.exit(f"PyInstaller did not produce {built}")
output_dir.mkdir(parents=True, exist_ok=True)
dest = output_dir / exe_name
shutil.copy2(built, dest)
if os.name != "nt":
dest.chmod(0o755)
print(f"Wrote {dest}")
def copy_runtime_sources(electron_root: Path, output_dir: Path) -> None:
source_bridge = electron_root / "resources" / "presence_bridge.py"
if not source_bridge.is_file():
sys.exit(f"Missing tracked bridge source: {source_bridge}")
shutil.copy2(source_bridge, output_dir / "presence_bridge.py")
print(f"Wrote {output_dir / 'presence_bridge.py'}")
mesh_net = electron_root / "resources" / "mesh-network.identity"
if not mesh_net.is_file():
sys.exit(f"Missing bundled mesh network identity: {mesh_net}")
shutil.copy2(mesh_net, output_dir / "mesh-network.identity")
print(f"Wrote {output_dir / 'mesh-network.identity'}")
mesh_passphrase = electron_root / "resources" / "mesh-network.passphrase"
if not mesh_passphrase.is_file():
sys.exit(f"Missing bundled mesh network passphrase: {mesh_passphrase}")
shutil.copy2(mesh_passphrase, output_dir / "mesh-network.passphrase")
print(f"Wrote {output_dir / 'mesh-network.passphrase'}")
def main() -> None:
script_dir = Path(__file__).resolve().parent
electron_root = script_dir.parent
default_out = electron_root / "resources" / "reticulum"
parser = argparse.ArgumentParser(description="Freeze rnsd with PyInstaller")
parser.add_argument(
"--output-dir",
type=Path,
default=default_out,
help="Directory for rnsd / rnsd.exe",
)
args = parser.parse_args()
build_root = electron_root / ".build" / "rnsd-frozen"
if build_root.exists():
shutil.rmtree(build_root)
build_root.mkdir(parents=True)
pyexe = sys.executable
ensure_pip(pyexe)
pip_install(pyexe, [RETICULUM_PIP_PACKAGE], upgrade=True, force_reinstall=True)
if not has_module(pyexe, "LXMF"):
pip_install(pyexe, ["lxmf"])
if not has_module(pyexe, "PyInstaller"):
pip_install(pyexe, ["pyinstaller"])
for target in BUILD_TARGETS:
entry_script = target["entry_resolver"](pyexe, electron_root)
freeze_target(
pyexe,
electron_root,
build_root,
args.output_dir,
name=target["name"],
entry_script=entry_script,
)
copy_runtime_sources(electron_root, args.output_dir)
marker = args.output_dir / "BUNDLE_READY"
marker.write_text(
f"frozen_at={datetime.datetime.now(datetime.timezone.utc).isoformat()}\npython={pyexe}\nreticulum={RETICULUM_PIP_PACKAGE}\n",
encoding="utf-8",
)
print(f"Wrote {marker}")
if __name__ == "__main__":
main()