"""CMBot launcher (docs/10-lan-update.md). Compiled to Launcher.exe (PyInstaller onefile) and run instead of launching app/CMBot.exe directly. On each start it: 1. seeds default config/templates/model config into ~/.cmbot on first run; 2. applies a previously staged update if one is ready (fast local swap of staging\\app.new into app\\, keeping app.old\\ for rollback); 3. launches app\\CMBot.exe. It does NOT download anything — downloading happens inside the app (manual "更新" button / background), so startup is never blocked on the network. The running program's data lives in ~/.cmbot (get_data_dir), untouched by updates. """ import argparse import logging import shutil import subprocess import sys from pathlib import Path # Make 'services' importable both frozen (PyInstaller --paths src) and from source. sys.path.insert(0, str(Path(__file__).resolve().parent)) from services import installer # noqa: E402 from services.file_service import get_data_dir # noqa: E402 APP_EXE = "CMBot.exe" CONFIG_FILES = ( "app_config.json", "templates.json", "ai_models.json", "outfit_prompt.txt", "title_prompt.txt", ) logger = logging.getLogger("launcher") def seed_defaults(app_dir, data_dir): """First run: copy factory config/templates/models from app\\config into the data root, without overwriting any existing user file.""" src = app_dir / "config" if not src.is_dir(): return dst = data_dir / "config" dst.mkdir(parents=True, exist_ok=True) for name in CONFIG_FILES: s, d = src / name, dst / name if s.exists() and not d.exists(): shutil.copy2(str(s), str(d)) logger.info("Seeded default %s into %s", name, dst) def run(install_root, no_launch=False): """Seed config, apply any staged update, then launch the app. Returns exit code.""" install_root = Path(install_root) app_dir = install_root / "app" data_dir = get_data_dir() seed_defaults(app_dir, data_dir) try: applied = installer.apply_staged(install_root) if applied: logger.info("Applied staged update v%s", applied) except OSError as exc: logger.warning("Apply staged update failed (using current version): %s", exc) exe = app_dir / APP_EXE if not exe.exists(): logger.error("Executable not found: %s", exe) return 1 if no_launch: logger.info("NoLaunch: would start %s (data=%s)", exe, data_dir) return 0 logger.info("Launching %s", exe) subprocess.Popen([str(exe)], cwd=str(exe.parent)) return 0 def _default_install_root(): if getattr(sys, "frozen", False): return Path(sys.executable).resolve().parent return Path.cwd() def _setup_logging(root): """Configure logging defensively for both console and --windowed builds. A PyInstaller --windowed exe has no stdout/stderr; a read-only install root makes the file handler fail. Add each handler only when it can be created so the launcher never crashes on logging. """ handlers = [] if sys.stderr is not None: handlers.append(logging.StreamHandler()) try: handlers.append(logging.FileHandler(str(root / "launcher.log"), encoding="utf-8")) except OSError: pass logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", handlers=handlers) def main(argv=None): parser = argparse.ArgumentParser(description="CMBot launcher") parser.add_argument("--install-root", default=None, help="install root (default: Launcher.exe's folder)") parser.add_argument("--no-launch", action="store_true", help="apply staged update only, do not start the app") args = parser.parse_args(argv) root = Path(args.install_root) if args.install_root else _default_install_root() _setup_logging(root) return run(root, no_launch=args.no_launch) if __name__ == "__main__": sys.exit(main())