#!/usr/bin/env python3 from __future__ import annotations import json import os import sys from pathlib import Path from typing import Any, Dict, Iterable, Optional, Tuple from factory_common.signing import SignatureError, verify_file_signature DEFAULT_CONFIG_NAME = "factory_private.json" CONF_DIR_NAME = "conf" CONFIG_ENV_KEYS: Tuple[str, ...] = ("FACTORY_PRIVATE_CONFIG", "FACTORY_PRIVATE_CONFIG_PATH") CACHE_DIR_NAME = "talkingq-factory" CACHE_FILENAME = "factory_private_path.txt" _MISSING = object() _ENC_MAGIC = b"FENC" class EncryptedConfigError(Exception): pass def resolve_private_config_path(base_dir: Path, allow_plain: bool = True) -> Optional[Path]: for key in CONFIG_ENV_KEYS: value = os.getenv(key) if value: return Path(value).expanduser() candidate = base_dir / CONF_DIR_NAME / DEFAULT_CONFIG_NAME enc_candidate = Path(str(candidate) + ".enc") if allow_plain and candidate.exists(): return candidate if enc_candidate.exists(): return enc_candidate return None def resolve_private_config_source_path(base_dir: Path) -> Optional[Path]: for key in CONFIG_ENV_KEYS: value = os.getenv(key) if value: path = Path(value).expanduser() if path.suffix == ".enc": plain = Path(str(path)[:-4]) if plain.exists(): return plain return path candidate = base_dir / CONF_DIR_NAME / DEFAULT_CONFIG_NAME if candidate.exists(): return candidate enc_candidate = Path(str(candidate) + ".enc") if enc_candidate.exists(): return enc_candidate return None def is_frozen() -> bool: if getattr(sys, "frozen", False): return True if getattr(sys, "nuitka_version", None): return True if globals().get("__compiled__") is not None: return True return "__compiled__" in sys.modules def _cache_dir() -> Path: if os.name == "nt": base = os.getenv("APPDATA") or os.getenv("LOCALAPPDATA") if base: return Path(base) / CACHE_DIR_NAME return Path.home() / "AppData" / "Roaming" / CACHE_DIR_NAME if sys.platform == "darwin": return Path.home() / "Library" / "Application Support" / CACHE_DIR_NAME base = os.getenv("XDG_STATE_HOME") or os.getenv("XDG_CONFIG_HOME") if base: return Path(base) / CACHE_DIR_NAME return Path.home() / ".config" / CACHE_DIR_NAME def _cache_path() -> Path: return _cache_dir() / CACHE_FILENAME def _load_cached_path() -> Optional[Path]: path = _cache_path() try: raw = path.read_text(encoding="utf-8").strip() except OSError: return None if not raw: return None return Path(raw).expanduser() def _save_cached_path(path: Path) -> None: cache_dir = _cache_dir() try: cache_dir.mkdir(parents=True, exist_ok=True) _cache_path().write_text(str(path), encoding="utf-8") except OSError: return def _prompt_private_config_path(title: str, initial_dir: Optional[Path] = None) -> Optional[Path]: try: import tkinter as tk from tkinter import filedialog except Exception: return None root = tk.Tk() root.withdraw() try: root.attributes("-topmost", True) except Exception: pass dialog_args = { "title": title, "filetypes": [("JSON", "*.json"), ("All", "*.*")], } if initial_dir: dialog_args["initialdir"] = str(initial_dir) path = filedialog.askopenfilename(**dialog_args) root.destroy() if not path: return None return Path(path).expanduser() def ensure_private_config_path(base_dir: Path, prompt_title: str = "选择工厂私有配置文件") -> Path: path = resolve_private_config_path(base_dir) if path is not None and path.exists(): _save_cached_path(path) return path cached = _load_cached_path() if cached is not None and cached.exists(): os.environ["FACTORY_PRIVATE_CONFIG"] = str(cached) return cached initial_dir = cached.parent if cached else None selected = _prompt_private_config_path(prompt_title, initial_dir=initial_dir) if selected is None: raise FileNotFoundError( "缺少工厂私有配置文件。请设置环境变量 " f"{'/'.join(CONFIG_ENV_KEYS)} 或在弹窗中选择配置文件。" ) os.environ["FACTORY_PRIVATE_CONFIG"] = str(selected) _save_cached_path(selected) return selected def _is_encrypted_payload(payload: bytes) -> bool: return payload.startswith(_ENC_MAGIC) def _load_private_config_from_path( path: Path, verify_signature: bool = True, ) -> Dict[str, Any]: if verify_signature: try: verify_file_signature(path) except SignatureError as exc: raise PermissionError(f"私有配置签名校验失败:{exc}") from exc try: payload = path.read_bytes() except OSError as exc: # noqa: BLE001 raise FileNotFoundError(f"无法读取私有配置文件:{path}") from exc if _is_encrypted_payload(payload): raise EncryptedConfigError("私有配置已加密。当前版本仅支持明文配置文件。") else: try: raw = payload.decode("utf-8") except UnicodeDecodeError as exc: raise ValueError(f"私有配置文件格式错误:{path}") from exc try: data = json.loads(raw) except json.JSONDecodeError as exc: raise ValueError(f"私有配置文件格式错误:{path}") from exc if not isinstance(data, dict): raise ValueError("私有配置文件根节点必须是对象。") return data def load_private_config(base_dir: Path) -> Dict[str, Any]: path = resolve_private_config_path(base_dir, allow_plain=True) if path is None or not path.exists(): raise FileNotFoundError( "缺少工厂私有配置文件。请设置环境变量 " f"{'/'.join(CONFIG_ENV_KEYS)} 指定配置路径。" ) return _load_private_config_from_path(path) def load_private_config_from_path(path: Path) -> Dict[str, Any]: return _load_private_config_from_path(path) def load_private_config_source(base_dir: Path) -> Dict[str, Any]: path = resolve_private_config_source_path(base_dir) if path is None or not path.exists(): raise FileNotFoundError( "缺少工厂私有配置文件。请设置环境变量 " f"{'/'.join(CONFIG_ENV_KEYS)} 指定配置路径。" ) if path.suffix == ".enc": raise EncryptedConfigError( "私有配置源需要明文 factory_private.json(打包用)。请提供明文配置文件。" ) return _load_private_config_from_path( path, verify_signature=False, ) def _walk(data: Dict[str, Any], keys: Iterable[str]) -> Any: current: Any = data for key in keys: if not isinstance(current, dict) or key not in current: return _MISSING current = current[key] return current def get_required( data: Dict[str, Any], keys: Iterable[str], expected_type: Optional[tuple[type, ...]] = None, allow_empty: bool = False, ) -> Any: value = _walk(data, keys) if value is _MISSING: raise KeyError("缺少私有配置项: " + ".".join(keys)) if expected_type and not isinstance(value, expected_type): raise TypeError("私有配置类型错误: " + ".".join(keys)) if not allow_empty and isinstance(value, str) and not value.strip(): raise ValueError("私有配置不能为空: " + ".".join(keys)) return value def get_optional(data: Dict[str, Any], keys: Iterable[str], default: Any = None) -> Any: value = _walk(data, keys) if value is _MISSING: return default return value