diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 858161b..1d9be93 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -142,7 +142,6 @@ 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` @@ -151,95 +150,120 @@ async function _tryR2Install(version, source, logs) { 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 sourceObj = manifest?.[sourceKey] + if (!sourceObj) throw new Error(`CDN 无 ${sourceKey} 配置`) - const cdnVersion = manifest?.[sourceKey]?.version || version + const cdnVersion = sourceObj.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})`) + // 优先通用 tarball(npm pack 产物,~50MB,全平台通用),其次平台特定 assets + const tarball = sourceObj.tarball + const asset = sourceObj.assets?.[platform] + const useTarball = !!tarball?.url + + if (!useTarball && !asset?.url) { + if (platform === 'unknown') throw new Error('当前平台不支持 R2 加速') + throw new Error(`CDN 无 ${sourceKey}/${platform} 归档`) + } + + const archiveUrl = useTarball ? tarball.url : asset.url + const expectedSha = useTarball ? (tarball.sha256 || '') : (asset.sha256 || '') + const expectedSize = useTarball ? (tarball.size || 0) : (asset.size || 0) + const sizeMb = expectedSize ? `${(expectedSize / 1048576).toFixed(0)}MB` : '未知大小' + const mode = useTarball ? '通用 tarball' : `${platform} 预装归档` + logs.push(`CDN 下载: ${cdnVersion} (${mode}, ${sizeMb})`) // 下载到临时文件 - const tmpPath = path.join(os.tmpdir(), `openclaw-${platform}.tgz`) - const dlResp = await globalThis.fetch(asset.url, { signal: AbortSignal.timeout(300000) }) + const tmpPath = path.join(os.tmpdir(), `openclaw-cdn.tgz`) + const dlResp = await globalThis.fetch(archiveUrl, { 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) { + if (expectedSha) { const crypto = require('crypto') const hash = crypto.createHash('sha256').update(buffer).digest('hex') - if (hash !== asset.sha256) { + if (hash !== expectedSha) { fs.unlinkSync(tmpPath) - throw new Error(`SHA256 校验失败: 期望 ${asset.sha256}, 实际 ${hash}`) + throw new Error(`SHA256 校验失败: 期望 ${expectedSha}, 实际 ${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 { + if (useTarball) { + // 通用 tarball 模式:npm install -g ./file.tgz(全平台通用,npm 自动处理原生模块) + logs.push('通用 tarball 模式,执行 npm install...') + const npmBin = isWindows ? 'npm.cmd' : 'npm' 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' + execSync(`${npmBin} install -g "${tmpPath}" --force 2>&1`, { timeout: 120000, windowsHide: true }) + logs.push('npm install 完成 ✓') + } catch (e) { + try { fs.unlinkSync(tmpPath) } catch {} + throw new Error('npm install -g tarball 失败: ' + (e.stderr?.toString() || e.message).slice(-300)) } - } - 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 }) - - // 归档内目录可能是 qingchencloud/(Windows tar 不支持 @ 前缀),需要重命名 - const noAtDir = path.join(modulesDir, 'qingchencloud') - if (fs.existsSync(noAtDir) && !fs.existsSync(qcDir)) { - fs.renameSync(noAtDir, qcDir) - logs.push('目录已修正: qingchencloud → @qingchencloud') - } - - // 创建 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)) { + // 平台特定归档模式:直接解压到 npm 全局 node_modules + let modulesDir 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) + 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 { - 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 {} + 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 }) + + // 归档内目录可能是 qingchencloud/(Windows tar 不支持 @ 前缀),需要重命名 + const noAtDir = path.join(modulesDir, 'qingchencloud') + if (fs.existsSync(noAtDir) && !fs.existsSync(qcDir)) { + fs.renameSync(noAtDir, qcDir) + logs.push('目录已修正: qingchencloud → @qingchencloud') + } + + // 创建 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 链接已创建 ✓') } - logs.push('bin 链接已创建 ✓') } // 清理临时文件 diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 14dba86..45481e9 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -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); + // 优先通用 tarball(npm 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 链接跳过"); + } } // 清理临时文件