"""One-click launcher — starts all 4 services from the new self-contained folder.
Port cleanup is handled by 启动.bat (taskkill /F /IM python.exe) before calling this script.
"""
import subprocess, sys, os, time, webbrowser

_SELF_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(_SELF_DIR)  # start_all.py 位于 _launcher/ 子目录,安装根 = 其上一级
os.chdir(ROOT)

# 便携版 Python 只带了 tkinter 的 DLL、缺 Tcl/Tk 数据文件(init.tcl)，
# 导致 import tkinter 能过但 Tk() 开窗即崩。数据文件已补进 _internal/python/tcl/，
# 这里显式指向它，原生品牌启动窗才能开出来。(必须在 import tkinter 之前设好)
_tcl_dir = os.path.join(ROOT, "_internal", "python", "tcl", "tcl8.6")
_tk_dir = os.path.join(ROOT, "_internal", "python", "tcl", "tk8.6")
if os.path.isdir(_tcl_dir):
    os.environ.setdefault("TCL_LIBRARY", _tcl_dir)
if os.path.isdir(_tk_dir):
    os.environ.setdefault("TK_LIBRARY", _tk_dir)

# 用 pythonw.exe(无控制台)运行时 sys.stdout/stderr 为 None，print 会崩溃。
# 把输出重定向到 logs/launcher.log，既防崩又留排错线索。
if sys.stdout is None or sys.stderr is None:
    try:
        os.makedirs(os.path.join(ROOT, "logs"), exist_ok=True)
        _log_name = "launcher_web.log" if "--web-window" in sys.argv else "launcher.log"
        _launcher_log = open(os.path.join(ROOT, "logs", _log_name), "w", encoding="utf-8", errors="ignore")
        sys.stdout = _launcher_log
        sys.stderr = _launcher_log
    except Exception:
        import io
        sys.stdout = sys.stderr = io.StringIO()

_procs: list[subprocess.Popen] = []


def _kill_all():
    """Terminate all child processes."""
    for p in _procs:
        try:
            p.terminate()
        except Exception:
            pass
    time.sleep(0.3)
    for p in _procs:
        try:
            p.kill()
        except Exception:
            pass


def _find_vite_js(root: str) -> str | None:
    """Locate vite.js inside pnpm's virtual store (no .cmd on Windows)."""
    pnpm = os.path.join(root, "node_modules", ".pnpm")
    if not os.path.isdir(pnpm):
        return None
    for dirname in os.listdir(pnpm):
        if dirname.startswith("vite@") and os.path.isdir(os.path.join(pnpm, dirname, "node_modules", "vite")):
            cand = os.path.join(pnpm, dirname, "node_modules", "vite", "bin", "vite.js")
            if os.path.isfile(cand):
                return cand
    return None


def start_service(name, cmd, cwd=None, env_extra=None, no_window=True):
    """Start a service in background."""
    print(f"[{name}] Starting...")
    try:
        env = os.environ.copy()
        if env_extra:
            env.update(env_extra)
        flags = subprocess.CREATE_NEW_PROCESS_GROUP
        if no_window:
            flags |= subprocess.CREATE_NO_WINDOW
        # 隐藏黑窗后，把各服务输出写到 logs/<名字>.log，方便排错
        out = None
        try:
            logs_dir = os.path.join(ROOT, "logs")
            os.makedirs(logs_dir, exist_ok=True)
            safe = name.split(":")[0].strip().replace(" ", "_")
            out = open(os.path.join(logs_dir, safe + ".log"), "w", encoding="utf-8", errors="ignore")
        except Exception:
            out = None
        p = subprocess.Popen(
            cmd, cwd=cwd or ROOT, env=env,
            creationflags=flags,
            stdout=out, stderr=subprocess.STDOUT if out else None,
        )
        print(f"  OK (PID={p.pid})")
        _procs.append(p)
        return p
    except Exception as e:
        print(f"  ERROR: {e}")
        return None


def _wait_frontend(url, timeout=30):
    """等前端端口就绪，最多等 timeout 秒（避免窗口打开时白屏）。"""
    import urllib.request
    end = time.time() + timeout
    while time.time() < end:
        try:
            urllib.request.urlopen(url, timeout=2)
            return True
        except Exception:
            time.sleep(0.5)
    return False


