diff --git a/CHANGELOG.md b/CHANGELOG.md index 69b7356..d905af0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,48 @@ ## [未发布] +## [0.18.0] - 2026-06-06 + +### 新功能 (Features) + +- **接入官方独立站 API** — 客户端版本发现、推荐安装包、下载链接、公告通知统一走 `https://claw.qt.cool`,由 Rust/Tauri 后端封装后提供给前端使用 +- **完整安装包更新流程** — 启动检查和关于页改为推荐下载官方完整安装包,按 Windows / macOS / Linux 展示安装引导,不再把 Web 热更新作为用户主路径 +- **官网公告与通知中心** — 新增左下角系统公告入口,支持通知与固定公告分流、关闭去重、中文/英文 fallback,以及官网 `surface=client` 公告接口 +- **桌面端心跳统计** — Tauri 后台任务定时上报匿名稳定 client id、版本、平台、架构、语言等粗粒度信息,用于官网统计在线情况 +- **登录页语言切换** — 首次登录和锁屏登录页支持直接切换语言 + +### 改进 (Improvements) + +- **更新弹窗重设计** — 支持 Markdown 更新日志、安全渲染、长日志滚动、底部按钮固定、移动端适配和官网 / GitHub 下载入口 +- **关于页更新入口收口** — 版本卡片只展示推荐安装包和 GitHub 备用下载,不再暴露热更新按钮 +- **公告 UI 收紧** — 公告弹窗、底部工具按钮、默认密码提醒条都改为更轻量的客户端风格,减少遮挡 +- **多引擎侧边栏适配** — 修复 Hermes / Xintian 主题对底部工具按钮的旧样式污染,通知、夜间模式、语言按钮统一为紧凑图标按钮 +- **官网 URL 安全归一化** — 下载链接只允许官方站和 GitHub fallback,latest / announcements / legacy update 请求都附带 `_t` 缓存小尾巴 +- **移除旧官网单页** — 删除仓库内 `docs/index.html`,官网由独立站维护;版本同步脚本不再处理旧官网 SEO 内容 + +### 修复 (Fixes) + +- **Windows OpenClaw CLI 路径识别** — 补充 `openclaw.cmd` / `.exe` / `.bat` / `.js` 识别与规范化,避免把 npm 目录下不可直接执行的 `openclaw` shim 当作 Gateway 启动命令 +- **更新日志显示不完整** — 更新弹窗内日志区域限制高度并独立滚动,避免内容截断或按钮被挤出视口 +- **关于页更新信息挤压** — 修复推荐安装包文件名过长导致卡片文字竖排、布局破坏的问题 +- **默认密码提醒过重** — 顶部提醒从大色块横幅改为轻量安全提示条,PC 和移动端都降低高度和视觉干扰 +- **公告标签无法切换** — 修复通知为空时点击“通知”标签无反应的问题 + +### 兼容性 (Compatibility) + +- `/update/latest.json` 继续只服务 `web-*.zip` 热更新包;新版用户升级主路径为完整安装包 +- 完整安装包更新只打开浏览器下载,不做静默安装 + +### 测试与验证 (Testing) + +- 已通过 `git diff --check` +- 已通过 `node --test tests\site-message-center.test.js` +- 已通过 `cd src-tauri && cargo fmt --check` +- 已通过 `cd src-tauri && cargo test site_api::tests` +- 已通过 `cd src-tauri && cargo check` +- 已通过 `cd src-tauri && cargo clippy --all-targets -- -D warnings` +- 已通过 `npm run build` + ## [0.17.0] - 2026-05-28 ### 新功能 (Features) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 218ed83..cdf3f25 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -135,8 +135,8 @@ clawpanel/ │ ├── build.sh # macOS/Linux 编译与打包 │ ├── linux-deploy.sh # Linux 服务器一键部署 │ └── sync-version.js # 版本号同步脚本 -├── docs/ # 文档与截图 -│ ├── index.html # 官网(claw.qt.cool) +├── docs/ # 文档、截图与更新清单 +│ ├── update/latest.json # 旧版前端热更新清单 │ ├── linux-deploy.md # Linux 部署指南 │ └── docker-deploy.md # Docker 部署指南 ├── public/ # 静态资源(图标、Logo) @@ -197,7 +197,6 @@ tauri-api.js → isTauri? | `package.json` | `version` | **主版本源** — npm、前端构建、侧边栏显示 | | `src-tauri/tauri.conf.json` | `version` | Tauri 打包版本号 | | `src-tauri/Cargo.toml` | `version` | Rust crate 版本号 | -| `docs/index.html` | `softwareVersion` | 官网 JSON-LD SEO | | `CHANGELOG.md` | `## [x.y.z]` | 变更日志(需手动编写内容) | ### 同步命令 @@ -228,7 +227,7 @@ import { version as APP_VERSION } from '../../package.json' # 1. 确认工作区干净 git status -# 2. 设置新版本号(自动同步到 tauri.conf.json / Cargo.toml / docs/index.html) +# 2. 设置新版本号(自动同步到 tauri.conf.json / Cargo.toml / Cargo.lock) npm run version:set 0.6.0 # 3. 编写 CHANGELOG.md 变更记录 diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index 17b93c4..0000000 --- a/docs/index.html +++ /dev/null @@ -1,1957 +0,0 @@ - - - - - - ClawPanel - OpenClaw & Hermes Agent 多引擎 AI 管理面板 | 快速搭建、配置、监控你的 AI 智能体 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
-
-
-
-
-
-
内置 AI 助手 — 支持 OpenClaw & Hermes Agent 双引擎管理
-

AI 助手驱动的
多引擎 AI 管理面板

-

支持 OpenClawHermes Agent 双引擎,内置智能 AI 助手帮你一键安装、自动诊断配置、排查问题、修复错误。
内置 Hermes Agent 对话、消息渠道管理、QQ 机器人,并支持 Telegram、Discord 等外部渠道。

- -
- ClawPanel AI 助手 — 8 大技能卡片 -
-
-
-
- - -
-
-
-
-
0
功能模块
-
Tauri v2
桌面框架
-
3
跨平台支持
-
AGPL-3.0
开源协议
-
-
-
- - -
-
-
-
-

加入社区

-

这里聚集了一群对 AI Agent 充满热情的开发者和玩家。
交流使用技巧、分享落地经验、反馈问题、第一时间获取新版本动态。

- -
-
-
- QQ 群二维码 - QQ 群 - 扫码加入 -
-
- 微信群二维码 - 微信群 - 扫码加入 -
-
- 抖音群二维码 - 抖音群 - 扫码加入 -
-
- 飞书群二维码 - 飞书群 - 扫码加入 -
-
-
-
-
- - -
-
-
-
-
- - 50 秒快速了解 -
-

产品 演示视频

-

从仪表盘到记忆管理,一个视频看完所有核心功能

-
-
-
-
-
- -
-
- -
-
-
-
-
- - -
-
-
-
-

强大的 功能矩阵

-

一个面板,管理 OpenClaw & Hermes Agent 双引擎

-
- -
-
-
-
-
Hermes Agent 第二引擎
-

让 AI Agent 拥有会话、记忆与人格的长期生命线

-

ClawPanel 不只是 OpenClaw 的控制台,也内置 Hermes Agent 管理能力:你可以直接管理会话、长期记忆、灵魂档案、工具调用和消息渠道,把 Agent 从“临时聊天”升级为可持续运营的智能体。

-
-
长期记忆Notes / User Profile / Soul 三份 Markdown 持续沉淀上下文。
-
会话运营统一查看对话、消息流、工具调用与运行状态。
-
人格塑造把 Agent 的表达风格、价值观和偏好固化为可编辑资产。
-
多渠道连接面向 QQ、Telegram、Discord 等外部渠道做统一管理。
-
-
-
- Hermes Agent 总览 -
-
-
-
Hermes Agent 总览
Hermes Agent 控制台
-
Hermes Agent 记忆
Agent 长期记忆
-
Hermes Agent 会话
会话与消息流
-
Hermes Agent 工具
工具与运行细节
-
-
-
- - -
-
ClawPanel 仪表盘
-
-
全景概览
-

仪表盘 — 运行状态一目了然

-

Gateway 运行状态、版本信息、Agent 数量、模型池总览,搭配内网穿透状态、基础服务监控和实时日志流。一屏掌握 OpenClaw 所有运行指标。

-
    -
  • Gateway / 隧道 / 服务实时状态卡片
  • -
  • 配置版本标识 & 最近备份时间
  • -
  • 并行推理队列 & 工作区文件隔离
  • -
  • 重启 / 检查更新 / 创建备份快捷操作
  • -
-
-
- - -
-
-
开箱即用
-

晴辰云 AI 接口 — 签到领额度

-

内置捴辰云 AI 接口,每日签到可领取测试额度,邀请好友可获得更多。选择模型、一键接入,快速开始使用。也支持接入任意 OpenAI 兼容 API。

-
    -
  • 晴辰云接口一键接入
  • -
  • GPT-5 全系列模型可选
  • -
  • 兼容 Chat Completions & Responses API
  • -
  • 独立配置,无需安装 OpenClaw
  • -
-
-
AI 助手设置 — 晴辰云 AI 接口一键接入
-
- - -
-
AI 助手人设 — Agent 灵魂加载
-
-
借尸还魂
-

继承 Agent 人格与记忆

-

从 OpenClaw Agent 加载完整灵魂——SOUL.md(人格)、IDENTITY.md(身份)、USER.md(用户偏好)、AGENTS.md(行为规则)、TOOLS.md(工具)一键注入。让 AI 助手拥有你 Agent 的全部能力。

-
    -
  • 一键加载 Agent 灵魂(5/6 核心文件)
  • -
  • 多 Agent 灵魂自由切换
  • -
  • 保留 ClawPanel 工具调用能力
  • -
  • 工具权限细粒度控制
  • -
-
-
- - -
-
-
核心功能
-

实时聊天 — 多模型流式对话

-

WebSocket 直连 Gateway,流式响应逐字显示。自动列出所有已配置模型(含晴辰云 AI 接口),支持图片附件、Markdown 渲染和快捷指令。

-
    -
  • 多 Provider 模型自动聚合
  • -
  • 多会话管理与历史记录
  • -
  • 图片拖拽 & 多模态对话
  • -
  • / 快捷指令系统
  • -
-
-
实时聊天
-
- - -
-
模型配置
-
-
配置中心
-

多服务商统一管理

-

OpenAI、晴辰云 AI、DeepSeek、Kimi 等多家服务商统一管理。内置晴辰云 AI 接口一键添加全部模型。可视化主模型 + 备选自动切换,批量测试连通性。

-
    -
  • 晴辰云接口一键添加全部模型
  • -
  • 批量连通性测试 & 延迟检测
  • -
  • 主模型 + 备选自动切换
  • -
  • 拖拽排序 & 实时保存
  • -
-
-
- - -
-
-
数据管理
-

让 Agent 拥有记忆

-

在线编辑 Agent 工作记忆、浏览记忆归档、管理核心配置文件(SOUL.md、AGENTS.md 等)。支持 ZIP 一键打包导出,多 Agent 记忆完全隔离。

-
    -
  • 工作记忆 & 记忆归档 & 核心文件
  • -
  • 在线编辑 & 实时预览
  • -
  • 多 Agent 记忆隔离
  • -
  • ZIP 打包下载
  • -
-
-
记忆文件
-
- - -
-
Gateway 安全认证与工具权限
-
-
安全防护
-

安全认证 + 工具权限管控

-

Token 密钥 / 密码认证双模式,卡片式直观选择。Agent 工具调用权限三档可调(完整 / 受限 / 禁用),会话可见性细粒度控制。安全与灵活兼得。

-
    -
  • Token & 密码双认证模式
  • -
  • Agent 工具权限三档管控
  • -
  • 会话可见性控制
  • -
  • 改完自动重启生效
  • -
-
-
- - -
-
-
智能体
-

多 Agent 协作管理

-

创建和管理多个 AI Agent,各自配置名称、模型和独立工作区。支持备份、编辑与默认 Agent 快速切换。

-
    -
  • 多 Agent 独立工作区
  • -
  • 身份与模型单独配置
  • -
  • Agent 配置备份 & 编辑
  • -
  • 默认 Agent 快速切换
  • -
-
-
Agent 管理
-
-
-
- - -
-
-
-
-

还有 更多

-

运维、监控、诊断,面面俱到

-
- -
-

服务管理

启停控制、版本检测、一键升级、npm 源切换、配置备份与恢复

-

知识库

自定义知识注入 AI 助手,Markdown 格式,对话时自动激活

-

安全设置

访问密码保护、无视风险模式切换、面板访问安全管控

-

扩展工具

cftunnel 内网穿透、ClawApp 移动客户端一键安装

-

系统诊断

全面健康检测、WebSocket 测试、网络日志、一键修复配对

-

关于

版本信息、社群入口(QQ / 微信 / 抖音)、相关项目链接

-
-
-
- - -
-
-
-
-

技术架构

-

精挑细选的技术栈,追求极致性能与开发体验

-
-
- ClawPanel 数据概览 -
-
-
-
Rust + Tauri v2
后端运行时
-
原生编译,内存安全,跨平台打包。通过 IPC 与前端高效通信,Shell Plugin 执行本地命令。
-
-
-
Vanilla JS + Vite
前端架构
-
零框架依赖,SPA 路由,组件化设计。Vite 极速 HMR,支持浏览器 mock 模式独立调试。
-
-
-
WebSocket + RPC
实时通信
-
WsClient 管理 WebSocket 连接、心跳保活与自动重连。RPC 请求-响应 + 事件订阅双模式。
-
-
-
CSS Variables
主题系统
-
暗色 / 亮色主题无闪烁切换,玻璃拟态 UI 风格,CSS 自定义属性实现全局样式管理。
-
-
-
-
- - -
-
-
-

快速开始

-

几条命令,开箱即用

-
-
-
- - Terminal -
-
-
# 克隆仓库
-
git clone https://github.com/qingchencloud/clawpanel.git
-
cd clawpanel && npm install
-
-
# macOS / Linux — 启动完整 Tauri 桌面应用
-
./scripts/dev.sh
-
-
# Windows — 启动完整 Tauri 桌面应用
-
npm run tauri dev
-
-
# 仅前端调试(浏览器 mock 模式)
-
npm run dev
-
-
-
-
- - -
-
-
-
-

服务器 部署指南

-

没有桌面环境?在 Linux 或 Docker 上部署 ClawPanel Web 版,浏览器即可管理 OpenClaw

-
-
- -
-
-
- -
-
-

Linux 一键部署

-

支持 Ubuntu、Debian、CentOS、Fedora、Alpine 等主流发行版

-
-
-
-
- - bash -
-
-
# 一键部署 ClawPanel Web + OpenClaw + systemd 自启
-
curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/scripts/linux-deploy.sh | bash
-
-
-
- - - 完整教程 - - Node.js · OpenClaw · systemd/PM2 · Nginx · Firewall -
-
- -
-
-
- -
-
-

Docker 部署

-

容器化隔离,支持 Compose 编排、自定义镜像、Nginx 反代

-
-
-
-
- - bash -
-
-
# 一条命令启动 ClawPanel Web
-
docker run -d --name clawpanel -p 1420:1420 \
-
-v clawpanel-data:/root/.openclaw node:22-slim \
-
sh -c "apt-get update && apt-get install -y git && ..."
-
-
-
- - - 完整教程 - - Compose · Dockerfile · Gateway · Nginx -
-
-
-
-

- - 部署完成后,访问 http://服务器IP:1420 即可通过浏览器管理 OpenClaw,功能与桌面版一致。 -

-
-
-
- - -
-
-
-
-

文档中心

-

遇到问题?这里有你需要的一切

-
-
- -
-
-
-

Linux 部署指南

-

在 Linux 服务器上部署 ClawPanel Web 版,通过浏览器远程管理 OpenClaw

