perf: ARM设备性能优化 — in-flight请求去重+后端缓存+仪表盘轮询降频+R2 CDN加速

This commit is contained in:
晴天
2026-03-16 13:55:41 +08:00
parent b11c9533ef
commit 61434137d7
8 changed files with 645 additions and 52 deletions

View File

@@ -1,4 +1,8 @@
{
"r2": {
"baseUrl": "https://dl.qrj.ai/openclaw-zh",
"enabled": true
},
"default": {
"official": {
"recommended": "2026.3.13"

View File

@@ -122,6 +122,126 @@ function loadVersionPolicy() {
}
}
function r2Config() {
const policy = loadVersionPolicy()
return policy?.r2 || { enabled: false }
}
function r2PlatformKey() {
const arch = process.arch // x64, arm64, etc.
const plat = process.platform // linux, darwin, win32
if (plat === 'win32' && arch === 'x64') return 'win-x64'
if (plat === 'darwin' && arch === 'arm64') return 'darwin-arm64'
if (plat === 'darwin' && arch === 'x64') return 'darwin-x64'
if (plat === 'linux' && arch === 'x64') return 'linux-x64'
if (plat === 'linux' && arch === 'arm64') return 'linux-arm64'
return 'unknown'
}
async function _tryR2Install(version, source, logs) {
const r2 = r2Config()
if (!r2.enabled || !r2.baseUrl) return false
const platform = r2PlatformKey()
if (platform === 'unknown') return false
logs.push('尝试从 CDN 加速下载...')
const manifestUrl = `${r2.baseUrl}/latest.json`
const resp = await globalThis.fetch(manifestUrl, { signal: AbortSignal.timeout(10000) })
if (!resp.ok) throw new Error(`CDN 清单不可用 (HTTP ${resp.status})`)
const manifest = await resp.json()
const sourceKey = source === 'official' ? 'official' : 'chinese'
const asset = manifest?.[sourceKey]?.assets?.[platform]
if (!asset?.url) throw new Error(`CDN 无 ${sourceKey}/${platform} 归档`)
const cdnVersion = manifest?.[sourceKey]?.version || version
if (version !== 'latest' && !versionsMatch(cdnVersion, version)) {
throw new Error(`CDN 版本 ${cdnVersion} 与请求版本 ${version} 不匹配`)
}
const sizeMb = asset.size ? `${(asset.size / 1048576).toFixed(0)}MB` : '未知大小'
logs.push(`CDN 下载: ${cdnVersion} (${platform}, ${sizeMb})`)
// 下载到临时文件
const tmpPath = path.join(os.tmpdir(), `openclaw-${platform}.tgz`)
const dlResp = await globalThis.fetch(asset.url, { signal: AbortSignal.timeout(300000) })
if (!dlResp.ok) throw new Error(`CDN 下载失败 (HTTP ${dlResp.status})`)
const buffer = Buffer.from(await dlResp.arrayBuffer())
fs.writeFileSync(tmpPath, buffer)
// SHA256 校验
if (asset.sha256) {
const crypto = require('crypto')
const hash = crypto.createHash('sha256').update(buffer).digest('hex')
if (hash !== asset.sha256) {
fs.unlinkSync(tmpPath)
throw new Error(`SHA256 校验失败: 期望 ${asset.sha256}, 实际 ${hash}`)
}
logs.push('SHA256 校验通过 ✓')
}
// 确定 npm 全局 node_modules 目录
let modulesDir
if (isWindows) {
modulesDir = path.join(process.env.APPDATA || '', 'npm', 'node_modules')
} else if (isMac) {
modulesDir = fs.existsSync('/opt/homebrew/lib/node_modules')
? '/opt/homebrew/lib/node_modules'
: '/usr/local/lib/node_modules'
} else {
try {
const prefix = execSync('npm config get prefix', { encoding: 'utf8', timeout: 5000 }).trim()
modulesDir = path.join(prefix, 'lib', 'node_modules')
} catch {
modulesDir = '/usr/local/lib/node_modules'
}
}
if (!fs.existsSync(modulesDir)) fs.mkdirSync(modulesDir, { recursive: true })
// 清理旧目录
const qcDir = path.join(modulesDir, '@qingchencloud')
if (fs.existsSync(qcDir)) fs.rmSync(qcDir, { recursive: true, force: true })
// 解压
logs.push(`解压到 ${modulesDir}`)
execSync(`tar -xzf "${tmpPath}" -C "${modulesDir}"`, { timeout: 60000, windowsHide: true })
// 创建 bin 链接
let binDir
if (isWindows) {
binDir = path.join(process.env.APPDATA || '', 'npm')
} else if (isMac) {
binDir = fs.existsSync('/opt/homebrew/bin') ? '/opt/homebrew/bin' : '/usr/local/bin'
} else {
try {
const prefix = execSync('npm config get prefix', { encoding: 'utf8', timeout: 5000 }).trim()
binDir = path.join(prefix, 'bin')
} catch {
binDir = '/usr/local/bin'
}
}
const openclawJs = path.join(modulesDir, '@qingchencloud', 'openclaw-zh', 'bin', 'openclaw.js')
if (fs.existsSync(openclawJs)) {
if (isWindows) {
const cmdContent = `@ECHO off\r\nGOTO start\r\n:find_dp0\r\nSET dp0=%~dp0\r\nEXIT /b\r\n:start\r\nSETLOCAL\r\nCALL :find_dp0\r\n\r\nIF EXIST "%dp0%\\node.exe" (\r\n SET "_prog=%dp0%\\node.exe"\r\n) ELSE (\r\n SET "_prog=node"\r\n SET PATHEXT=%PATHEXT:;.JS;=;%\r\n)\r\n\r\nendLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "${openclawJs}" %*\r\n`
fs.writeFileSync(path.join(binDir, 'openclaw.cmd'), cmdContent)
} else {
const linkPath = path.join(binDir, 'openclaw')
try { fs.unlinkSync(linkPath) } catch {}
fs.symlinkSync(openclawJs, linkPath)
try { fs.chmodSync(openclawJs, 0o755) } catch {}
try { fs.chmodSync(linkPath, 0o755) } catch {}
}
logs.push('bin 链接已创建 ✓')
}
// 清理临时文件
try { fs.unlinkSync(tmpPath) } catch {}
logs.push(`✅ CDN 加速安装完成,当前版本: ${cdnVersion}`)
return true
}
function recommendedVersionFor(source = 'chinese') {
const policy = loadVersionPolicy()
return policy?.panels?.[PANEL_VERSION]?.[source]?.recommended
@@ -1202,6 +1322,32 @@ function _normalizeBaseUrl(raw) {
return base
}
// === 后端内存缓存ARM 设备性能优化)===
// 防止短时间内重复 spawn CLI 进程,显著降低 CPU 占用
const _serverCache = new Map()
function serverCached(key, ttlMs, fn) {
const entry = _serverCache.get(key)
if (entry && Date.now() - entry.ts < ttlMs) return entry.val
// in-flight 去重:同一 key 正在执行中,复用 Promise
if (entry && entry.pending) return entry.pending
const result = fn()
if (result && typeof result.then === 'function') {
// async
const pending = result.then(val => {
_serverCache.set(key, { val, ts: Date.now() })
return val
}).catch(err => {
_serverCache.delete(key)
throw err
})
_serverCache.set(key, { ...(entry || {}), pending })
return pending
}
// sync
_serverCache.set(key, { val: result, ts: Date.now() })
return result
}
// === API Handlers ===
const handlers = {
@@ -1230,33 +1376,35 @@ const handlers = {
return true
},
// 服务管理
async get_services_status() {
const label = 'ai.openclaw.gateway'
let { running, pid } = isMac ? macCheckService(label) : isLinux ? linuxCheckGateway() : await winCheckGateway()
// 服务管理10s 服务端缓存 + in-flight 去重ARM 设备关键优化)
get_services_status() {
return serverCached('svc_status', 10000, async () => {
const label = 'ai.openclaw.gateway'
let { running, pid } = isMac ? macCheckService(label) : isLinux ? linuxCheckGateway() : await winCheckGateway()
// 通用兜底:进程检测说没运行,但端口实际在监听 → Gateway 已在运行
if (!running) {
const port = readGatewayPort()
const portOpen = await new Promise(resolve => {
const sock = net.createConnection(port, '127.0.0.1', () => { sock.destroy(); resolve(true) })
sock.on('error', () => resolve(false))
sock.setTimeout(2000, () => { sock.destroy(); resolve(false) })
})
if (portOpen) { running = true }
}
// 通用兜底:进程检测说没运行,但端口实际在监听 → Gateway 已在运行
if (!running) {
const port = readGatewayPort()
const portOpen = await new Promise(resolve => {
const sock = net.createConnection(port, '127.0.0.1', () => { sock.destroy(); resolve(true) })
sock.on('error', () => resolve(false))
sock.setTimeout(2000, () => { sock.destroy(); resolve(false) })
})
if (portOpen) { running = true }
}
let cliInstalled = false
if (isMac) {
cliInstalled = fs.existsSync('/opt/homebrew/bin/openclaw') || fs.existsSync('/usr/local/bin/openclaw')
} else if (isWindows) {
try { cliInstalled = fs.existsSync(path.join(process.env.APPDATA || '', 'npm', 'openclaw.cmd')) }
catch { cliInstalled = false }
} else {
cliInstalled = !!findOpenclawBin()
}
let cliInstalled = false
if (isMac) {
cliInstalled = fs.existsSync('/opt/homebrew/bin/openclaw') || fs.existsSync('/usr/local/bin/openclaw')
} else if (isWindows) {
try { cliInstalled = fs.existsSync(path.join(process.env.APPDATA || '', 'npm', 'openclaw.cmd')) }
catch { cliInstalled = false }
} else {
cliInstalled = !!findOpenclawBin()
}
return [{ label, running, pid, description: 'OpenClaw Gateway', cli_installed: cliInstalled }]
return [{ label, running, pid, description: 'OpenClaw Gateway', cli_installed: cliInstalled }]
})
},
start_service({ label }) {
@@ -2589,25 +2737,27 @@ const handlers = {
}
},
// 运行时状态摘要(openclaw status --json
// 运行时状态摘要(60s 服务端缓存ARM 设备上此调用是最大 CPU 消耗源
get_status_summary() {
try {
const raw = execSync('openclaw status --json 2>&1', { windowsHide: true, timeout: 10000 }).toString()
// 提取第一个 JSON 对象
const idx = raw.indexOf('{')
if (idx >= 0) {
try { return JSON.parse(raw.slice(idx)) } catch {}
// 流式解析:找到匹配的 } 结束
let depth = 0
for (let i = idx; i < raw.length; i++) {
if (raw[i] === '{') depth++
else if (raw[i] === '}') { depth--; if (depth === 0) { try { return JSON.parse(raw.slice(idx, i + 1)) } catch { break } } }
return serverCached('status_summary', 60000, () => {
try {
const raw = execSync('openclaw status --json 2>&1', { windowsHide: true, timeout: 10000 }).toString()
// 提取第一个 JSON 对象
const idx = raw.indexOf('{')
if (idx >= 0) {
try { return JSON.parse(raw.slice(idx)) } catch {}
// 流式解析:找到匹配的 } 结束
let depth = 0
for (let i = idx; i < raw.length; i++) {
if (raw[i] === '{') depth++
else if (raw[i] === '}') { depth--; if (depth === 0) { try { return JSON.parse(raw.slice(idx, i + 1)) } catch { break } } }
}
}
return { error: '解析失败' }
} catch (e) {
return { error: e.message || String(e) }
}
return { error: '解析失败' }
} catch (e) {
return { error: e.message || String(e) }
}
})
},
// 版本信息
@@ -2935,7 +3085,7 @@ const handlers = {
throw new Error('查询版本失败: ' + (lastError?.message || lastError || 'unknown error'))
},
upgrade_openclaw({ source = 'chinese', version } = {}) {
async upgrade_openclaw({ source = 'chinese', version } = {}) {
const currentSource = detectInstalledSource()
const pkg = npmPackageName(source)
const recommended = recommendedVersionFor(source)
@@ -2944,12 +3094,23 @@ const handlers = {
const needUninstallOld = currentSource !== source
const npmBin = isWindows ? 'npm.cmd' : 'npm'
const registry = pickRegistryForPackage(pkg)
const gitConfigured = configureGitHttpsRules()
const gitEnv = buildGitInstallEnv()
const logs = []
// ── R2 CDN 加速:优先尝试从 CDN 下载预装归档 ──
if (source !== 'official') {
try {
const r2Result = await _tryR2Install(ver, source, logs)
if (r2Result) return logs.join('\n')
} catch (e) {
logs.push(`CDN 加速不可用(${e.message}),降级到 npm 安装...`)
}
}
if (!version && recommended) {
logs.push(`ClawPanel ${PANEL_VERSION} 默认绑定 OpenClaw 稳定版: ${recommended}`)
}
const gitConfigured = configureGitHttpsRules()
const gitEnv = buildGitInstallEnv()
logs.push(`Git HTTPS 规则已就绪 (${gitConfigured}/${GIT_HTTPS_REWRITES.length})`)
const runInstall = (targetRegistry) => execSync(
`${npmBin} install -g ${pkg}@${ver} --force --registry ${targetRegistry} --verbose 2>&1`,

19
src-tauri/Cargo.lock generated
View File

@@ -334,6 +334,7 @@ dependencies = [
"chrono",
"dirs",
"ed25519-dalek",
"futures-util",
"rand 0.8.5",
"regex",
"reqwest 0.12.28",
@@ -2951,6 +2952,7 @@ dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
@@ -2970,12 +2972,14 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots",
]
@@ -3010,7 +3014,7 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"wasm-streams 0.5.0",
"web-sys",
]
@@ -4567,6 +4571,19 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"

View File

@@ -23,7 +23,8 @@ serde_json = "1"
dirs = "6"
chrono = "0.4"
zip = { version = "2", default-features = false, features = ["deflate"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"], default-features = false }
futures-util = "0.3"
ed25519-dalek = { version = "2", features = ["rand_core"] }
sha2 = "0.10"
rand = "0.8"

View File

@@ -66,8 +66,19 @@ struct VersionPolicyEntry {
chinese: VersionPolicySource,
}
#[derive(Debug, Deserialize, Default)]
struct R2Config {
#[serde(default)]
#[serde(rename = "baseUrl")]
base_url: Option<String>,
#[serde(default)]
enabled: bool,
}
#[derive(Debug, Deserialize, Default)]
struct VersionPolicy {
#[serde(default)]
r2: R2Config,
#[serde(default)]
default: VersionPolicyEntry,
#[serde(default)]
@@ -113,6 +124,10 @@ fn load_version_policy() -> VersionPolicy {
serde_json::from_str(include_str!("../../../openclaw-version-policy.json")).unwrap_or_default()
}
fn r2_config() -> R2Config {
load_version_policy().r2
}
fn recommended_version_for(source: &str) -> Option<String> {
let policy = load_version_policy();
let panel_entry = policy.panels.get(panel_version());
@@ -863,6 +878,359 @@ pub async fn upgrade_openclaw(
Ok("任务已启动".into())
}
/// 检测当前平台标识(用于 R2 归档文件名)
fn r2_platform_key() -> &'static str {
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
{
"win-x64"
}
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
"darwin-arm64"
}
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
{
"darwin-x64"
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
{
"linux-x64"
}
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
{
"linux-arm64"
}
#[cfg(not(any(
all(target_os = "windows", target_arch = "x86_64"),
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "macos", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "aarch64"),
)))]
{
"unknown"
}
}
/// npm 全局 node_modules 目录
fn npm_global_modules_dir() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
std::env::var("APPDATA")
.ok()
.map(|a| PathBuf::from(a).join("npm").join("node_modules"))
}
#[cfg(target_os = "macos")]
{
// homebrew 或系统 node
let brew = PathBuf::from("/opt/homebrew/lib/node_modules");
if brew.exists() {
return Some(brew);
}
let sys = PathBuf::from("/usr/local/lib/node_modules");
if sys.exists() {
return Some(sys);
}
Some(brew) // fallback to homebrew path
}
#[cfg(target_os = "linux")]
{
// 尝试 npm config get prefix
if let Ok(output) = Command::new("npm")
.args(["config", "get", "prefix"])
.output()
{
let prefix = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !prefix.is_empty() {
return Some(PathBuf::from(prefix).join("lib").join("node_modules"));
}
}
Some(PathBuf::from("/usr/local/lib/node_modules"))
}
}
/// npm 全局 bin 目录
fn npm_global_bin_dir() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
std::env::var("APPDATA")
.ok()
.map(|a| PathBuf::from(a).join("npm"))
}
#[cfg(target_os = "macos")]
{
let brew = PathBuf::from("/opt/homebrew/bin");
if brew.exists() {
return Some(brew);
}
Some(PathBuf::from("/usr/local/bin"))
}
#[cfg(target_os = "linux")]
{
if let Ok(output) = Command::new("npm")
.args(["config", "get", "prefix"])
.output()
{
let prefix = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !prefix.is_empty() {
return Some(PathBuf::from(prefix).join("bin"));
}
}
Some(PathBuf::from("/usr/local/bin"))
}
}
/// 尝试从 R2 CDN 下载预装归档安装 OpenClaw跳过 npm 依赖解析)
/// 成功返回 Ok(版本号),失败返回 Err(原因) 供 caller 降级到 npm install
async fn try_r2_install(
app: &tauri::AppHandle,
version: &str,
source: &str,
) -> Result<String, String> {
use sha2::{Digest, Sha256};
use tauri::Emitter;
let r2 = r2_config();
if !r2.enabled {
return Err("R2 加速未启用".into());
}
let base_url = r2.base_url.as_deref().ok_or("R2 baseUrl 未配置")?;
let platform = r2_platform_key();
if platform == "unknown" {
return Err("当前平台不支持 R2 预装归档".into());
}
// 1. 获取 latest.json
let _ = app.emit("upgrade-log", "尝试从 CDN 加速下载...");
let manifest_url = format!("{}/latest.json", base_url);
let client = crate::commands::build_http_client(std::time::Duration::from_secs(10), None)
.map_err(|e| format!("HTTP 客户端创建失败: {e}"))?;
let manifest_resp = client
.get(&manifest_url)
.send()
.await
.map_err(|e| format!("获取 CDN 清单失败: {e}"))?;
if !manifest_resp.status().is_success() {
return Err(format!("CDN 清单不可用 (HTTP {})", manifest_resp.status()));
}
let manifest: Value = manifest_resp
.json()
.await
.map_err(|e| format!("CDN 清单解析失败: {e}"))?;
// 2. 查找版本和平台对应的归档 URL
let source_key = if source == "official" {
"official"
} else {
"chinese"
};
let asset = manifest
.get(source_key)
.and_then(|s| s.get("assets"))
.and_then(|a| a.get(platform))
.ok_or_else(|| format!("CDN 无 {source_key}/{platform} 归档"))?;
let archive_url = asset
.get("url")
.and_then(|v| v.as_str())
.ok_or("CDN 归档 URL 缺失")?;
let expected_sha = asset.get("sha256").and_then(|v| v.as_str()).unwrap_or("");
let expected_size = asset.get("size").and_then(|v| v.as_u64()).unwrap_or(0);
let cdn_version = manifest
.get(source_key)
.and_then(|s| s.get("version"))
.and_then(|v| v.as_str())
.unwrap_or(version);
// 版本匹配检查如果用户指定了版本CDN 版本必须匹配)
if version != "latest" && !versions_match(cdn_version, version) {
return Err(format!(
"CDN 版本 {cdn_version} 与请求版本 {version} 不匹配"
));
}
let size_mb = if expected_size > 0 {
format!("{:.0}MB", expected_size as f64 / 1_048_576.0)
} else {
"未知大小".into()
};
let _ = app.emit(
"upgrade-log",
format!("CDN 下载: {cdn_version} ({platform}, {size_mb})"),
);
let _ = app.emit("upgrade-progress", 15);
// 3. 流式下载到临时文件
let tmp_dir = std::env::temp_dir();
let archive_path = tmp_dir.join(format!("openclaw-{platform}.tgz"));
let dl_client = crate::commands::build_http_client(std::time::Duration::from_secs(300), None)
.map_err(|e| format!("下载客户端创建失败: {e}"))?;
let dl_resp = dl_client
.get(archive_url)
.send()
.await
.map_err(|e| format!("CDN 下载失败: {e}"))?;
if !dl_resp.status().is_success() {
return Err(format!("CDN 下载失败 (HTTP {})", dl_resp.status()));
}
let total_bytes = dl_resp.content_length().unwrap_or(expected_size);
{
use tokio::io::AsyncWriteExt;
let mut file = tokio::fs::File::create(&archive_path)
.await
.map_err(|e| format!("创建临时文件失败: {e}"))?;
let mut stream = dl_resp.bytes_stream();
let mut downloaded: u64 = 0;
let mut last_progress: u32 = 15;
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("下载中断: {e}"))?;
file.write_all(&chunk)
.await
.map_err(|e| format!("写入失败: {e}"))?;
downloaded += chunk.len() as u64;
if total_bytes > 0 {
let pct = 15 + ((downloaded as f64 / total_bytes as f64) * 50.0) as u32;
if pct > last_progress {
last_progress = pct;
let _ = app.emit("upgrade-progress", pct.min(65));
}
}
}
file.flush()
.await
.map_err(|e| format!("刷新文件失败: {e}"))?;
}
let _ = app.emit("upgrade-log", "下载完成,校验中...");
let _ = app.emit("upgrade-progress", 68);
// 4. SHA256 校验
if !expected_sha.is_empty() {
let file_bytes = std::fs::read(&archive_path).map_err(|e| format!("读取归档失败: {e}"))?;
let mut hasher = Sha256::new();
hasher.update(&file_bytes);
let actual_sha = format!("{:x}", hasher.finalize());
if actual_sha != expected_sha {
let _ = std::fs::remove_file(&archive_path);
return Err(format!(
"SHA256 校验失败: 期望 {expected_sha}, 实际 {actual_sha}"
));
}
let _ = app.emit("upgrade-log", "SHA256 校验通过 ✓");
}
let _ = app.emit("upgrade-progress", 72);
// 5. 解压到 npm 全局 node_modules 目录
let modules_dir = npm_global_modules_dir().ok_or("无法确定 npm 全局 node_modules 目录")?;
if !modules_dir.exists() {
std::fs::create_dir_all(&modules_dir)
.map_err(|e| format!("创建 node_modules 目录失败: {e}"))?;
}
let _ = app.emit("upgrade-log", format!("解压到 {}", modules_dir.display()));
// 清理旧的 @qingchencloud 目录(如果存在)
let qc_dir = modules_dir.join("@qingchencloud");
if qc_dir.exists() {
let _ = std::fs::remove_dir_all(&qc_dir);
}
// 解压 tgz
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
let status = Command::new("tar")
.args([
"-xzf",
&archive_path.to_string_lossy(),
"-C",
&modules_dir.to_string_lossy(),
])
.creation_flags(0x08000000)
.status()
.map_err(|e| format!("解压失败: {e}"))?;
if !status.success() {
return Err("tar 解压失败".into());
}
}
#[cfg(not(target_os = "windows"))]
{
let status = Command::new("tar")
.args([
"-xzf",
&archive_path.to_string_lossy(),
"-C",
&modules_dir.to_string_lossy(),
])
.status()
.map_err(|e| format!("解压失败: {e}"))?;
if !status.success() {
return Err("tar 解压失败".into());
}
}
let _ = app.emit("upgrade-progress", 85);
let _ = app.emit("upgrade-log", "解压完成,创建 bin 链接...");
// 6. 创建 bin 链接
let bin_dir = npm_global_bin_dir().ok_or("无法确定 npm bin 目录")?;
let openclaw_js = modules_dir
.join("@qingchencloud")
.join("openclaw-zh")
.join("bin")
.join("openclaw.js");
if openclaw_js.exists() {
#[cfg(target_os = "windows")]
{
// Windows: 创建 .cmd 包装脚本
let cmd_path = bin_dir.join("openclaw.cmd");
let cmd_content = format!(
"@ECHO off\r\nGOTO start\r\n:find_dp0\r\nSET dp0=%~dp0\r\nEXIT /b\r\n:start\r\nSETLOCAL\r\nCALL :find_dp0\r\n\r\nIF EXIST \"%dp0%\\node.exe\" (\r\n SET \"_prog=%dp0%\\node.exe\"\r\n) ELSE (\r\n SET \"_prog=node\"\r\n SET PATHEXT=%PATHEXT:;.JS;=;%\r\n)\r\n\r\nendLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & \"%_prog%\" \"{}\" %*\r\n",
openclaw_js.display()
);
std::fs::write(&cmd_path, cmd_content)
.map_err(|e| format!("创建 openclaw.cmd 失败: {e}"))?;
// 也创建 .ps1 版本
let ps1_path = bin_dir.join("openclaw.ps1");
let ps1_content = format!(
"#!/usr/bin/env pwsh\r\n$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent\r\n\r\n$exe=\"\"\r\nif ($PSVersionTable.PSVersion -lt \"6.0\" -or $IsWindows) {{\r\n $exe=\".exe\"\r\n}}\r\n$ret=0\r\nif (Test-Path \"$basedir/node$exe\") {{\r\n if ($MyInvocation.ExpectingInput) {{\r\n $input | & \"$basedir/node$exe\" \"{}\" $args\r\n }} else {{\r\n & \"$basedir/node$exe\" \"{}\" $args\r\n }}\r\n $ret=$LASTEXITCODE\r\n}} else {{\r\n if ($MyInvocation.ExpectingInput) {{\r\n $input | & \"node$exe\" \"{}\" $args\r\n }} else {{\r\n & \"node$exe\" \"{}\" $args\r\n }}\r\n $ret=$LASTEXITCODE\r\n}}\r\nexit $ret\r\n",
openclaw_js.display(), openclaw_js.display(), openclaw_js.display(), openclaw_js.display()
);
let _ = std::fs::write(&ps1_path, ps1_content);
}
#[cfg(not(target_os = "windows"))]
{
// Unix: 创建 symlink
let link_path = bin_dir.join("openclaw");
let _ = std::fs::remove_file(&link_path);
#[cfg(unix)]
{
std::os::unix::fs::symlink(&openclaw_js, &link_path)
.map_err(|e| format!("创建 symlink 失败: {e}"))?;
// 确保可执行权限
let _ = Command::new("chmod")
.args(["+x", &openclaw_js.to_string_lossy()])
.status();
let _ = Command::new("chmod")
.args(["+x", &link_path.to_string_lossy()])
.status();
}
}
let _ = app.emit("upgrade-log", "bin 链接已创建 ✓");
} else {
let _ = app.emit("upgrade-log", "⚠️ openclaw.js 未找到bin 链接跳过");
}
// 清理临时文件
let _ = std::fs::remove_file(&archive_path);
let _ = app.emit("upgrade-progress", 95);
Ok(cdn_version.to_string())
}
async fn upgrade_openclaw_inner(
app: tauri::AppHandle,
source: String,
@@ -883,6 +1251,29 @@ async fn upgrade_openclaw_inner(
.unwrap_or("latest");
let pkg = format!("{}@{}", pkg_name, ver);
// ── R2 CDN 加速:优先尝试从 CDN 下载预装归档 ──
if source != "official" {
// 目前仅汉化版支持 R2 加速
match try_r2_install(&app, ver, &source).await {
Ok(installed_ver) => {
let _ = app.emit("upgrade-progress", 100);
// 刷新缓存
super::refresh_enhanced_path();
crate::commands::service::invalidate_cli_detection_cache();
let msg = format!("✅ CDN 加速安装完成,当前版本: {installed_ver}");
let _ = app.emit("upgrade-log", &msg);
return Ok(msg);
}
Err(reason) => {
let _ = app.emit(
"upgrade-log",
format!("CDN 加速不可用({reason}),降级到 npm 安装..."),
);
let _ = app.emit("upgrade-progress", 5);
}
}
}
// 切换源时需要卸载旧包,但为避免安装失败导致 CLI 丢失,
// 先安装新包,成功后再卸载旧包
let old_pkg = npm_package_name(&current_source);

View File

@@ -220,7 +220,7 @@ let _pollTimer = null
/** 启动 Gateway 状态轮询(每 15 秒,避免过于频繁) */
export function startGatewayPoll() {
if (_pollTimer) return
_pollTimer = setInterval(() => refreshGatewayStatus(), 15000)
_pollTimer = setInterval(() => refreshGatewayStatus(), 30000)
}
export function stopGatewayPoll() {
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null }

View File

@@ -19,6 +19,7 @@ const _invokeReady = isTauri
// 简单缓存:避免页面切换时重复请求后端
const _cache = new Map()
const _inflight = new Map() // in-flight 请求去重,防止缓存过期后同一命令并发 spawn 多个进程
const CACHE_TTL = 15000 // 15秒
// 网络请求日志(用于调试)
@@ -56,10 +57,21 @@ function cachedInvoke(cmd, args = {}, ttl = CACHE_TTL) {
logRequest(cmd, args, 0, true)
return Promise.resolve(cached.val)
}
return invoke(cmd, args).then(val => {
// in-flight 去重:同一个 key 的请求正在执行中,复用同一个 Promise
// 避免缓存过期瞬间多个调用者同时 spawn 进程ARM 设备上的 CPU 爆满根因)
if (_inflight.has(key)) {
return _inflight.get(key)
}
const p = invoke(cmd, args).then(val => {
_cache.set(key, { val, ts: Date.now() })
_inflight.delete(key)
return val
}).catch(err => {
_inflight.delete(key)
throw err
})
_inflight.set(key, p)
return p
}
// 清除指定命令的缓存(写操作后调用)
@@ -147,7 +159,7 @@ export async function checkBackendHealth() {
// 导出 API
export const api = {
// 服务管理(状态用短缓存,操作不缓存)
getServicesStatus: () => cachedInvoke('get_services_status', {}, 3000),
getServicesStatus: () => cachedInvoke('get_services_status', {}, 10000),
startService: (label) => { invalidate('get_services_status'); return invoke('start_service', { label }) },
stopService: (label) => { invalidate('get_services_status'); return invoke('stop_service', { label }) },
restartService: (label) => { invalidate('get_services_status'); return invoke('restart_service', { label }) },
@@ -155,7 +167,7 @@ export const api = {
// 配置(读缓存,写清缓存)
getVersionInfo: () => cachedInvoke('get_version_info', {}, 30000),
getStatusSummary: () => cachedInvoke('get_status_summary', {}, 5000),
getStatusSummary: () => cachedInvoke('get_status_summary', {}, 60000),
readOpenclawConfig: () => cachedInvoke('read_openclaw_config'),
writeOpenclawConfig: (config) => { invalidate('read_openclaw_config'); return invoke('write_openclaw_config', { config }) },
readMcpConfig: () => cachedInvoke('read_mcp_config'),

View File

@@ -56,18 +56,23 @@ export function cleanup() {
if (_unsubGw) { _unsubGw(); _unsubGw = null }
}
async function loadDashboardData(page) {
let _dashboardInitialized = false
async function loadDashboardData(page, fullRefresh = false) {
// 分波加载:关键数据先渲染,次要数据后填充,减少白屏等待
// 轻量调用读文件每次都做重量调用spawn CLI/网络请求)只在首次或手动刷新时做
const coreP = Promise.allSettled([
api.getServicesStatus(),
api.getVersionInfo(),
api.readOpenclawConfig(),
// 版本信息:首次加载或手动刷新时才查询(避免 ARM 设备上频繁查 npm registry
(!_dashboardInitialized || fullRefresh) ? api.getVersionInfo() : Promise.resolve(null),
])
const secondaryP = Promise.allSettled([
api.listAgents(),
api.readMcpConfig(),
api.listBackups(),
api.getStatusSummary(),
// getStatusSummary 是最重的调用spawn openclaw status --json只在首次加载时调用
(!_dashboardInitialized || fullRefresh) ? api.getStatusSummary() : Promise.resolve(null),
])
const logsP = api.readLogTail('gateway', 20).catch(() => '')
@@ -111,6 +116,8 @@ async function loadDashboardData(page) {
// 第三波:日志(最低优先级)
const logs = await logsP
renderLogs(page, logs)
_dashboardInitialized = true
}
function renderStatCards(page, services, version, agents, config) {