def _open_web_window(url):
    """打开【网页主程序窗】并阻塞，窗口关闭后返回（返回后 main 会收尾关服务）。
    顺序：① 原生窗口 pywebview → ② Edge/Chrome 应用窗 → ③ 默认浏览器常驻。"""
    _wait_frontend(url)

    # ① 原生窗口（pywebview + WebView2）
    try:
        import webview  # noqa
        # 启用右键菜单（复制/粘贴等），但关闭 DevTools 自动弹出
        webview.settings['OPEN_DEVTOOLS_IN_DEBUG'] = False
        webview.create_window("YuaHuman", url, width=1280, height=840)
        _icon = os.path.join(_SELF_DIR, "app_icon.ico")
        _kwargs = {"debug": True}
        if os.path.isfile(_icon):
            _kwargs["icon"] = _icon
        webview.start(**_kwargs)  # 阻塞至窗口关闭
        return
    except Exception as e:
        print(f"[窗口] 原生窗口不可用，回退应用窗模式：{e}")

    # ② Edge/Chrome 应用模式（独立进程，关窗即返回）
    pf = os.environ.get("ProgramFiles", r"C:\Program Files")
    pfx = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
    local = os.environ.get("LocalAppData", "")
    candidates = [
        os.path.join(pfx, "Microsoft", "Edge", "Application", "msedge.exe"),
        os.path.join(pf, "Microsoft", "Edge", "Application", "msedge.exe"),
        os.path.join(pf, "Google", "Chrome", "Application", "chrome.exe"),
        os.path.join(pfx, "Google", "Chrome", "Application", "chrome.exe"),
    ]
    if local:
        candidates.append(os.path.join(local, "Google", "Chrome", "Application", "chrome.exe"))
    profile = os.path.join(os.environ.get("TEMP", ROOT), "videoagent_appwin")
    for exe in candidates:
        if os.path.isfile(exe):
            try:
                proc = subprocess.Popen([exe, f"--app={url}", f"--user-data-dir={profile}",
                                         "--no-first-run", "--window-size=1280,840"])
                proc.wait()  # 阻塞至窗口关闭
                return
            except Exception:
                pass

    # ③ 默认浏览器 + 常驻
    try:
        os.startfile(url)
    except Exception:
        try:
            webbrowser.open(url)
        except Exception:
            print(f"Please visit {url}")
    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        pass


def _make_logo_photo(px):
    """返回首页/系统 logo 的 Tk PhotoImage(px×px)。优先加载 app_icon.ico
    （与网页窗标题栏、favicon 同一 logo）；缺失或失败则退化旧的渐变圆角徽标。失败返回 None。"""
    try:
        from PIL import Image, ImageTk
        _ico = os.path.join(_SELF_DIR, "app_icon.ico")
        if os.path.isfile(_ico):
            _img = Image.open(_ico).convert("RGBA").resize((px, px), Image.LANCZOS)
            return ImageTk.PhotoImage(_img)
    except Exception:
        pass
    try:
        from PIL import Image, ImageDraw, ImageTk
        top, bot = (58, 91, 255), (122, 92, 255)  # #3b5bff → #7a5cff
        grad = Image.new("RGB", (px, px), top)
        d = ImageDraw.Draw(grad)
        for y in range(px):
            t = y / max(1, px - 1)
            d.line([(0, y), (px, y)], fill=(
                int(top[0] + (bot[0] - top[0]) * t),
                int(top[1] + (bot[1] - top[1]) * t),
                int(top[2] + (bot[2] - top[2]) * t)))
        mask = Image.new("L", (px, px), 0)
        ImageDraw.Draw(mask).rounded_rectangle([0, 0, px - 1, px - 1], radius=int(px * 0.28), fill=255)
        img = Image.new("RGBA", (px, px), (0, 0, 0, 0))
        img.paste(grad, (0, 0), mask)
        dd = ImageDraw.Draw(img)
        cx = cy = px * 0.5
        s = px * 0.22
        dd.polygon([(cx - s * 0.55, cy - s), (cx - s * 0.55, cy + s), (cx + s, cy)], fill=(255, 255, 255, 240))
        return ImageTk.PhotoImage(img)
    except Exception:
        return None