-
- 一键部署 - systemd - PM2 - Nginx 反代 -
-
-
-
-
🐳
-
-

Docker 部署指南

-

用 Docker 部署 ClawPanel Web 版,支持 Compose 编排、自定义镜像、Gateway 联动

-
- Docker Compose - Dockerfile - Gateway 联动 - Nginx 反代 -
-
-
-
-
📖
-
-

项目主页 README

-

完整的项目介绍,包含安装方式、功能特性、技术架构、源码构建、常见问题

-
- 安装指南 - 功能介绍 - 源码构建 - 常见问题 -
-
-
-
-
📋
-
-

更新日志

-

每个版本的新增功能、Bug 修复和改进记录

-
- 新功能 - Bug 修复 - 版本记录 -
-
-
-
-
-
- - -
-
-
-

-
- 在 GitHub 上查看 - -
-
-
-
-
- - -
-
-
-
-

生态项目

-

ClawPanel 是 OpenClaw 生态的一部分

-
- -
-
- - -
-
-
-
-
v0.17.0 最新版
-

下载安装

-

选择你的操作系统,一键下载安装

-
-
-
- -

macOS

-

支持 Apple Silicon 和 Intel 芯片

- -
-

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

-

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

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

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

-

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

-
-
- -
- -

Linux

-

支持主流 Linux 发行版

- -
-
-
-

查看 所有版本 · 需要帮助?阅读 安装文档

-

国内网络下载慢?加入 QQ 群微信群 获取安装包直传

-
-
-
- - -
-
-
-
-

晴辰云 AI 接口

-

内部技术测试平台,签到领额度,邀请得更多

-
-
-
-
-
-
-

晴辰云 AI 接口是面向部分用户开放的内部技术测试平台。每日签到可领取测试额度,邀请好友可获得更多。

-

兼容 OpenAI /v1/chat/completions/v1/responses 接口,可无缝对接 OpenClaw。

-

在 ClawPanel 内置助手设置中,选择模型 → 点击「测试」→「一键接入」即可开始使用。如需独立密钥,可前往活动站签到领取。

-
-
-
-
-
签到领额度
-
每日签到 + 邀请好友
-
-
-
-
OpenAI 兼容
-
无缝对接 OpenClaw
-
-
-
-
独立密钥
-
签到即可免费领取
-
-
-
- 签到领额度 - 用量查询 - 晴辰云 AI -
-
-

⚠️ 本平台仅提供技术测试,禁止用于违法违规、绕过安全机制等用途。模型/接口以实际页面展示为准,可能灰度或版本切换。请妥善保管 API Key,具体规则以平台最新公告为准。

-
-
-
-
-
- - -
-
-

赞助商

-

感谢以下赞助商对 ClawPanel 项目的支持

- -
-
- - -
-
-

Star 趋势

-

感谢每一位 Star,你们是我们持续前进的动力

- -
-
- - - - - - - - - - - - diff --git a/docs/update-dialog-design.html b/docs/update-dialog-design.html new file mode 100644 index 0000000..08e177f --- /dev/null +++ b/docs/update-dialog-design.html @@ -0,0 +1,915 @@ + + + + + + ClawPanel 更新弹窗设计预览 + + + + +
+ + +
+
+
+

版本更新弹窗预览

+

安装包更新版:贴近客户端现有 modal、按钮和变量体系。

