From 6d09a6490d1b8e1b17dce27bb800d07f1f52990a Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Mon, 10 Aug 2026 14:05:57 +0800 Subject: [PATCH] feat(update): support Linux and macOS auto-updates --- .github/workflows/release.yml | 31 +- .gitignore | 1 + README.ja.md | 4 + README.md | 4 + README.zh-CN.md | 4 + scripts/manifest.mjs | 31 +- scripts/portable.mjs | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/app_update.rs | 938 ++++++++++++++++++++++++--- src-tauri/src/main.rs | 34 +- src-tauri/src/tests/app_update.rs | 190 ++++-- src-tauri/tauri.dmg.conf.json | 3 +- tests/portableUpdateManifest.test.ts | 36 + 13 files changed, 1126 insertions(+), 154 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d5ffd61..ccb29d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -293,6 +293,16 @@ jobs: COPYFILE_DISABLE=1 tar -czf "$signed_archive" -C "$signed_core_dir" . mv "$signed_archive" "$core_archive" + cp "release/${{ steps.package.outputs.stem }}/portable-app.json" portable-app.json + node -e ' + const marker = require("./portable-app.json"); + if (marker.schemaVersion !== 1 || marker.application !== "EasyCLIProxyAPI" + || marker.version !== process.argv[1] || marker.platform !== "darwin" + || marker.arch !== process.argv[2] || marker.autoUpdate !== true) { + throw new Error("macOS auto-update marker does not match the release target"); + } + ' "${{ steps.package.outputs.version }}" "${{ matrix.arch }}" + # Build the notarized app with Tauri, then create the DMG with hdiutil. # Tauri's generated create-dmg script is unreliable on GitHub's macOS runners. env -u APPLE_CERTIFICATE -u APPLE_CERTIFICATE_PASSWORD \ @@ -331,6 +341,7 @@ jobs: resources_dir="$app_path/Contents/Resources" test -d "$app_path" test -s "$resources_dir/core-version.txt" + test -s "$resources_dir/portable-app.json" core_count="$(find "$resources_dir/cpa-core" -maxdepth 1 -type f -name 'CLIProxyAPI_*.tar.gz' | wc -l | tr -d '[:space:]')" if [ "$core_count" -ne 1 ]; then echo "macOS app must contain exactly one bundled core archive" >&2 @@ -425,17 +436,21 @@ jobs: with: path: artifacts merge-multiple: true - - name: Generate Windows portable update manifest + - name: Generate portable update manifests shell: bash env: RELEASE_TAG: ${{ needs.version.outputs.tag }} - run: >- - node scripts/manifest.mjs - --directory artifacts - --output artifacts/portable-update-windows.json - --repository "${{ github.repository }}" - --gitcode-repository "${{ vars.GITCODE_GUI_REPOSITORY }}" - --tag "$RELEASE_TAG" + run: | + set -euo pipefail + for platform in windows linux darwin; do + node scripts/manifest.mjs \ + --directory artifacts \ + --output "artifacts/portable-update-${platform}.json" \ + --platform "$platform" \ + --repository "${{ github.repository }}" \ + --gitcode-repository "${{ vars.GITCODE_GUI_REPOSITORY }}" \ + --tag "$RELEASE_TAG" + done - name: Publish release shell: bash env: diff --git a/.gitignore b/.gitignore index f6f8eb6..c5b9f6c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ src-tauri/gen/ src-tauri/target/ bin-work/ cpa-core/ +/portable-app.json agent_docs/ diff --git a/README.ja.md b/README.ja.md index 095d9d5..2a910f2 100644 --- a/README.ja.md +++ b/README.ja.md @@ -121,6 +121,10 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で すべての Windows リリースで、完全版 ZIP と旧クライアント互換の `update` ZIP を同時に公開します。まだ移行できていない旧クライアントでもアプリ内更新を利用でき、新しいクライアントでは内蔵 core も更新できる完全版パッケージを使用します。 +現在の Windows、Linux、macOS リリースはアプリ内自動更新に対応しています。Linux では実行時データを保持したままポータブルアプリのファイルを置き換え、macOS では署名済みアプリバンドル全体を置き換えます。新しいバージョンの起動確認に失敗した場合は自動的にロールバックします。インストール先は現在のユーザーが書き込める必要があります。 + +既存の Linux および macOS インストールでは、クロスプラットフォーム自動更新マーカーを含むリリースへ一度手動で更新する必要があります。そのリリースを起動した後は、以降の更新をアプリ内で実行できます。 + v0.2.5 以前を使用している場合は、一度だけ手動で移行してください。EasyCLIProxyAPI を終了し、使用中のアーキテクチャに対応する最新版の完全版 Windows ZIP をダウンロードして、その最上位ディレクトリの内容を既存のインストール先へコピーし、同名ファイルを上書きします。既存のディレクトリを先に削除しないでください。`config.toml`、`oauth`、`cpa-core/config.yaml` などのユーザーデータは保持されます。新しいバージョンを起動した後は、以降のリリースでアプリ内自動更新を利用できます。 ## 対応プラットフォーム diff --git a/README.md b/README.md index 8a13148..0c907e3 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,10 @@ previous configuration. Every Windows release publishes both the complete ZIP and the legacy `update` ZIP. This keeps in-app updates available to older clients that have not migrated yet, while newer clients use the complete package so the bundled core can be updated too. +Current Windows, Linux, and macOS release packages support in-app automatic updates. Linux replaces the portable application files while preserving runtime data, and macOS replaces the signed application bundle. Each platform waits for the new version to confirm a successful launch and rolls back automatically if startup fails. The installation directory must be writable by the current user. + +Existing Linux and macOS installations need one manual upgrade to a release that includes the cross-platform auto-update marker. In-app updates are available after that release has been launched once. + If you are running v0.2.5 or earlier, perform one manual migration: exit EasyCLIProxyAPI, download the latest complete Windows ZIP for your architecture, then copy the contents of its top-level directory over the existing installation directory. Do not delete the existing directory first; user data such as `config.toml`, `oauth`, and `cpa-core/config.yaml` will remain in place. After launching the new version, later releases can use in-app automatic updates. ## Supported Platforms diff --git a/README.zh-CN.md b/README.zh-CN.md index ff6c5cc..7f2104d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -118,6 +118,10 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址 每个 Windows 版本都会同时发布完整 ZIP 和兼容旧客户端的 `update` ZIP。这样尚未及时迁移的旧客户端仍可使用应用内更新;新版客户端则使用完整包,同时更新内置 core。 +当前 Windows、Linux 和 macOS 发行包都支持应用内自动升级。Linux 会替换便携版程序文件并保留运行数据;macOS 会整体替换已签名的应用包。各平台都会等待新版完成启动确认,启动失败时自动回滚。安装目录必须允许当前用户写入。 + +现有 Linux 和 macOS 安装需要先手动升级一次,安装带有跨平台自动更新标识的版本;成功启动该版本后,后续即可使用应用内自动升级。 + 如果当前版本是 v0.2.5 或更早版本,请执行一次手动迁移:退出 EasyCLIProxyAPI,下载最新版对应架构的完整 Windows ZIP,将 ZIP 顶层目录内的内容复制到现有安装目录并覆盖同名文件。不要先删除现有安装目录;`config.toml`、`oauth` 和 `cpa-core/config.yaml` 等用户数据会被保留。启动新版后,后续版本即可继续使用应用内自动升级。 ## 支持的平台 diff --git a/scripts/manifest.mjs b/scripts/manifest.mjs index e78c75b..4a06a03 100644 --- a/scripts/manifest.mjs +++ b/scripts/manifest.mjs @@ -7,13 +7,22 @@ import { validateAppVersion } from './version.mjs'; export async function generatePortableUpdateManifest({ directory, output, + platform = 'windows', repository, gitcodeRepository, tag: rawTag, publishedAt = new Date().toISOString(), }) { const resolvedDirectory = resolve(directory ?? 'artifacts'); - const resolvedOutput = resolve(output ?? join(resolvedDirectory, 'portable-update-windows.json')); + const platformSpecs = { + windows: { display: 'Windows', suffix: 'zip', legacy: true }, + linux: { display: 'Linux', suffix: 'tar.gz', legacy: false }, + darwin: { display: 'Darwin', suffix: 'dmg', legacy: false }, + }; + const normalizedPlatform = String(platform).trim().toLowerCase(); + const platformSpec = platformSpecs[normalizedPlatform]; + if (!platformSpec) throw new Error(`Unsupported update platform: ${platform}`); + const resolvedOutput = resolve(output ?? join(resolvedDirectory, `portable-update-${normalizedPlatform}.json`)); const resolvedRepository = repository ?? 'router-for-me/EasyCLIProxyAPI'; const resolvedGitcodeRepository = String(gitcodeRepository ?? '').trim(); const normalizedRawTag = String(rawTag ?? '').trim(); @@ -35,10 +44,14 @@ export async function generatePortableUpdateManifest({ const assets = {}; const fullAssets = {}; for (const arch of ['amd64', 'aarch64']) { - for (const [collection, filename] of [ - [assets, `EasyCLIProxyAPI-update-${tag}-Windows-${arch}.zip`], - [fullAssets, `EasyCLIProxyAPI-${tag}-Windows-${arch}.zip`], - ]) { + const fullFilename = `EasyCLIProxyAPI-${tag}-${platformSpec.display}-${arch}.${platformSpec.suffix}`; + const candidates = platformSpec.legacy + ? [ + [assets, `EasyCLIProxyAPI-update-${tag}-Windows-${arch}.zip`], + [fullAssets, fullFilename], + ] + : [[assets, fullFilename]]; + for (const [collection, filename] of candidates) { const path = join(resolvedDirectory, filename); const [contents, metadata] = await Promise.all([readFile(path), stat(path)]); if (!metadata.isFile() || metadata.size === 0) { @@ -56,7 +69,7 @@ export async function generatePortableUpdateManifest({ filename, )]; } - collection[`windows-${arch}`] = asset; + collection[`${normalizedPlatform}-${arch}`] = asset; } } @@ -66,7 +79,7 @@ export async function generatePortableUpdateManifest({ publishedAt, releaseUrl: `https://github.com/${resolvedRepository}/releases/tag/${tag}`, assets, - fullAssets, + ...(platformSpec.legacy ? { fullAssets } : {}), }; await writeFile(resolvedOutput, `${JSON.stringify(manifest, null, 2)}\n`); @@ -85,10 +98,12 @@ async function main() { args.set(process.argv[index], process.argv[index + 1]); } const directory = resolve(args.get('--directory') ?? 'artifacts'); - const output = resolve(args.get('--output') ?? join(directory, 'portable-update-windows.json')); + const platform = args.get('--platform') ?? 'windows'; + const output = resolve(args.get('--output') ?? join(directory, `portable-update-${platform}.json`)); const manifest = await generatePortableUpdateManifest({ directory, output, + platform, repository: args.get('--repository'), gitcodeRepository: args.get('--gitcode-repository'), tag: args.get('--tag'), diff --git a/scripts/portable.mjs b/scripts/portable.mjs index 27726e1..53ceb69 100644 --- a/scripts/portable.mjs +++ b/scripts/portable.mjs @@ -69,7 +69,7 @@ await writeFile(join(output, 'portable-app.json'), `${JSON.stringify({ version: appVersion, platform: targetOS, arch: targetArch, - autoUpdate: targetOS === 'windows', + autoUpdate: ['windows', 'linux', 'darwin'].includes(targetOS), }, null, 2)}\n`); const coreOutput = join(output, 'cpa-core'); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d9fb570..6ff7b58 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -37,7 +37,7 @@ yaml-serde-edit = { version = "0.1", default-features = false } yaml-edit = "0.2" zip = { version = "2", default-features = false, features = ["deflate"] } -[target.'cfg(target_os = "linux")'.dependencies] +[target.'cfg(unix)'.dependencies] libc = "0.2" [target.'cfg(target_os = "macos")'.dependencies] diff --git a/src-tauri/src/app_update.rs b/src-tauri/src/app_update.rs index bc83608..09726e1 100644 --- a/src-tauri/src/app_update.rs +++ b/src-tauri/src/app_update.rs @@ -30,15 +30,13 @@ pub(crate) async fn check_app_update( let portable_support = target .map(|(_, arch)| validate_local_portable_app_manifest(arch)) .transpose()?; - let auto_update_supported = cfg!(windows) && portable_support == Some(true) && asset.is_some(); + let auto_update_supported = portable_support == Some(true) && asset.is_some(); let unsupported_reason = if auto_update_supported { None - } else if !cfg!(windows) { - Some("应用内自动升级当前仅支持 Windows 便携版".to_string()) } else if portable_support != Some(true) { Some("当前程序不是支持自动升级的便携版,请手动下载首个支持版本".to_string()) } else { - Some("更新清单不包含当前 Windows 架构".to_string()) + Some("更新清单不包含当前平台或架构".to_string()) }; let pending = if update_available && auto_update_supported { @@ -261,71 +259,73 @@ pub(crate) fn validate_portable_update_manifest( { return Err("软件更新发布地址不受信任".to_string()); } - if manifest.assets.len() != 2 { - return Err("软件更新清单必须包含两个 Windows 架构".to_string()); - } - for (key, arch) in [("windows-amd64", "amd64"), ("windows-aarch64", "aarch64")] { - let asset = manifest - .assets - .get(key) - .ok_or_else(|| format!("软件更新清单缺少 {key}"))?; - validate_portable_update_asset(asset)?; - let full_package_name = format!( - "EasyCLIProxyAPI-v{}-Windows-{arch}.zip", - manifest.version.trim().trim_start_matches('v') - ); - let full_package_url = format!( - "{APP_RELEASE_DOWNLOAD_PREFIX}v{}/{}", - manifest.version.trim().trim_start_matches('v'), - full_package_name - ); - let legacy_package_name = format!( - "EasyCLIProxyAPI-update-v{}-Windows-{arch}.zip", - manifest.version.trim().trim_start_matches('v') - ); - let legacy_package_url = format!( - "{APP_RELEASE_DOWNLOAD_PREFIX}v{}/{}", - manifest.version.trim().trim_start_matches('v'), - legacy_package_name - ); - if asset.url != full_package_url && asset.url != legacy_package_url { - return Err(format!("软件更新资产名称与 {key} 不匹配")); - } - validate_portable_update_asset_fallbacks( - asset, - &format!("v{}", manifest.version.trim().trim_start_matches('v')), - &[&full_package_name, &legacy_package_name], + let (platform, display_platform, suffix) = portable_update_asset_platform()?; + validate_portable_update_asset_catalog( + &manifest.assets, + manifest, + platform, + display_platform, + suffix, + platform == "windows", + )?; + if let Some(full_assets) = &manifest.full_assets { + validate_portable_update_asset_catalog( + full_assets, + manifest, + platform, + display_platform, + suffix, + false, )?; } - if let Some(full_assets) = &manifest.full_assets { - if full_assets.len() != 2 { - return Err( - "Full update asset catalog must contain both Windows architectures".to_string(), - ); + Ok(()) +} + +pub(crate) fn portable_update_asset_platform( +) -> Result<(&'static str, &'static str, &'static str), String> { + match portable_update_platform_key() { + Some("windows") => Ok(("windows", "Windows", "zip")), + Some("linux") => Ok(("linux", "Linux", "tar.gz")), + Some("darwin") => Ok(("darwin", "Darwin", "dmg")), + _ => Err("当前平台不支持应用内自动升级".to_string()), + } +} + +fn validate_portable_update_asset_catalog( + assets: &std::collections::HashMap, + manifest: &PortableUpdateManifest, + platform: &str, + display_platform: &str, + suffix: &str, + allow_windows_legacy: bool, +) -> Result<(), String> { + if assets.len() != 2 { + return Err(format!("软件更新清单必须包含两个 {display_platform} 架构")); + } + let version = manifest.version.trim().trim_start_matches('v'); + let tag = format!("v{version}"); + for arch in ["amd64", "aarch64"] { + let key = format!("{platform}-{arch}"); + let asset = assets + .get(&key) + .ok_or_else(|| format!("软件更新清单缺少 {key}"))?; + validate_portable_update_asset(asset)?; + let full_package_name = + format!("EasyCLIProxyAPI-v{version}-{display_platform}-{arch}.{suffix}"); + let full_package_url = format!("{APP_RELEASE_DOWNLOAD_PREFIX}{tag}/{full_package_name}"); + let legacy_package_name = format!("EasyCLIProxyAPI-update-v{version}-Windows-{arch}.zip"); + let legacy_package_url = + format!("{APP_RELEASE_DOWNLOAD_PREFIX}{tag}/{legacy_package_name}"); + let is_expected = asset.url == full_package_url + || (allow_windows_legacy && asset.url == legacy_package_url); + if !is_expected { + return Err(format!("软件更新资产名称与 {key} 不匹配")); } - for (key, arch) in [("windows-amd64", "amd64"), ("windows-aarch64", "aarch64")] { - let asset = full_assets - .get(key) - .ok_or_else(|| format!("Full update asset catalog is missing {key}"))?; - validate_portable_update_asset(asset)?; - let package_name = format!( - "EasyCLIProxyAPI-v{}-Windows-{arch}.zip", - manifest.version.trim().trim_start_matches('v') - ); - let expected_url = format!( - "{APP_RELEASE_DOWNLOAD_PREFIX}v{}/{}", - manifest.version.trim().trim_start_matches('v'), - package_name - ); - if asset.url != expected_url { - return Err(format!("Full update asset name does not match {key}")); - } - validate_portable_update_asset_fallbacks( - asset, - &format!("v{}", manifest.version.trim().trim_start_matches('v')), - &[&package_name], - )?; + let mut expected_filenames = vec![full_package_name.as_str()]; + if allow_windows_legacy { + expected_filenames.push(legacy_package_name.as_str()); } + validate_portable_update_asset_fallbacks(asset, &tag, &expected_filenames)?; } Ok(()) } @@ -391,18 +391,43 @@ pub(crate) fn validate_portable_update_asset_fallbacks_for_repository( } pub(crate) fn portable_update_target() -> Option<(&'static str, &'static str)> { - if !cfg!(windows) { - return None; - } - match env::consts::ARCH { - "x86_64" => Some(("windows-amd64", "amd64")), - "aarch64" => Some(("windows-aarch64", "aarch64")), + let platform = portable_update_platform_key()?; + let arch = match env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "aarch64", + _ => return None, + }; + match (platform, arch) { + ("windows", "amd64") => Some(("windows-amd64", "amd64")), + ("windows", "aarch64") => Some(("windows-aarch64", "aarch64")), + ("linux", "amd64") => Some(("linux-amd64", "amd64")), + ("linux", "aarch64") => Some(("linux-aarch64", "aarch64")), + ("darwin", "amd64") => Some(("darwin-amd64", "amd64")), + ("darwin", "aarch64") => Some(("darwin-aarch64", "aarch64")), _ => None, } } +pub(crate) fn portable_update_platform_key() -> Option<&'static str> { + match env::consts::OS { + "windows" => Some("windows"), + "linux" => Some("linux"), + "macos" => Some("darwin"), + _ => None, + } +} + +pub(crate) fn local_portable_app_manifest_path() -> Result { + let executable_dir = executable_dir()?; + #[cfg(target_os = "macos")] + if let Some(resources_dir) = macos_app_resources_dir(&executable_dir) { + return Ok(resources_dir.join(PORTABLE_APP_MANIFEST_FILE)); + } + Ok(executable_dir.join(PORTABLE_APP_MANIFEST_FILE)) +} + pub(crate) fn validate_local_portable_app_manifest(expected_arch: &str) -> Result { - let path = executable_dir()?.join(PORTABLE_APP_MANIFEST_FILE); + let path = local_portable_app_manifest_path()?; if !path.is_file() { return Ok(false); } @@ -412,7 +437,7 @@ pub(crate) fn validate_local_portable_app_manifest(expected_arch: &str) -> Resul .map_err(|error| format!("解析便携版标识失败: {error}"))?; Ok(manifest.schema_version == 1 && manifest.application == "EasyCLIProxyAPI" - && manifest.platform == "windows" + && Some(manifest.platform.as_str()) == portable_update_platform_key() && manifest.arch == expected_arch && manifest.auto_update && normalize_version(&manifest.version) == normalize_version(env!("CARGO_PKG_VERSION"))) @@ -439,8 +464,8 @@ pub(crate) async fn start_app_update( state: tauri::State<'_, AppUpdateState>, gui_config_state: tauri::State<'_, GuiConfigState>, ) -> Result<(), String> { - if !cfg!(windows) { - return Err("应用内自动升级当前仅支持 Windows 便携版".to_string()); + if portable_update_platform_key().is_none() { + return Err("当前平台不支持应用内自动升级".to_string()); } let proxy_url = gui_config_state.snapshot()?.proxy_url; let token = CancellationToken::new(); @@ -474,10 +499,10 @@ pub(crate) async fn download_and_stage_portable_app_update( token: &CancellationToken, proxy_url: &str, ) -> Result<(), String> { - #[cfg(not(windows))] + #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] { let _ = (app, pending, token, proxy_url); - Err("应用内自动升级当前仅支持 Windows 便携版".to_string()) + Err("当前平台不支持应用内自动升级".to_string()) } #[cfg(windows)] @@ -592,9 +617,188 @@ pub(crate) async fn download_and_stage_portable_app_update( } result } + + #[cfg(target_os = "linux")] + { + validate_portable_update_asset(&pending.asset)?; + let work_dir = portable_update_work_dir(&pending.version); + fs::create_dir_all(&work_dir) + .map_err(|error| format!("创建应用更新临时目录失败: {error}"))?; + let archive_path = work_dir.join("update.tar.gz"); + let result = async { + download_portable_update_archive(app, pending, token, &archive_path, proxy_url).await?; + ensure_portable_update_download(token, &archive_path, pending)?; + update_app_task(app, |task| { + task.cancellable = false; + task.phase = "staging".to_string(); + task.message = Some("正在准备应用更新".to_string()); + }); + + let staging_dir = work_dir.join("staging"); + let package = extract_portable_update_tar_gz(&archive_path, &staging_dir)?; + if normalize_version(&package.manifest.version) != normalize_version(&pending.version) + || package.manifest.platform != "linux" + || package.manifest.arch != pending.arch + { + return Err("应用更新包版本或架构不匹配".to_string()); + } + let core_archive_name = package + .core_archive_name + .ok_or_else(|| "Linux 应用更新包缺少内置核心".to_string())?; + let current_exe = + env::current_exe().map_err(|error| format!("读取当前程序路径失败: {error}"))?; + let app_dir = current_exe + .parent() + .ok_or_else(|| "当前程序路径没有父目录".to_string())?; + preflight_portable_update_directory(app_dir)?; + let helper_path = work_dir.join("EasyCLIProxyAPI-updater"); + fs::copy(¤t_exe, &helper_path) + .map_err(|error| format!("准备应用更新助手失败: {error}"))?; + + let descriptor = PortableUpdateDescriptor { + parent_pid: std::process::id(), + current_exe: current_exe.clone(), + staged_exe: staging_dir.join(PORTABLE_APP_BINARY), + current_manifest: app_dir.join(PORTABLE_APP_MANIFEST_FILE), + staged_manifest: staging_dir.join(PORTABLE_APP_MANIFEST_FILE), + backup_exe: app_dir.join(".EasyCLIProxyAPI.update-backup"), + backup_manifest: app_dir.join(".portable-app.json.update-backup"), + current_core_version: app_dir.join(CORE_VERSION_FILE), + staged_core_version: staging_dir.join(CORE_VERSION_FILE), + backup_core_version: app_dir.join(".core-version.txt.update-backup"), + staged_core_archive: staging_dir.join("cpa-core").join(&core_archive_name), + target_core_archive: app_dir.join("cpa-core").join(&core_archive_name), + install_core_archive: !app_dir.join("cpa-core").join(&core_archive_name).is_file(), + ack_path: work_dir.join("update-started.ack"), + work_dir: work_dir.clone(), + target_version: pending.version.clone(), + }; + launch_portable_update_helper(app, &helper_path, &work_dir, &descriptor).await + } + .await; + if result.is_err() { + let _ = fs::remove_dir_all(&work_dir); + } + result + } + + #[cfg(target_os = "macos")] + { + validate_portable_update_asset(&pending.asset)?; + let work_dir = portable_update_work_dir(&pending.version); + fs::create_dir_all(&work_dir) + .map_err(|error| format!("创建应用更新临时目录失败: {error}"))?; + let archive_path = work_dir.join("update.dmg"); + let result = async { + download_portable_update_archive(app, pending, token, &archive_path, proxy_url).await?; + ensure_portable_update_download(token, &archive_path, pending)?; + update_app_task(app, |task| { + task.cancellable = false; + task.phase = "staging".to_string(); + task.message = Some("正在准备应用更新".to_string()); + }); + + let staged_app = work_dir.join("staging").join("EasyCLIProxyAPI.app"); + stage_macos_application_from_dmg(&archive_path, &work_dir, &staged_app)?; + validate_macos_staged_application(&staged_app, pending)?; + let current_exe = + env::current_exe().map_err(|error| format!("读取当前程序路径失败: {error}"))?; + let current_app = macos_application_bundle_from_executable(¤t_exe)?; + preflight_macos_update_directory(¤t_app)?; + let executable_relative_path = current_exe + .strip_prefix(¤t_app) + .map_err(|_| "macOS 应用程序路径无效".to_string())? + .to_path_buf(); + let helper_path = work_dir.join("EasyCLIProxyAPI-updater"); + fs::copy(¤t_exe, &helper_path) + .map_err(|error| format!("准备应用更新助手失败: {error}"))?; + let app_parent = current_app + .parent() + .ok_or_else(|| "macOS 应用程序路径没有父目录".to_string())?; + let descriptor = MacosUpdateDescriptor { + parent_pid: std::process::id(), + current_app, + staged_app, + backup_app: app_parent.join(".EasyCLIProxyAPI.app.update-backup"), + executable_relative_path, + ack_path: work_dir.join("update-started.ack"), + work_dir: work_dir.clone(), + target_version: pending.version.clone(), + }; + launch_portable_update_helper(app, &helper_path, &work_dir, &descriptor).await + } + .await; + if result.is_err() { + let _ = fs::remove_dir_all(&work_dir); + } + result + } } -#[cfg(windows)] +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn portable_update_work_dir(version: &str) -> PathBuf { + env::temp_dir().join(format!( + "EasyCLIProxyAPI-update-{}-{}-{}", + version, + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn ensure_portable_update_download( + token: &CancellationToken, + archive_path: &Path, + pending: &PendingAppUpdate, +) -> Result<(), String> { + if token.is_cancelled() { + return Err("应用更新下载已取消".to_string()); + } + let actual_sha256 = sha256_file(archive_path)?; + if actual_sha256 != pending.asset.sha256.trim().to_ascii_lowercase() { + return Err("应用更新包 SHA-256 校验失败".to_string()); + } + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +async fn launch_portable_update_helper( + app: &tauri::AppHandle, + helper_path: &Path, + work_dir: &Path, + descriptor: &T, +) -> Result<(), String> { + let descriptor_path = work_dir.join("update-descriptor.json"); + fs::write( + &descriptor_path, + serde_json::to_vec_pretty(descriptor) + .map_err(|error| format!("序列化应用更新描述失败: {error}"))?, + ) + .map_err(|error| format!("写入应用更新描述失败: {error}"))?; + let mut command = Command::new(helper_path); + command + .arg("--portable-update-helper") + .arg(&descriptor_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + command + .spawn() + .map_err(|error| format!("启动应用更新助手失败: {error}"))?; + update_app_task(app, |task| { + task.cancellable = false; + task.phase = "restarting".to_string(); + task.message = Some("更新已准备完成,应用即将重启".to_string()); + }); + tokio::time::sleep(Duration::from_millis(350)).await; + app.exit(0); + Ok(()) +} + +#[cfg(any(windows, target_os = "linux", target_os = "macos"))] pub(crate) async fn download_portable_update_archive( app: &tauri::AppHandle, pending: &PendingAppUpdate, @@ -634,7 +838,7 @@ pub(crate) async fn download_portable_update_archive( Err(format!("所有应用更新下载源均失败: {}", failures.join("; "))) } -#[cfg(windows)] +#[cfg(any(windows, target_os = "linux", target_os = "macos"))] pub(crate) async fn download_portable_update_archive_url( app: &tauri::AppHandle, pending: &PendingAppUpdate, @@ -689,7 +893,7 @@ pub(crate) async fn download_portable_update_archive_url( Ok(()) } -#[cfg(windows)] +#[cfg(any(windows, target_os = "linux", target_os = "macos"))] pub(crate) fn update_app_task(app: &tauri::AppHandle, update: F) where F: FnOnce(&mut AppUpdateTask), @@ -699,7 +903,7 @@ where let _ = app.emit(APP_UPDATE_PROGRESS_EVENT, task); } -#[cfg(windows)] +#[cfg(any(windows, target_os = "linux", target_os = "macos"))] pub(crate) fn format_byte_count(bytes: u64) -> String { const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"]; let mut value = bytes as f64; @@ -879,6 +1083,165 @@ pub(crate) fn extract_portable_update_archive( }) } +#[cfg(target_os = "linux")] +pub(crate) fn extract_portable_update_tar_gz( + archive_path: &Path, + staging_dir: &Path, +) -> Result { + fs::create_dir_all(staging_dir) + .map_err(|error| format!("创建应用更新暂存目录失败: {error}"))?; + let file = File::open(archive_path).map_err(|error| format!("打开应用更新包失败: {error}"))?; + let decoder = GzDecoder::new(file); + let mut archive = Archive::new(decoder); + let entries = archive + .entries() + .map_err(|error| format!("读取应用更新 TAR.GZ 失败: {error}"))?; + let mut package_root = None::; + let mut seen_binary = false; + let mut seen_manifest = false; + let mut seen_core_version = false; + let mut core_archive_name = None::; + let mut regular_file_count = 0_u32; + + for entry in entries { + let mut entry = entry.map_err(|error| format!("读取应用更新条目失败: {error}"))?; + let entry_type = entry.header().entry_type(); + if entry_type.is_dir() { + continue; + } + if !entry_type.is_file() { + return Err("应用更新包不能包含链接或特殊文件".to_string()); + } + let entry_path = entry + .path() + .map_err(|error| format!("读取应用更新条目路径失败: {error}"))?; + let mut components = Vec::new(); + for component in entry_path.components() { + match component { + Component::Normal(value) => components.push(value.to_string_lossy().into_owned()), + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err("应用更新包包含不安全路径".to_string()); + } + } + } + if components.len() < 2 || components.iter().any(String::is_empty) { + return Err("应用更新包目录结构无效".to_string()); + } + let root = &components[0]; + if package_root.as_deref().is_some_and(|value| value != root) { + return Err("应用更新包必须只包含一个顶层目录".to_string()); + } + package_root.get_or_insert_with(|| root.clone()); + let relative = &components[1..]; + let relative_name = relative.join("/"); + regular_file_count = regular_file_count.saturating_add(1); + let size = entry.size(); + let destination = match relative_name.as_str() { + PORTABLE_APP_BINARY if !seen_binary => { + if size == 0 || size > 256 * 1024 * 1024 { + return Err("应用更新程序大小异常".to_string()); + } + seen_binary = true; + staging_dir.join(PORTABLE_APP_BINARY) + } + PORTABLE_APP_MANIFEST_FILE if !seen_manifest => { + if size == 0 || size > 64 * 1024 { + return Err("应用更新标识大小异常".to_string()); + } + seen_manifest = true; + staging_dir.join(PORTABLE_APP_MANIFEST_FILE) + } + CORE_VERSION_FILE if !seen_core_version => { + if size == 0 || size > 1024 { + return Err("应用更新包内核版本文件大小异常".to_string()); + } + seen_core_version = true; + staging_dir.join(CORE_VERSION_FILE) + } + _ if relative.len() == 2 + && relative[0] == "cpa-core" + && core_archive_name.is_none() => + { + let filename = &relative[1]; + if !filename.starts_with("CLIProxyAPI_") + || !filename.ends_with(".tar.gz") + || filename.contains("_no-plugin") + || size == 0 + || size > 512 * 1024 * 1024 + { + return Err("应用更新包内置内核压缩包无效".to_string()); + } + core_archive_name = Some(filename.clone()); + let core_staging_dir = staging_dir.join("cpa-core"); + fs::create_dir_all(&core_staging_dir) + .map_err(|error| format!("创建内置内核暂存目录失败: {error}"))?; + core_staging_dir.join(filename) + } + _ => return Err(format!("应用更新包包含未知文件: {relative_name}")), + }; + let mut output = File::create(&destination) + .map_err(|error| format!("创建应用更新暂存文件失败: {error}"))?; + io::copy(&mut entry, &mut output) + .map_err(|error| format!("解压应用更新文件失败: {error}"))?; + output + .flush() + .map_err(|error| format!("保存应用更新暂存文件失败: {error}"))?; + } + if regular_file_count != 4 + || !seen_binary + || !seen_manifest + || !seen_core_version + || core_archive_name.is_none() + { + return Err("应用更新包缺少必要文件".to_string()); + } + use std::os::unix::fs::PermissionsExt; + fs::set_permissions( + staging_dir.join(PORTABLE_APP_BINARY), + fs::Permissions::from_mode(0o755), + ) + .map_err(|error| format!("设置应用更新程序权限失败: {error}"))?; + let manifest = fs::read_to_string(staging_dir.join(PORTABLE_APP_MANIFEST_FILE)) + .map_err(|error| format!("读取应用更新标识失败: {error}"))?; + let manifest = serde_json::from_str::(&manifest) + .map_err(|error| format!("解析应用更新标识失败: {error}"))?; + if manifest.schema_version != 1 + || manifest.application != "EasyCLIProxyAPI" + || manifest.platform != "linux" + || !manifest.auto_update + { + return Err("应用更新标识无效".to_string()); + } + let expected_root = format!( + "EasyCLIProxyAPI-v{}-Linux-{}", + manifest.version.trim().trim_start_matches('v'), + manifest.arch + ); + if package_root.as_deref() != Some(expected_root.as_str()) { + return Err("应用更新包顶层目录与版本或架构不匹配".to_string()); + } + let core_version = fs::read_to_string(staging_dir.join(CORE_VERSION_FILE)) + .map_err(|error| format!("读取内置内核版本失败: {error}"))?; + let core_version = core_version.trim().trim_start_matches('v'); + if core_version.is_empty() + || !core_version + .split('.') + .all(|segment| !segment.is_empty() && segment.bytes().all(|byte| byte.is_ascii_digit())) + { + return Err("应用更新包内置内核版本无效".to_string()); + } + let expected_core_archive = + format!("CLIProxyAPI_{core_version}_linux_{}.tar.gz", manifest.arch); + if core_archive_name.as_deref() != Some(expected_core_archive.as_str()) { + return Err("应用更新包内置内核版本或架构不匹配".to_string()); + } + Ok(PortablePackagePayload { + manifest, + core_archive_name, + }) +} + #[cfg(windows)] pub(crate) fn stage_current_portable_core_payload( app_dir: &Path, @@ -916,7 +1279,7 @@ pub(crate) fn stage_current_portable_core_payload( Ok(core_archive_name) } -#[cfg(windows)] +#[cfg(any(windows, target_os = "linux"))] pub(crate) fn preflight_portable_update_directory(app_dir: &Path) -> Result<(), String> { if !app_dir.join(CORE_VERSION_FILE).is_file() || !app_dir.join("cpa-core").is_dir() { return Err("当前便携版目录不完整,请下载最新版完整包覆盖升级".to_string()); @@ -974,6 +1337,35 @@ pub(crate) fn wait_for_windows_process_exit( } #[cfg(windows)] +pub(crate) fn wait_for_portable_parent_exit(pid: u32, timeout: Duration) -> Result<(), String> { + wait_for_windows_process_exit(pid, timeout) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn wait_for_portable_parent_exit(pid: u32, timeout: Duration) -> Result<(), String> { + if pid == 0 || pid > i32::MAX as u32 { + return Err("应用更新父进程编号无效".to_string()); + } + let deadline = Instant::now() + timeout; + loop { + let result = unsafe { libc::kill(pid as i32, 0) }; + if result != 0 { + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + return Ok(()); + } + if error.raw_os_error() != Some(libc::EPERM) { + return Err(format!("检查旧版应用进程失败: {error}")); + } + } + if Instant::now() >= deadline { + return Err("等待旧版应用退出超时".to_string()); + } + thread::sleep(Duration::from_millis(100)); + } +} + +#[cfg(any(windows, target_os = "linux"))] pub(crate) fn validate_portable_update_descriptor( descriptor_path: &Path, descriptor: &PortableUpdateDescriptor, @@ -990,7 +1382,7 @@ pub(crate) fn validate_portable_update_descriptor( if !descriptor.current_exe.is_absolute() || descriptor.current_exe != app_dir.join(PORTABLE_APP_BINARY) || descriptor.current_manifest != app_dir.join(PORTABLE_APP_MANIFEST_FILE) - || descriptor.backup_exe != app_dir.join(".EasyCLIProxyAPI.exe.update-backup") + || descriptor.backup_exe != app_dir.join(portable_update_backup_executable_name()) || descriptor.backup_manifest != app_dir.join(".portable-app.json.update-backup") || descriptor.current_core_version != app_dir.join(CORE_VERSION_FILE) || descriptor.backup_core_version != app_dir.join(".core-version.txt.update-backup") @@ -1005,7 +1397,9 @@ pub(crate) fn validate_portable_update_descriptor( || !core_archive_name .to_string_lossy() .starts_with("CLIProxyAPI_") - || !core_archive_name.to_string_lossy().ends_with(".zip") + || !core_archive_name + .to_string_lossy() + .ends_with(portable_update_core_archive_suffix()) || descriptor.install_core_archive == descriptor.target_core_archive.is_file() { return Err("应用更新内置内核目标路径无效".to_string()); @@ -1069,7 +1463,34 @@ pub(crate) fn validate_portable_update_descriptor( Ok(()) } -#[cfg(windows)] +#[cfg(any(windows, target_os = "linux"))] +fn portable_update_backup_executable_name() -> &'static str { + if cfg!(windows) { + ".EasyCLIProxyAPI.exe.update-backup" + } else { + ".EasyCLIProxyAPI.update-backup" + } +} + +#[cfg(any(windows, target_os = "linux"))] +fn portable_update_replacement_executable_name() -> &'static str { + if cfg!(windows) { + ".EasyCLIProxyAPI.exe.update-new" + } else { + ".EasyCLIProxyAPI.update-new" + } +} + +#[cfg(any(windows, target_os = "linux"))] +fn portable_update_core_archive_suffix() -> &'static str { + if cfg!(windows) { + ".zip" + } else { + ".tar.gz" + } +} + +#[cfg(any(windows, target_os = "linux"))] pub(crate) fn restore_portable_update_backup( descriptor: &PortableUpdateDescriptor, ) -> Result<(), String> { @@ -1107,7 +1528,7 @@ pub(crate) fn restore_portable_update_backup( Ok(()) } -#[cfg(windows)] +#[cfg(any(windows, target_os = "linux"))] pub(crate) fn replace_portable_update_files( descriptor: &PortableUpdateDescriptor, ) -> Result<(), String> { @@ -1115,7 +1536,7 @@ pub(crate) fn replace_portable_update_files( .current_exe .parent() .ok_or_else(|| "应用更新目标路径无效".to_string())?; - let replacement_exe = app_dir.join(".EasyCLIProxyAPI.exe.update-new"); + let replacement_exe = app_dir.join(portable_update_replacement_executable_name()); let replacement_manifest = app_dir.join(".portable-app.json.update-new"); let replacement_core_version = app_dir.join(".core-version.txt.update-new"); let replacement_core_archive = app_dir.join(".cpa-core-archive.update-new"); @@ -1199,7 +1620,7 @@ pub(crate) fn replace_portable_update_files( Ok(()) } -#[cfg(windows)] +#[cfg(any(windows, target_os = "linux"))] pub(crate) fn cleanup_superseded_bundled_core_archives(descriptor: &PortableUpdateDescriptor) { let Some(core_dir) = descriptor.target_core_archive.parent() else { return; @@ -1220,7 +1641,7 @@ pub(crate) fn cleanup_superseded_bundled_core_archives(descriptor: &PortableUpda if path.is_file() && name != current_name && name.starts_with("CLIProxyAPI_") - && name.ends_with(".zip") + && name.ends_with(portable_update_core_archive_suffix()) && !name.contains("_no-plugin") { if let Err(error) = fs::remove_file(&path) { @@ -1233,25 +1654,29 @@ pub(crate) fn cleanup_superseded_bundled_core_archives(descriptor: &PortableUpda } } -#[cfg(windows)] +#[cfg(any(windows, target_os = "linux"))] pub(crate) fn cleanup_portable_update_payload( descriptor_path: &Path, descriptor: &PortableUpdateDescriptor, ) { - let _ = fs::remove_file(descriptor.work_dir.join("update.zip")); + let _ = fs::remove_file(descriptor.work_dir.join(if cfg!(windows) { + "update.zip" + } else { + "update.tar.gz" + })); let _ = fs::remove_dir_all(descriptor.work_dir.join("staging")); let _ = fs::remove_file(&descriptor.ack_path); let _ = fs::remove_file(descriptor_path); } -#[cfg(windows)] +#[cfg(any(windows, target_os = "linux"))] pub(crate) fn run_portable_update_helper(descriptor_path: &Path) -> Result<(), String> { let descriptor = serde_json::from_slice::( &fs::read(descriptor_path).map_err(|error| format!("读取应用更新描述失败: {error}"))?, ) .map_err(|error| format!("解析应用更新描述失败: {error}"))?; validate_portable_update_descriptor(descriptor_path, &descriptor)?; - wait_for_windows_process_exit(descriptor.parent_pid, Duration::from_secs(120))?; + wait_for_portable_parent_exit(descriptor.parent_pid, Duration::from_secs(120))?; if let Err(error) = replace_portable_update_files(&descriptor) { let mut rollback = Command::new(&descriptor.current_exe); @@ -1321,6 +1746,333 @@ pub(crate) fn run_portable_update_helper(descriptor_path: &Path) -> Result<(), S )) } +#[cfg(target_os = "macos")] +fn run_macos_update_command(command: &mut Command, action: &str) -> Result<(), String> { + let output = command + .output() + .map_err(|error| format!("{action}失败: {error}"))?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("{action}失败: {}", stderr.trim())) +} + +#[cfg(target_os = "macos")] +pub(crate) fn stage_macos_application_from_dmg( + dmg_path: &Path, + work_dir: &Path, + staged_app: &Path, +) -> Result<(), String> { + let mount_dir = work_dir.join("mount"); + fs::create_dir_all(&mount_dir).map_err(|error| format!("创建 DMG 挂载目录失败: {error}"))?; + let mut attach = Command::new("hdiutil"); + attach + .arg("attach") + .arg(dmg_path) + .args(["-nobrowse", "-readonly", "-mountpoint"]) + .arg(&mount_dir); + run_macos_update_command(&mut attach, "挂载应用更新 DMG")?; + + let source_app = mount_dir.join("EasyCLIProxyAPI.app"); + let stage_result = (|| -> Result<(), String> { + if !source_app.is_dir() { + return Err("应用更新 DMG 缺少 EasyCLIProxyAPI.app".to_string()); + } + let staging_parent = staged_app + .parent() + .ok_or_else(|| "应用更新暂存路径无效".to_string())?; + fs::create_dir_all(staging_parent) + .map_err(|error| format!("创建应用更新暂存目录失败: {error}"))?; + let mut ditto = Command::new("ditto"); + ditto.arg(&source_app).arg(staged_app); + run_macos_update_command(&mut ditto, "暂存新版 macOS 应用")?; + let mut codesign = Command::new("codesign"); + codesign + .args(["--verify", "--deep", "--strict"]) + .arg(staged_app); + run_macos_update_command(&mut codesign, "校验新版 macOS 应用签名") + })(); + + let mut detach = Command::new("hdiutil"); + detach.arg("detach").arg(&mount_dir); + let detach_result = run_macos_update_command(&mut detach, "卸载应用更新 DMG"); + match (stage_result, detach_result) { + (Err(error), _) => Err(error), + (Ok(()), Err(error)) => Err(error), + (Ok(()), Ok(())) => Ok(()), + } +} + +#[cfg(target_os = "macos")] +pub(crate) fn validate_macos_staged_application( + staged_app: &Path, + pending: &PendingAppUpdate, +) -> Result<(), String> { + let manifest_path = staged_app + .join("Contents") + .join("Resources") + .join(PORTABLE_APP_MANIFEST_FILE); + let contents = fs::read_to_string(&manifest_path) + .map_err(|error| format!("读取新版 macOS 自动更新标识失败: {error}"))?; + let manifest = serde_json::from_str::(&contents) + .map_err(|error| format!("解析新版 macOS 自动更新标识失败: {error}"))?; + if manifest.schema_version != 1 + || manifest.application != "EasyCLIProxyAPI" + || manifest.platform != "darwin" + || manifest.arch != pending.arch + || !manifest.auto_update + || normalize_version(&manifest.version) != normalize_version(&pending.version) + { + return Err("新版 macOS 应用标识与更新目标不匹配".to_string()); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +pub(crate) fn macos_application_bundle_from_executable( + executable: &Path, +) -> Result { + let macos_dir = executable + .parent() + .filter(|path| path.file_name().and_then(|name| name.to_str()) == Some("MacOS")) + .ok_or_else(|| "当前程序不在标准 macOS 应用包中".to_string())?; + let contents_dir = macos_dir + .parent() + .filter(|path| path.file_name().and_then(|name| name.to_str()) == Some("Contents")) + .ok_or_else(|| "当前程序不在标准 macOS 应用包中".to_string())?; + let app = contents_dir + .parent() + .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("app")) + .ok_or_else(|| "当前程序不在标准 macOS 应用包中".to_string())?; + Ok(app.to_path_buf()) +} + +#[cfg(target_os = "macos")] +pub(crate) fn preflight_macos_update_directory(current_app: &Path) -> Result<(), String> { + if !current_app.is_dir() { + return Err("当前 macOS 应用包不存在".to_string()); + } + let app_parent = current_app + .parent() + .ok_or_else(|| "macOS 应用包没有父目录".to_string())?; + let probe = app_parent.join(format!( + ".easycliproxy-update-write-test-{}", + std::process::id() + )); + fs::write(&probe, b"update-write-test") + .map_err(|error| format!("macOS 应用目录不可写,无法自动升级: {error}"))?; + fs::remove_file(&probe).map_err(|error| format!("清理应用更新写入测试失败: {error}")) +} + +#[cfg(target_os = "macos")] +pub(crate) fn validate_macos_update_descriptor( + descriptor_path: &Path, + descriptor: &MacosUpdateDescriptor, +) -> Result<(), String> { + if descriptor.parent_pid == 0 + || semver::Version::parse(descriptor.target_version.trim().trim_start_matches('v')).is_err() + || !descriptor.current_app.is_absolute() + || descriptor + .current_app + .extension() + .and_then(|value| value.to_str()) + != Some("app") + { + return Err("macOS 应用更新描述无效".to_string()); + } + let app_parent = descriptor + .current_app + .parent() + .ok_or_else(|| "macOS 应用更新目标路径无效".to_string())?; + if descriptor.backup_app != app_parent.join(".EasyCLIProxyAPI.app.update-backup") + || descriptor.executable_relative_path.is_absolute() + || descriptor + .executable_relative_path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + || !descriptor + .executable_relative_path + .starts_with(Path::new("Contents").join("MacOS")) + { + return Err("macOS 应用更新目标路径无效".to_string()); + } + let canonical_work_dir = fs::canonicalize(&descriptor.work_dir) + .map_err(|error| format!("读取应用更新临时目录失败: {error}"))?; + let canonical_temp_dir = fs::canonicalize(env::temp_dir()) + .map_err(|error| format!("读取系统临时目录失败: {error}"))?; + if !canonical_work_dir.starts_with(&canonical_temp_dir) + || !canonical_work_dir + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.starts_with("EasyCLIProxyAPI-update-")) + { + return Err("应用更新工作目录无效".to_string()); + } + let canonical_descriptor = fs::canonicalize(descriptor_path) + .map_err(|error| format!("读取应用更新描述路径失败: {error}"))?; + let canonical_staged_app = fs::canonicalize(&descriptor.staged_app) + .map_err(|error| format!("读取新版 macOS 应用路径失败: {error}"))?; + if canonical_descriptor.parent() != Some(canonical_work_dir.as_path()) + || canonical_descriptor + .file_name() + .and_then(|value| value.to_str()) + != Some("update-descriptor.json") + || descriptor.staged_app + != descriptor + .work_dir + .join("staging") + .join("EasyCLIProxyAPI.app") + || !canonical_staged_app.starts_with(&canonical_work_dir) + || descriptor.ack_path != descriptor.work_dir.join("update-started.ack") + { + return Err("macOS 应用更新暂存路径越界".to_string()); + } + if !descriptor + .current_app + .join(&descriptor.executable_relative_path) + .is_file() + || !descriptor + .staged_app + .join(&descriptor.executable_relative_path) + .is_file() + { + return Err("macOS 应用更新可执行文件缺失".to_string()); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +pub(crate) fn restore_macos_update_backup( + descriptor: &MacosUpdateDescriptor, +) -> Result<(), String> { + if !descriptor.backup_app.is_dir() { + return Err("macOS 应用更新备份不完整,无法回滚".to_string()); + } + if descriptor.current_app.exists() { + fs::remove_dir_all(&descriptor.current_app) + .map_err(|error| format!("移除新版 macOS 应用失败: {error}"))?; + } + fs::rename(&descriptor.backup_app, &descriptor.current_app) + .map_err(|error| format!("恢复旧版 macOS 应用失败: {error}")) +} + +#[cfg(target_os = "macos")] +pub(crate) fn replace_macos_application(descriptor: &MacosUpdateDescriptor) -> Result<(), String> { + let app_parent = descriptor + .current_app + .parent() + .ok_or_else(|| "macOS 应用更新目标路径无效".to_string())?; + let replacement_app = app_parent.join(".EasyCLIProxyAPI.app.update-new"); + if replacement_app.exists() { + fs::remove_dir_all(&replacement_app) + .map_err(|error| format!("清理旧的 macOS 更新暂存失败: {error}"))?; + } + let mut ditto = Command::new("ditto"); + ditto.arg(&descriptor.staged_app).arg(&replacement_app); + run_macos_update_command(&mut ditto, "准备新版 macOS 应用")?; + let mut codesign = Command::new("codesign"); + codesign + .args(["--verify", "--deep", "--strict"]) + .arg(&replacement_app); + if let Err(error) = run_macos_update_command(&mut codesign, "校验待替换 macOS 应用签名") + { + let _ = fs::remove_dir_all(&replacement_app); + return Err(error); + } + if descriptor.backup_app.exists() { + fs::remove_dir_all(&descriptor.backup_app) + .map_err(|error| format!("清理旧的 macOS 应用备份失败: {error}"))?; + } + fs::rename(&descriptor.current_app, &descriptor.backup_app) + .map_err(|error| format!("备份旧版 macOS 应用失败: {error}"))?; + if let Err(error) = fs::rename(&replacement_app, &descriptor.current_app) { + let _ = fs::rename(&descriptor.backup_app, &descriptor.current_app); + let _ = fs::remove_dir_all(&replacement_app); + return Err(format!("替换 macOS 应用失败: {error}")); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn cleanup_macos_update_payload(descriptor_path: &Path, descriptor: &MacosUpdateDescriptor) { + let _ = fs::remove_file(descriptor.work_dir.join("update.dmg")); + let _ = fs::remove_dir_all(descriptor.work_dir.join("staging")); + let _ = fs::remove_dir_all(descriptor.work_dir.join("mount")); + let _ = fs::remove_file(&descriptor.ack_path); + let _ = fs::remove_file(descriptor_path); +} + +#[cfg(target_os = "macos")] +pub(crate) fn run_portable_update_helper(descriptor_path: &Path) -> Result<(), String> { + let descriptor = serde_json::from_slice::( + &fs::read(descriptor_path).map_err(|error| format!("读取应用更新描述失败: {error}"))?, + ) + .map_err(|error| format!("解析应用更新描述失败: {error}"))?; + validate_macos_update_descriptor(descriptor_path, &descriptor)?; + wait_for_portable_parent_exit(descriptor.parent_pid, Duration::from_secs(120))?; + if let Err(error) = replace_macos_application(&descriptor) { + let rollback_exe = descriptor + .current_app + .join(&descriptor.executable_relative_path); + let _ = Command::new(rollback_exe).spawn(); + cleanup_macos_update_payload(descriptor_path, &descriptor); + return Err(error); + } + + let current_exe = descriptor + .current_app + .join(&descriptor.executable_relative_path); + let _ = fs::remove_file(&descriptor.ack_path); + let mut command = Command::new(¤t_exe); + command + .arg("--portable-update-ack") + .arg(&descriptor.ack_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + restore_macos_update_backup(&descriptor)?; + let rollback_exe = descriptor + .current_app + .join(&descriptor.executable_relative_path); + let _ = Command::new(rollback_exe).spawn(); + cleanup_macos_update_payload(descriptor_path, &descriptor); + return Err(format!("启动新版 macOS 应用失败: {error}")); + } + }; + let deadline = Instant::now() + Duration::from_secs(60); + while Instant::now() < deadline { + if descriptor.ack_path.is_file() { + let _ = fs::remove_dir_all(&descriptor.backup_app); + cleanup_macos_update_payload(descriptor_path, &descriptor); + return Ok(()); + } + if child + .try_wait() + .map_err(|error| format!("检查新版 macOS 应用状态失败: {error}"))? + .is_some() + { + break; + } + thread::sleep(Duration::from_millis(200)); + } + let _ = child.kill(); + let _ = child.wait(); + restore_macos_update_backup(&descriptor)?; + let rollback_exe = descriptor + .current_app + .join(&descriptor.executable_relative_path); + let _ = Command::new(rollback_exe).spawn(); + cleanup_macos_update_payload(descriptor_path, &descriptor); + Err(format!( + "新版应用 {} 未能在 60 秒内完成启动确认,已回滚", + descriptor.target_version + )) +} + pub(crate) fn portable_update_ack_argument() -> Option { let mut args = env::args_os(); while let Some(argument) = args.next() { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 45c0939..ef4cd70 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -73,10 +73,20 @@ const RELEASE_PAGE_URL: &str = "https://github.com/router-for-me/CLIProxyAPI/rel const RELEASE_ATOM_URL: &str = "https://github.com/router-for-me/CLIProxyAPI/releases.atom"; const RELEASE_DOWNLOAD_PREFIX: &str = "https://github.com/router-for-me/CLIProxyAPI/releases/download/"; +#[cfg(windows)] const APP_UPDATE_MANIFEST_URL: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest/download/portable-update-windows.json"; +#[cfg(target_os = "linux")] +const APP_UPDATE_MANIFEST_URL: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest/download/portable-update-linux.json"; +#[cfg(target_os = "macos")] +const APP_UPDATE_MANIFEST_URL: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest/download/portable-update-darwin.json"; const APP_RELEASE_DOWNLOAD_PREFIX: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/download/"; +#[cfg(windows)] const APP_UPDATE_MANIFEST_NAME: &str = "portable-update-windows.json"; +#[cfg(target_os = "linux")] +const APP_UPDATE_MANIFEST_NAME: &str = "portable-update-linux.json"; +#[cfg(target_os = "macos")] +const APP_UPDATE_MANIFEST_NAME: &str = "portable-update-darwin.json"; const CODEX_MODEL_CATALOG_URL: &str = "https://raw.githubusercontent.com/router-for-me/EasyCLIProxyAPI/main/src-tauri/resources/codex_models/model-catalog.json"; const CODEX_MODEL_CATALOG_OVERRIDE_DIR: &str = "codex_models"; const CODEX_MODEL_CATALOG_SOURCE_FILE: &str = "model-catalog.json"; @@ -85,6 +95,8 @@ const APP_UPDATE_PROGRESS_EVENT: &str = "app-update-progress"; const PORTABLE_APP_MANIFEST_FILE: &str = "portable-app.json"; #[cfg(windows)] const PORTABLE_APP_BINARY: &str = "EasyCLIProxyAPI.exe"; +#[cfg(target_os = "linux")] +const PORTABLE_APP_BINARY: &str = "EasyCLIProxyAPI"; const CORE_INSTALL_PROGRESS_EVENT: &str = "core-install-progress"; const CORE_STATUS_EVENT: &str = "core-status-changed"; const CONFIG_FILES_CHANGED_EVENT: &str = "config-files-changed"; @@ -449,13 +461,13 @@ impl Default for AppUpdateTask { } } -#[cfg(windows)] +#[cfg(any(windows, target_os = "linux"))] struct PortablePackagePayload { manifest: PortableAppManifest, core_archive_name: Option, } -#[cfg(windows)] +#[cfg(any(windows, target_os = "linux"))] #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct PortableUpdateDescriptor { @@ -477,6 +489,20 @@ struct PortableUpdateDescriptor { target_version: String, } +#[cfg(target_os = "macos")] +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MacosUpdateDescriptor { + parent_pid: u32, + current_app: PathBuf, + staged_app: PathBuf, + backup_app: PathBuf, + executable_relative_path: PathBuf, + ack_path: PathBuf, + work_dir: PathBuf, + target_version: String, +} + #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct BundledCoreInfo { @@ -1215,7 +1241,7 @@ impl AppUpdateState { Ok(pending) } - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux", target_os = "macos"))] fn update_task(&self, update: F) -> AppUpdateTask where F: FnOnce(&mut AppUpdateTask), @@ -1575,7 +1601,7 @@ struct GithubAsset { } fn main() { - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux", target_os = "macos"))] { let mut args = env::args_os(); while let Some(argument) = args.next() { diff --git a/src-tauri/src/tests/app_update.rs b/src-tauri/src/tests/app_update.rs index 8b0c45b..c9bdea0 100644 --- a/src-tauri/src/tests/app_update.rs +++ b/src-tauri/src/tests/app_update.rs @@ -25,7 +25,8 @@ fn app_update_comparison_uses_semantic_versions() { } fn portable_update_test_asset(version: &str, arch: &str) -> PortableUpdateAsset { - let name = format!("EasyCLIProxyAPI-v{version}-Windows-{arch}.zip"); + let (_, display, suffix) = portable_update_asset_platform().unwrap(); + let name = format!("EasyCLIProxyAPI-v{version}-{display}-{arch}.{suffix}"); PortableUpdateAsset { url: format!("{APP_RELEASE_DOWNLOAD_PREFIX}v{version}/{name}"), fallback_urls: Vec::new(), @@ -34,6 +35,7 @@ fn portable_update_test_asset(version: &str, arch: &str) -> PortableUpdateAsset } } +#[cfg(windows)] fn portable_update_test_legacy_asset(version: &str, arch: &str) -> PortableUpdateAsset { let name = format!("EasyCLIProxyAPI-update-v{version}-Windows-{arch}.zip"); PortableUpdateAsset { @@ -45,6 +47,7 @@ fn portable_update_test_legacy_asset(version: &str, arch: &str) -> PortableUpdat } fn portable_update_test_manifest(version: &str) -> PortableUpdateManifest { + let (platform, _, _) = portable_update_asset_platform().unwrap(); PortableUpdateManifest { schema_version: 1, version: version.to_string(), @@ -54,11 +57,11 @@ fn portable_update_test_manifest(version: &str) -> PortableUpdateManifest { ), assets: [ ( - "windows-amd64".to_string(), + format!("{platform}-amd64"), portable_update_test_asset(version, "amd64"), ), ( - "windows-aarch64".to_string(), + format!("{platform}-aarch64"), portable_update_test_asset(version, "aarch64"), ), ] @@ -73,48 +76,52 @@ fn portable_update_manifest_requires_both_matching_github_assets() { let manifest = portable_update_test_manifest("1.2.3"); assert!(validate_portable_update_manifest(&manifest).is_ok()); - let mut legacy_manifest = portable_update_test_manifest("1.2.3"); - legacy_manifest.assets.insert( - "windows-amd64".to_string(), - portable_update_test_legacy_asset("1.2.3", "amd64"), - ); - legacy_manifest.assets.insert( - "windows-aarch64".to_string(), - portable_update_test_legacy_asset("1.2.3", "aarch64"), - ); - assert!(validate_portable_update_manifest(&legacy_manifest).is_ok()); - - let mut dual_manifest = portable_update_test_manifest("1.2.3"); - dual_manifest.assets = [ - ( + #[cfg(windows)] + { + let mut legacy_manifest = portable_update_test_manifest("1.2.3"); + legacy_manifest.assets.insert( "windows-amd64".to_string(), portable_update_test_legacy_asset("1.2.3", "amd64"), - ), - ( + ); + legacy_manifest.assets.insert( "windows-aarch64".to_string(), portable_update_test_legacy_asset("1.2.3", "aarch64"), - ), - ] - .into_iter() - .collect(); - dual_manifest.full_assets = Some( - [ + ); + assert!(validate_portable_update_manifest(&legacy_manifest).is_ok()); + + let mut dual_manifest = portable_update_test_manifest("1.2.3"); + dual_manifest.assets = [ ( "windows-amd64".to_string(), - portable_update_test_asset("1.2.3", "amd64"), + portable_update_test_legacy_asset("1.2.3", "amd64"), ), ( "windows-aarch64".to_string(), - portable_update_test_asset("1.2.3", "aarch64"), + portable_update_test_legacy_asset("1.2.3", "aarch64"), ), ] .into_iter() - .collect(), - ); - assert!(validate_portable_update_manifest(&dual_manifest).is_ok()); + .collect(); + dual_manifest.full_assets = Some( + [ + ( + "windows-amd64".to_string(), + portable_update_test_asset("1.2.3", "amd64"), + ), + ( + "windows-aarch64".to_string(), + portable_update_test_asset("1.2.3", "aarch64"), + ), + ] + .into_iter() + .collect(), + ); + assert!(validate_portable_update_manifest(&dual_manifest).is_ok()); + } + let (platform, _, _) = portable_update_asset_platform().unwrap(); let mut missing_arch = portable_update_test_manifest("1.2.3"); - missing_arch.assets.remove("windows-aarch64"); + missing_arch.assets.remove(&format!("{platform}-aarch64")); assert!(validate_portable_update_manifest(&missing_arch).is_err()); let mut invalid_timestamp = portable_update_test_manifest("1.2.3"); @@ -124,37 +131,44 @@ fn portable_update_manifest_requires_both_matching_github_assets() { let mut foreign_host = portable_update_test_manifest("1.2.3"); foreign_host .assets - .get_mut("windows-amd64") + .get_mut(&format!("{platform}-amd64")) .unwrap() .url = "https://github.com.example.invalid/router-for-me/EasyCLIProxyAPI/releases/download/v1.2.3/update.zip".to_string(); assert!(validate_portable_update_manifest(&foreign_host).is_err()); let mut mismatched_tag = portable_update_test_manifest("1.2.3"); - mismatched_tag.assets.get_mut("windows-amd64").unwrap().url = - format!("{APP_RELEASE_DOWNLOAD_PREFIX}v9.9.9/EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip"); + let (_, display, suffix) = portable_update_asset_platform().unwrap(); + mismatched_tag + .assets + .get_mut(&format!("{platform}-amd64")) + .unwrap() + .url = format!( + "{APP_RELEASE_DOWNLOAD_PREFIX}v9.9.9/EasyCLIProxyAPI-v1.2.3-{display}-amd64.{suffix}" + ); assert!(validate_portable_update_manifest(&mismatched_tag).is_err()); } #[test] fn portable_update_asset_accepts_only_the_configured_gitcode_fallback() { let mut asset = portable_update_test_asset("1.2.3", "amd64"); - let filename = "EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip"; + let (_, display, suffix) = portable_update_asset_platform().unwrap(); + let filename = format!("EasyCLIProxyAPI-v1.2.3-{display}-amd64.{suffix}"); asset.fallback_urls = vec![gitcode_release_attachment_url( "mirror-owner/EasyCLIProxyAPI", "v1.2.3", - filename, + &filename, )]; assert!(validate_portable_update_asset_fallbacks_for_repository( &asset, "v1.2.3", - &[filename], + &[&filename], Some("mirror-owner/EasyCLIProxyAPI"), ) .is_ok()); assert!(validate_portable_update_asset_fallbacks_for_repository( &asset, "v1.2.3", - &[filename], + &[&filename], Some("another-owner/EasyCLIProxyAPI"), ) .is_err()); @@ -408,6 +422,106 @@ fn portable_update_replacement_preserves_user_data_and_can_roll_back() { fs::remove_dir_all(root).unwrap(); } +#[cfg(target_os = "linux")] +#[test] +fn portable_update_tar_gz_accepts_the_complete_linux_release() { + use flate2::{write::GzEncoder, Compression}; + use tar::{Builder, Header}; + + let root = agent_test_home("portable-linux-tar"); + let archive_path = root.join("update.tar.gz"); + let file = File::create(&archive_path).unwrap(); + let encoder = GzEncoder::new(file, Compression::default()); + let mut archive = Builder::new(encoder); + let package_root = "EasyCLIProxyAPI-v1.2.3-Linux-amd64"; + let manifest = br#"{"schemaVersion":1,"application":"EasyCLIProxyAPI","version":"1.2.3","platform":"linux","arch":"amd64","autoUpdate":true}"#; + for (path, contents, mode) in [ + ( + format!("{package_root}/EasyCLIProxyAPI"), + b"binary".as_slice(), + 0o755, + ), + ( + format!("{package_root}/portable-app.json"), + manifest.as_slice(), + 0o644, + ), + ( + format!("{package_root}/core-version.txt"), + b"7.2.109\n".as_slice(), + 0o644, + ), + ( + format!("{package_root}/cpa-core/CLIProxyAPI_7.2.109_linux_amd64.tar.gz"), + b"core archive".as_slice(), + 0o644, + ), + ] { + let mut header = Header::new_gnu(); + header.set_size(contents.len() as u64); + header.set_mode(mode); + header.set_cksum(); + archive.append_data(&mut header, path, contents).unwrap(); + } + archive.into_inner().unwrap().finish().unwrap(); + + let staging = root.join("staging"); + let package = extract_portable_update_tar_gz(&archive_path, &staging).unwrap(); + assert_eq!(package.manifest.platform, "linux"); + assert_eq!(package.manifest.arch, "amd64"); + assert_eq!( + package.core_archive_name.as_deref(), + Some("CLIProxyAPI_7.2.109_linux_amd64.tar.gz") + ); + assert_eq!( + fs::read(staging.join(PORTABLE_APP_BINARY)).unwrap(), + b"binary" + ); + fs::remove_dir_all(root).unwrap(); +} + +#[cfg(target_os = "macos")] +#[test] +fn macos_update_descriptor_is_confined_to_the_app_and_temp_directories() { + let root = agent_test_home("portable-macos-descriptor"); + let work_dir = root.join("EasyCLIProxyAPI-update-1.2.3-1-1"); + let current_app = root.join("Applications").join("EasyCLIProxyAPI.app"); + let executable_relative_path = PathBuf::from("Contents/MacOS/cpa-gui"); + let current_exe = current_app.join(&executable_relative_path); + let staged_app = work_dir.join("staging").join("EasyCLIProxyAPI.app"); + let staged_exe = staged_app.join(&executable_relative_path); + fs::create_dir_all(current_exe.parent().unwrap()).unwrap(); + fs::create_dir_all(staged_exe.parent().unwrap()).unwrap(); + fs::write(¤t_exe, b"old").unwrap(); + fs::write(&staged_exe, b"new").unwrap(); + let descriptor = MacosUpdateDescriptor { + parent_pid: 1, + current_app: current_app.clone(), + staged_app, + backup_app: current_app + .parent() + .unwrap() + .join(".EasyCLIProxyAPI.app.update-backup"), + executable_relative_path, + ack_path: work_dir.join("update-started.ack"), + work_dir: work_dir.clone(), + target_version: "1.2.3".to_string(), + }; + fs::create_dir_all(&work_dir).unwrap(); + let descriptor_path = work_dir.join("update-descriptor.json"); + fs::write( + &descriptor_path, + serde_json::to_vec_pretty(&descriptor).unwrap(), + ) + .unwrap(); + assert!(validate_macos_update_descriptor(&descriptor_path, &descriptor).is_ok()); + assert_eq!( + macos_application_bundle_from_executable(¤t_exe).unwrap(), + current_app + ); + fs::remove_dir_all(root).unwrap(); +} + #[test] fn synthetic_release_uses_official_asset_names_and_urls() { let release = release_from_tag("7.2.80"); diff --git a/src-tauri/tauri.dmg.conf.json b/src-tauri/tauri.dmg.conf.json index e92119b..3c17410 100644 --- a/src-tauri/tauri.dmg.conf.json +++ b/src-tauri/tauri.dmg.conf.json @@ -3,7 +3,8 @@ "bundle": { "resources": { "../core-version.txt": "core-version.txt", - "../cpa-core/CLIProxyAPI_*": "cpa-core/" + "../cpa-core/CLIProxyAPI_*": "cpa-core/", + "../portable-app.json": "portable-app.json" } } } diff --git a/tests/portableUpdateManifest.test.ts b/tests/portableUpdateManifest.test.ts index 713d6a5..a5e7b95 100644 --- a/tests/portableUpdateManifest.test.ts +++ b/tests/portableUpdateManifest.test.ts @@ -117,3 +117,39 @@ describe('Windows 便携更新清单', () => { } }); }); + +describe('跨平台便携更新清单', () => { + test.each([ + ['linux', 'Linux', 'tar.gz'], + ['darwin', 'Darwin', 'dmg'], + ] as const)('%s 清单直接引用完整发布包', async (platform, display, suffix) => { + const root = await mkdtemp(join(tmpdir(), `easycli-${platform}-manifest-test-`)); + try { + for (const arch of ['amd64', 'aarch64']) { + await writeFile( + join(root, `EasyCLIProxyAPI-v1.2.3-${display}-${arch}.${suffix}`), + `${platform} ${arch} full package`, + ); + } + const output = join(root, `portable-update-${platform}.json`); + const manifest = await generatePortableUpdateManifest({ + directory: root, + output, + platform, + repository: 'router-for-me/EasyCLIProxyAPI', + tag: 'v1.2.3', + publishedAt: '2026-08-10T00:00:00.000Z', + }); + + expect(manifest.fullAssets).toBeUndefined(); + for (const arch of ['amd64', 'aarch64']) { + expect(manifest.assets[`${platform}-${arch}`].url).toEndWith( + `/EasyCLIProxyAPI-v1.2.3-${display}-${arch}.${suffix}`, + ); + } + expect(JSON.parse(await readFile(output, 'utf8'))).toEqual(manifest); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +});