mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-10 17:42:49 +08:00
perf: ARM设备性能优化 — in-flight请求去重+后端缓存+仪表盘轮询降频+R2 CDN加速
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"r2": {
|
||||
"baseUrl": "https://dl.qrj.ai/openclaw-zh",
|
||||
"enabled": true
|
||||
},
|
||||
"default": {
|
||||
"official": {
|
||||
"recommended": "2026.3.13"
|
||||
|
||||
@@ -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
19
src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(¤t_source);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user