main_qml.py 12.1 KB
"""QML 主入口 — 装载业务桥层 + Apple 风格 QtQuick UI。

启动序列(与旧 image_generator.main() 对齐):
  Phase 0  enable_crash_diagnostics(faulthandler / excepthook / Qt msgHandler)
  Phase 1  init_logging(RotatingFileHandler 写 app.log,5MB×5 滚动)
  Phase 2  log_system_info(OS / Python / PySide6 / Qt / Pillow / google-genai 版本)
  Phase 2.5 cleanup_clipboard_tempfiles
  Phase 3  config 路径 + 从 bundled 拷贝(frozen 时) + sync_bundled_api_key
  Phase 4  QGuiApplication + 窗口图标
  Phase 4.5 preflight_check(任一失败弹错退出,避免后续撞错)
  Phase 5  业务核心(HistoryManager / JewelryLibraryManager / TaskQueueManager)
  Phase 5.5 init_audit_logger(单例,后台 UploadWorker 跑)
  Phase 6  实例化 5 个桥 + AppState
  Phase 7  QQmlApplicationEngine 装 contextProperty 并 load Main.qml
  Phase 8  退出前 audit shutdown / flush

跑:.venv/Scripts/pythonw.exe qml_poc/main_qml.py
"""
import logging
import os
import platform
import shutil
import sys
from pathlib import Path

from PySide6.QtCore import Property, QObject, QUrl, Signal, Slot
from PySide6.QtGui import QGuiApplication, QIcon
from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtQuickControls2 import QQuickStyle

# 确保 import 路径包含项目根(qml_poc/ 跑 main_qml.py 时也能 import core/bridges)
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))

from audit_logger import init_audit_logger  # noqa: E402
from bridges.auth import AuthBridge  # noqa: E402
from bridges.history import HistoryBridge  # noqa: E402
from bridges.imagegen import ImageGenBridge  # noqa: E402
from bridges.jewelry import JewelryBridge  # noqa: E402
from bridges.taskqueue import TaskQueueBridge  # noqa: E402
from config_util import (  # noqa: E402
    get_config_dir,
    get_config_path,
    sync_bundled_api_key,
)
from core.history import HistoryManager  # noqa: E402
from core.jewelry import JewelryLibraryManager  # noqa: E402
from core.runtime import (  # noqa: E402
    cleanup_clipboard_tempfiles,
    enable_crash_diagnostics,
    init_logging,
    log_system_info,
)
from task_queue import TaskQueueManager  # noqa: E402

logger = logging.getLogger(__name__)


class AppState(QObject):
    """全局轻量状态。登录态委托给 AuthBridge,本类只管当前 tab 索引等纯 UI 状态。

    PoC 调试:
      QML_AUTO_LOGIN=1 跳过登录
      QML_DEBUG_TAB=0|1|2 控制启动 tab
    """
    loggedInChanged = Signal()
    currentTabChanged = Signal()

    def __init__(self, auth_bridge: AuthBridge):
        super().__init__()
        self._auth = auth_bridge
        self._poc_force_login = os.environ.get("QML_AUTO_LOGIN", "") == "1"
        try:
            self._current_tab = int(os.environ.get("QML_DEBUG_TAB", "0"))
        except ValueError:
            self._current_tab = 0
        self._auth.loggedInChanged.connect(self.loggedInChanged.emit)

    @Property(bool, notify=loggedInChanged)
    def loggedIn(self) -> bool:
        return self._poc_force_login or self._auth.loggedIn

    @Property(int, notify=currentTabChanged)
    def currentTab(self) -> int:
        return self._current_tab

    @Slot(str, str, result=bool)
    def login(self, username: str, password: str) -> bool:
        return self._auth.login(username, password)

    @Slot()
    def logout(self) -> None:
        self._auth.logout()

    @Slot(int)
    def setTab(self, idx: int) -> None:
        self._current_tab = idx
        self.currentTabChanged.emit()


# -------------------- 启动期 helpers --------------------

