#!/usr/bin/env python3
"""ai-toolbox 后端 — Flask API + pywebview 桌面窗口"""
import sys, json, subprocess, os, threading, time, glob, tempfile, shutil, uuid
from pathlib import Path

# ── 修复非交互式 shell（AppleScript/.app）找不到用户级 pip 包的问题 ──
_home = os.path.expanduser("~")
for _py in ["3.9", "3.10", "3.11", "3.12", "3.13"]:
    _usp = os.path.join(_home, f"Library/Python/{_py}/lib/python/site-packages")
    if os.path.isdir(_usp) and _usp not in sys.path:
        sys.path.insert(0, _usp)

from flask import Flask, jsonify, request
import urllib.request

APP_DIR = Path(__file__).parent
CONFIG_FILE = APP_DIR / "config.json"

# 用户数据目录（解决 .app 内文件只读无法保存的问题）
USER_DATA_DIR = Path.home() / "Library" / "Application Support" / "Octoma"
USER_DATA_DIR.mkdir(parents=True, exist_ok=True)
CUSTOM_FILE = USER_DATA_DIR / "custom_items.json"
INSTALL_STATE_FILE = USER_DATA_DIR / "install_state.json"

CACHE_DIR = APP_DIR / "installers"
CACHE_DIR.mkdir(exist_ok=True)

# 云端下载地址
CLOUD_BASE = os.environ.get("CLOUD_BASE", "http://39.106.218.12")
CLOUD_API = f"{CLOUD_BASE}/api/download/"

app = Flask(__name__, static_folder="dist", static_url_path="")

# ── 模拟模式开关 ───────────────────────────────────────
SIM_MODE = os.environ.get("SIM_MODE", "False").lower() in ("true", "1", "yes")

SIM_CHECK = {
    "homebrew": {"ok": True, "output": "Homebrew 5.1.11"},
    "git": {"ok": True, "output": "git version 2.45.0"},
    "python": {"ok": True, "output": "Python 3.12.8"},
    "nodejs": {"ok": True, "output": "v22.22.2"},
    "docker": {"ok": True, "output": "Docker Desktop 4.x running"},
    "ollama": {"ok": True, "output": "ollama version is 0.9.0"},
    "claude": {"ok": True, "output": "claude-code 1.0.0"},
    "openclaw": {"ok": True, "output": "openclaw version 1.0.0"},
    "hermes": {"ok": True, "output": "hermes-agent version 1.0.0"},
    "dify": {"ok": True, "output": "Dify server running on localhost"},
    "comfyui": {"ok": True, "output": "ComfyUI running on localhost:8188"},
    "ripgrep": {"ok": True, "output": "ripgrep 14.1.1"},
    "ffmpeg": {"ok": True, "output": "ffmpeg version 6.1.1"},
    "veee": {"ok": True, "output": "/Applications/Veee.app"},
    "obsidian": {"ok": True, "output": "/Applications/Obsidian.app"},
    "vscode": {"ok": True, "output": "Visual Studio Code 1.99.0"},
    "codex": {"ok": True, "output": "codex 0.1.0"},
    "openhuman": {"ok": True, "output": "/Applications/OpenHuman.app"},
    "ccswitch": {"ok": True, "output": "/Applications/CC Switch.app"},
}

SIM_INSTALL = {
    "homebrew": {"status": "simulated", "cmd": "自动安装 Homebrew-5.1.14.pkg"},
    "git": {"status": "simulated", "cmd": "xcode-select --install"},
    "python": {"status": "simulated", "cmd": "自动安装 python-3.12.pkg"},
    "nodejs": {"status": "simulated", "cmd": "自动安装 node-v24.pkg"},
    "docker": {"status": "simulated", "cmd": "自动安装 Docker.dmg"},
    "ollama": {"status": "simulated", "cmd": "自动挂载 Ollama.dmg 并复制到 /Applications"},
    "claude": {"status": "simulated", "cmd": "云端下载 claude 二进制 → 复制到 /usr/local/bin"},
    "openclaw": {"status": "simulated", "cmd": "云端下载 OpenClaw 完整包 (100MB) → 解压到 ~/.openclaw/"},
    "hermes": {"status": "simulated", "cmd": "云端下载 Hermes 完整包 (444MB) → 解压到 ~/.hermes/"},
    "dify": {"status": "simulated", "cmd": "云端下载 Dify 镜像包 (1.9GB) → docker load → docker compose up"},
    "comfyui": {"status": "simulated", "cmd": "云端下载 ComfyUI 离线包 (827MB) → 解压到 ~/ComfyUI_Workspace/"},
    "ripgrep": {"status": "simulated", "cmd": "云端下载 ripgrep tar.gz → 解压 → 复制到 /usr/local/bin"},
    "ffmpeg": {"status": "simulated", "cmd": "云端下载 ffmpeg tar.gz → 解压 → 复制到 /usr/local/bin"},
    "veee": {"status": "simulated", "cmd": "自动挂载 Veee_Mac.dmg 并复制到 /Applications"},
    "obsidian": {"status": "simulated", "cmd": "自动挂载 Obsidian.dmg 并复制到 /Applications"},
    "vscode": {"status": "simulated", "cmd": "brew install --cask visual-studio-code"},
    "codex": {"status": "simulated", "cmd": "云端下载 codex 二进制 → 复制到 /usr/local/bin"},
    "openhuman": {"status": "simulated", "cmd": "从官网下载 OpenHuman.dmg 并挂载后复制到 /Applications"},
    "ccswitch": {"status": "simulated", "cmd": "从官网下载 CC-Switch.dmg 并挂载后复制到 /Applications"},
}

with open(CONFIG_FILE, "r", encoding="utf-8") as f:
    config = json.load(f)

try:
    with open(CUSTOM_FILE, "r", encoding="utf-8") as f:
        custom_items = json.load(f)
except:
    custom_items = []

