Files
AI_Printer/tools/esptool-factory/package_factory_tools.py

807 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Package the factory flashing GUI into a distributable local-only app.
- No network dependency.
- Always packages encrypted firmware envelope (.bin.enc).
- Preserves local signature verification assets for packaged files.
"""
from __future__ import annotations
import argparse
import os
import shutil
import stat
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Optional
from factory_common.firmware_envelope import (
FirmwareEnvelopeError,
encrypt_firmware_blob,
load_firmware_encrypt_key,
)
from factory_common.idf_artifacts import sync_bins_from_idf_build
from factory_common.signing import sign_file, sig_path_for
from factory_private_config import get_required, load_private_config_source, resolve_private_config_path
ROOT = Path(__file__).resolve().parent
REPO_ROOT = ROOT.parent.parent
VENV_DIR = ROOT / ".venv"
DIST_DIR = ROOT / "dist"
BUILD_DIR = ROOT / "build"
FACTORY_APP_NAME = "TalkingQ_Factory"
REQUIREMENTS = ROOT / "requirements.txt"
PYINSTALLER_CACHE_DIR = ROOT / ".pyinstaller"
FACTORY_ENTRY = ROOT / "flash_encrypted_gui.py"
ICON_FILE = ROOT / "talkingq_logo_256x256.ico"
ICON_ICNS = ROOT / "talkingq_logo_256x256.icns"
CLEAN_OLD = True
CONF_DIR_NAME = "conf"
SIGNING_KEY_ENV = "FACTORY_SIGNING_KEY_PATH"
SIGNING_KEY_PASSWORD_ENV = "FACTORY_SIGNING_KEY_PASSWORD"
SIGNING_PUBKEY_ENV = "FACTORY_SIGNING_PUBKEY_PATH"
DEFAULT_SIGNING_PUBKEY = ROOT / "keys" / "factory_signing" / "factory_signing_pubkey.pem"
DEFAULT_SIGNING_KEY = ROOT / "keys" / "factory_signing" / "factory_signing_key.pem"
DEFAULT_FIRMWARE_KEY = ROOT / "keys" / "firmware" / "fw_key.bin"
DEFAULT_IDF_BUILD_DIR = REPO_ROOT / "build"
_ALLOWED_FLASH_MODE = {"qio", "qout", "dio", "dout", "keep"}
_ALLOWED_FLASH_FREQ = {
"keep",
"80m",
"60m",
"48m",
"40m",
"30m",
"26m",
"24m",
"20m",
"16m",
"15m",
"12m",
}
_ALLOWED_FLASH_SIZE = {
"keep",
"detect",
"256kb",
"512kb",
"1mb",
"2mb",
"2mb-c1",
"4mb",
"4mb-c1",
"8mb",
"16mb",
"32mb",
"64mb",
"128mb",
}
_ALLOWED_FLASH_BEFORE = {"default-reset", "usb-reset", "no-reset", "no-reset-no-sync"}
_ALLOWED_FLASH_AFTER = {
"default-reset",
"hard-reset",
"soft-reset",
"watchdog-reset",
"no-reset",
"no-reset-stub",
"noreset",
"none",
"no",
}
@dataclass(frozen=True)
class ToolSpec:
name: str
entry: Path
app_name: str
hidden_imports: tuple[str, ...] = ()
collect_all: tuple[str, ...] = ()
FACTORY_TOOL = ToolSpec(
name="factory",
entry=FACTORY_ENTRY,
app_name=FACTORY_APP_NAME,
hidden_imports=(
"esptool",
"esptool.__main__",
"espefuse",
"espefuse.__main__",
"serial",
"serial.tools.list_ports",
),
collect_all=("esptool", "espefuse", "cryptography"),
)
def _rel(path: Path) -> str:
try:
return str(path.relative_to(ROOT))
except ValueError:
return path.name
def _parse_bin_encryption(flash_cfg: dict) -> None:
enc_cfg = flash_cfg.get("bin_encryption")
if enc_cfg is not None:
raise ValueError("私有配置无效: flash.bin_encryption 已移除。")
def _coerce_str(value: object, label: str) -> str:
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str) and value.strip():
return value.strip()
raise ValueError(f"私有配置无效: {label}")
def _coerce_offset_int(value: str, label: str) -> int:
try:
return int(value, 0)
except ValueError as exc:
raise ValueError(f"私有配置无效: {label}") from exc
def _validate_allowed(
value: str,
label: str,
allowed: set[str],
*,
normalize_dash: bool = False,
) -> str:
normalized = value.strip().lower()
if normalize_dash:
normalized = normalized.replace("_", "-")
if normalized not in allowed:
allowed_text = ", ".join(sorted(allowed))
raise ValueError(f"私有配置无效: {label}={value}(允许: {allowed_text}")
return normalized
def _validate_app_layout_binding(
layout: list[tuple[str, str]],
app_bin_name: str,
app_partition_offset: str,
) -> None:
app_offsets = [offset for offset, name in layout if name == app_bin_name]
if not app_offsets:
raise ValueError("私有配置无效: flash.app_bin_name 必须出现在 flash.layout")
app_layout_offsets = {_coerce_offset_int(offset, "flash.layout.offset") for offset in app_offsets}
if len(app_layout_offsets) != 1:
raise ValueError("私有配置无效: flash.layout 中 app_bin_name 对应多个偏移地址")
app_offset = _coerce_offset_int(app_partition_offset, "flash.app_partition_offset")
if app_offset not in app_layout_offsets:
layout_offset = app_offsets[0]
raise ValueError(
"私有配置无效: flash.app_partition_offset 与 flash.layout 中 app_bin_name 的 offset 不一致"
f"app_partition_offset={app_partition_offset}, layout_offset={layout_offset}"
)
def _validate_flash_args(flash_cfg: dict) -> None:
flash_args_raw = get_required(flash_cfg, ("flash_args",), (dict,))
_validate_allowed(
_coerce_str(
get_required(flash_args_raw, ("flash_mode",), (str, int)),
"flash.flash_args.flash_mode",
),
"flash.flash_args.flash_mode",
_ALLOWED_FLASH_MODE,
)
_validate_allowed(
_coerce_str(
get_required(flash_args_raw, ("flash_freq",), (str, int)),
"flash.flash_args.flash_freq",
),
"flash.flash_args.flash_freq",
_ALLOWED_FLASH_FREQ,
)
_validate_allowed(
_coerce_str(
get_required(flash_args_raw, ("flash_size",), (str, int)),
"flash.flash_args.flash_size",
),
"flash.flash_args.flash_size",
_ALLOWED_FLASH_SIZE,
)
_validate_allowed(
_coerce_str(
get_required(flash_args_raw, ("before",), (str, int)),
"flash.flash_args.before",
),
"flash.flash_args.before",
_ALLOWED_FLASH_BEFORE,
normalize_dash=True,
)
_validate_allowed(
_coerce_str(
get_required(flash_args_raw, ("after",), (str, int)),
"flash.flash_args.after",
),
"flash.flash_args.after",
_ALLOWED_FLASH_AFTER,
normalize_dash=True,
)
def _load_flash_inputs() -> tuple[Path, list[str]]:
raw = load_private_config_source(ROOT)
flash_cfg = get_required(raw, ("flash",), (dict,))
bin_dir_raw = get_required(flash_cfg, ("bin_dir",), (str,))
bin_dir = Path(bin_dir_raw)
if not bin_dir.is_absolute():
bin_dir = ROOT / bin_dir
_parse_bin_encryption(flash_cfg)
layout_raw = get_required(flash_cfg, ("layout",), (list,))
layout: list[tuple[str, str]] = []
required_bins: list[str] = []
for entry in layout_raw:
offset: Optional[str] = None
name: Optional[str] = None
if isinstance(entry, (list, tuple)) and len(entry) == 2:
offset = _coerce_str(entry[0], "flash.layout.offset")
name = _coerce_str(entry[1], "flash.layout.name")
elif isinstance(entry, dict):
offset = _coerce_str(entry.get("offset"), "flash.layout.offset")
name = _coerce_str(entry.get("name"), "flash.layout.name")
else:
raise ValueError("私有配置无效: flash.layout")
clean_name = name.strip()
layout.append((offset, clean_name))
if clean_name.endswith(".enc"):
raise ValueError("私有配置无效: flash.layout 不能包含 .enc 文件")
required_bins.append(clean_name)
if not required_bins:
raise ValueError("私有配置无效: flash.layout 不能为空")
app_bin_name = _coerce_str(
get_required(flash_cfg, ("app_bin_name",), (str,)),
"flash.app_bin_name",
)
if app_bin_name.endswith(".enc"):
raise ValueError("私有配置无效: flash.app_bin_name 不应包含 .enc 后缀。")
app_partition_offset = _coerce_str(
get_required(flash_cfg, ("app_partition_offset",), (str, int)),
"flash.app_partition_offset",
)
_validate_app_layout_binding(layout, app_bin_name, app_partition_offset)
_validate_flash_args(flash_cfg)
return bin_dir, required_bins
def print_step(step: int, total: int, message: str) -> None:
print(f"[步骤 {step}/{total}] {message}")
def venv_python() -> Path:
scripts = "Scripts" if os.name == "nt" else "bin"
exe = "python.exe" if os.name == "nt" else "python3"
return VENV_DIR / scripts / exe
def run(cmd: Iterable[str]) -> None:
cmd_list = [str(c) for c in cmd]
display_cmd = []
for item in cmd_list:
if item.startswith(str(ROOT)):
display_cmd.append(_rel(Path(item)))
elif item.endswith("python") or item.endswith("python3") or item.endswith("python.exe"):
display_cmd.append("python")
else:
display_cmd.append(item)
print(">>", " ".join(display_cmd))
env = os.environ.copy()
env["PYINSTALLER_CONFIG_DIR"] = str(PYINSTALLER_CACHE_DIR)
result = subprocess.run(cmd_list, env=env)
if result.returncode != 0:
raise SystemExit(result.returncode)
def ensure_venv() -> None:
if venv_python().exists():
return
raise FileNotFoundError(
"Missing virtualenv at "
f"{VENV_DIR}. Create it and install deps:\n"
f" python3 -m venv {VENV_DIR}\n"
f" {venv_python()} -m pip install -r {REQUIREMENTS}"
)
def clean_old(enabled: bool) -> None:
if not enabled:
return
for path in (BUILD_DIR, DIST_DIR):
if path.exists():
_make_tree_writable(path)
print(f"[INFO] Removing {_rel(path)}")
_rmtree(path)
def ensure_entry_exists(entry: Path) -> None:
if not entry.exists():
raise FileNotFoundError(f"Missing required input: {_rel(entry)}")
def ensure_flash_files(bin_dir: Path, required_bins: Iterable[str]) -> None:
missing = [str(p) for p in [FACTORY_ENTRY, bin_dir] if not p.exists()]
if missing:
raise FileNotFoundError(
"Missing required inputs:\n " + "\n ".join(_rel(Path(p)) for p in missing)
)
missing_bins: list[str] = []
for name in required_bins:
path = bin_dir / name
if not path.exists():
missing_bins.append(_rel(path))
if missing_bins:
raise FileNotFoundError(
"Missing required stage binaries:\n " + "\n ".join(missing_bins)
)
def _sync_bins_from_build(
bin_dir: Path,
required_bins: Iterable[str],
build_dir: Path,
enabled: bool,
) -> None:
if not enabled:
return
copied = sync_bins_from_idf_build(bin_dir, required_bins, build_dir)
for source, target in copied:
print(f"[INFO] Sync build bin: {source} -> {_rel(target)}")
def _resolve_icon() -> Optional[Path]:
if sys.platform == "darwin":
return ICON_ICNS if ICON_ICNS.exists() else None
return ICON_FILE if ICON_FILE.exists() else None
def _pyinstaller_mode_args() -> list[str]:
if sys.platform == "darwin":
return ["--onedir"]
return ["--onefile"]
def build_exe(tool: ToolSpec) -> None:
cmd = [
str(venv_python()),
"-m",
"PyInstaller",
"--noconsole",
"--clean",
]
cmd.extend(_pyinstaller_mode_args())
icon_path = _resolve_icon()
if icon_path is None and sys.platform == "darwin":
print("[WARN] macOS requires .icns icon; skipping icon (no .icns found).")
if icon_path is not None:
cmd.extend(["--icon", str(icon_path)])
cmd.extend(["--add-data", f"{icon_path}{os.pathsep}."])
for hidden in tool.hidden_imports:
cmd.extend(["--hidden-import", hidden])
for pkg in tool.collect_all:
cmd.extend(["--collect-all", pkg])
cmd.extend(
[
"--name",
tool.app_name,
"--distpath",
str(DIST_DIR),
"--workpath",
str(BUILD_DIR),
"--specpath",
str(ROOT),
str(tool.entry),
]
)
run(cmd)
def _resolve_private_config_for_signing(base_dir: Path) -> Optional[Path]:
env_path = os.getenv("FACTORY_PRIVATE_CONFIG_PATH") or os.getenv("FACTORY_PRIVATE_CONFIG")
if env_path:
path = Path(env_path).expanduser()
if path.suffix == ".enc":
plain = Path(str(path)[:-4])
if plain.exists():
return plain
return path
candidate = base_dir / CONF_DIR_NAME / "factory_private.json"
if candidate.exists():
return candidate
return resolve_private_config_path(base_dir)
def _set_readonly(path: Path) -> None:
try:
mode = path.stat().st_mode
if os.name == "nt":
path.chmod(mode & ~stat.S_IWRITE)
else:
path.chmod(mode & ~stat.S_IWUSR & ~stat.S_IWGRP & ~stat.S_IWOTH)
except OSError:
pass
def _clear_readonly(path: Path) -> None:
try:
mode = path.stat().st_mode
path.chmod(mode | stat.S_IWRITE)
except OSError:
pass
def _rmtree(path: Path) -> None:
def _on_error(func, failed_path, _exc_info): # type: ignore[no-untyped-def]
try:
os.chmod(failed_path, stat.S_IWRITE)
try:
func(failed_path)
except TypeError:
return
except Exception:
pass
shutil.rmtree(path, onerror=_on_error)
def _make_tree_writable(path: Path) -> None:
try:
if path.exists():
for root, dirs, files in os.walk(path):
for name in dirs:
try:
os.chmod(Path(root) / name, stat.S_IRWXU)
except OSError:
pass
for name in files:
try:
os.chmod(Path(root) / name, stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
os.chmod(path, stat.S_IRWXU)
except OSError:
pass
def _copy_file(src: Path, dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
def _resolve_signing_key() -> Optional[Path]:
key_path = os.getenv(SIGNING_KEY_ENV)
if not key_path:
return DEFAULT_SIGNING_KEY if DEFAULT_SIGNING_KEY.exists() else None
return Path(key_path).expanduser()
def _resolve_signing_pubkey() -> Path:
pubkey_path = os.getenv(SIGNING_PUBKEY_ENV)
if pubkey_path:
return Path(pubkey_path).expanduser()
return DEFAULT_SIGNING_PUBKEY
def _copy_signing_pubkey(dest_root: Path) -> None:
pubkey_path = _resolve_signing_pubkey()
if not pubkey_path.exists():
raise FileNotFoundError(f"Missing signing public key: {pubkey_path}")
dest = dest_root / "keys" / "factory_signing" / "factory_signing_pubkey.pem"
_copy_file(pubkey_path, dest)
_set_readonly(dest)
def _copy_firmware_key(dest_root: Path) -> None:
if not DEFAULT_FIRMWARE_KEY.exists():
raise FileNotFoundError(f"Missing firmware key: {DEFAULT_FIRMWARE_KEY}")
dest = dest_root / "keys" / "firmware" / "fw_key.bin"
_copy_file(DEFAULT_FIRMWARE_KEY, dest)
_set_readonly(dest)
def _resolve_packaged_app(tool: ToolSpec) -> tuple[Path, bool]:
candidates = [
DIST_DIR / f"{tool.app_name}.exe",
DIST_DIR / f"{tool.app_name}.app",
DIST_DIR / tool.app_name,
]
for path in candidates:
if path.exists():
return path, path.is_dir()
raise FileNotFoundError(
f"Missing packaged app. Expected one of: {', '.join(str(p) for p in candidates)}"
)
def _cleanup_root_artifacts(tool: ToolSpec) -> None:
for path in (
DIST_DIR / f"{tool.app_name}.exe",
DIST_DIR / f"{tool.app_name}.app",
DIST_DIR / tool.app_name,
):
if not path.exists():
continue
try:
if path.is_dir():
_rmtree(path)
else:
_clear_readonly(path)
path.unlink()
print(f"[INFO] Removed root artifact: {_rel(path)}")
except OSError:
print(f"[WARN] Failed to remove root artifact: {_rel(path)}")
def _copy_packaged_app(src: Path, is_dir: bool, dest_dir: Path) -> None:
dest = dest_dir / src.name
if is_dir:
if dest.exists():
_rmtree(dest)
shutil.copytree(src, dest)
else:
shutil.copy2(src, dest)
def _enc_name_for(name: str) -> str:
return f"{name}.enc"
def _encrypt_firmware_bins(
source_bin_dir: Path,
required_bins: Iterable[str],
enc_key: bytes,
out_bin_dir: Path,
) -> None:
missing: list[Path] = []
out_bin_dir.mkdir(parents=True, exist_ok=True)
for name in required_bins:
plain_path = source_bin_dir / name
if not plain_path.exists():
missing.append(plain_path)
continue
try:
plain = plain_path.read_bytes()
except OSError as exc:
raise OSError(f"读取固件失败: {plain_path}") from exc
enc_path = out_bin_dir / _enc_name_for(name)
enc_payload = encrypt_firmware_blob(plain, enc_key)
enc_path.write_bytes(enc_payload)
if missing:
raise FileNotFoundError(
"Missing required binaries for encryption:\n " + "\n ".join(_rel(p) for p in missing)
)
def _sign_encrypted_firmware_bins(enc_bin_dir: Path, required_bins: Iterable[str], key_path: Path) -> None:
if not key_path.exists():
raise FileNotFoundError(f"Missing signing key: {key_path}")
password = os.getenv(SIGNING_KEY_PASSWORD_ENV)
missing: list[Path] = []
for name in required_bins:
enc_path = enc_bin_dir / _enc_name_for(name)
if not enc_path.exists():
missing.append(enc_path)
continue
sign_file(enc_path, key_path, password=password)
if missing:
raise FileNotFoundError(
"Missing encrypted binaries for signing:\n " + "\n ".join(_rel(p) for p in missing)
)
def _copy_required_file(src: Path, dest_root: Path) -> None:
if not src.exists():
raise FileNotFoundError(f"Missing file for packaging: {src}")
dest = dest_root / src.name
_copy_file(src, dest)
_set_readonly(dest)
def copy_support_files(
bin_dir_name: str,
dest_dir: Path,
required_bins: Iterable[str],
enc_bin_dir: Path,
) -> None:
dest = dest_dir / bin_dir_name
if dest.exists():
_rmtree(dest)
dest.mkdir(parents=True, exist_ok=True)
print(f"[INFO] Copy selected firmware files -> {_rel(dest)}/")
for name in required_bins:
enc_path = enc_bin_dir / _enc_name_for(name)
enc_sig = sig_path_for(enc_path)
_copy_required_file(enc_path, dest)
_copy_required_file(enc_sig, dest)
def _bundle_private_config(cfg_path: Path, dest_dir: Path) -> None:
if not cfg_path.exists():
raise FileNotFoundError("Missing private config for bundling")
sig_path = sig_path_for(cfg_path)
if not sig_path.exists():
raise FileNotFoundError(f"Missing config signature: {sig_path}")
target_root = dest_dir / CONF_DIR_NAME
target_root.mkdir(parents=True, exist_ok=True)
for path in (cfg_path, sig_path):
dest = target_root / path.name
print(f"[INFO] Copy {path.name} -> {_rel(dest)}")
shutil.copy2(path, dest)
_set_readonly(dest)
def _warn_unexpected_dist_entries(dist_dir: Path, allowed_dirs: Iterable[str]) -> None:
if not dist_dir.exists():
return
allowed = set(allowed_dirs)
for entry in dist_dir.iterdir():
if entry.is_dir() and entry.name not in allowed:
print(
"[WARN] Unexpected directory in dist: "
f"{_rel(entry)} (release should only include the current package folders)."
)
def _sanitize_package_name(value: str) -> str:
cleaned = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in value.strip())
return cleaned or "factory"
def main() -> None:
parser = argparse.ArgumentParser(description="Package factory flashing tool.")
parser.add_argument(
"--no-clean",
action="store_true",
help="Keep existing build/dist outputs to reuse more cache.",
)
parser.add_argument(
"--package-name",
default="factory",
help="Output package directory name under dist/ (default: factory).",
)
parser.add_argument(
"--build-dir",
default=str(DEFAULT_IDF_BUILD_DIR),
help="ESP-IDF build directory for auto-syncing missing .bin files.",
)
parser.add_argument(
"--no-sync-build-bins",
action="store_true",
help="Disable auto-sync of missing bins from build/flasher_args.json.",
)
args = parser.parse_args()
ensure_venv()
targets = [FACTORY_TOOL]
total_steps = 5
print_step(1, total_steps, "检查输入与签名配置")
bin_dir, required_bins = _load_flash_inputs()
build_dir = Path(args.build_dir).expanduser()
_sync_bins_from_build(
bin_dir=bin_dir,
required_bins=required_bins,
build_dir=build_dir,
enabled=not args.no_sync_build_bins,
)
ensure_flash_files(bin_dir, required_bins)
for tool in targets:
ensure_entry_exists(tool.entry)
pubkey_path = _resolve_signing_pubkey()
if not pubkey_path.exists():
raise FileNotFoundError(f"Missing signing public key: {pubkey_path}")
if not DEFAULT_FIRMWARE_KEY.exists():
raise FileNotFoundError(f"Missing firmware key: {DEFAULT_FIRMWARE_KEY}")
signing_key = _resolve_signing_key()
if signing_key is None or not signing_key.exists():
raise FileNotFoundError(
"Missing signing key. Set FACTORY_SIGNING_KEY_PATH or provide "
f"{DEFAULT_SIGNING_KEY}."
)
signing_password = os.getenv(SIGNING_KEY_PASSWORD_ENV)
package_name = _sanitize_package_name(args.package_name)
try:
enc_key = load_firmware_encrypt_key()
except FirmwareEnvelopeError as exc:
raise ValueError(f"固件封装模式(enc)初始化失败:{exc}") from exc
with tempfile.TemporaryDirectory(prefix="talkingq_factory_pack_") as temp_dir:
staging_root = Path(temp_dir)
staging_conf_dir = staging_root / CONF_DIR_NAME
staging_enc_bin_dir = staging_root / "bin"
staging_conf_dir.mkdir(parents=True, exist_ok=True)
private_source = _resolve_private_config_for_signing(ROOT)
if private_source is None or not private_source.exists():
raise FileNotFoundError("Missing private config source: factory_private.json")
if private_source.suffix == ".enc":
raise ValueError("Encrypted private config is not supported.")
private_cfg_path = staging_conf_dir / private_source.name
_copy_file(private_source, private_cfg_path)
sign_file(private_cfg_path, signing_key, password=signing_password)
print(f"[INFO] Signed private config -> {_rel(private_cfg_path)}")
print_step(2, total_steps, "清理旧构建产物")
PYINSTALLER_CACHE_DIR.mkdir(parents=True, exist_ok=True)
clean_old(enabled=not args.no_clean and CLEAN_OLD)
DIST_DIR.mkdir(parents=True, exist_ok=True)
try:
os.chmod(DIST_DIR, stat.S_IRWXU)
except OSError:
pass
print_step(3, total_steps, "构建打包文件")
for tool in targets:
print(f"[INFO] Build {tool.app_name}")
build_exe(tool)
print_step(4, total_steps, "固件封装/签名")
_encrypt_firmware_bins(bin_dir, required_bins, enc_key, staging_enc_bin_dir)
_sign_encrypted_firmware_bins(staging_enc_bin_dir, required_bins, signing_key)
print_step(5, total_steps, "生成产线包")
for tool in targets:
exe_path, exe_is_dir = _resolve_packaged_app(tool)
package_dir = DIST_DIR / package_name
if package_dir.exists():
_rmtree(package_dir)
package_dir.mkdir(parents=True, exist_ok=True)
_copy_packaged_app(exe_path, exe_is_dir, package_dir)
copy_support_files(
bin_dir_name=bin_dir.name,
dest_dir=package_dir,
required_bins=required_bins,
enc_bin_dir=staging_enc_bin_dir,
)
_copy_firmware_key(package_dir)
_copy_signing_pubkey(package_dir)
_bundle_private_config(private_cfg_path, dest_dir=package_dir)
print(f"[DONE] Package: {_rel(package_dir)}")
_cleanup_root_artifacts(tool)
_warn_unexpected_dist_entries(DIST_DIR, [package_name])
print("推荐操作:将 dist/<package-name>/ 目录直接交付产线使用。")
if __name__ == "__main__":
try:
main()
except Exception as exc: # noqa: BLE001
print(f"[FAIL] {exc}", file=sys.stderr)
sys.exit(1)