def _resolve_app_icon_path() -> Path | None:
    """定位窗口图标:开发环境读项目根,frozen 读 _MEIPASS / exe 同目录。"""
    system = platform.system()
    icon_name = "zb100_windows.ico" if system == "Windows" else "zb100_mac.icns"

    candidates = []
    if getattr(sys, "frozen", False):
        if hasattr(sys, "_MEIPASS"):
            candidates.append(Path(sys._MEIPASS) / icon_name)
        candidates.append(Path(sys.executable).parent / icon_name)
        if system == "Darwin":
            candidates.append(Path(sys.executable).parent.parent / "Resources" / icon_name)
    else:
        candidates.append(ROOT / icon_name)

    for p in candidates:
        if p.exists():
            return p
    return None


def _ensure_user_config_from_bundle(config_path: Path) -> None:
    """frozen 启动时若用户目录缺 config.json,从打包 bundle 拷一份过来。"""
    if config_path.exists() or not getattr(sys, "frozen", False):
        return
    bundled = None
    if platform.system() == "Darwin":
        bundled = Path(sys.executable).parent.parent / "Resources" / "config.json"
    else:
        bundled = Path(sys.executable).parent / "config.json"
    if not bundled.exists() and hasattr(sys, "_MEIPASS"):
        meipass = Path(sys._MEIPASS) / "config.json"
        if meipass.exists():
            bundled = meipass
    if bundled and bundled.exists():
        try:
            config_path.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(bundled, config_path)
            logger.info(f"[BOOT] 已从 bundled 拷贝 config 到 {config_path}")
        except Exception:
            logger.exception(f"[BOOT] 从 bundled 拷贝 config 失败")


def _resolve_logs_dir() -> Path:
    """从 init_logging 设过的 RotatingFileHandler 取 logs 目录路径,给 audit_logger 用。"""
    for h in logging.getLogger().handlers:
        base = getattr(h, "baseFilename", None)
        if base:
            return Path(base).parent
    # 兜底
    return get_config_dir()


# -------------------- main --------------------

