2026-01-22 17:55:11 +08:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
"""
|
|
|
|
|
|
应用打包脚本
|
|
|
|
|
|
根据 app.yaml 中列出的文件,打包成 zip 文件
|
|
|
|
|
|
版本号从 version.py 中读取
|
|
|
|
|
|
"""
|
2026-01-23 11:28:40 +08:00
|
|
|
|
import argparse
|
2026-01-22 17:55:11 +08:00
|
|
|
|
import os
|
|
|
|
|
|
import yaml
|
|
|
|
|
|
import zipfile
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
import sys
|
2026-01-23 11:28:40 +08:00
|
|
|
|
import secrets
|
|
|
|
|
|
|
|
|
|
|
|
MAGIC = b"AROTAE1" # 7 bytes: Archery OTA Encrypted v1
|
|
|
|
|
|
GCM_NONCE_LEN = 12
|
|
|
|
|
|
GCM_TAG_LEN = 16
|
2026-01-22 17:55:11 +08:00
|
|
|
|
|
|
|
|
|
|
# 添加当前目录到路径,以便导入 version 模块
|
|
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_app_yaml(yaml_path='app.yaml'):
|
|
|
|
|
|
"""加载 app.yaml 文件"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(yaml_path, 'r', encoding='utf-8') as f:
|
|
|
|
|
|
return yaml.safe_load(f)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"[ERROR] 读取 {yaml_path} 失败: {e}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def check_files_exist(files, base_dir='.'):
|
|
|
|
|
|
"""检查文件是否存在"""
|
|
|
|
|
|
missing_files = []
|
|
|
|
|
|
existing_files = []
|
|
|
|
|
|
|
|
|
|
|
|
for file_path in files:
|
|
|
|
|
|
full_path = os.path.join(base_dir, file_path)
|
|
|
|
|
|
if os.path.exists(full_path):
|
|
|
|
|
|
existing_files.append(file_path)
|
|
|
|
|
|
else:
|
|
|
|
|
|
missing_files.append(file_path)
|
|
|
|
|
|
|
|
|
|
|
|
return existing_files, missing_files
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_version_from_version_py():
|
|
|
|
|
|
"""从 version.py 读取版本号"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
from version import VERSION
|
|
|
|
|
|
return VERSION
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
print("[WARNING] 无法导入 version.py,使用默认版本号 1.0.0")
|
|
|
|
|
|
return '1.0.0'
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"[WARNING] 读取 version.py 失败: {e},使用默认版本号 1.0.0")
|
|
|
|
|
|
return '1.0.0'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_zip_package(app_info, files, output_dir='.', base_dir='.'):
|
|
|
|
|
|
"""创建 zip 打包文件"""
|
|
|
|
|
|
# 生成输出文件名:{name}_v{version}_{timestamp}.zip
|
|
|
|
|
|
# 版本号从 version.py 读取,而不是从 app.yaml
|
|
|
|
|
|
app_name = app_info.get('name', 'app')
|
|
|
|
|
|
version = get_version_from_version_py() # 从 version.py 读取版本号
|
|
|
|
|
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
|
|
|
|
zip_filename = f"{app_name}_v{version}_{timestamp}.zip"
|
|
|
|
|
|
zip_path = os.path.join(output_dir, zip_filename)
|
|
|
|
|
|
|
|
|
|
|
|
print(f"[INFO] 开始打包: {zip_filename}")
|
|
|
|
|
|
print(f"[INFO] 包含文件数: {len(files)}")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
|
|
|
|
|
for file_path in files:
|
|
|
|
|
|
full_path = os.path.join(base_dir, file_path)
|
|
|
|
|
|
# 使用相对路径作为 zip 内的路径
|
|
|
|
|
|
zipf.write(full_path, file_path)
|
|
|
|
|
|
print(f" ✓ {file_path}")
|
|
|
|
|
|
|
|
|
|
|
|
# 获取文件大小
|
|
|
|
|
|
file_size = os.path.getsize(zip_path)
|
|
|
|
|
|
file_size_mb = file_size / (1024 * 1024)
|
|
|
|
|
|
|
|
|
|
|
|
print(f"\n[SUCCESS] 打包完成!")
|
|
|
|
|
|
print(f" 文件名: {zip_filename}")
|
|
|
|
|
|
print(f" 文件大小: {file_size_mb:.2f} MB ({file_size:,} 字节)")
|
|
|
|
|
|
print(f" 文件路径: {os.path.abspath(zip_path)}")
|
|
|
|
|
|
|
|
|
|
|
|
return zip_path
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"[ERROR] 打包失败: {e}")
|
|
|
|
|
|
import traceback
|
|
|
|
|
|
traceback.print_exc()
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-23 11:28:40 +08:00
|
|
|
|
def _validate_key_hex(key_hex: str) -> bytes:
|
|
|
|
|
|
if not isinstance(key_hex, str):
|
|
|
|
|
|
raise ValueError("aead key must be hex string")
|
|
|
|
|
|
key_hex = key_hex.strip().lower()
|
|
|
|
|
|
if key_hex.startswith("0x"):
|
|
|
|
|
|
key_hex = key_hex[2:]
|
|
|
|
|
|
if len(key_hex) != 64:
|
|
|
|
|
|
raise ValueError("aead key must be 64 hex chars (32 bytes)")
|
|
|
|
|
|
try:
|
|
|
|
|
|
key = bytes.fromhex(key_hex)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
raise ValueError(f"invalid hex key: {e}")
|
|
|
|
|
|
if len(key) != 32:
|
|
|
|
|
|
raise ValueError("aead key must be 32 bytes")
|
|
|
|
|
|
return key
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def encrypt_zip_aead(zip_path: str, key_hex: str, out_ext: str = ".enc") -> str:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Encrypt the whole zip file as one blob:
|
|
|
|
|
|
output format: MAGIC(7) | nonce(12) | ciphertext(N) | tag(16)
|
|
|
|
|
|
using AES-256-GCM (AEAD).
|
|
|
|
|
|
"""
|
|
|
|
|
|
# Lazy import: packaging-only dependency
|
|
|
|
|
|
try:
|
|
|
|
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
|
"Missing dependency: cryptography. Install with: pip install cryptography. "
|
|
|
|
|
|
f"Import error: {e}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
key = _validate_key_hex(key_hex)
|
|
|
|
|
|
with open(zip_path, "rb") as f:
|
|
|
|
|
|
plain = f.read()
|
|
|
|
|
|
|
|
|
|
|
|
nonce = secrets.token_bytes(GCM_NONCE_LEN)
|
|
|
|
|
|
aesgcm = AESGCM(key)
|
|
|
|
|
|
ct_and_tag = aesgcm.encrypt(nonce, plain, None) # ciphertext || tag (16 bytes)
|
|
|
|
|
|
|
|
|
|
|
|
enc_path = zip_path + out_ext if out_ext else (zip_path + ".enc")
|
|
|
|
|
|
with open(enc_path, "wb") as f:
|
|
|
|
|
|
f.write(MAGIC)
|
|
|
|
|
|
f.write(nonce)
|
|
|
|
|
|
f.write(ct_and_tag)
|
|
|
|
|
|
|
|
|
|
|
|
return enc_path
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-22 17:55:11 +08:00
|
|
|
|
def main():
|
|
|
|
|
|
"""主函数"""
|
2026-01-23 11:28:40 +08:00
|
|
|
|
parser = argparse.ArgumentParser(description="打包 app.yaml 文件列表到 zip,并可选进行 AES-256-GCM 加密输出 .enc")
|
|
|
|
|
|
parser.add_argument("--aead-key-hex", default=None, help="AES-256-GCM key (64 hex chars = 32 bytes). If set, output encrypted file.")
|
|
|
|
|
|
parser.add_argument("--keep-zip", action="store_true", help="Keep the plaintext zip when encryption is enabled.")
|
|
|
|
|
|
parser.add_argument("--out-ext", default=".enc", help="Encrypted output extension appended to zip path. Default: .enc (produces *.zip.enc)")
|
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
2026-01-22 17:55:11 +08:00
|
|
|
|
print("=" * 60)
|
|
|
|
|
|
print("应用打包脚本")
|
|
|
|
|
|
print("=" * 60)
|
|
|
|
|
|
|
|
|
|
|
|
# 1. 加载 app.yaml
|
|
|
|
|
|
app_info = load_app_yaml('app.yaml')
|
|
|
|
|
|
if app_info is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# 从 version.py 读取版本号
|
|
|
|
|
|
version = get_version_from_version_py()
|
|
|
|
|
|
|
|
|
|
|
|
print(f"\n[INFO] 应用信息:")
|
|
|
|
|
|
print(f" ID: {app_info.get('id', 'N/A')}")
|
|
|
|
|
|
print(f" 名称: {app_info.get('name', 'N/A')}")
|
|
|
|
|
|
print(f" 版本: {version} (来自 version.py)")
|
|
|
|
|
|
print(f" 作者: {app_info.get('author', 'N/A')}")
|
|
|
|
|
|
if app_info.get('version') != version:
|
|
|
|
|
|
print(f" [注意] app.yaml 中的版本 ({app_info.get('version', 'N/A')}) 与 version.py 不一致")
|
|
|
|
|
|
|
|
|
|
|
|
# 2. 获取文件列表
|
|
|
|
|
|
files = app_info.get('files', [])
|
|
|
|
|
|
if not files:
|
|
|
|
|
|
print("[ERROR] app.yaml 中没有找到 files 列表")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
print(f"\n[INFO] 文件列表 ({len(files)} 个文件):")
|
|
|
|
|
|
|
|
|
|
|
|
# 3. 检查文件是否存在
|
|
|
|
|
|
existing_files, missing_files = check_files_exist(files)
|
|
|
|
|
|
|
|
|
|
|
|
if missing_files:
|
|
|
|
|
|
print(f"\n[WARNING] 以下文件不存在,将被跳过:")
|
|
|
|
|
|
for f in missing_files:
|
|
|
|
|
|
print(f" ✗ {f}")
|
|
|
|
|
|
|
|
|
|
|
|
if not existing_files:
|
|
|
|
|
|
print("\n[ERROR] 没有找到任何有效文件,无法打包")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
print(f"\n[INFO] 找到 {len(existing_files)} 个有效文件")
|
|
|
|
|
|
|
|
|
|
|
|
# 4. 创建 zip 包
|
|
|
|
|
|
zip_path = create_zip_package(app_info, existing_files)
|
|
|
|
|
|
|
|
|
|
|
|
if zip_path:
|
2026-01-23 11:28:40 +08:00
|
|
|
|
enc_path = None
|
|
|
|
|
|
if args.aead_key_hex:
|
|
|
|
|
|
try:
|
|
|
|
|
|
enc_path = encrypt_zip_aead(zip_path, args.aead_key_hex, out_ext=args.out_ext)
|
|
|
|
|
|
enc_size = os.path.getsize(enc_path)
|
|
|
|
|
|
print(f"\n[SUCCESS] AEAD加密完成: {os.path.basename(enc_path)} ({enc_size:,} bytes)")
|
|
|
|
|
|
print(f" 文件路径: {os.path.abspath(enc_path)}")
|
|
|
|
|
|
if not args.keep_zip:
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.remove(zip_path)
|
|
|
|
|
|
print(f"[INFO] 已删除明文zip: {os.path.basename(zip_path)}")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"[WARNING] 删除明文zip失败(可忽略): {e}")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"\n[ERROR] AEAD加密失败: {e}")
|
|
|
|
|
|
print("[ERROR] 保留明文zip用于排查。")
|
|
|
|
|
|
|
2026-01-22 17:55:11 +08:00
|
|
|
|
print("\n" + "=" * 60)
|
|
|
|
|
|
print("打包成功完成!")
|
|
|
|
|
|
print("=" * 60)
|
|
|
|
|
|
else:
|
|
|
|
|
|
print("\n" + "=" * 60)
|
|
|
|
|
|
print("打包失败!")
|
|
|
|
|
|
print("=" * 60)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
main()
|