# ── 辅助函数 ────────────────────────────────────────────
def _load_state():
    try:
        with open(INSTALL_STATE_FILE, "r") as f:
            return json.load(f)
    except:
        return {}

def _save_state(state):
    with open(INSTALL_STATE_FILE, "w") as f:
        json.dump(state, f, ensure_ascii=False, indent=2)

def _save_custom():
    with open(CUSTOM_FILE, "w", encoding="utf-8") as f:
        json.dump(custom_items, f, ensure_ascii=False, indent=2)

# 确保 Homebrew、用户本地 bin 和 Docker CLI 路径在 PATH 中
def _env_with_brew():
    env = os.environ.copy()
    path = env.get("PATH", "")
    extra_paths = [
        "/opt/homebrew/bin",
        "/opt/homebrew/sbin",
        "/usr/local/bin",
        "/Applications/Docker.app/Contents/Resources/bin",
        str(Path.home() / ".local" / "bin"),
    ]
    for p in extra_paths:
        if p not in path:
            path = p + ":" + path
    env["PATH"] = path
    return env

def _download_from_cloud(item_id):
    """从云端下载文件到本地缓存，返回实际文件路径"""
    try:
        # 优先使用 config.json 中的 download_url
        item = _find_item(item_id)
        if item and item.get("download_url"):
            url = item["download_url"]
            filename = url.split("/")[-1]
        else:
            # 如果 config.json 中没有 download_url，则调用 API
            r = urllib.request.urlopen(CLOUD_API + item_id, timeout=10)
            data = json.loads(r.read())
            url = data.get("url", "")
            filename = data.get("filename", "")

        if not url:
            return None
        dest = CACHE_DIR / filename
        # 已存在且大小 > 0，直接返回实际文件
        if dest.exists() and dest.stat().st_size > 0:
            return dest
        # 流式下载
        req = urllib.request.urlopen(url, timeout=120)
        tmp_dest = dest.with_suffix(dest.suffix + ".part")
        with open(tmp_dest, "wb") as f:
            while True:
                chunk = req.read(64 * 1024)
                if not chunk:
                    break
                f.write(chunk)
        # 下载完成再重命名为正式文件名（原子操作）
        tmp_dest.rename(dest)
        return dest
    except Exception as e:
        return None

def _configure_brew_env():
    """自动配置 Homebrew Shell 环境变量"""
    home = Path.home()
    shell_files = [home / ".zprofile", home / ".zshrc"]
    env_line = 'eval "$(/opt/homebrew/bin/brew shellenv)"'

    for f_path in shell_files:
        content = f_path.read_text() if f_path.exists() else ""
        if env_line not in content:
            with open(f_path, "a") as f:
                f.write(f"\n# Added by Octoma\n{env_line}\n")

# ── API 路由 ────────────────────────────────────────────
@app.route("/api/health")
def api_health():
    """API 健康检查端点"""
    return jsonify({
        "status": "ok",
        "version": "v1.0",
        "sim_mode": SIM_MODE,
        "items_count": sum(len(cat.get("items", [])) for cat in config.get("categories", [])),
        "timestamp": time.time(),
    })

@app.route("/api/categories")
def api_categories():
    return jsonify(config["categories"])

@app.route("/api/check/<item_id>", methods=["POST"])
def api_check(item_id):
    """执行检查命令"""
    item = _find_item(item_id)
    if not item:
        return jsonify({"error": f"未找到项目: {item_id}"}), 404

    if SIM_MODE and item_id in SIM_CHECK:
        sim = SIM_CHECK[item_id]
        results = [{
            "label": cmd_info["label"],
            "cmd": cmd_info["cmd"],
            "ok": sim["ok"],
            "output": sim["output"][:100] if sim["ok"] else sim["output"],
        } for cmd_info in item.get("check_commands", [])]
        return jsonify({"item_id": item_id, "results": results, "sim_mode": True})

    results = []
    for cmd_info in item.get("check_commands", []):
        try:
            r = subprocess.run(cmd_info["cmd"], shell=True,
                capture_output=True, text=True, timeout=10, env=_env_with_brew())
            output = r.stdout.strip()
            expected = cmd_info.get("expected", "")
            ok = r.returncode == 0 and (not expected or expected in output)
            results.append({
                "label": cmd_info["label"],
                "cmd": cmd_info["cmd"],
                "ok": ok,
                "output": output[:100] if ok else f"exit={r.returncode}",
            })
        except Exception as e:
            results.append({
                "label": cmd_info["label"],
                "cmd": cmd_info["cmd"],
                "ok": False,
                "output": str(e)[:100],
            })
    return jsonify({"item_id": item_id, "results": results})

@app.route("/api/check-all", methods=["POST"])
def api_check_all():
    """批量检查所有项目安装状态"""
    states = {}
    for cat in config.get("categories", []):
        if cat["id"] == "custom":
            continue
        for item in cat.get("items", []):
            item_id = item.get("id")
            if not item_id:
                continue
            ok = False
            for cmd_info in item.get("check_commands", []):
                try:
                    r = subprocess.run(cmd_info["cmd"], shell=True,
                        capture_output=True, text=True, timeout=5, env=_env_with_brew())
                    expected = cmd_info.get("expected", "")
                    if r.returncode == 0 and (not expected or expected in r.stdout.strip()):
                        ok = True
                        break
                except:
                    pass
            states[item_id] = ok
    return jsonify(states)