def main():
    # Phase 0: 崩溃诊断(最早执行)
    print("[BOOT] Phase 0: 启用崩溃诊断…")
    enable_crash_diagnostics()

    # Phase 1: 日志系统
    print("[BOOT] Phase 1: 初始化日志系统…")
    if not init_logging():
        print("警告:日志系统初始化失败,将继续运行但不写文件日志")
    logger.info("[BOOT] Phase 1 完成: 日志系统就绪")

    # Phase 2: 系统环境信息
    log_system_info()

    # Phase 2.5: 清理遗留剪贴板临时文件
    try:
        removed = cleanup_clipboard_tempfiles(max_age_hours=24)
        if removed > 0:
            logger.info(f"[BOOT] 已清理 {removed} 个超过 24 小时的剪贴板临时文件")
    except Exception:
        logger.exception("[BOOT] 清理剪贴板临时文件失败")

    # Phase 3: config 路径 + 从 bundled 拷贝 + sync api_key
    logger.info("[BOOT] Phase 3: 定位配置文件…")
    config_dir = get_config_dir()
    config_path = get_config_path()
    _ensure_user_config_from_bundle(config_path)
    try:
        sync_bundled_api_key(config_path)
    except Exception:
        logger.exception("[BOOT] api_key 同步异常,继续启动")

    # Phase 4: QGuiApplication + 窗口图标
    logger.info("[BOOT] Phase 4: 创建 QGuiApplication…")
    QQuickStyle.setStyle("Basic")
    app = QGuiApplication(sys.argv)
    app.setApplicationName("珠宝壹佰图像生成器")
    app.setOrganizationName("ZB100")

    icon_path = _resolve_app_icon_path()
    if icon_path is not None:
        app_icon = QIcon(str(icon_path))
        if not app_icon.isNull():
            app.setWindowIcon(app_icon)
            logger.info(f"[BOOT] 窗口图标已设: {icon_path}")
        else:
            logger.warning(f"[BOOT] 图标文件无效: {icon_path}")
    else:
        logger.info("[BOOT] 未找到窗口图标资源(开发环境正常)")

    # Phase 4.5: preflight 启动门禁
    logger.info("[BOOT] Phase 4.5: 启动门禁 preflight…")
    audit_queue_path = config_dir / "audit_queue.ndjson"
    logs_dir = _resolve_logs_dir()

    try:
        from preflight import (
            handle_preflight_failure,
            handle_version_too_old,
            is_version_error,
            preflight_check,
        )
    except Exception:
        logger.exception("[BOOT] preflight 模块 import 失败,跳过门禁")
        loaded_config = {}
        preflight_ok = True
    else:
        try:
            preflight_ok, preflight_err, loaded_config = preflight_check(
                config_path, audit_queue_path
            )
        except Exception:
            logger.exception("[BOOT] preflight_check 抛异常,按通过处理")
            preflight_ok = True
            loaded_config = {}
            preflight_err = ""

        if not preflight_ok:
            logger.error(f"[BOOT] preflight 失败: {preflight_err}")
            try:
                if is_version_error(preflight_err):
                    handle_version_too_old(preflight_err, logs_dir)
                else:
                    handle_preflight_failure(preflight_err, logs_dir)
            except Exception:
                logger.exception("[BOOT] preflight 失败处理本身又出异常")
            return  # handle_* 会 sys.exit(1)

    # Phase 5: 加载 config 字段
    api_key = loaded_config.get("api_key", "") or ""
    db_config = loaded_config.get("db_config")  # preflight 通过时必非空
    saved_prompts = loaded_config.get("saved_prompts", []) or []
    last_user = loaded_config.get("last_user", "") or ""
    saved_password_hash = loaded_config.get("saved_password_hash", "") or ""

    # 业务核心
    logger.info("[BOOT] Phase 5: 实例化业务核心…")
    history_manager = HistoryManager()
    jewelry_manager = JewelryLibraryManager(config_dir)
    task_queue_manager = TaskQueueManager()  # 单例

    # Phase 5.5: audit_logger 单例
    audit_logger_inst = None
    if db_config:
        try:
            audit_logger_inst = init_audit_logger(db_config, audit_queue_path, logs_dir)
            audit_logger_inst.start()
            logger.info("[BOOT] audit logger 已启动")
        except Exception:
            logger.exception("[BOOT] audit_logger 启动失败(不影响 UI)")
            audit_logger_inst = None

    # Phase 6: 桥层
    logger.info("[BOOT] Phase 6: 实例化 QML 桥层…")
    auth_bridge = AuthBridge(
        db_config=db_config,
        audit_logger=audit_logger_inst,
        last_user=last_user,
        saved_password_hash=saved_password_hash,
        config_path=config_path,
    )
    image_gen_bridge = ImageGenBridge(
        task_queue_manager=task_queue_manager,
        history_manager=history_manager,
        auth_bridge=auth_bridge,
        api_key=api_key,
        saved_prompts=saved_prompts,
        config_path=config_path,
    )
    history_bridge = HistoryBridge(history_manager=history_manager)
    task_queue_bridge = TaskQueueBridge(task_queue_manager=task_queue_manager)
    jewelry_bridge = JewelryBridge(library_manager=jewelry_manager)
    app_state = AppState(auth_bridge=auth_bridge)

    # 新生成完图自动入历史 tab 列表
    image_gen_bridge.taskCompleted.connect(
        lambda task_id, result_path, prompt, model:
        history_bridge.addNew(Path(result_path).parent.name)
    )

    # 启动期预填一次历史
    history_bridge.refresh()

    # Phase 7: QML 装载
    logger.info("[BOOT] Phase 7: 装载 QML…")
    engine = QQmlApplicationEngine()
    ctx = engine.rootContext()
    ctx.setContextProperty("appState", app_state)
    ctx.setContextProperty("auth", auth_bridge)
    ctx.setContextProperty("imageGen", image_gen_bridge)
    ctx.setContextProperty("history", history_bridge)
    ctx.setContextProperty("taskQueue", task_queue_bridge)
    ctx.setContextProperty("jewelry", jewelry_bridge)

    qml_dir = Path(__file__).parent / "qml"
    engine.addImportPath(str(qml_dir))
    engine.load(QUrl.fromLocalFile(str(qml_dir / "Main.qml")))

    if not engine.rootObjects():
        logger.error("QML load failed")
        sys.exit(1)

    # Phase 8: 退出 hook
    def _flush_audit_on_quit():
        if audit_logger_inst is None:
            return
        try:
            audit_logger_inst.shutdown(timeout=5.0)
            logger.info("[QUIT] audit logger 已 flush")
        except Exception:
            logger.exception("[QUIT] audit shutdown 失败")

    app.aboutToQuit.connect(_flush_audit_on_quit)

    logger.info("[BOOT] 启动完成,进入主循环")
    rc = app.exec()
    logger.info(f"[QUIT] 主循环退出 rc={rc}")
    sys.exit(rc)


if __name__ == "__main__":
    main()