2026-06-18 11:58:06 +08:00
|
|
|
"""CMBot launcher (docs/10-lan-update.md).
|
2026-06-18 10:49:45 +08:00
|
|
|
|
|
|
|
|
Compiled to Launcher.exe (PyInstaller onefile) and run instead of launching
|
|
|
|
|
app/CMBot.exe directly. On each start it:
|
|
|
|
|
|
2026-06-22 16:57:11 +08:00
|
|
|
1. seeds default config/templates/model config into ~/.cmbot on first run;
|
2026-06-18 11:58:06 +08:00
|
|
|
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.
|
2026-06-18 10:49:45 +08:00
|
|
|
|
2026-06-18 11:58:06 +08:00
|
|
|
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.
|
2026-06-18 10:49:45 +08:00
|
|
|
"""
|
|
|
|
|
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))
|
|
|
|
|
|
2026-06-18 11:58:06 +08:00
|
|
|
from services import installer # noqa: E402
|
|
|
|
|
from services.file_service import get_data_dir # noqa: E402
|
2026-06-18 10:49:45 +08:00
|
|
|
|
|
|
|
|
APP_EXE = "CMBot.exe"
|
2026-06-23 17:51:46 +08:00
|
|
|
CONFIG_FILES = (
|
|
|
|
|
"app_config.json",
|
|
|
|
|
"templates.json",
|
|
|
|
|
"ai_models.json",
|
|
|
|
|
"outfit_prompt.txt",
|
|
|
|
|
"title_prompt.txt",
|
|
|
|
|
)
|
2026-06-18 10:49:45 +08:00
|
|
|
|
|
|
|
|
logger = logging.getLogger("launcher")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def seed_defaults(app_dir, data_dir):
|
2026-06-22 16:57:11 +08:00
|
|
|
"""First run: copy factory config/templates/models from app\\config into the data
|
2026-06-18 10:49:45 +08:00
|
|
|
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):
|
2026-06-18 11:58:06 +08:00
|
|
|
"""Seed config, apply any staged update, then launch the app. Returns exit code."""
|
2026-06-18 10:49:45 +08:00
|
|
|
install_root = Path(install_root)
|
|
|
|
|
app_dir = install_root / "app"
|
|
|
|
|
data_dir = get_data_dir()
|
|
|
|
|
|
|
|
|
|
seed_defaults(app_dir, data_dir)
|
|
|
|
|
|
2026-06-18 11:58:06 +08:00
|
|
|
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)
|
2026-06-18 10:49:45 +08:00
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 10:56:53 +08:00
|
|
|
def _setup_logging(root):
|
|
|
|
|
"""Configure logging defensively for both console and --windowed builds.
|
|
|
|
|
|
2026-06-18 11:58:06 +08:00
|
|
|
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.
|
2026-06-18 10:56:53 +08:00
|
|
|
"""
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 11:58:06 +08:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 10:49:45 +08:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
sys.exit(main())
|