@app.route("/api/install/<item_id>", methods=["POST"])
def api_install(item_id):
    """触发安装：云端下载 + 本地安装"""
    item = _find_item(item_id)
    if not item:
        return jsonify({"error": f"未找到项目: {item_id}"}), 404

    if SIM_MODE and item_id in SIM_INSTALL:
        time.sleep(1)
        return jsonify({
            "item_id": item_id,
            "status": "simulated",
            "cmd": SIM_INSTALL[item_id]["cmd"],
            "sim_mode": True
        })

    # .dmg 文件：从云端下载 → 挂载 → 复制到 /Applications
    dmg_items = {"veee", "obsidian", "docker", "vscode", "chrome", "openhuman", "ccswitch", "ollama", "codex"}
    # DMG 内的 .app 名称映射
    dmg_app_names = {
        "veee": "Veee",
        "obsidian": "Obsidian",
        "docker": "Docker",
        "vscode": "Visual Studio Code",
        "chrome": "Google Chrome",
        "openhuman": "OpenHuman",
        "ccswitch": "CC Switch",
        "ollama": "Ollama",
        "codex": "Codex",
    }
    if item_id in dmg_items:
        # 已安装检测：检查 /Applications 中是否存在对应的 .app
        app_name = dmg_app_names.get(item_id, item_id.title())
        app_path = Path(f"/Applications/{app_name}.app")
        if app_path.exists():
            state = _load_state()
            state[item_id] = {"installed": True, "time": time.time()}
            _save_state(state)
            return jsonify({"item_id": item_id, "status": "completed", "cmd": f"已安装: {app_path}"})

        local_path = _download_from_cloud(item_id)
        if not local_path or not local_path.exists():
            return jsonify({"error": f"下载失败，安装包不可用: {item_id}"}), 503
        try:
            r = subprocess.run(['hdiutil', 'attach', '-nobrowse', str(local_path)],
                capture_output=True, text=True, timeout=60, env=_env_with_brew())
            if r.returncode != 0:
                return jsonify({"error": f"DMG 挂载失败: {r.stderr[:200]}"}), 500
            mount_point = None
            for line in r.stdout.splitlines():
                parts = line.strip().split('\t')
                if len(parts) >= 3 and '/Volumes/' in parts[2]:
                    mount_point = parts[2]
                    break
            if not mount_point:
                return jsonify({"error": "无法解析 DMG 挂载点"}), 500
            app_name = dmg_app_names.get(item_id, item_id.title())
            app_candidates = glob.glob(f"{mount_point}*/{app_name}.app")
            if not app_candidates:
                subprocess.run(['hdiutil', 'detach', mount_point], capture_output=True, env=_env_with_brew())
                return jsonify({"error": f"在 DMG 中未找到 {app_name}.app"}), 500
            src_app = app_candidates[0]
            r2 = subprocess.run(['cp', '-R', src_app, '/Applications/'],
                capture_output=True, text=True, timeout=120, env=_env_with_brew())
            subprocess.run(['hdiutil', 'detach', mount_point], capture_output=True, env=_env_with_brew())
            if r2.returncode != 0:
                return jsonify({"error": f"复制失败: {r2.stderr[:200]}"}), 500
            state = _load_state()
            state[item_id] = {"installed": True, "time": time.time()}
            _save_state(state)
            return jsonify({
                "item_id": item_id, "status": "completed",
                "cmd": f"已安装到 /Applications/{app_name}.app"
            })
        except Exception as e:
            return jsonify({"error": str(e)}), 500

    # .pkg 文件：从云端下载 → 弹窗让用户安装
    pkg_items = {"python", "nodejs", "homebrew"}
    if item_id in pkg_items:
        # 已安装检测：跳过安装器，直接返回
        if item_id == "homebrew":
            r = subprocess.run(['which', 'brew'], capture_output=True, text=True, timeout=5, env=_env_with_brew())
            if r.returncode == 0:
                _configure_brew_env()  # 自动配置环境变量
                state = _load_state()
                state[item_id] = {"installed": True, "time": time.time()}
                _save_state(state)
                return jsonify({"item_id": item_id, "status": "completed", "cmd": f"已安装: {r.stdout.strip()}（已自动配置终端环境）"})
        if item_id == "python":
            # 检查 Python 3.12 是否已安装（先检查 /usr/local/bin 再检查 PATH）
            for python_path in ['/usr/local/bin/python3', '/usr/bin/python3', 'python3']:
                try:
                    if python_path == 'python3':
                        r = subprocess.run(python_path, shell=True, capture_output=True, text=True, timeout=5)
                    else:
                        r = subprocess.run([python_path, '--version'], capture_output=True, text=True, timeout=5)
                    if r.returncode == 0 and "Python 3.12" in r.stdout:
                        state = _load_state()
                        state[item_id] = {"installed": True, "time": time.time()}
                        _save_state(state)
                        return jsonify({"item_id": item_id, "status": "completed", "cmd": f"已安装: {r.stdout.strip()}"})
                except:
                    pass
        if item_id == "nodejs":
            # 检查 Node.js 24 是否已安装
            try:
                r = subprocess.run(['node', '-v'], capture_output=True, text=True, timeout=5, env=_env_with_brew())
                if r.returncode == 0 and r.stdout.strip().startswith('v24'):
                    state = _load_state()
                    state[item_id] = {"installed": True, "time": time.time()}
                    _save_state(state)
                    return jsonify({"item_id": item_id, "status": "completed", "cmd": f"已安装: {r.stdout.strip()}"})
            except:
                pass

        local_path = _download_from_cloud(item_id)
        if not local_path or not local_path.exists():
            return jsonify({"error": f"下载失败，安装包不可用: {item_id}"}), 503
        try:
            # 预先配置 Homebrew 环境变量（写入 Shell 配置文件）
            # 这样等用户安装完成后，重启终端即可直接使用，无需手动操作
            if item_id == "homebrew":
                _configure_brew_env()

            subprocess.Popen(['open', str(local_path)])

            # 根据工具返回不同的提示
            if item_id == "homebrew":
                msg = "系统安装器已弹出，请在弹窗中点击「安装」并输入密码\n\n✅ 已自动配置终端环境变量（安装完成后重启终端即可生效）"
            else:
                msg = f"系统安装器已弹出，请在弹窗中点击「安装」并输入密码"

            return jsonify({
                "item_id": item_id, "status": "started",
                "cmd": msg
            })
        except Exception as e:
            return jsonify({"error": str(e)}), 500

    # CLI 工具压缩包：从云端下载 tar.gz → 解压 → 复制二进制到 PATH 目录
    cli_items = {"ripgrep", "ffmpeg", "claude"}
    cli_bin_names = {"ripgrep": "rg", "ffmpeg": "ffmpeg", "claude": "claude"}
    if item_id in cli_items:
        # 已安装检测
        bin_name = cli_bin_names.get(item_id, item_id)
        for bin_path in [Path(f"/usr/local/bin/{bin_name}"), Path.home() / ".local" / "bin" / bin_name]:
            if bin_path.exists():
                try:
                    r = subprocess.run([str(bin_path), '--version'], capture_output=True, text=True, timeout=5)
                    if r.returncode == 0:
                        state = _load_state()
                        state[item_id] = {"installed": True, "time": time.time()}
                        _save_state(state)
                        return jsonify({"item_id": item_id, "status": "completed", "cmd": f"已安装: {bin_path}"})
                except:
                    pass

        local_path = _download_from_cloud(item_id)
        if not local_path or not local_path.exists():
            return jsonify({"error": f"下载失败，安装包不可用: {item_id}"}), 503
        try:
            with tempfile.TemporaryDirectory() as tmpdir:
                r = subprocess.run(['tar', '-xzf', str(local_path), '-C', tmpdir],
                    capture_output=True, text=True, timeout=60, env=_env_with_brew())
                if r.returncode != 0:
                    return jsonify({"error": f"解压失败: {r.stderr[:200]}"}), 500
                # 递归查找所有可执行文件，复制到 PATH 目录
                bin_dir = Path(tmpdir)
                copied = []
                # 只复制常见的 CLI 二进制名，排除 .so / .dylib / .sh / 文档
                skip_suffixes = {'.so', '.dylib', '.sh', '.md', '.txt', '.json', '.yaml', '.yml', '.toml', '.cfg', '.conf', '.gz', '.tar', '.zip', '.py'}
                for f in bin_dir.rglob('*'):
                    if f.is_file() and os.access(f, os.X_OK) and f.suffix.lower() not in skip_suffixes:
                        # 优先复制到 ~/.local/bin（不需要 sudo，.app 模式可用）
                        dest = Path.home() / ".local" / "bin" / f.name
                        dest.parent.mkdir(parents=True, exist_ok=True)
                        # 先尝试直接复制（~/.local/bin 用户有权限）
                        try:
                            shutil.copy2(f, dest)
                            subprocess.run(['chmod', '+x', str(dest)],
                                capture_output=True, text=True, timeout=5, env=_env_with_brew())
                        except PermissionError:
                            # 降级到 /usr/local/bin + sudo
                            dest = Path(f"/usr/local/bin/{f.name}")
                            dest.parent.mkdir(parents=True, exist_ok=True)
                            r = subprocess.run(['sudo', 'cp', str(f), str(dest)],
                                capture_output=True, text=True, timeout=30)
                            if r.returncode != 0:
                                return jsonify({"error": f"安装失败（需要管理员权限）: {r.stderr[:200]}"}), 500
                            subprocess.run(['sudo', 'chmod', '+x', str(dest)], timeout=10)
                        # 移除 macOS 隔离属性
                        subprocess.run(['xattr', '-d', 'com.apple.quarantine', str(dest)],
                            capture_output=True, text=True, timeout=5, env=_env_with_brew())
                        copied.append(f.name)

                # 自动配置 PATH：将 ~/.local/bin 添加到 ~/.zshrc
                zshrc = Path.home() / ".zshrc"
                local_bin_path = str(Path.home() / ".local" / "bin")
                if zshrc.exists():
                    zshrc_content = zshrc.read_text()
                else:
                    zshrc_content = ""
                if local_bin_path not in zshrc_content:
                    with open(zshrc, "a") as zf:
                        zf.write(f'\n# Added by Octoma\nexport PATH="{local_bin_path}:$PATH"\n')

                state = _load_state()
                state[item_id] = {"installed": True, "time": time.time()}
                _save_state(state)
                return jsonify({
                    "item_id": item_id, "status": "completed",
                    "cmd": f"已安装到 ~/.local/bin: {', '.join(copied)}（已自动配置 PATH，请重启终端生效）"
                })
        except Exception as e:
            return jsonify({"error": str(e)}), 500

    # Hermes Agent 完整包：从云端下载 tar.gz → 解压到 ~/.hermes/
    if item_id == "hermes":
        hermes_home = Path.home() / ".hermes"
        if (hermes_home / "hermes-agent" / "hermes_cli").exists():
            state = _load_state()
            state[item_id] = {"installed": True, "time": time.time()}
            _save_state(state)
            return jsonify({"item_id": item_id, "status": "completed", "cmd": "已安装: ~/.hermes/"})

        local_path = _download_from_cloud(item_id)
        if not local_path or not local_path.exists():
            return jsonify({"error": f"下载失败，安装包不可用: {item_id}"}), 503
        try:
            hermes_home = Path.home() / ".hermes"
            # 如果已存在，先备份
            if hermes_home.exists():
                backup = Path.home() / f".hermes.bak.{int(time.time())}"
                shutil.move(str(hermes_home), str(backup))
            with tempfile.TemporaryDirectory() as tmpdir:
                r = subprocess.run(['tar', '-xzf', str(local_path), '-C', tmpdir],
                    capture_output=True, text=True, timeout=300, env=_env_with_brew())
                if r.returncode != 0:
                    return jsonify({"error": f"解压失败: {r.stderr[:200]}"}), 500
                extracted = Path(tmpdir)
                # 移动各组件到 ~/.hermes/
                components = {
                    "hermes-agent": hermes_home / "hermes-agent",
                    "node": hermes_home / "node",
                    "bin": hermes_home / "bin",
                    "skills": hermes_home / "skills",
                }
                installed = []
                for src_name, dest_path in components.items():
                    src = extracted / src_name
                    if src.exists():
                        shutil.copytree(str(src), str(dest_path), symlinks=True)
                        installed.append(src_name)
                # 确保 hermes 可执行文件在 PATH 中
                hermes_bin = hermes_home / "hermes-agent" / "hermes"
                if hermes_bin.exists():
                    subprocess.run(['chmod', '+x', str(hermes_bin)],
                        capture_output=True, text=True, timeout=5, env=_env_with_brew())
                    # 创建 /usr/local/bin 软链接（用 sudo）
                    link = Path("/usr/local/bin/hermes")
                    if link.exists() or link.is_symlink():
                        subprocess.run(['sudo', 'rm', '-f', str(link)], timeout=10)
                    subprocess.run(['sudo', 'ln', '-sf', str(hermes_bin), str(link)], timeout=10)
                # 安装 Python 依赖（Hermes CLI 需要这些）
                try:
                    subprocess.run(['pip3', 'install', 'pyyaml', 'python-dotenv'],
                        capture_output=True, text=True, timeout=120, env=_env_with_brew())
                except:
                    pass
                state = _load_state()
                state[item_id] = {"installed": True, "time": time.time()}
                _save_state(state)
                return jsonify({
                    "item_id": item_id, "status": "completed",
                    "cmd": f"已安装到 ~/.hermes/ ({', '.join(installed)})"
                })
        except Exception as e:
            return jsonify({"error": str(e)}), 500

    # OpenClaw 完整包：从云端下载 tar.gz → 解压到 ~/.openclaw/
    if item_id == "openclaw":
        openclaw_home = Path.home() / ".openclaw"
        if (openclaw_home / "node_modules" / ".bin" / "openclaw").exists():
            state = _load_state()
            state[item_id] = {"installed": True, "time": time.time()}
            _save_state(state)
            return jsonify({"item_id": item_id, "status": "completed", "cmd": "已安装: ~/.openclaw/"})

        local_path = _download_from_cloud(item_id)
        if not local_path or not local_path.exists():
            return jsonify({"error": f"下载失败，安装包不可用: {item_id}"}), 503
        try:
            openclaw_home = Path.home() / ".openclaw"
            if openclaw_home.exists():
                backup = Path.home() / f".openclaw.bak.{int(time.time())}"
                shutil.move(str(openclaw_home), str(backup))
            with tempfile.TemporaryDirectory() as tmpdir:
                r = subprocess.run(['tar', '-xzf', str(local_path), '-C', tmpdir],
                    capture_output=True, text=True, timeout=300, env=_env_with_brew())
                if r.returncode != 0:
                    return jsonify({"error": f"解压失败: {r.stderr[:200]}"}), 500
                extracted = Path(tmpdir)
                # 复制 node_modules/ 到 ~/.openclaw/
                src_nm = extracted / "node_modules"
                if src_nm.exists():
                    shutil.copytree(str(src_nm), str(openclaw_home / "node_modules"), symlinks=True)
                # 复制 bin/ 到 ~/.openclaw/
                src_bin = extracted / "bin"
                if src_bin.exists():
                    shutil.copytree(str(src_bin), str(openclaw_home / "bin"), symlinks=True)
                # 创建 /usr/local/bin 软链接
                # 创建 /usr/local/bin 软链接（用 sudo）
                # OpenClaw 可能没有 bin/ 目录，可执行文件在 node_modules/.bin/openclaw
                openclaw_bin = openclaw_home / "bin" / "openclaw"
                if not openclaw_bin.exists():
                    openclaw_bin = openclaw_home / "node_modules" / ".bin" / "openclaw"
                if openclaw_bin.exists():
                    subprocess.run(['chmod', '+x', str(openclaw_bin)],
                        capture_output=True, text=True, timeout=5, env=_env_with_brew())
                    link = Path("/usr/local/bin/openclaw")
                    if link.exists() or link.is_symlink():
                        subprocess.run(['sudo', 'rm', '-f', str(link)], timeout=10)
                    subprocess.run(['sudo', 'ln', '-sf', str(openclaw_bin), str(link)], timeout=10)
                installed = []
                if (openclaw_home / "node_modules").exists():
                    installed.append("node_modules")
                if (openclaw_home / "bin").exists():
                    installed.append("bin")
                state = _load_state()
                state[item_id] = {"installed": True, "time": time.time()}
                _save_state(state)
                return jsonify({
                    "item_id": item_id, "status": "completed",
                    "cmd": f"已安装到 ~/.openclaw/ ({', '.join(installed)})"
                })
        except Exception as e:
            return jsonify({"error": str(e)}), 500

    # Dify：从云端下载 docker-compose.yaml + nginx.conf + 镜像包 → 加载 → 迁移 → 启动
    if item_id == "dify":
        dify_dir = Path.home() / "dify"
        dc_file = dify_dir / "docker-compose.yaml"

        # 已安装检测
        if dc_file.exists():
            state = _load_state()
            state[item_id] = {"installed": True, "time": time.time()}
            _save_state(state)
            return jsonify({"item_id": item_id, "status": "completed", "cmd": "已安装: ~/dify/"})

        # 1. 下载 docker-compose.yaml
        try:
            dc_dest = CACHE_DIR / "dify-docker-compose.yaml"
            req = urllib.request.urlopen(CLOUD_BASE + "/downloads/apps/dify-docker-compose.yaml", timeout=30)
            tmp_dest = dc_dest.with_suffix(".part")
            with open(tmp_dest, "wb") as f:
                while True:
                    chunk = req.read(64 * 1024)
                    if not chunk:
                        break
                    f.write(chunk)
            tmp_dest.rename(dc_dest)
        except Exception as e:
            return jsonify({"error": f"Dify 配置文件下载失败: {e}"}), 503

        # 2. 下载 nginx.conf
        try:
            nginx_dest = CACHE_DIR / "dify-nginx.conf"
            req = urllib.request.urlopen(CLOUD_BASE + "/downloads/apps/dify-nginx.conf", timeout=30)
            tmp_dest = nginx_dest.with_suffix(".part")
            with open(tmp_dest, "wb") as f:
                while True:
                    chunk = req.read(64 * 1024)
                    if not chunk:
                        break
                    f.write(chunk)
            tmp_dest.rename(nginx_dest)
        except Exception as e:
            return jsonify({"error": f"Dify nginx 配置下载失败: {e}"}), 503

        # 3. 下载 Docker 镜像包
        local_path = _download_from_cloud(item_id)
        if not local_path or not local_path.exists():
            return jsonify({"error": f"下载失败，安装包不可用: {item_id}"}), 503

        try:
            # 4. 备份旧目录
            if dify_dir.exists():
                backup = Path.home() / f"dify.bak.{int(time.time())}"
                shutil.move(str(dify_dir), str(backup))
            dify_dir.mkdir(parents=True, exist_ok=True)

            # 5. 复制配置
            shutil.copy2(str(dc_dest), str(dc_file))
            shutil.copy2(str(nginx_dest), str(dify_dir / "nginx.conf"))
            # 创建 dummy plugin-daemon 配置（避免 plugin daemon 报错）
            plugin_conf = dify_dir / "plugin-daemon.conf"
            plugin_conf.write_text('server {\n    listen 5003;\n    location / {\n        default_type application/json;\n        return 200 \'{"code": 0, "message": "success", "data": []}\';\n    }\n}\n')

            # 6. 加载 Docker 镜像
            r2 = subprocess.run(['docker', 'load', '-i', str(local_path)],
                capture_output=True, text=True, timeout=600, env=_env_with_brew())
            if r2.returncode != 0:
                return jsonify({"error": f"Docker load 失败: {r2.stderr[:200]}"}), 500

            # 7. 修复 Docker 凭据配置（macOS 常见问题）
            docker_config = Path.home() / ".docker" / "config.json"
            if docker_config.exists():
                try:
                    import json as _json
                    dcfg = _json.loads(docker_config.read_text())
                    if "credsStore" in dcfg:
                        del dcfg["credsStore"]
                        docker_config.write_text(_json.dumps(dcfg, indent=2))
                except:
                    pass

            # 8. 启动所有容器
            r3 = subprocess.run(
                ['docker', 'compose', 'up', '-d'],
                cwd=str(dify_dir),
                capture_output=True, text=True, timeout=300, env=_env_with_brew())
            if r3.returncode != 0:
                return jsonify({"error": f"Dify 启动失败: {r3.stderr[:300]}"}), 500

            # 9. 等 API 就绪后运行数据库迁移
            time.sleep(5)
            docker_bin = '/Applications/Docker.app/Contents/Resources/bin/docker'
            if not Path(docker_bin).exists():
                docker_bin = 'docker'
            r4 = subprocess.run(
                [docker_bin, 'exec', 'dify-api', 'flask', 'db', 'upgrade'],
                capture_output=True, text=True, timeout=120, env=_env_with_brew())
            if r4.returncode != 0:
                return jsonify({"error": f"数据库迁移失败: {r4.stderr[:300]}"}), 500

            state = _load_state()
            state[item_id] = {"installed": True, "time": time.time()}
            _save_state(state)
            return jsonify({
                "item_id": item_id, "status": "completed",
                "cmd": "Dify 已启动，浏览器访问 http://localhost"
            })
        except Exception as e:
            return jsonify({"error": str(e)}), 500

    # ComfyUI：从云端下载离线包 → 解压 → 创建 venv → 安装本地 wheels
    if item_id == "comfyui":
        comfyui_workspace = Path.home() / "ComfyUI_Workspace"
        comfyui_dir = comfyui_workspace / "ComfyUI"
        # 已安装检测
        if (comfyui_dir / "main.py").exists() and (comfyui_dir / "venv" / "bin" / "python3").exists():
            state = _load_state()
            state[item_id] = {"installed": True, "time": time.time()}
            _save_state(state)
            return jsonify({"item_id": item_id, "status": "completed", "cmd": "已安装: ~/ComfyUI_Workspace/ComfyUI/"})

        local_path = _download_from_cloud(item_id)
        if not local_path or not local_path.exists():
            return jsonify({"error": f"下载失败，安装包不可用: {item_id}"}), 503
        try:
            # 备份旧目录
            if comfyui_dir.exists():
                backup = comfyui_workspace / f"ComfyUI.bak.{int(time.time())}"
                shutil.move(str(comfyui_dir), str(backup))
            comfyui_workspace.mkdir(parents=True, exist_ok=True)
            with tempfile.TemporaryDirectory() as tmpdir:
                r = subprocess.run(['tar', '-xzf', str(local_path), '-C', tmpdir],
                    capture_output=True, text=True, timeout=600, env=_env_with_brew())
                if r.returncode != 0:
                    return jsonify({"error": f"解压失败: {r.stderr[:200]}"}), 500
                extracted = Path(tmpdir)
                # 新的包结构：comfyui-offline/{ComfyUI/, wheels/, setup-local.sh}
                src_comfyui = extracted / "comfyui-offline" / "ComfyUI"
                src_wheels = extracted / "comfyui-offline" / "wheels"
                if not src_comfyui.exists():
                    return jsonify({"error": "压缩包中未找到 ComfyUI 目录"}), 500
                # 复制 ComfyUI 代码
                shutil.copytree(str(src_comfyui), str(comfyui_dir), symlinks=True)
                # 复制 wheels 目录
                if src_wheels.exists():
                    shutil.copytree(str(src_wheels), str(comfyui_dir / "wheels"), symlinks=True)
            # 创建 models 目录
            model_dirs = ["checkpoints", "clip", "clip_vision", "controlnet",
                         "diffusion_models", "embeddings", "gligen", "hypernetworks",
                         "loras", "style_models", "unet", "upscale_models", "vae", "vae_approx"]
            for d in model_dirs:
                (comfyui_dir / "models" / d).mkdir(parents=True, exist_ok=True)
            # 创建 venv 并安装 wheels
            subprocess.run(['python3', '-m', 'venv', str(comfyui_dir / "venv")],
                capture_output=True, text=True, timeout=120, env=_env_with_brew())
            r2 = subprocess.run([str(comfyui_dir / "venv" / "bin" / "pip"),
                'install', '--no-index',
                '--find-links', str(comfyui_dir / "wheels"),
                '-r', str(comfyui_dir / "requirements.txt")],
                capture_output=True, text=True, timeout=600, env=_env_with_brew())
            if r2.returncode != 0:
                return jsonify({"error": f"依赖安装失败: {r2.stderr[:300]}"}), 500
            # 删除 wheels 目录释放空间（venv 已安装完成）
            shutil.rmtree(comfyui_dir / "wheels", ignore_errors=True)
            state = _load_state()
            state[item_id] = {"installed": True, "time": time.time()}
            _save_state(state)
            return jsonify({
                "item_id": item_id, "status": "completed",
                "cmd": f"已安装到 ~/ComfyUI_Workspace/ComfyUI/（模型需自行下载）"
            })
        except Exception as e:
            return jsonify({"error": str(e)}), 500

    # 命令行安装（homebrew, git 等）
    actions = {
        "homebrew": "NONINTERACTIVE=1 /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"",
        "git": "xcode-select --install",
        "ollama": "open https://ollama.com/download",
        "claude": "npm install -g @anthropic-ai/claude-code",
        "codex": "npm install -g @openai/codex",
    }
    cmd = actions.get(item_id)
    if not cmd:
        return jsonify({"error": f"未配置安装脚本: {item_id}"}), 404
    try:
        subprocess.Popen(cmd, shell=True)
        return jsonify({"item_id": item_id, "status": "started", "cmd": cmd})
    except Exception as e:
        return jsonify({"error": str(e)}), 500

