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 @@
-
-
-
-
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 += `