+
+
+
+ + +
+ +
+
+ +
+ + + +
+
+
+ + + + diff --git a/index.html b/index.html index 122acf5..c6aee2b 100644 --- a/index.html +++ b/index.html @@ -450,7 +450,6 @@
-
diff --git a/package-lock.json b/package-lock.json index d3c0c9d..6c4ddfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clawpanel", - "version": "0.17.0", + "version": "0.18.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clawpanel", - "version": "0.17.0", + "version": "0.18.0", "license": "AGPL-3.0", "dependencies": { "@tauri-apps/api": "^2.5.0", diff --git a/package.json b/package.json index 9385d7b..c7a029d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawpanel", - "version": "0.17.0", + "version": "0.18.0", "private": true, "description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用", "type": "module", diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 4020083..546e812 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -265,6 +265,7 @@ const PANEL_VERSION = (() => { return '0.0.0' } })() +const SITE_BASE_URL = 'https://claw.qt.cool' const VERSION_POLICY_PATH = path.join(__dev_dirname, '..', 'openclaw-version-policy.json') function normalizeCustomOpenclawDir(raw) { if (typeof raw !== 'string') return null @@ -321,13 +322,37 @@ function scanCliIdentity(rawPath) { return canonicalCliPath(identityPath) || identityPath } +function isWindowsLaunchableOpenclawPath(rawPath) { + if (!isWindows) return true + const normalized = normalizeCliPath(rawPath) + if (!normalized) return false + const base = path.basename(normalized).toLowerCase() + return ['openclaw.cmd', 'openclaw.exe', 'openclaw.bat', 'openclaw.js'].includes(base) +} + +export function canonicalWindowsOpenclawCliPath(rawPath) { + const normalized = normalizeCliPath(rawPath) + if (!normalized || !isWindows) return normalized + const base = path.basename(normalized).toLowerCase() + if (['openclaw', 'openclaw.exe', 'openclaw.ps1'].includes(base)) { + for (const name of ['openclaw.cmd', 'openclaw.exe', 'openclaw.bat', 'openclaw.js']) { + const candidate = path.join(path.dirname(normalized), name) + if (fs.existsSync(candidate) && !isRejectedCliPath(candidate)) return candidate + } + } + if (fs.existsSync(normalized) && isWindowsLaunchableOpenclawPath(normalized) && !isRejectedCliPath(normalized)) { + return normalized + } + return null +} + function isRejectedCliPath(cliPath) { const lower = String(cliPath || '').replace(/\\/g, '/').toLowerCase() return lower.includes('/.cherrystudio/') || lower.includes('cherry-studio') } function addCliCandidate(candidates, seen, rawPath) { - const normalized = normalizeCliPath(rawPath) + const normalized = isWindows ? canonicalWindowsOpenclawCliPath(rawPath) : normalizeCliPath(rawPath) if (!normalized || !fs.existsSync(normalized) || isRejectedCliPath(normalized)) return const identity = scanCliIdentity(normalized) || normalized const key = isWindows ? identity.toLowerCase() : identity @@ -547,13 +572,16 @@ function addCommonOpenclawCandidates(candidates, seen) { const standaloneDir = standaloneInstallDir() if (appdata) { addCliCandidate(candidates, seen, path.join(appdata, 'npm', 'openclaw.cmd')) - addCliCandidate(candidates, seen, path.join(appdata, 'npm', 'openclaw')) + addCliCandidate(candidates, seen, path.join(appdata, 'npm', 'openclaw.exe')) + addCliCandidate(candidates, seen, path.join(appdata, 'npm', 'openclaw.bat')) + addCliCandidate(candidates, seen, path.join(appdata, 'npm', 'openclaw.js')) } const customPrefix = readWindowsNpmGlobalPrefix() if (customPrefix) { addCliCandidate(candidates, seen, path.join(customPrefix, 'openclaw.cmd')) addCliCandidate(candidates, seen, path.join(customPrefix, 'openclaw.exe')) - addCliCandidate(candidates, seen, path.join(customPrefix, 'openclaw')) + addCliCandidate(candidates, seen, path.join(customPrefix, 'openclaw.bat')) + addCliCandidate(candidates, seen, path.join(customPrefix, 'openclaw.js')) } if (localappdata) { addCliCandidate(candidates, seen, path.join(localappdata, 'Programs', 'OpenClaw', 'openclaw.cmd')) @@ -604,7 +632,8 @@ function collectPreferredCliCandidates() { if (isWindows) { addCliCandidate(candidates, seen, path.join(trimmed, 'openclaw.cmd')) addCliCandidate(candidates, seen, path.join(trimmed, 'openclaw.exe')) - addCliCandidate(candidates, seen, path.join(trimmed, 'openclaw')) + addCliCandidate(candidates, seen, path.join(trimmed, 'openclaw.bat')) + addCliCandidate(candidates, seen, path.join(trimmed, 'openclaw.js')) } else { addCliCandidate(candidates, seen, path.join(trimmed, 'openclaw')) } @@ -624,7 +653,7 @@ function collectAllCliCandidates() { } function readBoundOpenclawCliPath() { - const normalized = normalizeCliPath(readPanelConfig()?.openclawCliPath || '') + const normalized = resolveOpenclawCliInput(readPanelConfig()?.openclawCliPath || '') if (!normalized || !fs.existsSync(normalized) || isRejectedCliPath(normalized)) return null return normalized } @@ -740,12 +769,12 @@ export function quarantineOpenclawPathForWeb(rawPath, options = {}) { } } -function resolveOpenclawCliInput(rawPath) { +export function resolveOpenclawCliInput(rawPath) { const normalized = normalizeCliPath(rawPath) if (!normalized) return null if (fs.existsSync(normalized) && fs.statSync(normalized).isDirectory()) { const candidates = isWindows - ? [path.join(normalized, 'openclaw.cmd'), path.join(normalized, 'openclaw.exe'), path.join(normalized, 'openclaw')] + ? [path.join(normalized, 'openclaw.cmd'), path.join(normalized, 'openclaw.exe'), path.join(normalized, 'openclaw.bat'), path.join(normalized, 'openclaw.js')] : [path.join(normalized, 'openclaw')] for (const candidate of candidates) { const resolved = normalizeCliPath(candidate) @@ -753,6 +782,7 @@ function resolveOpenclawCliInput(rawPath) { } return null } + if (isWindows) return canonicalWindowsOpenclawCliPath(normalized) if (!fs.existsSync(normalized) || isRejectedCliPath(normalized)) return null return normalized } @@ -762,6 +792,12 @@ function openclawProcessSpec(args = []) { if (!cliPath) throw new Error('openclaw CLI 未安装') if (isWindows) { const cliArg = /[\s&()]/.test(cliPath) ? `"${cliPath}"` : cliPath + if (path.extname(cliPath).toLowerCase() === '.js') { + return { + command: process.env.ComSpec || 'cmd.exe', + args: ['/d', '/s', '/c', 'node', cliArg, ...args], + } + } return { command: process.env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', cliArg, ...args], @@ -888,6 +924,126 @@ function recommendedIsNewer(recommended, current) { return false } +function cacheBustedSiteUrl(pathname, params = {}) { + const url = new URL(pathname, SITE_BASE_URL) + for (const [key, value] of Object.entries(params)) { + const normalized = String(value || '').trim() + if (normalized) url.searchParams.set(key, normalized) + } + url.searchParams.set('_t', Date.now().toString()) + return url.toString() +} + +function normalizeSiteLocale(locale) { + const value = String(locale || '').trim().toLowerCase() + return value.startsWith('zh') ? 'zh-CN' : 'en' +} + +function normalizePublicUrl(raw) { + const value = String(raw || '').trim() + if (!value) return '' + let url + try { + url = value.startsWith('/') ? new URL(value, SITE_BASE_URL) : new URL(value) + } catch { + return '' + } + const host = url.hostname.toLowerCase() + if (host === 'claw.qt.cool') { + url.protocol = 'https:' + return url.toString() + } + if ((host === 'github.com' || host === 'api.github.com') && url.protocol === 'https:') { + return url.toString() + } + return '' +} + +function normalizeSiteUrlFields(value) { + if (Array.isArray(value)) { + value.forEach(normalizeSiteUrlFields) + return value + } + if (!value || typeof value !== 'object') return value + for (const key of ['downloadUrl', 'url', 'ctaUrl']) { + if (typeof value[key] === 'string') { + const normalized = normalizePublicUrl(value[key]) + if (normalized || value[key].trim()) value[key] = normalized + } + } + for (const child of Object.values(value)) normalizeSiteUrlFields(child) + return value +} + +function assetDownloadable(asset) { + return asset?.source !== 'unavailable' && typeof asset?.downloadUrl === 'string' && asset.downloadUrl.trim() +} + +function assetMatches(asset, key, expected) { + return String(asset?.[key] || '').toLowerCase() === expected +} + +function selectRecommendedSiteAsset(assets = []) { + const targetPlatform = isWindows ? 'windows' : isMac ? 'macos' : isLinux ? 'linux' : '' + const targetArch = process.arch === 'arm64' ? 'arm64' : process.arch === 'x64' ? 'x64' : process.arch + const platformCandidates = assets.filter(asset => assetDownloadable(asset) && assetMatches(asset, 'platform', targetPlatform)) + const archMatches = (asset) => assetMatches(asset, 'arch', targetArch) || assetMatches(asset, 'arch', 'any') + + const remoteRecommended = platformCandidates.find(asset => asset?.recommended === true && archMatches(asset)) + || platformCandidates.find(asset => asset?.recommended === true) + if (remoteRecommended) return remoteRecommended + + const candidates = assets.filter(assetDownloadable) + if (isWindows) { + const lightSetup = platformCandidates.find(asset => { + const name = String(asset?.name || '').toLowerCase() + return archMatches(asset) + && assetMatches(asset, 'fileType', 'exe') + && name.includes('x64-setup.exe') + && !name.includes('full') + }) + if (lightSetup) return lightSetup + return platformCandidates.find(asset => archMatches(asset) && assetMatches(asset, 'fileType', 'exe')) || platformCandidates[0] || null + } + if (isMac) { + return platformCandidates.find(asset => archMatches(asset) && assetMatches(asset, 'fileType', 'dmg')) || platformCandidates[0] || null + } + if (isLinux) { + for (const fileType of ['appimage', 'deb', 'rpm']) { + const hit = platformCandidates.find(asset => assetMatches(asset, 'fileType', fileType)) + if (hit) return hit + } + } + return platformCandidates[0] || candidates[0] || null +} + +async function getSitePanelUpdate() { + const resp = await globalThis.fetch(cacheBustedSiteUrl('/api/v1/latest'), { + signal: AbortSignal.timeout(10000), + headers: { 'User-Agent': 'ClawPanel' }, + }) + if (!resp.ok) throw new Error(`site: HTTP ${resp.status}`) + const json = normalizeSiteUrlFields(await resp.json()) + const latest = String(json.version || json.tagName || '').replace(/^v/, '').trim() + if (!latest) throw new Error('site: 未找到版本号') + const assets = Array.isArray(json.assets) ? json.assets : [] + const recommendedAsset = selectRecommendedSiteAsset(assets) + return { + latest, + url: SITE_BASE_URL, + source: 'site', + downloadUrl: recommendedAsset?.downloadUrl || SITE_BASE_URL, + assets, + recommendedAsset: recommendedAsset || null, + releaseNotes: json.releaseNotes || '', + publishedAt: json.publishedAt || '', + tagName: json.tagName || '', + downloads: json.downloads || null, + telemetry: json.telemetry || null, + update: json.update || null, + } +} + function loadVersionPolicy() { try { return JSON.parse(fs.readFileSync(VERSION_POLICY_PATH, 'utf8')) @@ -12010,11 +12166,17 @@ const handlers = { }, async check_panel_update() { + let lastErr = '' + try { + return await getSitePanelUpdate() + } catch (e) { + lastErr = `site: ${e.message || e}` + } + const sources = [ { api: 'https://api.github.com/repos/qingchencloud/clawpanel/releases/latest', releases: 'https://github.com/qingchencloud/clawpanel/releases', name: 'github' }, { api: 'https://gitee.com/api/v5/repos/QtCodeCreators/clawpanel/releases/latest', releases: 'https://gitee.com/QtCodeCreators/clawpanel/releases', name: 'gitee' }, ] - let lastErr = '' for (const src of sources) { try { const resp = await globalThis.fetch(src.api, { @@ -12031,6 +12193,20 @@ const handlers = { return { latest: null, url: 'https://github.com/qingchencloud/clawpanel/releases', error: lastErr } }, + async check_site_announcements({ locale } = {}) { + const resp = await globalThis.fetch(cacheBustedSiteUrl('/api/v1/announcements', { + app: 'ClawPanel', + version: PANEL_VERSION, + locale: normalizeSiteLocale(locale), + surface: 'client', + }), { + signal: AbortSignal.timeout(10000), + headers: { 'User-Agent': 'ClawPanel' }, + }) + if (!resp.ok) throw new Error(`公告服务器返回 ${resp.status}`) + return normalizeSiteUrlFields(await resp.json()) + }, + 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_DIR} 下的文件`) @@ -14758,7 +14934,7 @@ const handlers = { download_frontend_update() { throw new Error('Web 模式无需前端热更新,刷新浏览器即可') }, rollback_frontend_update() { throw new Error('Web 模式不支持前端热更新回滚') }, get_update_status() { return { status: 'idle', mode: 'web' } }, - // 注意:check_panel_update 的真实实现在前面(line ~6785)—— 走 GitHub/Gitee release API。 + // 注意:check_panel_update 的真实实现在前面 —— 走官网 API,失败后再回退 GitHub/Gitee。 // 这里不能再 stub,否则 object literal 的后定义会覆盖前者,导致 Web 模式永远看不到新版。 // —— 应用重启(Web 端由 tauri-api.js 包装层直接调 location.reload,到这里说明绕过了包装)—— diff --git a/scripts/sync-version.js b/scripts/sync-version.js index e7a8e8d..6ad6f2d 100644 --- a/scripts/sync-version.js +++ b/scripts/sync-version.js @@ -80,18 +80,6 @@ const targets = [ return content.replace(pattern, `$1${version}$2`) }, }, - { - file: 'docs/index.html', - update(content) { - // JSON-LD softwareVersion - let result = content.replace(/"softwareVersion":\s*"[^"]*"/, `"softwareVersion": "${version}"`) - // 下载链接中的版本号: ClawPanel_x.y.z_xxx - result = result.replace(/ClawPanel_\d+\.\d+\.\d+_/g, `ClawPanel_${version}_`) - // 版本徽标: v0.x.x 最新版 - result = result.replace(/v\d+\.\d+\.\d+\s*最新版/, `v${version} 最新版`) - return result - }, - }, ] let changed = 0 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cfea890..ec4a574 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -366,7 +366,7 @@ dependencies = [ [[package]] name = "clawpanel" -version = "0.17.0" +version = "0.18.0" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cedd622..217b52b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "clawpanel" -version = "0.17.0" +version = "0.18.0" edition = "2021" description = "ClawPanel - OpenClaw 可视化管理面板" authors = ["qingchencloud"] diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 435f56e..8b616b7 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -2439,6 +2439,10 @@ fn scan_all_installations( if crate::utils::is_rejected_cli_path(&path.to_string_lossy()) { return; } + #[cfg(target_os = "windows")] + if !crate::utils::is_windows_launchable_openclaw_path(&path) { + return; + } let identity = scan_cli_identity(&path); if seen.contains(&identity) { return; @@ -2481,22 +2485,18 @@ fn scan_all_installations( #[cfg(target_os = "windows")] { if let Ok(appdata) = std::env::var("APPDATA") { - try_add( - std::path::PathBuf::from(&appdata) - .join("npm") - .join("openclaw.cmd"), - ); - try_add( - std::path::PathBuf::from(&appdata) - .join("npm") - .join("openclaw"), - ); + let appdata_npm = std::path::PathBuf::from(&appdata).join("npm"); + try_add(appdata_npm.join("openclaw.cmd")); + try_add(appdata_npm.join("openclaw.exe")); + try_add(appdata_npm.join("openclaw.bat")); + try_add(appdata_npm.join("openclaw.js")); } if let Some(prefix) = super::windows_npm_global_prefix() { let prefix_path = std::path::PathBuf::from(prefix); try_add(prefix_path.join("openclaw.cmd")); try_add(prefix_path.join("openclaw.exe")); - try_add(prefix_path.join("openclaw")); + try_add(prefix_path.join("openclaw.bat")); + try_add(prefix_path.join("openclaw.js")); } if let Ok(localappdata) = std::env::var("LOCALAPPDATA") { let localappdata_path = std::path::PathBuf::from(&localappdata); @@ -2675,18 +2675,34 @@ pub(crate) fn resolve_openclaw_cli_input_path( { candidates.push(input.join("openclaw.cmd")); candidates.push(input.join("openclaw.exe")); - candidates.push(input.join("openclaw")); + candidates.push(input.join("openclaw.bat")); + candidates.push(input.join("openclaw.js")); } #[cfg(not(target_os = "windows"))] { candidates.push(input.join("openclaw")); } } else { + #[cfg(target_os = "windows")] + { + if let Some(resolved) = crate::utils::canonicalize_windows_openclaw_cli_path(&input) { + return Some(resolved); + } + } candidates.push(input); } candidates.into_iter().find(|candidate| { - candidate.exists() && !crate::utils::is_rejected_cli_path(&candidate.to_string_lossy()) + candidate.exists() && !crate::utils::is_rejected_cli_path(&candidate.to_string_lossy()) && { + #[cfg(target_os = "windows")] + { + crate::utils::is_windows_launchable_openclaw_path(candidate) + } + #[cfg(not(target_os = "windows"))] + { + true + } + } }) } @@ -6447,9 +6463,13 @@ pub fn patch_model_vision() -> Result { Ok(changed) } -/// 检查 ClawPanel 自身是否有新版本(GitHub → Gitee 自动降级) +/// 检查 ClawPanel 自身是否有新版本(官网 → GitHub → Gitee 自动降级) #[tauri::command] pub async fn check_panel_update() -> Result { + if let Ok(site) = super::site_api::site_latest_for_panel_update().await { + return Ok(site); + } + let client = crate::commands::build_http_client(std::time::Duration::from_secs(8), Some("ClawPanel")) .map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?; @@ -7009,7 +7029,20 @@ pub fn invalidate_path_cache() -> Result<(), String> { #[cfg(test)] mod write_openclaw_config_merge_tests { use super::merge_configs_preserving_fields; + #[cfg(target_os = "windows")] + use super::resolve_openclaw_cli_input_path; use serde_json::json; + #[cfg(target_os = "windows")] + use std::path::PathBuf; + + #[cfg(target_os = "windows")] + fn unique_temp_dir(name: &str) -> PathBuf { + let suffix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("clawpanel-{name}-{}-{suffix}", std::process::id())) + } /// Regression guard: Issue #127 merge keeps full provider map when the UI payload /// only touches one provider — `sync_providers_to_agent_models` must use the same @@ -7046,4 +7079,37 @@ mod write_openclaw_config_merge_tests { ); assert_eq!(prov["a"]["baseUrl"], json!("http://example")); } + + #[cfg(target_os = "windows")] + #[test] + fn windows_cli_input_rejects_extensionless_openclaw_shim() { + let dir = unique_temp_dir("extensionless-openclaw"); + std::fs::create_dir_all(&dir).unwrap(); + let bare = dir.join("openclaw"); + std::fs::write(&bare, "#!/bin/sh\n").unwrap(); + + let resolved = resolve_openclaw_cli_input_path(&bare); + let _ = std::fs::remove_dir_all(&dir); + + assert!( + resolved.is_none(), + "Windows must not treat extensionless npm shell shims as launchable CLI" + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_cli_input_canonicalizes_bare_openclaw_to_cmd() { + let dir = unique_temp_dir("openclaw-cmd"); + std::fs::create_dir_all(&dir).unwrap(); + let bare = dir.join("openclaw"); + let cmd = dir.join("openclaw.cmd"); + std::fs::write(&bare, "#!/bin/sh\n").unwrap(); + std::fs::write(&cmd, "@echo off\r\n").unwrap(); + + let resolved = resolve_openclaw_cli_input_path(&bare); + let _ = std::fs::remove_dir_all(&dir); + + assert_eq!(resolved, Some(cmd)); + } } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 1b7f8ee..19065eb 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -27,6 +27,7 @@ pub mod memory; pub mod messaging; pub mod pairing; pub mod service; +pub mod site_api; pub mod skillhub; pub mod skills; pub mod update; diff --git a/src-tauri/src/commands/service.rs b/src-tauri/src/commands/service.rs index f4563df..7728aa9 100644 --- a/src-tauri/src/commands/service.rs +++ b/src-tauri/src/commands/service.rs @@ -1449,10 +1449,17 @@ mod platform { // standalone 安装目录(集中管理,避免多处硬编码) for sa_dir in crate::commands::config::all_standalone_dirs() { candidates.push(sa_dir.join("openclaw.cmd")); + candidates.push(sa_dir.join("openclaw.exe")); + candidates.push(sa_dir.join("openclaw.bat")); + candidates.push(sa_dir.join("openclaw.js")); } if let Ok(appdata) = env::var("APPDATA") { - candidates.push(Path::new(&appdata).join("npm").join("openclaw.cmd")); + let npm_dir = Path::new(&appdata).join("npm"); + candidates.push(npm_dir.join("openclaw.cmd")); + candidates.push(npm_dir.join("openclaw.exe")); + candidates.push(npm_dir.join("openclaw.bat")); + candidates.push(npm_dir.join("openclaw.js")); } if let Ok(localappdata) = env::var("LOCALAPPDATA") { candidates.push( @@ -1474,7 +1481,9 @@ mod platform { } let base = Path::new(dir); candidates.push(base.join("openclaw.cmd")); - candidates.push(base.join("openclaw")); + candidates.push(base.join("openclaw.exe")); + candidates.push(base.join("openclaw.bat")); + candidates.push(base.join("openclaw.js")); candidates.push( base.join("node_modules") .join("@qingchencloud") @@ -1496,7 +1505,7 @@ mod platform { // 方式1: 检查常见文件路径(零进程,最快) for path in candidate_cli_paths() { - if path.exists() { + if crate::utils::canonicalize_windows_openclaw_cli_path(&path).is_some() { return true; } } @@ -1511,12 +1520,12 @@ mod platform { if o.status.success() { let stdout = String::from_utf8_lossy(&o.stdout); for line in stdout.lines() { - let p = line.trim().to_lowercase(); - // 跳过已知第三方 openclaw 路径 - if p.contains(".cherrystudio") || p.contains("cherry-studio") { + let p = line.trim(); + if p.is_empty() { continue; } - if !p.is_empty() { + if crate::utils::canonicalize_windows_openclaw_cli_path(Path::new(p)).is_some() + { return true; } } diff --git a/src-tauri/src/commands/site_api.rs b/src-tauri/src/commands/site_api.rs new file mode 100644 index 0000000..709117f --- /dev/null +++ b/src-tauri/src/commands/site_api.rs @@ -0,0 +1,581 @@ +use rand::RngCore; +use reqwest::Url; +use serde_json::{json, Map, Value}; +use std::fs; +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +pub const SITE_BASE_URL: &str = "https://claw.qt.cool"; + +const LATEST_PATH: &str = "/api/v1/latest"; +const ANNOUNCEMENTS_PATH: &str = "/api/v1/announcements"; +const HEARTBEAT_PATH: &str = "/api/v1/client/heartbeat"; + +pub fn cache_busted_site_url(path: &str, params: &[(&str, String)]) -> String { + let mut url = Url::parse(SITE_BASE_URL).expect("site base url is valid"); + url.set_path(path); + { + let mut pairs = url.query_pairs_mut(); + for (key, value) in params { + if !value.trim().is_empty() { + pairs.append_pair(key, value); + } + } + pairs.append_pair("_t", ×tamp_millis().to_string()); + } + url.to_string() +} + +fn timestamp_millis() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} + +pub fn normalize_public_url(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + if trimmed.starts_with('/') { + return Url::parse(SITE_BASE_URL) + .ok()? + .join(trimmed) + .ok() + .map(|url| url.to_string()); + } + + let mut url = Url::parse(trimmed).ok()?; + let host = url.host_str()?.to_ascii_lowercase(); + match host.as_str() { + "claw.qt.cool" => { + let _ = url.set_scheme("https"); + Some(url.to_string()) + } + "github.com" | "api.github.com" => { + if url.scheme() == "https" { + Some(url.to_string()) + } else { + None + } + } + _ => None, + } +} + +fn normalize_download_fields(value: &mut Value) { + match value { + Value::Object(obj) => { + for key in ["downloadUrl", "url", "ctaUrl"] { + if let Some(entry) = obj.get_mut(key) { + if let Some(raw) = entry.as_str() { + if let Some(normalized) = normalize_public_url(raw) { + *entry = Value::String(normalized); + } else if !raw.trim().is_empty() { + *entry = Value::String(String::new()); + } + } + } + } + for child in obj.values_mut() { + normalize_download_fields(child); + } + } + Value::Array(items) => { + for item in items { + normalize_download_fields(item); + } + } + _ => {} + } +} + +fn downloadable_asset(asset: &Value) -> bool { + asset.get("source").and_then(Value::as_str) != Some("unavailable") + && asset + .get("downloadUrl") + .and_then(Value::as_str) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) +} + +fn matches_platform(asset: &Value, platform: &str) -> bool { + asset + .get("platform") + .and_then(Value::as_str) + .map(|v| v.eq_ignore_ascii_case(platform)) + .unwrap_or(false) +} + +fn matches_arch(asset: &Value, arch: &str) -> bool { + asset + .get("arch") + .and_then(Value::as_str) + .map(|v| v.eq_ignore_ascii_case(arch)) + .unwrap_or(false) +} + +fn matches_file_type(asset: &Value, file_type: &str) -> bool { + asset + .get("fileType") + .and_then(Value::as_str) + .map(|v| v.eq_ignore_ascii_case(file_type)) + .unwrap_or(false) +} + +fn asset_name(asset: &Value) -> String { + asset + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_ascii_lowercase() +} + +pub fn select_recommended_asset(assets: &[Value]) -> Option { + select_recommended_asset_for(assets, target_platform(), target_arch()) +} + +fn target_platform() -> &'static str { + #[cfg(target_os = "windows")] + { + "windows" + } + #[cfg(target_os = "macos")] + { + "macos" + } + #[cfg(target_os = "linux")] + { + "linux" + } + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { + "unknown" + } +} + +fn target_arch() -> &'static str { + match std::env::consts::ARCH { + "aarch64" => "arm64", + "x86_64" => "x64", + other => other, + } +} + +fn arch_matches_target(asset: &Value, arch: &str) -> bool { + matches_arch(asset, arch) || matches_arch(asset, "any") +} + +fn select_recommended_asset_for(assets: &[Value], platform: &str, arch: &str) -> Option { + let candidates: Vec<&Value> = assets + .iter() + .filter(|asset| downloadable_asset(asset)) + .collect(); + let platform_candidates: Vec<&Value> = candidates + .iter() + .copied() + .filter(|asset| matches_platform(asset, platform)) + .collect(); + + if let Some(asset) = platform_candidates.iter().find(|asset| { + asset + .get("recommended") + .and_then(Value::as_bool) + .unwrap_or(false) + && arch_matches_target(asset, arch) + }) { + return Some((**asset).clone()); + } + if let Some(asset) = platform_candidates.iter().find(|asset| { + asset + .get("recommended") + .and_then(Value::as_bool) + .unwrap_or(false) + }) { + return Some((**asset).clone()); + } + + if platform == "windows" { + for asset in &platform_candidates { + let name = asset_name(asset); + if arch_matches_target(asset, arch) + && matches_file_type(asset, "exe") + && name.contains("x64-setup.exe") + && !name.contains("full") + { + return Some((**asset).clone()); + } + } + for asset in &platform_candidates { + if arch_matches_target(asset, arch) && matches_file_type(asset, "exe") { + return Some((**asset).clone()); + } + } + } + + if platform == "macos" { + for asset in &platform_candidates { + if arch_matches_target(asset, arch) && matches_file_type(asset, "dmg") { + return Some((**asset).clone()); + } + } + } + + if platform == "linux" { + for file_type in ["appimage", "deb", "rpm"] { + for asset in &platform_candidates { + if matches_file_type(asset, file_type) { + return Some((**asset).clone()); + } + } + } + } + + platform_candidates + .into_iter() + .next() + .cloned() + .or_else(|| candidates.into_iter().next().cloned()) +} + +pub async fn site_latest_for_panel_update() -> Result { + let client = super::build_http_client(Duration::from_secs(10), Some("ClawPanel")) + .map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?; + let mut latest = fetch_site_latest(&client).await?; + normalize_download_fields(&mut latest); + + let version = latest + .get("version") + .and_then(Value::as_str) + .or_else(|| latest.get("tagName").and_then(Value::as_str)) + .unwrap_or_default() + .trim_start_matches('v') + .to_string(); + if version.is_empty() { + return Err("site: 未找到版本号".into()); + } + + let assets: Vec = latest + .get("assets") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let recommended_asset = select_recommended_asset(&assets); + let download_url = recommended_asset + .as_ref() + .and_then(|asset| asset.get("downloadUrl")) + .and_then(Value::as_str) + .filter(|url| !url.trim().is_empty()) + .unwrap_or(SITE_BASE_URL) + .to_string(); + + let mut result = Map::new(); + result.insert("latest".into(), Value::String(version)); + result.insert("url".into(), Value::String(SITE_BASE_URL.into())); + result.insert("source".into(), Value::String("site".into())); + result.insert("downloadUrl".into(), Value::String(download_url)); + result.insert("assets".into(), Value::Array(assets)); + if let Some(asset) = recommended_asset { + result.insert("recommendedAsset".into(), asset); + } else { + result.insert("recommendedAsset".into(), Value::Null); + } + for key in [ + "releaseNotes", + "publishedAt", + "tagName", + "downloads", + "telemetry", + "update", + ] { + if let Some(value) = latest.get(key) { + result.insert(key.into(), value.clone()); + } + } + + Ok(Value::Object(result)) +} + +async fn fetch_site_latest(client: &reqwest::Client) -> Result { + let url = cache_busted_site_url(LATEST_PATH, &[]); + let resp = client + .get(url) + .send() + .await + .map_err(|e| format!("site: 请求失败: {e}"))?; + if !resp.status().is_success() { + return Err(format!("site: HTTP {}", resp.status())); + } + resp.json() + .await + .map_err(|e| format!("site: 解析响应失败: {e}")) +} + +#[tauri::command] +pub async fn check_site_announcements(locale: Option) -> Result { + let client = super::build_http_client(Duration::from_secs(10), Some("ClawPanel")) + .map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?; + let raw_locale = locale + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(default_locale); + let locale = normalize_site_locale(&raw_locale); + let url = cache_busted_site_url( + ANNOUNCEMENTS_PATH, + &[ + ("app", "ClawPanel".to_string()), + ("version", env!("CARGO_PKG_VERSION").to_string()), + ("locale", locale), + ("surface", "client".to_string()), + ], + ); + let resp = client + .get(url) + .send() + .await + .map_err(|e| format!("公告请求失败: {e}"))?; + if !resp.status().is_success() { + return Err(format!("公告服务器返回 {}", resp.status())); + } + let mut body: Value = resp + .json() + .await + .map_err(|e| format!("公告解析失败: {e}"))?; + normalize_download_fields(&mut body); + Ok(body) +} + +pub fn start_heartbeat_loop() { + tauri::async_runtime::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + interval.tick().await; + loop { + send_heartbeat_once().await; + interval.tick().await; + } + }); +} + +async fn send_heartbeat_once() { + let client_id = match get_or_create_client_id() { + Ok(id) => id, + Err(_) => return, + }; + let client = match super::build_http_client(Duration::from_secs(8), Some("ClawPanel")) { + Ok(client) => client, + Err(_) => return, + }; + let payload = json!({ + "app": "ClawPanel", + "version": env!("CARGO_PKG_VERSION"), + "platform": std::env::consts::OS, + "arch": std::env::consts::ARCH, + "channel": "stable", + "runtime": "tauri", + "runtimeVersion": "tauri-v2", + "locale": default_locale(), + }); + let url = cache_busted_site_url(HEARTBEAT_PATH, &[]); + let _ = client + .post(url) + .header("X-ClawPanel-Client-ID", client_id) + .json(&payload) + .send() + .await; +} + +fn client_id_path() -> PathBuf { + default_openclaw_state_dir() + .join("clawpanel") + .join("client-id") +} + +fn default_openclaw_state_dir() -> PathBuf { + #[cfg(target_os = "windows")] + { + if let Ok(home) = std::env::var("USERPROFILE") { + let trimmed = home.trim(); + if !trimmed.is_empty() { + return PathBuf::from(trimmed).join(".openclaw"); + } + } + } + dirs::home_dir() + .map(|home| home.join(".openclaw")) + .unwrap_or_else(super::openclaw_dir) +} + +fn get_or_create_client_id() -> Result { + let path = client_id_path(); + if let Ok(existing) = fs::read_to_string(&path) { + let trimmed = existing.trim(); + if is_valid_client_id(trimmed) { + return Ok(trimmed.to_string()); + } + } + + let mut bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + let id = bytes.iter().map(|b| format!("{b:02x}")).collect::(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("创建 client-id 目录失败: {e}"))?; + } + fs::write(&path, &id).map_err(|e| format!("写入 client-id 失败: {e}"))?; + Ok(id) +} + +fn is_valid_client_id(value: &str) -> bool { + value.len() == 32 && value.chars().all(|ch| ch.is_ascii_hexdigit()) +} + +fn default_locale() -> String { + let raw = std::env::var("LC_ALL") + .or_else(|_| std::env::var("LC_MESSAGES")) + .or_else(|_| std::env::var("LANG")) + .unwrap_or_default(); + let normalized = raw + .split('.') + .next() + .unwrap_or("") + .replace('_', "-") + .trim() + .to_string(); + if normalized.is_empty() || normalized == "C" { + "zh-CN".to_string() + } else { + normalized + } +} + +fn normalize_site_locale(locale: &str) -> String { + let value = locale.trim().to_ascii_lowercase(); + if value.starts_with("zh") { + "zh-CN".to_string() + } else { + "en".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn asset(name: &str, platform: &str, arch: &str, file_type: &str, recommended: bool) -> Value { + json!({ + "name": name, + "platform": platform, + "arch": arch, + "fileType": file_type, + "recommended": recommended, + "source": "mirror", + "downloadUrl": format!("/api/v1/download/{name}") + }) + } + + #[test] + fn cache_busted_site_url_adds_timestamp_and_params() { + let url = cache_busted_site_url( + "/api/v1/latest", + &[ + ("platform", "windows".to_string()), + ("arch", "x64".to_string()), + ], + ); + assert!(url.starts_with("https://claw.qt.cool/api/v1/latest?")); + assert!(url.contains("platform=windows")); + assert!(url.contains("arch=x64")); + assert!(url.contains("_t=")); + } + + #[test] + fn announcements_url_targets_client_surface() { + let url = cache_busted_site_url( + ANNOUNCEMENTS_PATH, + &[ + ("app", "ClawPanel".to_string()), + ("version", "0.17.0".to_string()), + ("locale", "zh-CN".to_string()), + ("surface", "client".to_string()), + ], + ); + assert!(url.starts_with("https://claw.qt.cool/api/v1/announcements?")); + assert!(url.contains("app=ClawPanel")); + assert!(url.contains("version=0.17.0")); + assert!(url.contains("locale=zh-CN")); + assert!(url.contains("surface=client")); + } + + #[test] + fn site_locale_uses_chinese_or_english_only() { + assert_eq!(normalize_site_locale("zh-CN"), "zh-CN"); + assert_eq!(normalize_site_locale("zh-TW"), "zh-CN"); + assert_eq!(normalize_site_locale("ja"), "en"); + assert_eq!(normalize_site_locale("de-DE"), "en"); + assert_eq!(normalize_site_locale(""), "en"); + } + + #[test] + fn normalize_public_url_allows_only_site_and_github() { + assert_eq!( + normalize_public_url("http://claw.qt.cool/api/v1/download/1").as_deref(), + Some("https://claw.qt.cool/api/v1/download/1") + ); + assert_eq!( + normalize_public_url("/api/v1/download/1").as_deref(), + Some("https://claw.qt.cool/api/v1/download/1") + ); + assert!( + normalize_public_url("https://github.com/qingchencloud/clawpanel/releases").is_some() + ); + assert!(normalize_public_url("https://example.com/file.exe").is_none()); + } + + #[test] + fn select_recommended_asset_respects_remote_flag_on_target_platform() { + let assets = vec![ + asset( + "ClawPanel_0.17.0_x64-setup.exe", + "windows", + "x64", + "exe", + false, + ), + asset("ClawPanel_0.17.0_arm64.dmg", "macos", "arm64", "dmg", true), + asset("web-0.17.0.zip", "web", "any", "zip", true), + ]; + let selected = + select_recommended_asset_for(&assets, "windows", "x64").expect("asset selected"); + assert_eq!( + selected.get("name").and_then(Value::as_str), + Some("ClawPanel_0.17.0_x64-setup.exe") + ); + } + + #[test] + fn select_recommended_asset_ignores_unavailable_assets() { + let mut unavailable = asset( + "ClawPanel_0.17.0_x64-setup.exe", + "windows", + "x64", + "exe", + true, + ); + unavailable["source"] = Value::String("unavailable".into()); + unavailable["downloadUrl"] = Value::String(String::new()); + let fallback = asset( + "ClawPanel_0.17.0.AppImage", + "linux", + "x64", + "appimage", + false, + ); + let selected = select_recommended_asset_for(&[unavailable, fallback], "linux", "x64") + .expect("asset selected"); + assert_eq!( + selected.get("name").and_then(Value::as_str), + Some("ClawPanel_0.17.0.AppImage") + ); + } +} diff --git a/src-tauri/src/commands/update.rs b/src-tauri/src/commands/update.rs index eed3019..663d5f1 100644 --- a/src-tauri/src/commands/update.rs +++ b/src-tauri/src/commands/update.rs @@ -3,6 +3,7 @@ use sha2::{Digest, Sha256}; use std::fs; use std::io::Read; use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; /// 前端热更新目录 (~/.openclaw/clawpanel/web-update/) pub fn update_dir() -> PathBuf { @@ -18,8 +19,17 @@ pub async fn check_frontend_update() -> Result { let client = super::build_http_client(std::time::Duration::from_secs(10), Some("ClawPanel")) .map_err(|e| format!("HTTP 客户端错误: {e}"))?; + let url = format!( + "{}?_t={}", + LATEST_JSON_URL, + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + ); + let resp = client - .get(LATEST_JSON_URL) + .get(url) .send() .await .map_err(|e| format!("请求失败: {e}"))?; @@ -28,7 +38,8 @@ pub async fn check_frontend_update() -> Result { return Err(format!("服务器返回 {}", resp.status())); } - let manifest: Value = resp.json().await.map_err(|e| format!("解析失败: {e}"))?; + let mut manifest: Value = resp.json().await.map_err(|e| format!("解析失败: {e}"))?; + normalize_manifest_url(&mut manifest); let latest = manifest .get("version") @@ -210,6 +221,30 @@ fn version_gt(left: &str, right: &str) -> bool { version_ge(left, right) && !version_ge(right, left) } +fn normalize_manifest_url(manifest: &mut Value) { + let download_url = manifest + .get("downloadUrl") + .and_then(Value::as_str) + .filter(|v| !v.trim().is_empty()) + .map(String::from) + .or_else(|| { + manifest + .get("url") + .and_then(Value::as_str) + .filter(|v| !v.trim().is_empty()) + .map(String::from) + }); + + if let Some(raw) = download_url { + if let Some(normalized) = super::site_api::normalize_public_url(&raw) { + if let Some(obj) = manifest.as_object_mut() { + obj.insert("downloadUrl".into(), Value::String(normalized.clone())); + obj.insert("url".into(), Value::String(normalized)); + } + } + } +} + /// 根据文件扩展名推断 MIME 类型 pub fn mime_from_path(path: &str) -> &'static str { match path.rsplit('.').next().unwrap_or("") { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0ae3e13..0ef65fa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,7 +5,7 @@ mod utils; use commands::{ agent, assistant, cli_conflict, config, device, diagnose, extensions, hermes, hermes_providers, - logs, memory, messaging, pairing, service, skills, update, + logs, memory, messaging, pairing, service, site_api, skills, update, }; pub fn run() { @@ -67,6 +67,7 @@ pub fn run() { }) .setup(|app| { service::start_backend_guardian(app.handle().clone()); + site_api::start_heartbeat_loop(); tray::setup_tray(app.handle())?; Ok(()) }) @@ -120,6 +121,7 @@ pub fn run() { config::doctor_fix, config::doctor_check, config::relaunch_app, + site_api::check_site_announcements, // 设备密钥 + Gateway 握手 device::create_connect_frame, // 设备配对 diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index d3a5b74..7ad911a 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -21,7 +21,8 @@ fn push_windows_cli_files( ) { push_unique_candidate(candidates, seen, base.join("openclaw.cmd")); push_unique_candidate(candidates, seen, base.join("openclaw.exe")); - push_unique_candidate(candidates, seen, base.join("openclaw")); + push_unique_candidate(candidates, seen, base.join("openclaw.bat")); + push_unique_candidate(candidates, seen, base.join("openclaw.js")); push_unique_candidate( candidates, seen, @@ -137,6 +138,53 @@ fn common_windows_cli_candidates() -> Vec { candidates } +#[cfg(target_os = "windows")] +pub fn is_windows_launchable_openclaw_path(path: &std::path::Path) -> bool { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + matches!( + file_name.as_str(), + "openclaw.cmd" | "openclaw.exe" | "openclaw.bat" | "openclaw.js" + ) +} + +#[cfg(target_os = "windows")] +pub fn canonicalize_windows_openclaw_cli_path( + path: &std::path::Path, +) -> Option { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if matches!( + file_name.as_str(), + "openclaw" | "openclaw.exe" | "openclaw.ps1" + ) { + for name in [ + "openclaw.cmd", + "openclaw.exe", + "openclaw.bat", + "openclaw.js", + ] { + let candidate = path.with_file_name(name); + if candidate.exists() && !is_rejected_cli_path(&candidate.to_string_lossy()) { + return Some(candidate); + } + } + } + if path.exists() + && is_windows_launchable_openclaw_path(path) + && !is_rejected_cli_path(&path.to_string_lossy()) + { + return Some(path.to_path_buf()); + } + None +} + pub fn is_rejected_cli_path(cli_path: &str) -> bool { let lower = cli_path.replace('\\', "/").to_lowercase(); lower.contains("/.cherrystudio/") || lower.contains("cherry-studio") @@ -193,7 +241,7 @@ fn find_openclaw_cmd() -> Option { } common_windows_cli_candidates() .into_iter() - .find(|candidate| candidate.exists() && !is_rejected_cli_path(&candidate.to_string_lossy())) + .find_map(|candidate| canonicalize_windows_openclaw_cli_path(&candidate)) } #[cfg(not(target_os = "windows"))] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 958815e..d66f800 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json", "productName": "ClawPanel", - "version": "0.17.0", + "version": "0.18.0", "identifier": "ai.openclaw.clawpanel", "build": { "frontendDist": "../dist", diff --git a/src/components/sidebar.js b/src/components/sidebar.js index 955604f..e7a9f36 100644 --- a/src/components/sidebar.js +++ b/src/components/sidebar.js @@ -234,6 +234,7 @@ export function renderSidebar(el) { const isDark = getTheme() === 'dark' const sunIcon = '' const moonIcon = '' + const bellIcon = '' const langCode = getLang() const langs = getAvailableLangs() @@ -254,19 +255,22 @@ export function renderSidebar(el) { html += `