mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-15 12:40:03 +08:00
feat(update): integrate official site update flow
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
581
src-tauri/src/commands/site_api.rs
Normal file
581
src-tauri/src/commands/site_api.rs
Normal 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", ×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<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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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("") {
|
||||
|
||||
@@ -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,
|
||||
// 设备配对
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
Reference in New Issue
Block a user