# 卸载残留清理映射（除主程序外需删除的目录/文件）
UNINSTALL_EXTRAS = {
    "obsidian": [
        "~/Library/Application Support/obsidian",
        "~/Library/Preferences/md.obsidian.plist",
        "~/Library/Caches/md.obsidian",
        "~/Library/Saved Application State/md.obsidian.savedState",
    ],
    "veee": [
        "~/Library/Application Support/com.easyveee.macos",
        "~/Library/Preferences/com.easyveee.macos.plist",
        "~/Library/Caches/com.easyveee.macos",
    ],
    "ccswitch": [
        "~/.cc-switch",
        "~/Library/Application Support/com.ccswitch.desktop",
        "~/Library/Preferences/com.ccswitch.desktop.plist",
    ],
    "vscode": [
        "~/Library/Application Support/Code",
        "~/.vscode",
        "~/Library/Preferences/com.microsoft.VSCode.plist",
        "~/Library/Caches/com.microsoft.VSCode",
    ],
    "docker": [
        "~/Library/Containers/com.docker.docker",
        "~/Library/Group Containers/group.com.docker",
        "~/Library/Preferences/com.docker.docker.plist",
    ],
    "ollama": [
        "~/.ollama",
        "~/Library/Application Support/com.ollama.ollama",
    ],
    "openhuman": [
        "~/.openhuman",
        "~/Library/Application Support/com.openhuman.desktop",
        "~/Library/Preferences/com.openhuman.desktop.plist",
    ],
    "dify": [
        "~/dify",
    ],
    "openclaw": [
        "~/.openclaw",
        "~/Library/Application Support/clawhub",
    ],
    "claude": [
        "~/.claude",
        "~/.claude.json",
        "~/.local/bin/claude",
    ],
    "codex": [
        "~/Library/Application Support/com.openai.codex",
        "~/Library/Caches/com.openai.codex",
        "~/Library/Preferences/com.openai.codex.plist",
    ],
    "ripgrep": ["~/.local/bin/rg"],
    "ffmpeg": ["~/.local/bin/ffmpeg"],
    "hermes": ["~/.hermes", "/usr/local/bin/hermes", "~/.local/bin/hermes"],
    "python": [
        "~/Library/Frameworks/Python.framework/Versions/3.12",
        "/usr/local/bin/python3.12*",
        "~/.zprofile.pysave",
    ],
    "nodejs": [
        "/usr/local/lib/node_modules",
        "/usr/local/share/systemtap",
    ],
}