def _apply_win_taskbar_icon(root, ico_path):
    """用 Win32 WM_SETICON 把 .ico 硬设为窗口大/小图标(标题栏+任务栏)。

    绕开 Tk 在 Windows 上偶发丢图标(露出 Tk 羽毛)的时序问题:
    SetCurrentProcessExplicitAppUserModelID 会新建一个任务栏按钮,Windows 在按钮
    创建瞬间就去读窗口 HICON——这可能早于 Tk 的 iconbitmap/iconphoto 生效,于是
    回退到 Tk 自带羽毛。这里直接 LoadImage 出真 HICON 用 SendMessage 压进 HWND,
    确定性地覆盖,谁都改不回羽毛。非 Windows 或缺文件则安静跳过。"""
    if os.name != "nt" or not os.path.isfile(ico_path):
        return
    try:
        import ctypes
        user32 = ctypes.windll.user32
        WM_SETICON, ICON_SMALL, ICON_BIG, IMAGE_ICON = 0x0080, 0, 1, 1
        LR_LOADFROMFILE = 0x0010
        SM_CXICON, SM_CYICON, SM_CXSMICON, SM_CYSMICON = 11, 12, 49, 50

        def _load(cx, cy):
            return user32.LoadImageW(None, ico_path, IMAGE_ICON,
                                     user32.GetSystemMetrics(cx),
                                     user32.GetSystemMetrics(cy), LR_LOADFROMFILE)

        hicon_big = _load(SM_CXICON, SM_CYICON)      # 任务栏(通常 32/48)
        hicon_sm = _load(SM_CXSMICON, SM_CYSMICON)   # 标题栏(通常 16)
        # winfo_id 是内层窗口;GetParent 得到带任务栏按钮的顶层框
        hwnd = user32.GetParent(root.winfo_id()) or root.winfo_id()
        if hicon_big:
            user32.SendMessageW(hwnd, WM_SETICON, ICON_BIG, hicon_big)
        if hicon_sm:
            user32.SendMessageW(hwnd, WM_SETICON, ICON_SMALL, hicon_sm)
        root._hicon_refs = (hicon_big, hicon_sm)     # 存引用防句柄被回收
    except Exception:
        pass


def _launch_web_child(url):
    """开【网页操作窗】子进程(pythonw，无黑窗)，与常驻的原生后端窗并存。
    子进程跑本脚本的 --web-window 分支：只开网页窗、不重启服务。
    子进程登记进 _procs，关闭后端窗时由 _kill_all 一并收掉。"""
    pyw = os.path.join(ROOT, "_internal", "python", "pythonw.exe")
    if not os.path.isfile(pyw):
        pyw = sys.executable
    # 直接落到激活/登录页：登录页是空白布局，只调用未被拦的 /activation/info/，
    # 不会像首屏 /home 那样触发被激活闸门拦截的接口而报 403。激活成功后再跳主界面。
    open_url = url.rstrip("/") + "/login"
    try:
        p = subprocess.Popen([pyw, os.path.abspath(__file__), "--web-window", open_url],
                             creationflags=subprocess.CREATE_NO_WINDOW)
        _procs.append(p)
        print(f"[网页窗] 已开子进程 PID={p.pid} → {open_url}")
    except Exception as e:
        print(f"[网页窗] 打开失败：{e}")


# ---------- 版本更新 ----------
def _read_local_version():
    """读取本地已安装版本号。"""
    vf = os.path.join(ROOT, "backend", "VERSION")
    try:
        with open(vf, "r", encoding="utf-8") as f:
            return f.read().strip()
    except Exception:
        return "0"


def _check_update():
    """向本地后端查询是否有可用更新。返回 update dict 或 None。"""
    import urllib.request, json
    try:
        req = urllib.request.Request("http://127.0.0.1:8000/activation/info/")
        with urllib.request.urlopen(req, timeout=5) as resp:
            info = json.loads(resp.read().decode())
            upd = info.get("data", {}).get("update", {})
            if upd.get("update_needed"):
                return upd
    except Exception:
        pass
    return None


