feat: standalone 独立安装包集成 + 安装方式选择器 (0.9.4)

- 新增 standalone 安装链路(自带 Node.js,零依赖,无需 npm)
- 安装方式选择器:自动/CDN加速/GitHub/npm 四选一
- 动态查询 latest.json 获取最新版本,不怕旧资源被删除
- GitHub 模式:从 GitHub Releases 下载(CDN 不可用时备选)
- CherryStudio openclaw.exe 干扰过滤
- 默认源改为汉化优化版
- 日志隐藏 R2 下载地址(安全)
- 卸载兼容:standalone + npm 双清理
- 版本检测覆盖所有 standalone 安装路径
- README + 官网添加独立安装包说明
- macOS npm 权限问题通过 standalone 自动解决
This commit is contained in:
晴天
2026-03-17 06:32:23 +08:00
parent 3516f099ff
commit 1809329aaa
13 changed files with 682 additions and 54 deletions

2
src-tauri/Cargo.lock generated
View File

@@ -328,7 +328,7 @@ dependencies = [
[[package]]
name = "clawpanel"
version = "0.9.3"
version = "0.9.4"
dependencies = [
"base64 0.22.1",
"chrono",

View File

@@ -1,6 +1,6 @@
[package]
name = "clawpanel"
version = "0.9.3"
version = "0.9.4"
edition = "2021"
description = "ClawPanel - OpenClaw 可视化管理面板"
authors = ["qingchencloud"]

View File

@@ -75,8 +75,19 @@ struct R2Config {
enabled: bool,
}
#[derive(Debug, Deserialize, Default)]
struct StandaloneConfig {
#[serde(default)]
#[serde(rename = "baseUrl")]
base_url: Option<String>,
#[serde(default)]
enabled: bool,
}
#[derive(Debug, Deserialize, Default)]
struct VersionPolicy {
#[serde(default)]
standalone: StandaloneConfig,
#[serde(default)]
r2: R2Config,
#[serde(default)]
@@ -128,6 +139,76 @@ fn r2_config() -> R2Config {
load_version_policy().r2
}
fn standalone_config() -> StandaloneConfig {
load_version_policy().standalone
}
/// standalone 包的平台 key与 CI 构建矩阵一致)
fn standalone_platform_key() -> &'static str {
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
{ "win-x64" }
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{ "mac-arm64" }
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
{ "mac-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" }
}
/// standalone 包的文件扩展名
fn standalone_archive_ext() -> &'static str {
#[cfg(target_os = "windows")]
{ "zip" }
#[cfg(not(target_os = "windows"))]
{ "tar.gz" }
}
/// standalone 安装目录
fn standalone_install_dir() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
// Inno Setup PrivilegesRequired=lowest 默认安装到 %LOCALAPPDATA%\Programs
std::env::var("LOCALAPPDATA").ok().map(|d| PathBuf::from(d).join("Programs").join("OpenClaw"))
}
#[cfg(not(target_os = "windows"))]
{
dirs::home_dir().map(|h| h.join(".openclaw-bin"))
}
}
/// 所有可能的 standalone 安装位置(用于检测和卸载)
fn all_standalone_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
#[cfg(target_os = "windows")]
{
if let Ok(la) = std::env::var("LOCALAPPDATA") {
dirs.push(PathBuf::from(&la).join("Programs").join("OpenClaw"));
dirs.push(PathBuf::from(&la).join("OpenClaw"));
}
if let Ok(pf) = std::env::var("ProgramFiles") {
dirs.push(PathBuf::from(pf).join("OpenClaw"));
}
}
#[cfg(not(target_os = "windows"))]
{
if let Some(h) = dirs::home_dir() {
dirs.push(h.join(".openclaw-bin"));
}
dirs.push(PathBuf::from("/opt/openclaw"));
}
dirs
}
fn recommended_version_for(source: &str) -> Option<String> {
let policy = load_version_policy();
let panel_entry = policy.panels.get(panel_version());
@@ -659,11 +740,38 @@ async fn get_local_version() -> Option<String> {
}
}
}
// Windows: 直接读 npm 全局目录下的 package.json避免 spawn 进程
// Windows: 先查 standalone 安装,再查 npm 全局目录
#[cfg(target_os = "windows")]
{
// 检查所有 standalone 安装目录
for sa_dir in all_standalone_dirs() {
let version_file = sa_dir.join("VERSION");
if let Ok(content) = fs::read_to_string(&version_file) {
for line in content.lines() {
if let Some(ver) = line.strip_prefix("openclaw_version=") {
let ver = ver.trim();
if !ver.is_empty() {
return Some(ver.to_string());
}
}
}
}
let sa_pkg = sa_dir
.join("node_modules")
.join("@qingchencloud")
.join("openclaw-zh")
.join("package.json");
if let Ok(content) = fs::read_to_string(&sa_pkg) {
if let Some(ver) = serde_json::from_str::<Value>(&content)
.ok()
.and_then(|v| v.get("version")?.as_str().map(String::from))
{
return Some(ver);
}
}
}
// npm 全局目录
if let Ok(appdata) = std::env::var("APPDATA") {
// 先查汉化版,再查官方版
for pkg in &["@qingchencloud/openclaw-zh", "openclaw"] {
let pkg_json = PathBuf::from(&appdata)
.join("npm")
@@ -682,6 +790,25 @@ async fn get_local_version() -> Option<String> {
}
}
// 所有平台通用 fallback: CLI 输出(异步)
// Windows: 先确认 openclaw 不是第三方程序(如 CherryStudio
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
if let Ok(o) = std::process::Command::new("where")
.arg("openclaw")
.creation_flags(0x08000000)
.output()
{
let stdout = String::from_utf8_lossy(&o.stdout).to_lowercase();
let all_third_party = stdout
.lines()
.filter(|l| !l.trim().is_empty())
.all(|l| l.contains(".cherrystudio") || l.contains("cherry-studio"));
if all_third_party {
return None;
}
}
}
use crate::utils::openclaw_command_async;
let output = openclaw_command_async()
.arg("--version")
@@ -730,6 +857,17 @@ fn detect_installed_source() -> String {
// Windows: 优先通过文件系统检测,避免 npm list 阻塞
#[cfg(target_os = "windows")]
{
// 检查所有可能的 standalone 安装目录
for sa_dir in all_standalone_dirs() {
let sa_zh = sa_dir
.join("node_modules")
.join("@qingchencloud")
.join("openclaw-zh");
if sa_zh.exists() {
return "chinese".into();
}
}
// 检查 npm 全局目录
if let Some(appdata) = std::env::var_os("APPDATA") {
let zh_dir = PathBuf::from(&appdata)
.join("npm")
@@ -740,7 +878,8 @@ fn detect_installed_source() -> String {
return "chinese".into();
}
}
"official".into()
// 默认返回汉化版
"chinese".into()
}
// 所有平台通用: npm list 检测
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
@@ -875,11 +1014,12 @@ pub async fn upgrade_openclaw(
app: tauri::AppHandle,
source: String,
version: Option<String>,
method: Option<String>,
) -> Result<String, String> {
let app2 = app.clone();
tauri::async_runtime::spawn(async move {
use tauri::Emitter;
let result = upgrade_openclaw_inner(app2.clone(), source, version).await;
let result = upgrade_openclaw_inner(app2.clone(), source, version, method.unwrap_or_else(|| "auto".into())).await;
match result {
Ok(msg) => {
let _ = app2.emit("upgrade-done", &msg);
@@ -994,6 +1134,272 @@ fn npm_global_bin_dir() -> Option<PathBuf> {
}
}
/// 尝试从 standalone 独立安装包安装 OpenClaw自带 Node.js零依赖
/// 动态查询 latest.json 获取最新版本,下载对应平台的归档并解压
/// 成功返回 Ok(版本号),失败返回 Err(原因) 供 caller 降级到 R2/npm
async fn try_standalone_install(
app: &tauri::AppHandle,
version: &str,
override_base_url: Option<&str>,
) -> Result<String, String> {
let source_label = if override_base_url.is_some() { "GitHub" } else { "CDN" };
use tauri::Emitter;
let cfg = standalone_config();
if !cfg.enabled {
return Err("standalone 安装未启用".into());
}
let base_url = cfg.base_url.as_deref().ok_or("standalone baseUrl 未配置")?;
let platform = standalone_platform_key();
if platform == "unknown" {
return Err("当前平台不支持 standalone 安装包".into());
}
let install_dir = standalone_install_dir().ok_or("无法确定 standalone 安装目录")?;
// 1. 动态查询最新版本
let _ = app.emit("upgrade-log", "\u{1F4E6} 尝试 standalone 独立安装包(汉化版专属,自带 Node.js 运行时,无需 npm");
let _ = app.emit("upgrade-log", "查询最新版本...");
let manifest_url = format!("{base_url}/latest.json");
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!("standalone 清单获取失败: {e}"))?;
if !manifest_resp.status().is_success() {
return Err(format!("standalone 清单不可用 (HTTP {})", manifest_resp.status()));
}
let manifest: Value = manifest_resp
.json()
.await
.map_err(|e| format!("standalone 清单解析失败: {e}"))?;
let remote_version = manifest
.get("version")
.and_then(|v| v.as_str())
.ok_or("standalone 清单缺少 version 字段")?;
// 版本匹配检查
if version != "latest" && !versions_match(remote_version, version) {
return Err(format!(
"standalone 版本 {remote_version} 与请求版本 {version} 不匹配"
));
}
let default_base = format!("{base_url}/{remote_version}");
let remote_base = if let Some(ovr) = override_base_url {
ovr
} else {
manifest
.get("base_url")
.and_then(|v| v.as_str())
.unwrap_or(&default_base)
};
// 2. 构造下载 URL
let ext = standalone_archive_ext();
let filename = format!("openclaw-{remote_version}-{platform}.{ext}");
let download_url = format!("{remote_base}/{filename}");
let _ = app.emit(
"upgrade-log",
format!("{source_label} 下载: {filename}"),
);
let _ = app.emit("upgrade-progress", 15);
// 3. 流式下载
let tmp_dir = std::env::temp_dir();
let archive_path = tmp_dir.join(&filename);
let dl_client =
crate::commands::build_http_client(std::time::Duration::from_secs(600), None)
.map_err(|e| format!("下载客户端创建失败: {e}"))?;
let dl_resp = dl_client
.get(&download_url)
.send()
.await
.map_err(|e| format!("standalone 下载失败: {e}"))?;
if !dl_resp.status().is_success() {
return Err(format!(
"standalone 下载失败 (HTTP {}): {download_url}",
dl_resp.status()
));
}
let total_bytes = dl_resp.content_length().unwrap_or(0);
let size_mb = if total_bytes > 0 {
format!("{:.0}MB", total_bytes as f64 / 1_048_576.0)
} else {
"未知大小".into()
};
let _ = app.emit("upgrade-log", format!("下载中 ({size_mb})..."));
{
use futures_util::StreamExt;
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;
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) * 55.0) as u32;
if pct > last_progress {
last_progress = pct;
let _ = app.emit("upgrade-progress", pct.min(70));
}
}
}
file.flush()
.await
.map_err(|e| format!("刷新文件失败: {e}"))?;
}
let _ = app.emit("upgrade-log", "下载完成,解压安装中...");
let _ = app.emit("upgrade-progress", 72);
// 4. 清理旧安装 & 创建目录
if install_dir.exists() {
let _ = std::fs::remove_dir_all(&install_dir);
}
std::fs::create_dir_all(&install_dir)
.map_err(|e| format!("创建安装目录失败: {e}"))?;
// 5. 解压
#[cfg(target_os = "windows")]
{
// Windows: zip 解压
let archive_file = std::fs::File::open(&archive_path)
.map_err(|e| format!("打开归档失败: {e}"))?;
let mut zip_archive = zip::ZipArchive::new(archive_file)
.map_err(|e| format!("ZIP 解析失败: {e}"))?;
zip_archive
.extract(&install_dir)
.map_err(|e| format!("ZIP 解压失败: {e}"))?;
// 归档内可能有 openclaw/ 子目录,需要提升一层
let nested = install_dir.join("openclaw");
if nested.exists() && nested.join("node.exe").exists() {
for entry in std::fs::read_dir(&nested).map_err(|e| format!("读取目录失败: {e}"))? {
if let Ok(entry) = entry {
let dest = install_dir.join(entry.file_name());
let _ = std::fs::rename(entry.path(), &dest);
}
}
let _ = std::fs::remove_dir_all(&nested);
}
}
#[cfg(not(target_os = "windows"))]
{
// Unix: tar.gz 解压
let status = Command::new("tar")
.args([
"-xzf",
&archive_path.to_string_lossy(),
"-C",
&install_dir.to_string_lossy(),
"--strip-components=1",
])
.status()
.map_err(|e| format!("解压失败: {e}"))?;
if !status.success() {
return Err("tar 解压失败".into());
}
}
// 清理临时文件
let _ = std::fs::remove_file(&archive_path);
let _ = app.emit("upgrade-progress", 85);
// 6. 验证安装
#[cfg(target_os = "windows")]
let openclaw_bin = install_dir.join("openclaw.cmd");
#[cfg(not(target_os = "windows"))]
let openclaw_bin = install_dir.join("openclaw");
if !openclaw_bin.exists() {
return Err("standalone 解压后未找到 openclaw 可执行文件".into());
}
// 7. 添加到 PATHWindows 用户 PATHUnix 创建 symlink
#[cfg(target_os = "windows")]
{
let install_str = install_dir.to_string_lossy().to_string();
// 检查是否已在 PATH 中
let current_path = std::env::var("PATH").unwrap_or_default();
if !current_path
.split(';')
.any(|p| p.eq_ignore_ascii_case(&install_str))
{
// 写入用户 PATH注册表
let _ = Command::new("powershell")
.args([
"-NoProfile",
"-Command",
&format!(
"$p = [Environment]::GetEnvironmentVariable('Path','User'); if ($p -notlike '*{}*') {{ [Environment]::SetEnvironmentVariable('Path', $p + ';{}', 'User') }}",
install_str.replace('\'', "''"),
install_str.replace('\'', "''")
),
])
.creation_flags(0x08000000)
.status();
let _ = app.emit("upgrade-log", format!("已添加到 PATH: {install_str}"));
}
}
#[cfg(not(target_os = "windows"))]
{
// Unix: 创建 /usr/local/bin/openclaw symlink 或 ~/bin/openclaw
let link_targets = [
PathBuf::from("/usr/local/bin/openclaw"),
dirs::home_dir()
.unwrap_or_default()
.join("bin")
.join("openclaw"),
];
for link in &link_targets {
if let Some(parent) = link.parent() {
if parent.exists() {
let _ = std::fs::remove_file(link);
#[cfg(unix)]
{
if std::os::unix::fs::symlink(&openclaw_bin, link).is_ok() {
let _ = Command::new("chmod")
.args(["+x", &openclaw_bin.to_string_lossy()])
.status();
let _ = app.emit(
"upgrade-log",
format!("symlink 已创建: {}", link.display()),
);
break;
}
}
}
}
}
}
let _ = app.emit("upgrade-progress", 95);
let _ = app.emit(
"upgrade-log",
format!("✅ standalone 独立安装包安装完成 ({remote_version})"),
);
let _ = app.emit(
"upgrade-log",
format!("安装目录: {}", install_dir.display()),
);
// 刷新 CLI 检测缓存
crate::commands::service::invalidate_cli_detection_cache();
Ok(remote_version.to_string())
}
/// 尝试从 R2 CDN 下载预装归档安装 OpenClaw跳过 npm 依赖解析)
/// 成功返回 Ok(版本号),失败返回 Err(原因) 供 caller 降级到 npm install
async fn try_r2_install(
@@ -1293,6 +1699,7 @@ async fn upgrade_openclaw_inner(
app: tauri::AppHandle,
source: String,
version: Option<String>,
method: String,
) -> Result<String, String> {
use std::io::{BufRead, BufReader};
use std::process::Stdio;
@@ -1309,29 +1716,46 @@ 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 {
// ── standalone 安装auto / standalone-r2 / standalone-github ──
let try_standalone = source != "official"
&& (method == "auto" || method == "standalone-r2" || method == "standalone-github");
if try_standalone {
// standalone-github 模式:使用 GitHub Releases 下载地址
let github_base = if method == "standalone-github" {
Some(format!(
"https://github.com/qingchencloud/openclaw-standalone/releases/download/v{}",
ver
))
} else {
None
};
match try_standalone_install(&app, ver, github_base.as_deref()).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 label = if method == "standalone-github" { "GitHub" } else { "CDN" };
let msg = format!("✅ standalone ({label}) 安装完成,当前版本: {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);
if method == "auto" {
let _ = app.emit(
"upgrade-log",
format!("standalone 不可用({reason}),降级到 npm 安装..."),
);
let _ = app.emit("upgrade-progress", 5);
} else {
return Err(format!("standalone 安装失败: {reason}"));
}
}
}
}
// ── npm install兜底或用户明确选择 ──
// 切换源时需要卸载旧包,但为避免安装失败导致 CLI 丢失,
// 先安装新包,成功后再卸载旧包
let old_pkg = npm_package_name(&current_source);
@@ -1637,7 +2061,19 @@ async fn uninstall_openclaw_inner(
let _ = openclaw_command().args(["gateway", "uninstall"]).output();
}
// 3. npm uninstall
// 3. 清理 standalone 安装(所有可能的位置)
for sa_dir in &all_standalone_dirs() {
if sa_dir.exists() {
let _ = app.emit("upgrade-log", format!("清理 standalone 安装: {}", sa_dir.display()));
if let Err(e) = std::fs::remove_dir_all(sa_dir) {
let _ = app.emit("upgrade-log", format!("⚠️ 清理 standalone 失败: {e}(可能需要管理员权限)"));
} else {
let _ = app.emit("upgrade-log", "standalone 安装已清理 ✓");
}
}
}
// 4. npm uninstall
let _ = app.emit("upgrade-log", format!("$ npm uninstall -g {pkg}"));
let _ = app.emit("upgrade-progress", 20);

View File

@@ -653,6 +653,16 @@ mod platform {
fn candidate_cli_paths() -> Vec<PathBuf> {
let mut candidates = Vec::new();
// standalone 安装目录(优先检测,覆盖所有可能位置)
if let Ok(localappdata) = env::var("LOCALAPPDATA") {
// Inno Setup PrivilegesRequired=lowest 默认路径
candidates.push(Path::new(&localappdata).join("Programs").join("OpenClaw").join("openclaw.cmd"));
candidates.push(Path::new(&localappdata).join("OpenClaw").join("openclaw.cmd"));
}
if let Ok(pf) = env::var("ProgramFiles") {
candidates.push(Path::new(&pf).join("OpenClaw").join("openclaw.cmd"));
}
if let Ok(appdata) = env::var("APPDATA") {
candidates.push(Path::new(&appdata).join("npm").join("openclaw.cmd"));
}
@@ -698,24 +708,24 @@ mod platform {
}
// 方式2: 通过 where 查找(兼容 nvm、自定义 prefix 等)
// 过滤掉第三方 openclaw如 CherryStudio 的 .cherrystudio/bin/openclaw.exe
let mut where_cmd = std::process::Command::new("where");
where_cmd.arg("openclaw");
where_cmd.env("PATH", crate::commands::enhanced_path());
where_cmd.creation_flags(CREATE_NO_WINDOW);
if let Ok(o) = where_cmd.output() {
if o.status.success() && !String::from_utf8_lossy(&o.stdout).trim().is_empty() {
return true;
}
}
// 方式3: 直接执行版本命令兜底
let mut cmd = std::process::Command::new("cmd");
cmd.args(["/c", "openclaw", "--version"]);
cmd.env("PATH", crate::commands::enhanced_path());
cmd.creation_flags(CREATE_NO_WINDOW);
if let Ok(o) = cmd.output() {
if o.status.success() {
return true;
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") {
continue;
}
if !p.is_empty() {
return true;
}
}
}
}
false

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
"productName": "ClawPanel",
"version": "0.9.3",
"version": "0.9.4",
"identifier": "ai.openclaw.clawpanel",
"build": {
"frontendDist": "../dist",
@@ -42,7 +42,10 @@
"silent": true
},
"nsis": {
"languages": ["SimpChinese", "English"],
"languages": [
"SimpChinese",
"English"
],
"displayLanguageSelector": true
}
}