# 需要从 ~/.zshrc 中移除的 PATH 条目
ZSHRC_PATH_ENTRIES = {
    "claude": "export PATH=\"$HOME/.local/bin:$PATH\"",
    "codex": "export PATH=\"$HOME/.local/bin:$PATH\"",
    "ripgrep": "export PATH=\"$HOME/.local/bin:$PATH\"",
    "ffmpeg": "export PATH=\"$HOME/.local/bin:$PATH\"",
    "hermes": "export PATH=\"$HOME/.local/bin:$PATH\"",
}

def _remove_from_zshrc(entry):
    """从 ~/.zshrc 中移除指定条目"""
    zshrc = Path.home() / ".zshrc"
    if not zshrc.exists():
        return
    content = zshrc.read_text()
    if entry in content:
        # 移除条目及其前后的空行
        lines = content.splitlines()
        new_lines = [l for l in lines if entry not in l]
        # 移除多余的连续空行
        cleaned = []
        prev_empty = False
        for l in new_lines:
            if l.strip() == "":
                if not prev_empty:
                    cleaned.append(l)
                prev_empty = True
            else:
                cleaned.append(l)
                prev_empty = False
        zshrc.write_text("\n".join(cleaned) + "\n")

@app.route("/api/uninstall/<item_id>", methods=["POST"])
def api_uninstall(item_id):
    """触发卸载"""
    item = _find_item(item_id)
    if not item:
        return jsonify({"error": f"未找到项目: {item_id}"}), 404
    uninstall_cmd = item.get("uninstall_cmd")
    if not uninstall_cmd:
        return jsonify({"error": f"未配置卸载脚本: {item_id}"}), 404
    # 清除安装状态
    state = _load_state()
    if item_id in state:
        del state[item_id]
        _save_state(state)
    try:
        # 后台应用卸载：先退出应用再删除
        if item_id in ("docker", "ollama", "veee", "obsidian", "openhuman", "ccswitch"):
            app_names = {
                "docker": "Docker",
                "ollama": "Ollama",
                "veee": "Veee",
                "obsidian": "Obsidian",
                "openhuman": "OpenHuman",
                "ccswitch": "CC Switch",
            }
            app_name = app_names.get(item_id)
            if app_name:
                subprocess.run(['osascript', '-e', f'quit app "{app_name}"'], capture_output=True, timeout=10)
                time.sleep(2)

        # 执行主卸载命令
        if item_id == "homebrew":
            full_cmd = uninstall_cmd + " && sudo rm -rf /opt/homebrew /usr/local/Homebrew"
            subprocess.Popen(full_cmd, shell=True)
        else:
            subprocess.Popen(uninstall_cmd, shell=True)

        # 清理额外残留目录/文件
        extras = UNINSTALL_EXTRAS.get(item_id, [])
        for path in extras:
            expanded = os.path.expanduser(path)
            # 处理通配符路径（如 python3.12*）
            if '*' in expanded:
                import glob
                matches = glob.glob(expanded)
                for match in matches:
                    if os.path.isdir(match):
                        shutil.rmtree(match, ignore_errors=True)
                    else:
                        try:
                            os.remove(match)
                        except PermissionError:
                            pass  # sudo 卸载命令已处理
            elif os.path.exists(expanded):
                if os.path.isdir(expanded):
                    shutil.rmtree(expanded, ignore_errors=True)
                else:
                    try:
                        os.remove(expanded)
                    except PermissionError:
                        pass  # sudo 卸载命令已处理

        # 清理 ~/.zshrc 中的 PATH 配置
        if item_id in ZSHRC_PATH_ENTRIES:
            _remove_from_zshrc(ZSHRC_PATH_ENTRIES[item_id])

        return jsonify({"item_id": item_id, "status": "completed", "cmd": f"已卸载: {item_id}"})
    except Exception as e:
        return jsonify({"error": str(e)}), 500

