config_util.py 3.42 KB
"""
config.json 安全加载器。

公开接口:
- DEFAULT_CONFIG: 默认配置常量
- load_config_safe(path): 区分处理各类 IO / JSON 错误,返回 (config, error_message)
- get_config_dir(): 跨平台返回配置目录
- get_config_path(): 返回 config.json 绝对路径
"""
from __future__ import annotations

import json
import os
import shutil
import sys
import platform
from datetime import datetime
from pathlib import Path
from typing import Tuple


DEFAULT_CONFIG: dict = {
    "api_key": "",
    "saved_prompts": [],
    "db_config": None,
    "last_user": "",
    "saved_password_hash": "",
    "logging_config": {
        "enabled": True,
        "level": "INFO",
        "log_to_console": True,
    },
    "history_config": {
        "max_history_count": 100,
    },
}


def get_config_dir() -> Path:
    """跨平台返回用户级配置目录(与 image_generator.py 中 get_config_dir 一致)。"""
    if getattr(sys, "frozen", False):
        system = platform.system()
        if system == "Darwin":
            d = Path.home() / "Library" / "Application Support" / "ZB100ImageGenerator"
        elif system == "Windows":
            d = Path(os.getenv("APPDATA", str(Path.home()))) / "ZB100ImageGenerator"
        else:
            d = Path.home() / ".config" / "zb100imagegenerator"
    else:
        d = Path(".").resolve()
    d.mkdir(parents=True, exist_ok=True)
    return d


def get_config_path() -> Path:
    return get_config_dir() / "config.json"


def load_config_safe(config_path: Path) -> Tuple[dict, str]:
    """
    安全加载 config.json。

    返回 (config, error):
    - 成功:(合并后的 config dict, "")
    - 失败但可恢复:(DEFAULT_CONFIG 副本, 错误描述);preflight 会根据返回
      的 error 和 config 内容共同决定是否拦截启动
    - 不抛异常

    行为:
    - 文件不存在 → (DEFAULT_CONFIG 副本, "")  # 让 preflight 判断是否允许
    - 空文件 / JSON 损坏 → 备份为 .bak.<timestamp> + 返回默认值 + error
    - PermissionError / OSError → 返回默认值 + error(不备份,读都读不到)
    - 顶层不是 object → 返回默认值 + error
    - 正常 → defaults.update(loaded) 合并后返回(保留未知字段以兼容)
    """
    config_path = Path(config_path)

    if not config_path.exists():
        return dict(DEFAULT_CONFIG), ""

    try:
        content = config_path.read_text(encoding="utf-8")
    except PermissionError as e:
        return dict(DEFAULT_CONFIG), f"permission denied: {e}"
    except OSError as e:
        return dict(DEFAULT_CONFIG), f"IO error: {e}"

    if not content.strip():
        _backup(config_path, reason="empty")
        return dict(DEFAULT_CONFIG), "config.json was empty, using defaults"

    try:
        loaded = json.loads(content)
    except json.JSONDecodeError as e:
        _backup(config_path, reason="parse-error")
        return dict(DEFAULT_CONFIG), f"JSON parse error, backed up to .bak: {e}"

    if not isinstance(loaded, dict):
        return dict(DEFAULT_CONFIG), "config.json top-level is not an object"

    merged = dict(DEFAULT_CONFIG)
    merged.update(loaded)
    return merged, ""


def _backup(src: Path, reason: str) -> None:
    """把损坏 / 空的 config 文件备份,不抛异常。"""
    try:
        ts = datetime.now().strftime("%Y%m%d_%H%M%S")
        dst = src.with_suffix(f".json.bak.{reason}.{ts}")
        shutil.copy2(src, dst)
    except Exception:
        pass