feat(factory): add local flashing toolkit and secure OTA defaults

This commit is contained in:
admin
2026-03-02 01:57:39 +08:00
parent 809fcf6548
commit 3d3c3ebac2
27 changed files with 5260 additions and 4 deletions

View File

@@ -0,0 +1,246 @@
#!/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