@app.route("/api/install-skill", methods=["POST"])
def api_install_skill():
    """安装 Claude Code Skill/MCP 插件"""
    data = request.json
    skill_id = data.get("skill_id", "")
    cmd = data.get("cmd", "")
    name = data.get("name", "")
    if SIM_MODE:
        time.sleep(0.5)
        return jsonify({"ok": True, "skill_id": skill_id, "cmd": cmd, "sim_mode": True})
    try:
        subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=60, env=_env_with_brew())
        return jsonify({"ok": True, "skill_id": skill_id, "cmd": cmd})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500

@app.route("/api/item/<item_id>")
def api_item(item_id):
    item = _find_item(item_id)
    if not item:
        return jsonify({"error": f"未找到项目: {item_id}"}), 404
    return jsonify(item)

# ── 自定义常用命令 API ──────────────────────────────────
@app.route("/api/custom")
def api_custom_list():
    return jsonify(custom_items)

@app.route("/api/custom", methods=["POST"])
def api_custom_add():
    data = request.json
    item = {
        "id": "custom_" + uuid.uuid4().hex[:8],
        "title": data.get("title", "未命名"),
        "content1": data.get("content1", ""),
        "content2": data.get("content2", ""),
    }
    custom_items.append(item)
    _save_custom()
    return jsonify(item)

