feat: R2通用tarball模式 — 一个52MB包覆盖全平台(win/mac/linux/arm64)

This commit is contained in:
晴天
2026-03-16 16:08:15 +08:00
parent dbddb880ab
commit 68b3034403
2 changed files with 234 additions and 176 deletions

View File

@@ -1018,29 +1018,50 @@ async fn try_r2_install(
.await
.map_err(|e| format!("CDN 清单解析失败: {e}"))?;
// 2. 查找版本和平台对应的归档 URL
// 2. 查找归档:优先通用 tarball全平台其次平台特定 assets
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)
let source_obj = manifest.get(source_key);
let cdn_version = source_obj
.and_then(|s| s.get("version"))
.and_then(|v| v.as_str())
.unwrap_or(version);
// 优先通用 tarballnpm pack 产物,~50MB全平台通用
let tarball = source_obj.and_then(|s| s.get("tarball"));
// 其次平台特定 assets预装 node_modules~200MB
let asset = source_obj
.and_then(|s| s.get("assets"))
.and_then(|a| a.get(platform));
let use_tarball = tarball
.and_then(|t| t.get("url"))
.and_then(|v| v.as_str())
.is_some();
let (archive_url, expected_sha, expected_size) = if use_tarball {
let t = tarball.unwrap();
(
t.get("url")
.and_then(|v| v.as_str())
.ok_or("tarball URL 缺失")?,
t.get("sha256").and_then(|v| v.as_str()).unwrap_or(""),
t.get("size").and_then(|v| v.as_u64()).unwrap_or(0),
)
} else if let Some(a) = asset {
(
a.get("url")
.and_then(|v| v.as_str())
.ok_or("归档 URL 缺失")?,
a.get("sha256").and_then(|v| v.as_str()).unwrap_or(""),
a.get("size").and_then(|v| v.as_u64()).unwrap_or(0),
)
} else {
return Err(format!("CDN 无 {source_key} 可用归档"));
};
// 版本匹配检查如果用户指定了版本CDN 版本必须匹配)
if version != "latest" && !versions_match(cdn_version, version) {
return Err(format!(
@@ -1122,114 +1143,127 @@ async fn try_r2_install(
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());
// 5. 安装:通用 tarball 用 npm install -g平台归档用 tar 解压
if use_tarball {
// 通用 tarball 模式npm install -g ./file.tgz全平台通用npm 自动处理原生模块)
let _ = app.emit("upgrade-log", "通用 tarball 模式,执行 npm install...");
let mut install_cmd = npm_command();
install_cmd.args(["install", "-g", &archive_path.to_string_lossy(), "--force"]);
apply_git_install_env(&mut install_cmd);
let install_output = install_cmd
.output()
.map_err(|e| format!("npm install 执行失败: {e}"))?;
if !install_output.status.success() {
let stderr = String::from_utf8_lossy(&install_output.stderr);
let _ = std::fs::remove_file(&archive_path);
return Err(format!(
"npm install -g tarball 失败: {}",
&stderr[stderr.len().saturating_sub(300)..]
));
}
}
#[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-log", "npm install 完成 ✓");
} else {
// 平台特定归档模式:直接解压到 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/Windows tar 不支持 @ 前缀),需要重命名
let no_at_dir = modules_dir.join("qingchencloud");
if no_at_dir.exists() && !qc_dir.exists() {
std::fs::rename(&no_at_dir, &qc_dir)
.map_err(|e| format!("重命名 qingchencloud → @qingchencloud 失败: {e}"))?;
let _ = app.emit("upgrade-log", "目录已修正: qingchencloud → @qingchencloud");
}
let qc_dir = modules_dir.join("@qingchencloud");
if qc_dir.exists() {
let _ = std::fs::remove_dir_all(&qc_dir);
}
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);
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"))]
{
// 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 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-log", "bin 链接已创建 ✓");
} else {
let _ = app.emit("upgrade-log", "⚠️ openclaw.js 未找到bin 链接跳过");
// 归档内目录可能是 qingchencloud/Windows tar 不支持 @ 前缀),需要重命名
let no_at_dir = modules_dir.join("qingchencloud");
if no_at_dir.exists() && !qc_dir.exists() {
std::fs::rename(&no_at_dir, &qc_dir)
.map_err(|e| format!("重命名 qingchencloud → @qingchencloud 失败: {e}"))?;
let _ = app.emit("upgrade-log", "目录已修正: qingchencloud → @qingchencloud");
}
let _ = app.emit("upgrade-log", "解压完成,创建 bin 链接...");
// 创建 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")]
{
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}"))?;
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"))]
{
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 链接跳过");
}
}
// 清理临时文件