def _show_update_window(update_info):
    """显示新版本提示窗口。点「下载更新」→ 浏览器打开下载链接，用户手动下载解压覆盖。"""
    import tkinter as tk
    import webbrowser

    ver = update_info.get("latest_version", "?")
    notes = update_info.get("notes", "")
    size_mb = int(update_info.get("size", 0)) / 1024 / 1024
    url = update_info.get("url", "")

    win = tk.Tk()
    win.title("发现新版本")
    win.configure(bg="#eef3fa")
    W, H = 480, 340
    sw, sh = win.winfo_screenwidth(), win.winfo_screenheight()
    win.geometry(f"{W}x{H}+{max(0, (sw - W) // 2)}+{max(0, (sh - H) // 3)}")
    win.resizable(False, False)

    ico = os.path.join(_SELF_DIR, "app_icon.ico")
    if os.path.isfile(ico):
        try:
            win.iconbitmap(ico)
        except Exception:
            pass

    YH = "Microsoft YaHei"
    BG, CARD = "#eef3fa", "#ffffff"

    tk.Label(win, text="发现新版本", font=(YH, 22, "bold"), bg=BG, fg="#3546b8").pack(pady=(28, 6))
    tk.Label(win, text=f"版本 {ver}  -  {size_mb:.1f} MB", font=(YH, 12), bg=BG, fg="#64748b").pack(pady=(0, 12))

    if notes:
        card = tk.Frame(win, bg=CARD, highlightbackground="#d6e0ee", highlightthickness=1)
        card.pack(padx=40, fill="x")
        tk.Label(card, text="更新内容", font=(YH, 11, "bold"), bg=CARD, fg="#182235").pack(pady=(10, 4))
        tk.Label(card, text=notes, font=(YH, 10), bg=CARD, fg="#475569",
                 wraplength=380, justify="left").pack(padx=14, pady=(0, 10))

    tip = tk.Label(win, text="", font=(YH, 10), bg=BG, fg="#64748b")

    btn_frame = tk.Frame(win, bg=BG)
    btn_frame.pack(pady=(16, 10))
    skip_btn = tk.Button(btn_frame, text="稍后更新", font=(YH, 11),
                         bg="#e2e8f0", fg="#475569", relief="flat", cursor="hand2",
                         padx=24, pady=8)
    skip_btn.pack(side="left", padx=(0, 12))
    dl_btn = tk.Button(btn_frame, text="下载更新", font=(YH, 11, "bold"),
                       bg="#6257d9", fg="white", relief="flat", cursor="hand2",
                       padx=24, pady=8)

    def do_skip():
        win.destroy()

    def do_download():
        tip.config(text="正在打开下载页面…", fg="#64748b")
        if url:
            webbrowser.open(url)
        tip.config(text="下载后，解压覆盖到 YuaHuman 安装目录即可", fg="#237a3b")
        dl_btn.config(text="已打开", state="disabled", bg="#999")
        skip_btn.config(text="关闭")

    skip_btn.config(command=do_skip)
    dl_btn.config(command=do_download)

    dl_btn.pack(side="left")

    win.protocol("WM_DELETE_WINDOW", do_skip)
    win.mainloop()
    try:
        win.destroy()
    except Exception:
        pass


