ZB100ImageGenerator.spec 5.41 KB
# -*- mode: python ; coding: utf-8 -*-
"""
PyInstaller spec for ZB100ImageGenerator.

跨平台构建配置的唯一真相源 (macOS + Windows)。
build_mac_universal.sh / build_windows.bat 只负责环境准备,
实际构建调用 `pyinstaller ZB100ImageGenerator.spec`。

修复点:
- macOS 上 PIL/_imaging.so 依赖 @rpath/libtiff.6.dylib 等原生库,
  PyInstaller 把 @rpath 改写成 @loader_path/.. (= Contents/Frameworks/),
  因此 .dylibs/*.dylib 必须平铺到 bundle 根目录。

部署坑 (不是打包问题, 但会让人误以为打包挂了):
- PyInstaller 6.x 会把 PIL/.dylibs 编码成 PIL/__dot__dylibs 目录,
  再建一个 PIL/.dylibs -> __dot__dylibs 的符号链接.
- Frameworks/libtiff.6.dylib 又通过符号链接指向 PIL/.dylibs/libtiff.6.dylib,
  是**两层嵌套 symlink**.
- 走 NAS (某些 SMB 挂载) / 邮件 / 某些解压工具传 .app 时, 内层 symlink
  可能被吞掉, 导致打开时 dlopen 找不到 libtiff.6.dylib.
- 传输务必用 `tar czf` 或 `rsync -a`, 别用 cp / Finder 拖拽过 NAS.

策略 (三重保险):
1. PyInstaller 官方工具 collect_dynamic_libs(destdir='.') —— 推荐做法
2. 显式枚举 PIL/.dylibs/ 和 PIL/.libs/ —— 兜底
3. 详细打印每一步收集结果 —— 下次出错直接看日志定位
"""
import sys
from pathlib import Path
from PyInstaller.utils.hooks import collect_dynamic_libs

IS_MAC = sys.platform == 'darwin'
IS_WIN = sys.platform == 'win32'

print('=' * 60)
print(f'[spec] 平台: {sys.platform}, Python: {sys.version.split()[0]}')

# ----- 图标 -----
if IS_MAC:
    ICON = 'zb100_mac.icns'
elif IS_WIN:
    ICON = 'zb100_windows.ico'
else:
    ICON = None

# ----- 数据文件 -----
# QML 主入口跑时按 `Path(__file__).parent / "qml"` 加载,
# 而 PyInstaller frozen 把入口脚本平铺到 _MEIPASS/ 根(不带 qml_poc/ 前缀),
# 所以 QML 目标路径必须是 _MEIPASS/qml/,不是 _MEIPASS/qml_poc/qml/。
# qmldir + Theme.qml Singleton 注册依赖整个目录平铺过去。
datas = [
    ('config.json', '.'),
    ('qml_poc/qml', 'qml'),                # 整个 QML 目录递归 → _MEIPASS/qml/
    ('jewelry_library.json', '.'),         # 默认珠宝词库
]
if IS_WIN:
    datas.append(('zb100_windows.ico', '.'))
if IS_MAC:
    # macOS 走 BUNDLE 时 icon 由下方 BUNDLE() 处理,但开发期 fallback 也带上
    datas.append(('zb100_mac.icns', '.'))

# ===== Pillow 原生库收集 =====

# 策略 1: PyInstaller 官方推荐
try:
    libs_std = collect_dynamic_libs('PIL', destdir='.')
    print(f'[spec] collect_dynamic_libs("PIL", destdir=".") 返回 {len(libs_std)} 项:')
    for src, dst in libs_std:
        print(f'[spec]   STD  {Path(src).name}  ->  {dst}')
except Exception as e:
    libs_std = []
    print(f'[spec] collect_dynamic_libs 异常: {e}')

# 策略 2: 显式枚举 PIL 包里的原生库目录
libs_extra = []
pil_dir = None
try:
    import PIL
    pil_dir = Path(PIL.__file__).parent
    print(f'[spec] PIL 安装位置: {pil_dir}')
    for sub in ('.dylibs', '.libs'):
        d = pil_dir / sub
        if d.is_dir():
            print(f'[spec] 发现 {sub}/ 目录, 内容:')
            for lib in sorted(d.iterdir()):
                print(f'[spec]   {lib.name}  ({lib.stat().st_size} bytes)')
                if lib.suffix.lower() in ('.dylib', '.so', '.dll'):
                    libs_extra.append((str(lib), '.'))
        else:
            print(f'[spec] {sub}/ 不存在')
except Exception as e:
    print(f'[spec] 枚举 PIL 失败: {e}')

# 合并去重 (按源路径)
seen = set()
pil_native_libs = []
for src, dst in libs_std + libs_extra:
    if src not in seen:
        seen.add(src)
        pil_native_libs.append((src, dst))

print(f'[spec] 最终 Pillow 原生库数量: {len(pil_native_libs)}')
for src, dst in pil_native_libs:
    print(f'[spec]   FINAL  {Path(src).name}  ->  {dst}')

if IS_MAC and len(pil_native_libs) == 0:
    print('[spec] !!! 警告: macOS 构建但未发现 Pillow 原生库, 打包产物必然启动失败 !!!')

print('=' * 60)

a = Analysis(
    ['qml_poc/main_qml.py'],
    # main_qml.py 顶部用 sys.path.insert(0, ROOT) 把项目根加到搜索路径,
    # 但 PyInstaller 静态分析看不到这一步 — 显式给 pathex 让它扫到
    # audit_logger / task_queue / config_util / preflight / bridges / core 等顶层模块。
    pathex=[str(Path('.').resolve())],
    binaries=pil_native_libs,
    datas=datas,
    # PyInstaller 6.x 自动 hook PySide6 大部分子模块,这里显式列出兜底;
    # QtQuick.Dialogs 等纯 QML 模块由 datas QML 目录间接拉入。
    hiddenimports=[
        'PySide6.QtQml',
        'PySide6.QtQuick',
        'PySide6.QtQuickControls2',
    ],
    hookspath=[],
    hooksconfig={},
    runtime_hooks=[],
    excludes=[],
    noarchive=False,
    optimize=0,
)
pyz = PYZ(a.pure)

exe = EXE(
    pyz,
    a.scripts,
    [],
    exclude_binaries=True,
    name='ZB100ImageGenerator',
    debug=False,
    bootloader_ignore_signals=False,
    strip=False,
    upx=True,
    console=False,
    disable_windowed_traceback=False,
    argv_emulation=False,
    target_arch=None,
    codesign_identity=None,
    entitlements_file=None,
    icon=[ICON] if ICON else None,
)
coll = COLLECT(
    exe,
    a.binaries,
    a.datas,
    strip=False,
    upx=True,
    upx_exclude=[],
    name='ZB100ImageGenerator',
)

if IS_MAC:
    app = BUNDLE(
        coll,
        name='ZB100ImageGenerator.app',
        icon=ICON,
        bundle_identifier=None,
    )