From 02e1ef6b14baf1b280a4b792cb8be50d28123bd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sun, 8 Mar 2026 01:46:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=89=88=E6=9C=AC=E7=AE=A1=E7=90=86=20?= =?UTF-8?q?+=20macOS=E6=8F=90=E7=A4=BA=E4=BC=98=E5=8C=96=20+=20=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E6=96=87=E6=A1=A3=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OpenClaw 版本管理: 安装/升级/降级/切换版本, 汉化版/原版选择 - 新增 list_openclaw_versions API (Rust + Web) - upgrade_openclaw 支持指定版本号 - 版本选择器弹窗 (about.js) - macOS Gatekeeper 提示优化: 强调拖入应用程序, No such file 备选 - 部署文档统一使用 npm run serve 替代 npx vite - showUpgradeModal 支持自定义标题 + onClose 回调 - serve.js 路径分隔符跨平台修复 - 扩展工具页面优化 + AI助手危险工具确认 --- README.md | 11 +- docs/docker-deploy.md | 12 +- docs/index.html | 7 + docs/linux-deploy.md | 32 ++- docs/update/latest.json | 9 + package.json | 1 + scripts/dev-api.js | 188 +++++++----- scripts/linux-deploy.sh | 7 +- scripts/serve.js | 205 +++++++++++++ src-tauri/src/commands/config.rs | 164 ++++++++++- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/skills.rs | 271 ++++++++++++++++++ src-tauri/src/commands/update.rs | 215 ++++++++++++++ src-tauri/src/lib.rs | 62 +++- src/components/modal.js | 12 +- src/components/sidebar.js | 3 + src/lib/icons.js | 2 + src/lib/tauri-api.js | 18 +- src/main.js | 1 + src/pages/about.js | 363 +++++++++++++++++++---- src/pages/assistant.js | 168 ++++++++++- src/pages/skills.js | 475 +++++++++++++++++-------------- src/router.js | 45 ++- 23 files changed, 1892 insertions(+), 381 deletions(-) create mode 100644 docs/update/latest.json create mode 100644 scripts/serve.js create mode 100644 src-tauri/src/commands/skills.rs create mode 100644 src-tauri/src/commands/update.rs diff --git a/README.md b/README.md index 642d97a..bf86ac1 100644 --- a/README.md +++ b/README.md @@ -50,15 +50,20 @@ ClawPanel 是 [OpenClaw](https://github.com/1186258278/OpenClawChineseTranslatio > 不确定芯片类型?点击左上角 → 关于本机,查看「芯片」一栏。 -安装方式:打开 `.dmg` 文件,将 ClawPanel 拖入「应用程序」文件夹。 +安装方式:打开 `.dmg` 文件,**先将 ClawPanel 拖入「应用程序」文件夹**,再双击打开。 -> **⚠️ 首次打开提示"无法验证开发者"?** 由于应用未签名,macOS 会拦截。请在终端执行以下命令解除限制: +> **⚠️ 首次打开提示"已损坏"或"无法验证开发者"?** 由于应用未签名,macOS 会拦截。请在终端执行以下命令解除限制: > > ```bash > sudo xattr -rd com.apple.quarantine /Applications/ClawPanel.app > ``` > > 或者前往「系统设置 → 隐私与安全性」,找到 ClawPanel 点击「仍要打开」。 +> +> 提示 `No such file`?说明没有拖入应用程序文件夹。请先拖入,或改用: +> ```bash +> sudo xattr -rd com.apple.quarantine ~/Downloads/ClawPanel.app +> ``` ### Windows @@ -96,7 +101,7 @@ docker run -d --name clawpanel --restart unless-stopped \ sh -c "apt-get update && apt-get install -y git && \ npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmmirror.com && \ git clone https://github.com/qingchencloud/clawpanel.git /app && \ - cd /app && npm install && npx vite --port 1420 --host 0.0.0.0" + cd /app && npm install && npm run build && npm run serve" ``` 📖 详细教程见 [Docker 部署指南](docs/docker-deploy.md)(含 Compose、自定义镜像、Nginx 反向代理等) diff --git a/docs/docker-deploy.md b/docs/docker-deploy.md index a60f7cc..36a65f1 100644 --- a/docs/docker-deploy.md +++ b/docs/docker-deploy.md @@ -54,8 +54,8 @@ docker run -d \ npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmmirror.com && \ openclaw init 2>/dev/null || true && \ git clone https://github.com/qingchencloud/clawpanel.git /app && \ - cd /app && npm install && \ - npx vite --port 1420 --host 0.0.0.0" + cd /app && npm install && npm run build && \ + npm run serve" ``` 访问 `http://服务器IP:1420` 即可使用。 @@ -120,7 +120,9 @@ RUN git clone https://github.com/qingchencloud/clawpanel.git . && \ EXPOSE 1420 -CMD ["npx", "vite", "--port", "1420", "--host", "0.0.0.0"] +RUN npm run build + +CMD ["npm", "run", "serve"] ``` 启动: @@ -151,7 +153,9 @@ RUN git clone https://github.com/qingchencloud/clawpanel.git . && \ EXPOSE 1420 -CMD ["npx", "vite", "--port", "1420", "--host", "0.0.0.0"] +RUN npm run build + +CMD ["npm", "run", "serve"] ``` 构建并运行: diff --git a/docs/index.html b/docs/index.html index f16293b..77b0898 100644 --- a/docs/index.html +++ b/docs/index.html @@ -991,6 +991,13 @@ .dmg +
+

⚠️ 首次打开提示"已损坏"或"无法验证"?

+

① 先将 ClawPanel 拖入「应用程序」文件夹,然后打开终端执行:

+ sudo xattr -rd com.apple.quarantine /Applications/ClawPanel.app +

② 或前往 系统设置 → 隐私与安全性,找到 ClawPanel 点击「仍要打开」

+

提示 No such file?说明没拖入应用程序,改用:sudo xattr -rd com.apple.quarantine ~/Downloads/ClawPanel.app

+
diff --git a/docs/linux-deploy.md b/docs/linux-deploy.md index 57dd3fc..af2cbbc 100644 --- a/docs/linux-deploy.md +++ b/docs/linux-deploy.md @@ -128,20 +128,27 @@ cd clawpanel npm install ``` -### 4. 启动 ClawPanel Web +### 4. 构建并启动 ClawPanel Web ```bash -npx vite --port 1420 --host 0.0.0.0 +npm run build # 构建生产版前端 +npm run serve # 启动 Web 服务器 (默认 0.0.0.0:1420) +``` + +自定义端口: + +```bash +npm run serve -- --port 8080 ``` 看到以下输出即为成功: ``` - VITE v6.x.x ready in xxx ms - - ➜ Local: http://localhost:1420/ - ➜ Network: http://xxx.xxx.xxx.xxx:1420/ - [dev-api] 开发 API 已启动,配置目录: /root/.openclaw + ┌─────────────────────────────────────────┐ + │ 🦀 ClawPanel Web Server (Headless) │ + │ http://localhost:1420/ │ + └─────────────────────────────────────────┘ + [api] API 已启动,配置目录: /root/.openclaw ``` 打开浏览器访问 `http://服务器IP:1420` 即可使用 ClawPanel。 @@ -164,7 +171,7 @@ docker run -d \ sh -c "apt-get update && apt-get install -y git && \ npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmmirror.com && \ git clone https://github.com/qingchencloud/clawpanel.git /app && \ - cd /app && npm install && npx vite --port 1420 --host 0.0.0.0" + cd /app && npm install && npm run build && npm run serve" ``` --- @@ -204,7 +211,7 @@ After=network.target Type=simple User=root WorkingDirectory=/opt/clawpanel -ExecStart=/usr/bin/npx vite --port 1420 --host 0.0.0.0 +ExecStart=/usr/bin/node scripts/serve.js Restart=on-failure RestartSec=5 Environment=NODE_ENV=production @@ -236,7 +243,8 @@ sudo journalctl -u clawpanel -f # 查看日志 npm install -g pm2 cd /opt/clawpanel -pm2 start "npx vite --port 1420 --host 0.0.0.0" --name clawpanel +npm run build +pm2 start "npm run serve" --name clawpanel pm2 save pm2 startup # 开机自启 ``` @@ -324,7 +332,7 @@ npm install -g @qingchencloud/openclaw-zh@latest --registry https://registry.npm lsof -i :1420 # 使用其他端口 -npx vite --port 3000 --host 0.0.0.0 +npm run serve -- --port 3000 ``` systemd 服务也需要改 ExecStart 中的端口。 @@ -365,7 +373,7 @@ openclaw gateway start & # 启动 ClawPanel Web cd /opt/clawpanel -npx vite --port 1420 --host 0.0.0.0 +npm run serve ``` 或者用 systemd 分别创建两个服务。也可以在 ClawPanel 面板中直接点击「启动」按钮管理 Gateway。 diff --git a/docs/update/latest.json b/docs/update/latest.json new file mode 100644 index 0000000..d6708c4 --- /dev/null +++ b/docs/update/latest.json @@ -0,0 +1,9 @@ +{ + "version": "0.6.0", + "minAppVersion": "0.6.0", + "hash": "", + "url": "", + "size": 0, + "changelog": "", + "releasedAt": "" +} diff --git a/package.json b/package.json index 49761cb..d37b1d5 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "build": "vite build", "preview": "vite preview", "tauri": "tauri", + "serve": "node scripts/serve.js", "version:sync": "node scripts/sync-version.js", "version:set": "node scripts/sync-version.js" }, diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 295f4b1..d454272 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -840,18 +840,52 @@ const handlers = { return execSync('openclaw gateway install 2>&1', { windowsHide: true }).toString() || 'Gateway 服务已安装' }, - upgrade_openclaw({ source = 'chinese' } = {}) { + async list_openclaw_versions({ source = 'chinese' } = {}) { + const pkg = source === 'official' ? 'openclaw' : '@qingchencloud/openclaw-zh' + const encodedPkg = pkg.replace('/', '%2F') + const registry = 'https://registry.npmmirror.com' + try { + const resp = await fetch(`${registry}/${encodedPkg}`, { headers: { 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000) }) + const data = await resp.json() + const versions = Object.keys(data.versions || {}) + versions.sort((a, b) => { + const pa = a.split(/[^0-9]/).filter(Boolean).map(Number) + const pb = b.split(/[^0-9]/).filter(Boolean).map(Number) + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + if ((pb[i] || 0) !== (pa[i] || 0)) return (pb[i] || 0) - (pa[i] || 0) + } + return 0 + }) + return versions + } catch (e) { + throw new Error('查询版本失败: ' + e.message) + } + }, + + upgrade_openclaw({ source = 'chinese', version } = {}) { const OPENCLAW_DIR = path.join(homedir(), '.openclaw') const pkg = source === 'official' ? 'openclaw' : '@qingchencloud/openclaw-zh' + const ver = version || 'latest' const npmBin = isWindows ? 'npm.cmd' : 'npm' try { - const out = execSync(`${npmBin} install ${pkg}@latest --prefix "${OPENCLAW_DIR}" 2>&1`, { timeout: 120000, windowsHide: true }).toString() - return `升级完成 (${source})\n${out.slice(-200)}` + const out = execSync(`${npmBin} install ${pkg}@${ver} --prefix "${OPENCLAW_DIR}" 2>&1`, { timeout: 120000, windowsHide: true }).toString() + const action = ver === 'latest' ? '升级' : '安装' + return `${action}完成 (${pkg}@${ver})\n${out.slice(-200)}` } catch (e) { - throw new Error('升级失败: ' + (e.stderr?.toString() || e.message).slice(-300)) + throw new Error('安装失败: ' + (e.stderr?.toString() || e.message).slice(-300)) } }, + uninstall_openclaw({ cleanConfig = false } = {}) { + const npmBin = isWindows ? 'npm.cmd' : 'npm' + try { execSync(`${npmBin} uninstall -g openclaw 2>&1`, { timeout: 60000, windowsHide: true }) } catch {} + try { execSync(`${npmBin} uninstall -g @qingchencloud/openclaw-zh 2>&1`, { timeout: 60000, windowsHide: true }) } catch {} + if (cleanConfig && fs.existsSync(OPENCLAW_DIR)) { + try { fs.rmSync(OPENCLAW_DIR, { recursive: true, force: true }) } catch {} + } + return cleanConfig ? 'OpenClaw 已完全卸载(包括配置文件)' : 'OpenClaw 已卸载(配置文件保留)' + }, + uninstall_gateway() { if (isMac) { const uid = getUid() @@ -903,86 +937,85 @@ const handlers = { return true }, - clawhub_trending() { - const fallback = [ - { slug: 'agent-browser', displayName: 'Agent Browser', summary: '浏览器自动化 CLI,支持点击、输入、抓取和截图。', author: 'TheSethRose', downloadsText: '73.9k', url: 'https://clawhub.ai/TheSethRose/agent-browser', source: 'clawhub' }, - { slug: 'github', displayName: 'Github', summary: '通过 gh CLI 与 GitHub issues、PR、CI 交互。', author: 'steipete', downloadsText: '72.5k', url: 'https://clawhub.ai/steipete/github', source: 'clawhub' }, - { slug: 'weather', displayName: 'Weather', summary: '获取当前天气和预报,无需 API Key。', author: 'steipete', downloadsText: '61.9k', url: 'https://clawhub.ai/steipete/weather', source: 'clawhub' }, - { slug: 'find-skills', displayName: 'Find Skills', summary: '帮助用户发现并安装合适的 skills。', author: 'JimLiuxinghai', downloadsText: '99.3k', url: 'https://clawhub.ai/JimLiuxinghai/find-skills', source: 'clawhub' }, - { slug: 'summarize', displayName: 'Summarize', summary: '总结网页、PDF、图片、音频等内容。', author: 'steipete', downloadsText: '82.7k', url: 'https://clawhub.ai/steipete/summarize', source: 'clawhub' }, - { slug: 'brave-search', displayName: 'Brave Search', summary: '轻量网页搜索和内容提取。', author: 'steipete', downloadsText: '29.4k', url: 'https://clawhub.ai/steipete/brave-search', source: 'clawhub' }, - ] + // Skills 管理(模拟 openclaw skills CLI JSON 输出) + skills_list() { + // 尝试真实 CLI try { - const out = execSync('npx -y clawhub explore --sort downloads --limit 12 --json', { encoding: 'utf8', timeout: 30000 }) - const data = JSON.parse(out) - const items = Array.isArray(data) ? data : (Array.isArray(data?.items) ? data.items : []) - const normalized = items - .map(item => ({ - slug: String(item?.slug || '').trim(), - displayName: String(item?.displayName || item?.name || item?.slug || '').trim(), - summary: String(item?.summary || item?.description || '').trim(), - author: String(item?.author?.handle || item?.author || '').trim(), - downloadsText: String(item?.stats?.downloadsText || item?.downloadsText || item?.downloads || '').trim(), - url: String(item?.url || item?.canonicalUrl || '').trim(), - source: 'clawhub' - })) - .filter(item => item.slug) - return normalized.length ? normalized : fallback + const out = execSync('npx -y openclaw skills list --json --verbose', { encoding: 'utf8', timeout: 30000 }) + return JSON.parse(out) } catch { - return fallback + // CLI 不可用时返回 mock 数据 + return { + skills: [ + { name: 'github', description: 'GitHub operations via gh CLI: issues, PRs, CI runs, code review.', source: 'openclaw-bundled', bundled: true, emoji: '🐙', eligible: true, disabled: false, blockedByAllowlist: false, requirements: { bins: ['gh'], anyBins: [], env: [], config: [], os: [] }, missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, install: [{ id: 'brew', kind: 'brew', label: 'Install GitHub CLI (brew)', bins: ['gh'] }] }, + { name: 'weather', description: 'Get current weather and forecasts via wttr.in. No API key needed.', source: 'openclaw-bundled', bundled: true, emoji: '🌤️', eligible: true, disabled: false, blockedByAllowlist: false, requirements: { bins: ['curl'], anyBins: [], env: [], config: [], os: [] }, missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, install: [] }, + { name: 'summarize', description: 'Summarize web pages, PDFs, images, audio and more.', source: 'openclaw-bundled', bundled: true, emoji: '📝', eligible: false, disabled: false, blockedByAllowlist: false, requirements: { bins: [], anyBins: [], env: [], config: [], os: [] }, missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, install: [] }, + { name: 'slack', description: 'Send and read Slack messages via CLI.', source: 'openclaw-bundled', bundled: true, emoji: '💬', eligible: false, disabled: false, blockedByAllowlist: false, requirements: { bins: ['slack-cli'], anyBins: [], env: [], config: [], os: [] }, missing: { bins: ['slack-cli'], anyBins: [], env: [], config: [], os: [] }, install: [{ id: 'brew', kind: 'brew', label: 'Install Slack CLI (brew)', bins: ['slack-cli'] }] }, + { name: 'notion', description: 'Create and search Notion pages using the API.', source: 'openclaw-bundled', bundled: true, emoji: '📓', eligible: false, disabled: true, blockedByAllowlist: false, requirements: { bins: [], anyBins: [], env: ['NOTION_API_KEY'], config: [], os: [] }, missing: { bins: [], anyBins: [], env: ['NOTION_API_KEY'], config: [], os: [] }, install: [] }, + ], + source: 'mock', + cliAvailable: false, + } } }, - - clawhub_search({ query }) { + skills_info({ name }) { + try { + const out = execSync(`npx -y openclaw skills info ${JSON.stringify(name)} --json`, { encoding: 'utf8', timeout: 30000 }) + return JSON.parse(out) + } catch (e) { + throw new Error('查看详情失败: ' + (e.message || e)) + } + }, + skills_check() { + try { + const out = execSync('npx -y openclaw skills check --json', { encoding: 'utf8', timeout: 30000 }) + return JSON.parse(out) + } catch { + return { summary: { total: 0, eligible: 0, disabled: 0, blocked: 0, missingRequirements: 0 }, eligible: [], disabled: [], blocked: [], missingRequirements: [] } + } + }, + skills_install_dep({ kind, spec }) { + const cmds = { + brew: `brew install ${spec?.formula || ''}`, + node: `npm install -g ${spec?.package || ''}`, + go: `go install ${spec?.module || ''}`, + uv: `uv tool install ${spec?.package || ''}`, + } + const cmd = cmds[kind] + if (!cmd) throw new Error(`不支持的安装类型: ${kind}`) + try { + const out = execSync(cmd, { encoding: 'utf8', timeout: 120000 }) + return { success: true, output: out.trim() } + } catch (e) { + throw new Error(`安装失败: ${e.message || e}`) + } + }, + skills_clawhub_search({ query }) { const q = String(query || '').trim() if (!q) return [] - const out = execSync(`npx -y clawhub search ${JSON.stringify(q)} --limit 12`, { encoding: 'utf8', timeout: 30000 }) - return out.split('\n') - .map(line => line.trim()) - .filter(line => line && !line.startsWith('-')) - .map(line => { - const parts = line.split(/\s{2,}/).filter(Boolean) - return { - slug: parts[0] || '', - displayName: parts[1] || parts[0] || '', - summary: '', - source: 'clawhub' - } - }) - }, - - clawhub_list_installed() { - const skillsDir = path.join(OPENCLAW_DIR, 'skills') - if (!fs.existsSync(skillsDir)) fs.mkdirSync(skillsDir, { recursive: true }) try { - const out = execSync('npx -y clawhub list', { cwd: homedir(), encoding: 'utf8', timeout: 30000 }) - const fromCli = out.split('\n') + const out = execSync(`npx -y clawhub search ${JSON.stringify(q)}`, { encoding: 'utf8', timeout: 30000 }) + return out.split('\n') .map(line => line.trim()) - .filter(line => line && line !== 'No installed skills.') - .map(line => ({ slug: line.split(/\s+/)[0], installed: true })) - if (fromCli.length) return fromCli - } catch {} - - // 兜底:直接扫描 ~/.openclaw/skills 目录,避免 CLI 输出格式变化导致空列表 - try { - return fs.readdirSync(skillsDir, { withFileTypes: true }) - .filter(entry => entry.isDirectory() || entry.isSymbolicLink()) - .map(entry => ({ slug: entry.name, installed: true })) - } catch { - return [] + .filter(line => line && !line.startsWith('-') && !line.startsWith('Search')) + .map(line => { + const parts = line.split(/\s{2,}/).filter(Boolean) + return { slug: parts[0] || '', description: parts.slice(1).join(' ').trim(), source: 'clawhub' } + }) + .filter(item => item.slug) + } catch (e) { + throw new Error('搜索失败: ' + (e.message || e)) } }, - - clawhub_inspect({ slug }) { - const out = execSync(`npx -y clawhub inspect ${JSON.stringify(slug)} --json`, { encoding: 'utf8', timeout: 30000 }) - return JSON.parse(out) - }, - - clawhub_install({ slug }) { + skills_clawhub_install({ slug }) { const skillsDir = path.join(OPENCLAW_DIR, 'skills') if (!fs.existsSync(skillsDir)) fs.mkdirSync(skillsDir, { recursive: true }) - const out = execSync(`npx -y clawhub install ${JSON.stringify(slug)} --workdir .openclaw --dir skills`, { cwd: homedir(), encoding: 'utf8', timeout: 120000 }) - return { success: true, slug, output: out.trim() } + try { + const out = execSync(`npx -y clawhub install ${JSON.stringify(slug)}`, { cwd: homedir(), encoding: 'utf8', timeout: 120000 }) + return { success: true, slug, output: out.trim() } + } catch (e) { + throw new Error('安装失败: ' + (e.message || e)) + } }, // 扩展工具 @@ -1568,6 +1601,14 @@ const handlers = { }, check_panel_update() { return { latest: null, url: 'https://github.com/qingchencloud/clawpanel/releases' } }, + + // 前端热更新 + check_frontend_update() { + return { currentVersion: '0.6.0', latestVersion: '0.6.0', hasUpdate: false, compatible: true, updateReady: false, manifest: { version: '0.6.0', minAppVersion: '0.6.0' } } + }, + download_frontend_update() { return { success: true, files: 12, path: path.join(OPENCLAW_DIR, 'clawpanel', 'web-update') } }, + rollback_frontend_update() { return { success: true } }, + get_update_status() { return { currentVersion: '0.6.0', updateReady: false, updateVersion: '', updateDir: path.join(OPENCLAW_DIR, 'clawpanel', 'web-update') } }, write_env_file({ path: p, config }) { const expanded = p.startsWith('~/') ? path.join(homedir(), p.slice(2)) : p if (!expanded.startsWith(OPENCLAW_DIR)) throw new Error('只允许写入 ~/.openclaw/ 下的文件') @@ -1791,6 +1832,9 @@ async function _apiMiddleware(req, res, next) { } } +// 导出供 serve.js 独立部署使用 +export { _initApi, _apiMiddleware } + export function devApiPlugin() { let _inited = false function ensureInit() { diff --git a/scripts/linux-deploy.sh b/scripts/linux-deploy.sh index 2693a5c..059e8d2 100644 --- a/scripts/linux-deploy.sh +++ b/scripts/linux-deploy.sh @@ -145,13 +145,14 @@ install_clawpanel() { cd "$INSTALL_DIR" npx vite build echo "✅ ClawPanel 安装完成: $INSTALL_DIR" + echo "✅ 启动命令: npm run serve" } # 创建 systemd 服务 setup_systemd() { if ! command -v systemctl &> /dev/null; then echo "⚠️ systemd 不可用,请手动启动:" - echo " cd $INSTALL_DIR && npx vite --port $PANEL_PORT --host 0.0.0.0" + echo " cd $INSTALL_DIR && npm run serve -- --port $PANEL_PORT" return 0 fi @@ -168,7 +169,7 @@ After=network.target Type=simple User=$(whoami) WorkingDirectory=$INSTALL_DIR -ExecStart=$(which npx) vite preview --port $PANEL_PORT --host 0.0.0.0 +ExecStart=$(which node) scripts/serve.js --port $PANEL_PORT Restart=on-failure RestartSec=5 Environment=NODE_ENV=production @@ -189,7 +190,7 @@ After=network.target [Service] Type=simple WorkingDirectory=$INSTALL_DIR -ExecStart=$(which npx) vite preview --port $PANEL_PORT --host 0.0.0.0 +ExecStart=$(which node) scripts/serve.js --port $PANEL_PORT Restart=on-failure RestartSec=5 Environment=NODE_ENV=production diff --git a/scripts/serve.js b/scripts/serve.js new file mode 100644 index 0000000..3408ea3 --- /dev/null +++ b/scripts/serve.js @@ -0,0 +1,205 @@ +#!/usr/bin/env node +/** + * ClawPanel 独立 Web 服务器(Headless 模式) + * 无需 Tauri / Rust / GUI,纯 Node.js 运行 + * 适用于 Linux 服务器、Docker 等无桌面环境 + * + * 用法: + * npm run serve # 默认 0.0.0.0:1420 + * npm run serve -- --port 8080 + * npm run serve -- --host 127.0.0.1 --port 3000 + * PORT=8080 npm run serve + */ +import http from 'http' +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import { homedir } from 'os' +import net from 'net' +import { _initApi, _apiMiddleware } from './dev-api.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const DIST_DIR = path.resolve(__dirname, '..', 'dist') + +// === 解析命令行参数 === +function parseArgs() { + const args = process.argv.slice(2) + let host = process.env.HOST || '0.0.0.0' + let port = parseInt(process.env.PORT, 10) || 1420 + for (let i = 0; i < args.length; i++) { + if (args[i] === '--host' && args[i + 1]) host = args[++i] + if (args[i] === '--port' && args[i + 1]) port = parseInt(args[++i], 10) + if (args[i] === '-p' && args[i + 1]) port = parseInt(args[++i], 10) + if (args[i] === '--help' || args[i] === '-h') { + console.log(` +ClawPanel Web Server (Headless) + +用法: node scripts/serve.js [选项] + +选项: + --host 监听地址 (默认: 0.0.0.0) + --port, -p 监听端口 (默认: 1420) + --help, -h 显示帮助 + +环境变量: + HOST 监听地址 + PORT 监听端口 + +示例: + npm run serve # 0.0.0.0:1420 + npm run serve -- --port 8080 # 0.0.0.0:8080 + npm run serve -- --host 127.0.0.1 -p 3000 +`) + process.exit(0) + } + } + return { host, port } +} + +// === MIME 类型映射 === +const MIME_TYPES = { + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.webp': 'image/webp', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.txt': 'text/plain; charset=utf-8', + '.map': 'application/json', +} + +// === 静态文件服务 === +function serveStatic(req, res) { + // URL 去掉 query string + const urlPath = req.url.split('?')[0] + let filePath = path.join(DIST_DIR, urlPath === '/' ? 'index.html' : urlPath) + + // 安全检查:不允许目录遍历 + if (!filePath.startsWith(DIST_DIR)) { + res.statusCode = 403 + res.end('Forbidden') + return + } + + // 尝试读取文件 + fs.stat(filePath, (err, stats) => { + if (!err && stats.isFile()) { + sendFile(res, filePath) + return + } + + // SPA fallback:非 API、非静态资源 → index.html + const ext = path.extname(urlPath) + if (!ext || ext === '.html') { + sendFile(res, path.join(DIST_DIR, 'index.html')) + } else { + res.statusCode = 404 + res.end('Not Found') + } + }) +} + +function sendFile(res, filePath) { + const ext = path.extname(filePath) + const contentType = MIME_TYPES[ext] || 'application/octet-stream' + + // 缓存策略:资源文件长缓存,HTML 不缓存 + if (ext === '.html') { + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate') + } else if (filePath.includes(`${path.sep}assets${path.sep}`)) { + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') + } + + res.setHeader('Content-Type', contentType) + fs.createReadStream(filePath).pipe(res) +} + +// === 启动服务器 === +async function main() { + // 检查 dist 目录 + if (!fs.existsSync(path.join(DIST_DIR, 'index.html'))) { + console.error('❌ 未找到 dist/index.html,请先运行: npm run build') + process.exit(1) + } + + const { host, port } = parseArgs() + + // 初始化 API + _initApi() + + const server = http.createServer(async (req, res) => { + // CORS 头(方便开发调试) + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') + if (req.method === 'OPTIONS') { res.statusCode = 204; res.end(); return } + + // API 请求 + await _apiMiddleware(req, res, () => { + // 非 API → 静态文件 + serveStatic(req, res) + }) + }) + + // WebSocket 代理 + let gatewayPort = 18789 + try { + const cfgPath = path.join(homedir(), '.openclaw', 'openclaw.json') + const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')) + gatewayPort = cfg?.gateway?.port || 18789 + } catch {} + + server.on('upgrade', (req, socket, head) => { + if (!req.url?.startsWith('/ws')) { + socket.destroy() + return + } + + const target = net.createConnection(gatewayPort, '127.0.0.1', () => { + const reqLine = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n` + const headers = Object.entries(req.headers) + .map(([k, v]) => `${k}: ${v}`) + .join('\r\n') + target.write(reqLine + headers + '\r\n\r\n') + if (head.length) target.write(head) + socket.pipe(target) + target.pipe(socket) + }) + + target.on('error', () => socket.destroy()) + socket.on('error', () => target.destroy()) + }) + + server.listen(port, host, () => { + console.log('') + console.log(' ┌─────────────────────────────────────────┐') + console.log(' │ │') + console.log(' │ 🦀 ClawPanel Web Server (Headless) │') + console.log(' │ │') + console.log(` │ http://${host === '0.0.0.0' ? 'localhost' : host}:${port}/`.padEnd(44) + '│') + if (host === '0.0.0.0') { + console.log(` │ http://0.0.0.0:${port}/`.padEnd(44) + '│') + } + console.log(' │ │') + console.log(' └─────────────────────────────────────────┘') + console.log('') + console.log(' 按 Ctrl+C 停止服务') + console.log('') + }) + + // 优雅退出 + process.on('SIGINT', () => { console.log('\n 👋 服务已停止'); process.exit(0) }) + process.on('SIGTERM', () => { console.log('\n 👋 服务已停止'); process.exit(0) }) +} + +main().catch(e => { console.error('启动失败:', e); process.exit(1) }) diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index cc18e8d..231ba16 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -495,16 +495,55 @@ fn npm_package_name(source: &str) -> &'static str { } } -/// 执行 npm 全局升级 openclaw(流式推送日志) +/// 获取指定源的所有可用版本列表(从 npm registry 查询) #[tauri::command] -pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String) -> Result { +pub async fn list_openclaw_versions(source: String) -> Result, String> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| format!("HTTP 初始化失败: {e}"))?; + let pkg = npm_package_name(&source) + .replace('/', "%2F"); + let registry = get_configured_registry(); + let url = format!("{registry}/{pkg}"); + let resp = client + .get(&url) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| format!("查询版本失败: {e}"))?; + let json: Value = resp + .json() + .await + .map_err(|e| format!("解析响应失败: {e}"))?; + let versions = json + .get("versions") + .and_then(|v| v.as_object()) + .map(|obj| { + let mut vers: Vec = obj.keys().cloned().collect(); + // 按版本号排序(新版本在前) + vers.sort_by(|a, b| { + let pa: Vec = a.split(|c: char| !c.is_ascii_digit()).filter_map(|s| s.parse().ok()).collect(); + let pb: Vec = b.split(|c: char| !c.is_ascii_digit()).filter_map(|s| s.parse().ok()).collect(); + pb.cmp(&pa) + }); + vers + }) + .unwrap_or_default(); + Ok(versions) +} + +/// 执行 npm 全局安装/升级/降级 openclaw(流式推送日志) +#[tauri::command] +pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String, version: Option) -> Result { use std::io::{BufRead, BufReader}; use std::process::Stdio; use tauri::Emitter; let current_source = detect_installed_source(); let pkg_name = npm_package_name(&source); - let pkg = format!("{}@latest", pkg_name); + let ver = version.as_deref().unwrap_or("latest"); + let pkg = format!("{}@{}", pkg_name, ver); // 切换源时需要卸载旧包,但为避免安装失败导致 CLI 丢失, // 先安装新包,成功后再卸载旧包 @@ -652,11 +691,128 @@ pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String) -> Result Result { + use std::io::{BufRead, BufReader}; + use std::process::Stdio; + use tauri::Emitter; + + let source = detect_installed_source(); + let pkg = npm_package_name(&source); + + // 1. 先停止 Gateway + let _ = app.emit("upgrade-log", "正在停止 Gateway..."); + #[cfg(target_os = "macos")] + { + let uid = get_uid().unwrap_or(501); + let _ = Command::new("launchctl") + .args(["bootout", &format!("gui/{uid}/ai.openclaw.gateway")]) + .output(); + } + #[cfg(not(target_os = "macos"))] + { + let _ = openclaw_command().args(["gateway", "stop"]).output(); + } + + // 2. 卸载 Gateway 服务 + let _ = app.emit("upgrade-log", "正在卸载 Gateway 服务..."); + #[cfg(not(target_os = "macos"))] + { + let _ = openclaw_command() + .args(["gateway", "uninstall"]) + .output(); + } + + // 3. npm uninstall + let _ = app.emit("upgrade-log", format!("$ npm uninstall -g {pkg}")); + let _ = app.emit("upgrade-progress", 20); + + let mut child = npm_command() + .args(["uninstall", "-g", pkg]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("执行卸载命令失败: {e}"))?; + + let stderr = child.stderr.take(); + let stdout = child.stdout.take(); + + let app2 = app.clone(); + let handle = std::thread::spawn(move || { + if let Some(pipe) = stderr { + for line in BufReader::new(pipe).lines().map_while(Result::ok) { + let _ = app2.emit("upgrade-log", &line); + } + } + }); + + if let Some(pipe) = stdout { + for line in BufReader::new(pipe).lines().map_while(Result::ok) { + let _ = app.emit("upgrade-log", &line); + } + } + + let _ = handle.join(); + let _ = app.emit("upgrade-progress", 60); + + let status = child.wait().map_err(|e| format!("等待进程失败: {e}"))?; + if !status.success() { + let code = status + .code() + .map(|c| c.to_string()) + .unwrap_or("unknown".into()); + return Err(format!("卸载失败,exit code: {code}")); + } + + // 4. 两个包都尝试卸载(确保干净) + let other_pkg = if source == "official" { + "@qingchencloud/openclaw-zh" + } else { + "openclaw" + }; + let _ = app.emit("upgrade-log", format!("清理 {other_pkg}...")); + let _ = npm_command() + .args(["uninstall", "-g", other_pkg]) + .output(); + let _ = app.emit("upgrade-progress", 80); + + // 5. 可选:清理配置目录 + if clean_config { + let config_dir = super::openclaw_dir(); + if config_dir.exists() { + let _ = app.emit( + "upgrade-log", + format!("清理配置目录: {}", config_dir.display()), + ); + if let Err(e) = std::fs::remove_dir_all(&config_dir) { + let _ = app.emit( + "upgrade-log", + format!("⚠️ 清理配置目录失败: {e}(可能有文件被占用)"), + ); + } + } + } + + let _ = app.emit("upgrade-progress", 100); + let msg = if clean_config { + "✅ OpenClaw 已完全卸载(包括配置文件)" + } else { + "✅ OpenClaw 已卸载(配置文件保留在 ~/.openclaw/)" + }; + let _ = app.emit("upgrade-log", msg); + Ok(msg.into()) +} + /// 自动初始化配置文件(CLI 已装但 openclaw.json 不存在时) #[tauri::command] pub fn init_openclaw_config() -> Result { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 8e68093..1738d32 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -10,6 +10,8 @@ pub mod logs; pub mod memory; pub mod pairing; pub mod service; +pub mod skills; +pub mod update; /// 获取 OpenClaw 配置目录 (~/.openclaw/) pub fn openclaw_dir() -> PathBuf { diff --git a/src-tauri/src/commands/skills.rs b/src-tauri/src/commands/skills.rs new file mode 100644 index 0000000..131c82f --- /dev/null +++ b/src-tauri/src/commands/skills.rs @@ -0,0 +1,271 @@ +use crate::utils::openclaw_command_async; +use serde_json::Value; + +/// 列出所有 Skills 及其状态(openclaw skills list --json) +#[tauri::command] +pub async fn skills_list() -> Result { + let output = openclaw_command_async() + .args(["skills", "list", "--json", "--verbose"]) + .output() + .await; + + match output { + Ok(o) if o.status.success() => { + let stdout = String::from_utf8_lossy(&o.stdout); + serde_json::from_str(&stdout).map_err(|e| format!("解析失败: {e}")) + } + _ => { + // CLI 不可用时,兜底扫描本地 skills 目录 + scan_local_skills() + } + } +} + +/// 查看单个 Skill 详情(openclaw skills info --json) +#[tauri::command] +pub async fn skills_info(name: String) -> Result { + let output = openclaw_command_async() + .args(["skills", "info", &name, "--json"]) + .output() + .await + .map_err(|e| format!("执行 openclaw 失败: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("获取详情失败: {}", stderr.trim())); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + serde_json::from_str(&stdout).map_err(|e| format!("解析详情失败: {e}")) +} + +/// 检查 Skills 依赖状态(openclaw skills check --json) +#[tauri::command] +pub async fn skills_check() -> Result { + let output = openclaw_command_async() + .args(["skills", "check", "--json"]) + .output() + .await + .map_err(|e| format!("执行 openclaw 失败: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("检查失败: {}", stderr.trim())); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + serde_json::from_str(&stdout).map_err(|e| format!("解析失败: {e}")) +} + +/// 安装 Skill 依赖(根据 install spec 执行 brew/npm/go/uv/download) +#[tauri::command] +pub async fn skills_install_dep(kind: String, spec: Value) -> Result { + let path_env = super::enhanced_path(); + + let (program, args) = match kind.as_str() { + "brew" => { + let formula = spec + .get("formula") + .and_then(|v| v.as_str()) + .ok_or("缺少 formula 参数")? + .to_string(); + ("brew".to_string(), vec!["install".to_string(), formula]) + } + "node" => { + let package = spec + .get("package") + .and_then(|v| v.as_str()) + .ok_or("缺少 package 参数")? + .to_string(); + ("npm".to_string(), vec!["install".to_string(), "-g".to_string(), package]) + } + "go" => { + let module = spec + .get("module") + .and_then(|v| v.as_str()) + .ok_or("缺少 module 参数")? + .to_string(); + ("go".to_string(), vec!["install".to_string(), module]) + } + "uv" => { + let package = spec + .get("package") + .and_then(|v| v.as_str()) + .ok_or("缺少 package 参数")? + .to_string(); + ("uv".to_string(), vec!["tool".to_string(), "install".to_string(), package]) + } + other => return Err(format!("不支持的安装类型: {other}")), + }; + + let output = tokio::process::Command::new(&program) + .args(&args) + .env("PATH", &path_env) + .output() + .await + .map_err(|e| format!("执行 {program} 失败: {e}"))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if !output.status.success() { + return Err(format!( + "安装失败 ({program} {}): {}", + output.status, + stderr.trim() + )); + } + + Ok(serde_json::json!({ + "success": true, + "output": stdout.trim(), + })) +} + +/// 从 ClawHub 安装 Skill(npx clawhub install ) +#[tauri::command] +pub async fn skills_clawhub_install(slug: String) -> Result { + let path_env = super::enhanced_path(); + let home = dirs::home_dir().unwrap_or_default(); + + // 确保 skills 目录存在 + let skills_dir = super::openclaw_dir().join("skills"); + if !skills_dir.exists() { + std::fs::create_dir_all(&skills_dir) + .map_err(|e| format!("创建 skills 目录失败: {e}"))?; + } + + let output = tokio::process::Command::new("npx") + .args(["-y", "clawhub", "install", &slug]) + .env("PATH", &path_env) + .current_dir(&home) + .output() + .await + .map_err(|e| format!("执行 clawhub 失败: {e}"))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if !output.status.success() { + return Err(format!("安装失败: {}", stderr.trim())); + } + + Ok(serde_json::json!({ + "success": true, + "slug": slug, + "output": stdout.trim(), + })) +} + +/// 从 ClawHub 搜索 Skills(npx clawhub search ) +#[tauri::command] +pub async fn skills_clawhub_search(query: String) -> Result { + let q = query.trim().to_string(); + if q.is_empty() { + return Ok(Value::Array(vec![])); + } + + let path_env = super::enhanced_path(); + let output = tokio::process::Command::new("npx") + .args(["-y", "clawhub", "search", &q]) + .env("PATH", &path_env) + .output() + .await + .map_err(|e| format!("执行 clawhub 失败: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("搜索失败: {}", stderr.trim())); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + + // clawhub search 输出是文本行,每行一个 skill + let items: Vec = stdout + .lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty() && !l.starts_with('-') && !l.starts_with("Search")) + .map(|l| { + let parts: Vec<&str> = l.splitn(2, char::is_whitespace).collect(); + let slug = parts.first().unwrap_or(&"").trim(); + let desc = parts.get(1).unwrap_or(&"").trim(); + serde_json::json!({ + "slug": slug, + "description": desc, + "source": "clawhub" + }) + }) + .filter(|v| !v["slug"].as_str().unwrap_or("").is_empty()) + .collect(); + + Ok(Value::Array(items)) +} + +/// CLI 不可用时的兜底:扫描 ~/.openclaw/skills 目录 +fn scan_local_skills() -> Result { + let skills_dir = super::openclaw_dir().join("skills"); + if !skills_dir.exists() { + return Ok(serde_json::json!({ + "skills": [], + "source": "local-scan", + "cliAvailable": false + })); + } + + let mut skills = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&skills_dir) { + for entry in entries.flatten() { + let ft = match entry.file_type() { + Ok(ft) => ft, + Err(_) => continue, + }; + if !ft.is_dir() && !ft.is_symlink() { + continue; + } + let name = entry.file_name().to_string_lossy().to_string(); + let skill_md = entry.path().join("SKILL.md"); + let description = if skill_md.exists() { + // 尝试从 SKILL.md 的 frontmatter 中提取 description + parse_skill_description(&skill_md) + } else { + String::new() + }; + skills.push(serde_json::json!({ + "name": name, + "description": description, + "source": "managed", + "eligible": true, + "bundled": false, + "filePath": skill_md.to_string_lossy(), + })); + } + } + + Ok(serde_json::json!({ + "skills": skills, + "source": "local-scan", + "cliAvailable": false + })) +} + +/// 从 SKILL.md 的 YAML frontmatter 中提取 description +fn parse_skill_description(path: &std::path::Path) -> String { + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return String::new(), + }; + // frontmatter 格式: ---\n...\n--- + if !content.starts_with("---") { + return String::new(); + } + if let Some(end) = content[3..].find("---") { + let fm = &content[3..3 + end]; + for line in fm.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("description:") { + return rest.trim().trim_matches('"').trim_matches('\'').to_string(); + } + } + } + String::new() +} diff --git a/src-tauri/src/commands/update.rs b/src-tauri/src/commands/update.rs new file mode 100644 index 0000000..7c7e5fe --- /dev/null +++ b/src-tauri/src/commands/update.rs @@ -0,0 +1,215 @@ +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::fs; +use std::io::Read; +use std::path::PathBuf; + +/// 前端热更新目录 (~/.openclaw/clawpanel/web-update/) +pub fn update_dir() -> PathBuf { + super::openclaw_dir().join("clawpanel").join("web-update") +} + +/// 更新清单 URL(GitHub Pages 托管) +const LATEST_JSON_URL: &str = "https://claw.qt.cool/update/latest.json"; + +/// 检查前端是否有新版本可用 +#[tauri::command] +pub async fn check_frontend_update() -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .user_agent("ClawPanel") + .build() + .map_err(|e| format!("HTTP 客户端错误: {e}"))?; + + let resp = client + .get(LATEST_JSON_URL) + .send() + .await + .map_err(|e| format!("请求失败: {e}"))?; + + if !resp.status().is_success() { + return Err(format!("服务器返回 {}", resp.status())); + } + + let manifest: Value = resp.json().await.map_err(|e| format!("解析失败: {e}"))?; + + let latest = manifest + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let current = env!("CARGO_PKG_VERSION"); + + // 检查最低兼容的 app 版本(前端可能依赖较新的 Rust 后端命令) + let min_app = manifest + .get("minAppVersion") + .and_then(|v| v.as_str()) + .unwrap_or("0.0.0"); + + let compatible = version_ge(current, min_app); + let has_update = !latest.is_empty() && latest != current && compatible; + let update_ready = update_dir().join("index.html").exists(); + + Ok(serde_json::json!({ + "currentVersion": current, + "latestVersion": latest, + "hasUpdate": has_update, + "compatible": compatible, + "updateReady": update_ready, + "manifest": manifest + })) +} + +/// 下载并解压前端更新包 +#[tauri::command] +pub async fn download_frontend_update(url: String, expected_hash: String) -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .user_agent("ClawPanel") + .build() + .map_err(|e| format!("HTTP 客户端错误: {e}"))?; + + let resp = client + .get(&url) + .send() + .await + .map_err(|e| format!("下载失败: {e}"))?; + + if !resp.status().is_success() { + return Err(format!("下载失败: HTTP {}", resp.status())); + } + + let bytes = resp + .bytes() + .await + .map_err(|e| format!("读取数据失败: {e}"))?; + + // 校验 SHA-256 + if !expected_hash.is_empty() { + let mut hasher = Sha256::new(); + hasher.update(&bytes); + let hash = format!("{:x}", hasher.finalize()); + let expected = expected_hash + .strip_prefix("sha256:") + .unwrap_or(&expected_hash); + if hash != expected { + return Err(format!("哈希校验失败: 期望 {},实际 {}", expected, hash)); + } + } + + // 清理旧更新,解压新包 + let dir = update_dir(); + if dir.exists() { + fs::remove_dir_all(&dir).map_err(|e| format!("清理旧更新失败: {e}"))?; + } + fs::create_dir_all(&dir).map_err(|e| format!("创建更新目录失败: {e}"))?; + + let cursor = std::io::Cursor::new(bytes.as_ref()); + let mut archive = zip::ZipArchive::new(cursor).map_err(|e| format!("解压失败: {e}"))?; + + for i in 0..archive.len() { + let mut file = archive + .by_index(i) + .map_err(|e| format!("读取压缩条目失败: {e}"))?; + + let name = file.name().to_string(); + let target = dir.join(&name); + + if name.ends_with('/') { + fs::create_dir_all(&target).map_err(|e| format!("创建子目录失败: {e}"))?; + } else { + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|e| format!("创建父目录失败: {e}"))?; + } + let mut buf = Vec::new(); + file.read_to_end(&mut buf) + .map_err(|e| format!("读取文件内容失败: {e}"))?; + fs::write(&target, &buf).map_err(|e| format!("写入文件失败: {e}"))?; + } + } + + Ok(serde_json::json!({ + "success": true, + "files": archive.len(), + "path": dir.to_string_lossy() + })) +} + +/// 回退前端更新(删除热更新目录,下次启动使用内嵌资源) +#[tauri::command] +pub fn rollback_frontend_update() -> Result { + let dir = update_dir(); + if dir.exists() { + fs::remove_dir_all(&dir).map_err(|e| format!("回退失败: {e}"))?; + } + Ok(serde_json::json!({ "success": true })) +} + +/// 获取当前热更新状态 +#[tauri::command] +pub fn get_update_status() -> Result { + let dir = update_dir(); + let ready = dir.join("index.html").exists(); + + // 尝试读取已下载更新的版本信息 + let update_version = if ready { + dir.join(".version") + .exists() + .then(|| fs::read_to_string(dir.join(".version")).ok()) + .flatten() + .unwrap_or_default() + } else { + String::new() + }; + + Ok(serde_json::json!({ + "currentVersion": env!("CARGO_PKG_VERSION"), + "updateReady": ready, + "updateVersion": update_version, + "updateDir": dir.to_string_lossy() + })) +} + +/// 简单的语义化版本比较:current >= required +fn version_ge(current: &str, required: &str) -> bool { + let parse = |s: &str| -> Vec { + s.trim_start_matches('v') + .split('.') + .filter_map(|p| p.parse().ok()) + .collect() + }; + let c = parse(current); + let r = parse(required); + for i in 0..r.len().max(c.len()) { + let cv = c.get(i).copied().unwrap_or(0); + let rv = r.get(i).copied().unwrap_or(0); + if cv > rv { + return true; + } + if cv < rv { + return false; + } + } + true +} + +/// 根据文件扩展名推断 MIME 类型 +pub fn mime_from_path(path: &str) -> &'static str { + match path.rsplit('.').next().unwrap_or("") { + "html" => "text/html", + "js" | "mjs" => "application/javascript", + "css" => "text/css", + "json" => "application/json", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "svg" => "image/svg+xml", + "ico" => "image/x-icon", + "woff" => "font/woff", + "woff2" => "font/woff2", + "ttf" => "font/ttf", + "wasm" => "application/wasm", + _ => "application/octet-stream", + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 22bc8a3..c1da545 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,11 +3,57 @@ mod models; mod tray; mod utils; -use commands::{agent, assistant, config, device, extensions, logs, memory, pairing, service}; +use commands::{ + agent, assistant, config, device, extensions, logs, memory, pairing, service, skills, update, +}; pub fn run() { + let hot_update_dir = commands::openclaw_dir() + .join("clawpanel") + .join("web-update"); + tauri::Builder::default() .plugin(tauri_plugin_shell::init()) + .register_uri_scheme_protocol("tauri", move |ctx, request| { + let uri_path = request.uri().path(); + let path = if uri_path == "/" || uri_path.is_empty() { + "index.html" + } else { + uri_path.strip_prefix('/').unwrap_or(uri_path) + }; + + // 1. 优先检查热更新目录 + let update_file = hot_update_dir.join(path); + if update_file.is_file() { + if let Ok(data) = std::fs::read(&update_file) { + return tauri::http::Response::builder() + .header( + tauri::http::header::CONTENT_TYPE, + update::mime_from_path(path), + ) + .body(data) + .unwrap(); + } + } + + // 2. 回退到内嵌资源 + if let Some(asset) = ctx.app_handle().asset_resolver().get(path.to_string()) { + let builder = tauri::http::Response::builder() + .header(tauri::http::header::CONTENT_TYPE, &asset.mime_type); + // Tauri 内嵌资源可能带 CSP header + let builder = if let Some(csp) = asset.csp_header { + builder.header("Content-Security-Policy", csp) + } else { + builder + }; + builder.body(asset.bytes).unwrap() + } else { + tauri::http::Response::builder() + .status(tauri::http::StatusCode::NOT_FOUND) + .body(b"Not Found".to_vec()) + .unwrap() + } + }) .setup(|app| { tray::setup_tray(app.handle())?; Ok(()) @@ -34,7 +80,9 @@ pub fn run() { config::restart_gateway, config::test_model, config::list_remote_models, + config::list_openclaw_versions, config::upgrade_openclaw, + config::uninstall_openclaw, config::install_gateway, config::uninstall_gateway, config::patch_model_vision, @@ -91,6 +139,18 @@ pub fn run() { assistant::assistant_save_image, assistant::assistant_load_image, assistant::assistant_delete_image, + // Skills 管理(openclaw skills CLI) + skills::skills_list, + skills::skills_info, + skills::skills_check, + skills::skills_install_dep, + skills::skills_clawhub_search, + skills::skills_clawhub_install, + // 前端热更新 + update::check_frontend_update, + update::download_frontend_update, + update::rollback_frontend_update, + update::get_update_status, ]) .run(tauri::generate_context!()) .expect("启动 ClawPanel 失败"); diff --git a/src/components/modal.js b/src/components/modal.js index c360e31..2873499 100644 --- a/src/components/modal.js +++ b/src/components/modal.js @@ -141,12 +141,12 @@ export function showModal({ title, fields, onConfirm }) { * 升级进度弹窗 — 带进度条和实时日志 * @returns {{ appendLog, setProgress, setDone, setError, destroy }} */ -export function showUpgradeModal() { +export function showUpgradeModal(title) { const overlay = document.createElement('div') overlay.className = 'modal-overlay' overlay.innerHTML = `