def _show_native_window(url, _auto_close_ms=None):
    """显示【原生后端窗口】(Tkinter，系统真窗口)，常驻代表后端在运行。
    点【进入系统】→ 另开网页操作窗(子进程)，本窗保持打开(可重复打开操作窗)；
    关闭本窗(确认后) → mainloop 返回 → 由 main 收尾停止全部服务、退出程序。
    _auto_close_ms 仅供自检：到点自动关窗，用来验证窗口能否正常构建。"""
    import tkinter as tk

    # 任务栏图标：让 Windows 用本窗口自己的图标，而不是 pythonw.exe 的默认（空白）图标。
    # 必须在创建窗口前调用。
    try:
        import ctypes
        ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("baokuan.ai.agent.native")
    except Exception:
        pass

    BG, CARD, CARD_BD = "#eef3fa", "#ffffff", "#d6e0ee"
    TITLE, ITEM_BG, ITEM_FG, BRAND = "#3546b8", "#f6f8fc", "#334155", "#6257d9"
    YH = "Microsoft YaHei"

    root = tk.Tk()
    root.title("YuaHuman")
    root.configure(bg=BG)
    W, H = 760, 600
    sw, sh = root.winfo_screenwidth(), root.winfo_screenheight()
    root.geometry(f"{W}x{H}+{max(0, (sw - W) // 2)}+{max(0, (sh - H) // 3)}")
    root.minsize(W, H)

    # 标题栏图标：直接给【当前窗口】设 .ico。default= 只对将来新建的 toplevel 生效、
    # 对已创建的 root 不一定生效，所以必须用无 default 的形式；default 再设一次作兜底。
    _ico = os.path.join(_SELF_DIR, "app_icon.ico")
    if os.path.isfile(_ico):
        try:
            root.iconbitmap(_ico)          # 当前窗口标题栏
        except Exception:
            pass
        try:
            root.iconbitmap(default=_ico)  # 后续 toplevel 兜底
        except Exception:
            pass

    icon = _make_logo_photo(64)
    if icon is not None:
        try:
            root.iconphoto(True, icon)     # 备用（部分环境标题栏/任务栏靠它）
            root._icon_ref = icon  # 防被回收
        except Exception:
            pass

    # Windows 任务栏图标硬保障：Tk 的 iconbitmap/iconphoto 偶发被羽毛顶掉,
    # 用 Win32 WM_SETICON 确定性压一次(窗口已实体化才拿得到 HWND),
    # 并在窗口 mapped、任务栏按钮建好后再压一次绕开竞态。
    root.update_idletasks()
    _apply_win_taskbar_icon(root, _ico)
    root.after(500, lambda: _apply_win_taskbar_icon(root, _ico))
    root.bind("<Map>", lambda _e: _apply_win_taskbar_icon(root, _ico), add="+")

    logo = _make_logo_photo(92)
    if logo is not None:
        lb = tk.Label(root, image=logo, bg=BG)
        lb.image = logo  # 防被回收
        lb.pack(pady=(46, 14))
    else:
        tk.Label(root, text="AI", font=(YH, 34, "bold"), bg=BG, fg=BRAND).pack(pady=(46, 14))

    tk.Label(root, text="用 AI 做超级个体", font=(YH, 28, "bold"), bg=BG, fg=TITLE).pack(pady=(0, 26))

    card = tk.Frame(root, bg=CARD, highlightbackground=CARD_BD, highlightthickness=1, bd=0)
    card.pack(padx=60, fill="x")
    tk.Label(card, text="爆款口播智能体", font=(YH, 15, "bold"), bg=CARD, fg="#182235").pack(pady=(18, 14))
    grid = tk.Frame(card, bg=CARD)
    grid.pack(padx=26, pady=(0, 20))
    for i, f in enumerate(["文案生成", "复刻形象", "自动剪辑", "矩阵发布"]):
        cell = tk.Frame(grid, bg=ITEM_BG, highlightbackground="#dfe6f1", highlightthickness=1)
        cell.grid(row=i // 2, column=i % 2, padx=8, pady=7, sticky="ew", ipadx=16, ipady=9)
        tk.Label(cell, text="●", font=(YH, 10), bg=ITEM_BG, fg=BRAND).pack(side="left", padx=(12, 8))
        tk.Label(cell, text=f, font=(YH, 12), bg=ITEM_BG, fg=ITEM_FG).pack(side="left", padx=(0, 14))
    grid.columnconfigure(0, weight=1)
    grid.columnconfigure(1, weight=1)

    status = tk.Label(root, text="", font=(YH, 10), bg=BG, fg="#64748b")
    btn = tk.Button(root, text="进入系统", font=(YH, 14, "bold"),
                    bg=BRAND, fg="white", activebackground="#5146c7", activeforeground="white",
                    relief="flat", bd=0, cursor="hand2", padx=60, pady=12)

    def go():
        import threading
        btn.config(state="disabled", text="正在进入系统…")
        status.config(text="正在启动操作界面，请稍候…", fg="#64748b")

        def worker():
            _wait_frontend(url)

            def done():
                _launch_web_child(url)  # 另开网页操作窗(子进程)，本后端窗保持打开
                btn.config(state="normal", text="重新打开操作界面")
                status.config(text="● 后端运行中，请勿关闭本窗口（关闭将退出程序）", fg="#237a3b")
            try:
                root.after(0, done)
            except Exception:
                pass

        threading.Thread(target=worker, daemon=True).start()

    def on_close():
        # 关闭本窗=停止全部服务、退出程序，先确认防误关
        from tkinter import messagebox
        try:
            ok = messagebox.askokcancel("退出程序", "关闭本窗口将停止后端服务并退出整个程序。\n确定要退出吗？")
        except Exception:
            ok = True
        if not ok:
            return
        try:
            root.destroy()
        except Exception:
            pass

    btn.config(command=go)
    btn.pack(pady=(30, 8))
    status.pack()
    root.protocol("WM_DELETE_WINDOW", on_close)

    if _auto_close_ms:  # 自检用：到点直接销毁(跳过确认框)，验证窗口能正常构建
        root.after(_auto_close_ms, root.destroy)

    try:
        root.mainloop()
    except Exception:
        pass


def _run_app_window(url):
    """显示【原生后端窗口】并常驻(用户关闭才返回)。点【进入系统】开网页操作窗(子进程)，
    与后端窗并存；关闭后端窗=停止服务、退出程序(main 的 finally _kill_all 收尾)。
    原生窗不可用时回退为在本进程直接开网页窗。"""
    try:
        _show_native_window(url)
    except Exception as e:
        print(f"[窗口] 原生后端窗不可用，回退直接开网页窗：{e}")
        _open_web_window(url)


def main():
    # 子进程模式：只开【网页操作窗】，不启动服务、不开后端窗（由父进程的后端窗调起）
    if "--web-window" in sys.argv:
        try:
            i = sys.argv.index("--web-window")
            child_url = sys.argv[i + 1] if i + 1 < len(sys.argv) else "http://localhost:9527"
            _open_web_window(child_url)
        except Exception as e:
            print(f"[网页窗子进程] 出错：{e}")
        return
    try:
        _main_impl()
    except KeyboardInterrupt:
        print("\nCtrl+C pressed — shutting down...")
    finally:
        _kill_all()
        print("All services stopped.")


def _main_impl():
    print("=" * 44)
    print("  YuaHuman - All Services")
    print(f"  {ROOT}")
    print("=" * 44)
    print()

    python = os.path.join(ROOT, "_internal", "python", "python.exe")
    tts_dir = os.path.join(ROOT, "tts-1")
    tts_py = os.path.join(tts_dir, "wzf", "python.exe")
    node = os.path.join(ROOT, "_internal", "node", "node.exe")
    if not os.path.exists(node):
        node = "node"
    kr_dir = os.path.join(ROOT, "KrLongAI_2.0")
    vite_js = _find_vite_js(ROOT)
    if not vite_js or not os.path.isfile(vite_js):
        vite_js = os.path.join(ROOT, "node_modules", "vite", "bin", "vite.js")
    if not os.path.isfile(vite_js):
        vite_js = None

    # Launch ALL services in parallel — no waiting between them

    if os.path.exists(tts_py):
        start_service("IndexTTS:5000", [tts_py, "-s", "api.py"], cwd=tts_dir, env_extra={
            "PATH": os.pathsep.join([os.path.join(tts_dir, "wzf"), os.path.join(tts_dir, "wzf", "Scripts"),
                                      os.path.join(tts_dir, "wzf", "ffmpeg", "bin"), os.environ.get("PATH", "")]),
            "PYTHONHOME": "", "PYTHONPATH": "",
        })
    else:
        print("[IndexTTS] SKIP — tts-1 not found")

    if os.path.exists(os.path.join(kr_dir, "main.py")):
        start_service("KrLongAI:35005", [python, "-u", "main.py"], cwd=kr_dir, no_window=True)
    else:
        print("[KrLongAI] SKIP — not found")

    # 优先跑打包好的 Backend.exe(冻结版,cwd=backend 让 _paths 定位正确);
    # 没有 exe 时回退到明文 python backend/main.py(开发环境)。
    backend_exe = os.path.join(ROOT, "backend", "Backend.exe")
    backend_env = {
        "PLAYWRIGHT_BROWSERS_PATH": os.path.join(ROOT, "_internal", "playwright_browsers"),
        "PATH": os.path.join(ROOT, "backend", "libs", "bin") + os.pathsep + os.environ.get("PATH", ""),
        "BACKEND_PORT": "8000",
    }
    if os.path.exists(backend_exe):
        start_service("Backend:8000", [backend_exe], cwd=os.path.join(ROOT, "backend"),
                      env_extra=backend_env, no_window=True)
    else:
        start_service("Backend:8000", [python, "backend/main.py"],
                      env_extra=backend_env, no_window=True)

    frontend_started = False
    dist_dir = os.path.join(ROOT, "dist")
    if os.path.isdir(dist_dir):
        frontend_server_py = os.path.join(dist_dir, "_server.py")
        # Write the SPA server launcher (idempotent — same content each time)
        with open(frontend_server_py, "w") as fw:
            fw.write("""# Auto-generated SPA server for dist/
import http.server, os, sys

class SPAHandler(http.server.SimpleHTTPRequestHandler):
    def _no_cache(self):
        self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
        self.send_header("Pragma", "no-cache")
        self.send_header("Expires", "0")
    def do_GET(self):
        path = self.path.split("?", 1)[0].split("#", 1)[0]
        ext_map = {".js": "text/javascript", ".mjs": "text/javascript",
                   ".ts": "text/javascript", ".tsx": "text/javascript",
                   ".css": "text/css", ".html": "text/html",
                   ".json": "application/json", ".svg": "image/svg+xml",
                   ".png": "image/png", ".jpg": "image/jpeg",
                   ".ico": "image/x-icon", ".woff2": "font/woff2"}
        fn = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                          path.lstrip("/"))
        if os.path.isfile(fn):
            ext = os.path.splitext(fn)[1]
            if ext in ext_map:
                self.send_response(200)
                self.send_header("Content-Type", ext_map[ext])
                self._no_cache()
                self.end_headers()
                with open(fn, "rb") as f:
                    self.wfile.write(f.read())
            else:
                super().do_GET()
        else:
            self.send_response(200)
            self.send_header("Content-Type", "text/html")
            self._no_cache()
            self.end_headers()
            with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html"), "rb") as f:
                self.wfile.write(f.read())
    def log_message(self, fmt, *args):
        pass

if __name__ == "__main__":
    port = int(sys.argv[1]) if len(sys.argv) > 1 else 9527
    os.chdir(os.path.dirname(os.path.abspath(__file__)))
    srv = http.server.HTTPServer(("0.0.0.0", port), SPAHandler)
    print(f"SPA server on :{port}")
    srv.serve_forever()
""")
        start_service("Frontend:9527", [python, frontend_server_py, "9527"])
        frontend_started = True
    elif node and vite_js:
        start_service("Frontend:9527", [node, vite_js, "--host", "0.0.0.0", "--port", "9527"])
        frontend_started = True
    else:
        print(f"[Frontend] SKIP — no dist, no vite dev server")

    print()
    print("  Backend : http://127.0.0.1:8000")
    print("  Frontend: http://localhost:9527")
    print()

    # 等待后端就绪
    print("Waiting for backend...")
    _wait_frontend("http://127.0.0.1:8000/health", timeout=30)

    # 检查更新
    update_info = _check_update()
    if update_info:
        _show_update_window(update_info)

    # 打开桌面窗口
    print("Starting services, opening app window...")
    target_url = "http://localhost:9527" if frontend_started else "http://localhost:8000"
    _run_app_window(target_url)


if __name__ == "__main__":
    main()