@app.route("/api/custom/<item_id>", methods=["DELETE"])
def api_custom_del(item_id):
    global custom_items
    custom_items = [i for i in custom_items if i["id"] != item_id]
    _save_custom()
    return jsonify({"ok": True})

@app.route("/api/copy", methods=["POST"])
def api_copy():
    data = request.json
    text = data.get("text", "")
    try:
        subprocess.run("pbcopy", input=text, text=True)
        return jsonify({"ok": True})
    except Exception as e:
        return jsonify({"error": str(e)}), 500

@app.route("/api/shutdown", methods=["POST"])
def api_shutdown():
    """用户关闭浏览器页面时自动关闭服务"""
    def _do_shutdown():
        import os, time
        time.sleep(0.5)
        os._exit(0)
    threading.Thread(target=_do_shutdown, daemon=True).start()
    return jsonify({"ok": True})

# ── 前端静态文件 ────────────────────────────────────────
@app.route("/")
def index():
    return app.send_static_file("index.html")

def _find_item(item_id):
    for cat in config.get("categories", []):
        for item in cat.get("items", []):
            if item.get("id") == item_id:
                return item
    for item in custom_items:
        if item.get("id") == item_id:
            return item
    return None

# ── pywebview 启动 ──────────────────────────────────────
def start_gui():
    try:
        import webview
    except ImportError:
        # 无 pywebview 时直接运行 Flask（服务器/无头环境）
        print("pywebview 未安装，以纯 Flask 模式运行在 http://0.0.0.0:17730")
        app.run(host="0.0.0.0", port=17730, debug=False)
        return
    t = threading.Thread(target=lambda: app.run(port=17730, debug=False), daemon=True)
    t.start()
    webview.create_window("Octoma", "http://127.0.0.1:17730",
        width=1200, height=780, min_size=(1000, 650))
    webview.start()

if __name__ == "__main__":
    start_gui()
