feat(update): integrate official site update flow

This commit is contained in:
晴天
2026-06-06 13:59:52 +08:00
parent 38934fe754
commit f340b64028
35 changed files with 4074 additions and 2230 deletions

View File

@@ -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<bool, String> {
Ok(changed)
}
/// 检查 ClawPanel 自身是否有新版本GitHub → Gitee 自动降级)
/// 检查 ClawPanel 自身是否有新版本(官网 → GitHub → Gitee 自动降级)
#[tauri::command]
pub async fn check_panel_update() -> Result<Value, String> {
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));
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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", &timestamp_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<String> {
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<Value> {
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<Value> {
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<Value, String> {
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<Value> = 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<Value, String> {
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<String>) -> Result<Value, String> {
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<String, String> {
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::<String>();
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")
);
}
}

View File

@@ -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<Value, String> {
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<Value, String> {
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("") {

View File

@@ -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,
// 设备配对

View File

@@ -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<std::path::PathBuf> {
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<std::path::PathBuf> {
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<std::path::PathBuf> {
}
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"))]