diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eea2f2e..037d19d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -190,8 +190,8 @@ jobs: throw new Error(`cpa-core must contain exactly one archive; found: ${entries.map((entry) => entry.name).join(', ')}`); } NODE - - name: Prepare Windows portable updater payload - if: matrix.os == 'windows' + - name: Prepare v0.2.10 legacy updater bridge + if: matrix.os == 'windows' && steps.package.outputs.version == '0.2.10' shell: pwsh run: | $updateDir = "release/update-${{ matrix.arch }}" @@ -201,11 +201,11 @@ jobs: $marker = Get-Content -Raw -LiteralPath "$updateDir/portable-app.json" | ConvertFrom-Json if ($marker.schemaVersion -ne 1 -or $marker.application -ne "EasyCLIProxyAPI" -or - $marker.version -ne "${{ steps.package.outputs.version }}" -or + $marker.version -ne "0.2.10" -or $marker.platform -ne "windows" -or $marker.arch -ne "${{ matrix.arch }}" -or $marker.autoUpdate -ne $true) { - throw "portable-app.json does not match the Windows updater target" + throw "v0.2.10 legacy updater bridge does not match the Windows target" } - name: Build signed macOS DMG if: matrix.os == 'darwin' @@ -341,22 +341,40 @@ jobs: - name: Archive Windows release if: matrix.os == 'windows' shell: pwsh + env: + LEGACY_UPDATE_BRIDGE: ${{ steps.package.outputs.version == '0.2.10' }} run: | Compress-Archive -Path "release/${{ steps.package.outputs.stem }}" -DestinationPath "${{ steps.package.outputs.stem }}.zip" - $updateArchive = "EasyCLIProxyAPI-update-v${{ steps.package.outputs.version }}-Windows-${{ matrix.arch }}.zip" - Compress-Archive -Path "release/update-${{ matrix.arch }}/*" -DestinationPath $updateArchive Add-Type -AssemblyName System.IO.Compression.FileSystem - $zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $updateArchive)) + $releaseArchive = "${{ steps.package.outputs.stem }}.zip" + $zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $releaseArchive)) try { $entries = @($zip.Entries | ForEach-Object { $_.FullName.Replace('\', '/') }) - if ($entries.Count -ne 2 -or - $entries -notcontains "EasyCLIProxyAPI.exe" -or - $entries -notcontains "portable-app.json") { - throw "Portable updater ZIP contains unexpected entries: $($entries -join ', ')" + $root = "${{ steps.package.outputs.stem }}" + if ($entries -notcontains "$root/EasyCLIProxyAPI.exe" -or + $entries -notcontains "$root/portable-app.json" -or + $entries -notcontains "$root/core-version.txt" -or + -not ($entries | Where-Object { $_ -match "^$([regex]::Escape($root))/cpa-core/CLIProxyAPI_.+_windows_${{ matrix.arch }}\.zip$" })) { + throw "Full Windows release ZIP is incomplete: $($entries -join ', ')" } } finally { $zip.Dispose() } + if ($env:LEGACY_UPDATE_BRIDGE -eq "true") { + $updateArchive = "EasyCLIProxyAPI-update-v0.2.10-Windows-${{ matrix.arch }}.zip" + Compress-Archive -Path "release/update-${{ matrix.arch }}/*" -DestinationPath $updateArchive + $updateZip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $updateArchive)) + try { + $updateEntries = @($updateZip.Entries | ForEach-Object { $_.FullName.Replace('\', '/') }) + if ($updateEntries.Count -ne 2 -or + $updateEntries -notcontains "EasyCLIProxyAPI.exe" -or + $updateEntries -notcontains "portable-app.json") { + throw "v0.2.10 legacy updater bridge contains unexpected entries: $($updateEntries -join ', ')" + } + } finally { + $updateZip.Dispose() + } + } - uses: actions/upload-artifact@v7 with: name: ${{ steps.package.outputs.stem }} @@ -364,13 +382,12 @@ jobs: if-no-files-found: error retention-days: 14 - uses: actions/upload-artifact@v7 - if: matrix.os == 'windows' + if: matrix.os == 'windows' && steps.package.outputs.version == '0.2.10' with: - name: portable-updater-windows-${{ matrix.arch }} - path: EasyCLIProxyAPI-update-v${{ steps.package.outputs.version }}-Windows-${{ matrix.arch }}.zip + name: legacy-updater-bridge-windows-${{ matrix.arch }} + path: EasyCLIProxyAPI-update-v0.2.10-Windows-${{ matrix.arch }}.zip if-no-files-found: error retention-days: 14 - publish: name: Publish GitHub release if: >- diff --git a/README.ja.md b/README.ja.md index 7c6a381..1c6119f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -106,6 +106,12 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で 4. **コア** 画面を開き、同梱版または最新版の CLIProxyAPI コアをインストールします。 5. コアを起動し、必要なローカル API エンドポイントをコピーするか、OAuth/API プロバイダーを設定します。 +## アップグレード + +v0.2.10 は一度限りの移行用リリースです。完全版 Windows ZIP と旧クライアント互換の `update` ZIP を同時に公開し、v0.2.6~v0.2.9 からアプリ内で v0.2.10 へ更新できます。v0.2.11 以降は完全版 ZIP のみを公開し、v0.2.10 へ移行済みのクライアントは途中のバージョンを個別に導入せず直接アップグレードできます。 + +v0.2.5 以前を使用している場合は、一度だけ手動で移行してください。EasyCLIProxyAPI を終了し、使用中のアーキテクチャに対応する最新版の完全版 Windows ZIP をダウンロードして、その最上位ディレクトリの内容を既存のインストール先へコピーし、同名ファイルを上書きします。既存のディレクトリを先に削除しないでください。`config.toml`、`oauth`、`cpa-core/config.yaml` などのユーザーデータは保持されます。新しいバージョンを起動した後は、以降のリリースでアプリ内自動更新を利用できます。 + ## 対応プラットフォーム GitHub Actions では、次のリリースパッケージをビルドします。 diff --git a/README.md b/README.md index 6c46ac3..ce89c9e 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,12 @@ previous configuration, and launch an available desktop or CLI entry point. 4. Open the **Core** page and install the bundled or latest CLIProxyAPI core. 5. Start the core, then copy the required local endpoint or configure an OAuth/API provider. +## Upgrading + +v0.2.10 is a one-time bridge release. It publishes both the complete Windows ZIP and the legacy `update` ZIP so v0.2.6–v0.2.9 can upgrade to v0.2.10 in the app. Starting with v0.2.11, only the complete ZIP is published; clients already on v0.2.10 can consume it and jump directly across intermediate releases. + +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 GitHub Actions builds the following release packages: diff --git a/README.zh-CN.md b/README.zh-CN.md index 1513819..6f431f4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -102,6 +102,12 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址 4. 打开 **内核** 页面,安装内置版本或最新版本的 CLIProxyAPI 内核。 5. 启动内核,然后复制所需的本地 API 地址,或配置 OAuth/API Provider。 +## 升级 + +v0.2.10 是一次性桥接版本:它会同时发布完整 Windows ZIP 和兼容旧客户端的 `update` ZIP,使 v0.2.6–v0.2.9 可以通过应用内更新升级到 v0.2.10。v0.2.11 起只发布完整 ZIP;已升级到 v0.2.10 的客户端可以识别完整包并跨过多个中间版本直接升级。 + +如果当前版本是 v0.2.5 或更早版本,请执行一次手动迁移:退出 EasyCLIProxyAPI,下载最新版对应架构的完整 Windows ZIP,将 ZIP 顶层目录内的内容复制到现有安装目录并覆盖同名文件。不要先删除现有安装目录;`config.toml`、`oauth` 和 `cpa-core/config.yaml` 等用户数据会被保留。启动新版后,后续版本即可继续使用应用内自动升级。 + ## 支持的平台 GitHub Actions 会构建以下发行包: diff --git a/scripts/generate-portable-update-manifest.d.mts b/scripts/generate-portable-update-manifest.d.mts index 88b6dce..2daf8d5 100644 --- a/scripts/generate-portable-update-manifest.d.mts +++ b/scripts/generate-portable-update-manifest.d.mts @@ -4,6 +4,8 @@ export type PortableUpdateManifestAsset = { sizeBytes: number; }; +export const LEGACY_UPDATE_BRIDGE_VERSION: string; + export type PortableUpdateManifest = { schemaVersion: number; version: string; diff --git a/scripts/generate-portable-update-manifest.mjs b/scripts/generate-portable-update-manifest.mjs index 82cfab9..0a62cb5 100644 --- a/scripts/generate-portable-update-manifest.mjs +++ b/scripts/generate-portable-update-manifest.mjs @@ -3,6 +3,8 @@ import { readFile, stat, writeFile } from 'node:fs/promises'; import { basename, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; +export const LEGACY_UPDATE_BRIDGE_VERSION = '0.2.10'; + export async function generatePortableUpdateManifest({ directory, output, @@ -29,12 +31,15 @@ export async function generatePortableUpdateManifest({ } const assets = {}; + const useLegacyBridgeAssets = version === LEGACY_UPDATE_BRIDGE_VERSION; for (const arch of ['amd64', 'aarch64']) { - const filename = `EasyCLIProxyAPI-update-${tag}-Windows-${arch}.zip`; + const filename = useLegacyBridgeAssets + ? `EasyCLIProxyAPI-update-${tag}-Windows-${arch}.zip` + : `EasyCLIProxyAPI-${tag}-Windows-${arch}.zip`; const path = join(resolvedDirectory, filename); const [contents, metadata] = await Promise.all([readFile(path), stat(path)]); if (!metadata.isFile() || metadata.size === 0) { - throw new Error(`Portable updater asset is empty or not a file: ${filename}`); + throw new Error(`Portable release asset is empty or not a file: ${filename}`); } assets[`windows-${arch}`] = { url: `https://github.com/${resolvedRepository}/releases/download/${tag}/${filename}`, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 236ba47..e2f8f26 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -398,6 +398,12 @@ impl Default for AppUpdateTask { } } +#[cfg(windows)] +struct PortablePackagePayload { + manifest: PortableAppManifest, + core_archive_name: Option, +} + #[cfg(windows)] #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -409,6 +415,12 @@ struct PortableUpdateDescriptor { staged_manifest: PathBuf, backup_exe: PathBuf, backup_manifest: PathBuf, + current_core_version: PathBuf, + staged_core_version: PathBuf, + backup_core_version: PathBuf, + staged_core_archive: PathBuf, + target_core_archive: PathBuf, + install_core_archive: bool, ack_path: PathBuf, work_dir: PathBuf, target_version: String, @@ -2210,17 +2222,17 @@ async fn reset_agent_config_to_default( let _guard = AGENT_CONFIG_FILE_LOCK .lock() .map_err(|_| "智能体配置文件锁已损坏".to_string())?; - reset_agent_configuration_to_default_with_oauth( + reset_agent_configuration_to_default_with_oauth(AgentDefaultConfiguration { client, - &home, - config.port, + home: &home, + port: config.port, api_key, - &model, - &prepared.models, - prepared.codex_catalog.as_deref(), + model: &model, + models: &prepared.models, + codex_catalog: prepared.codex_catalog.as_deref(), oauth_configuration, - claude_desktop_model_mappings.as_ref(), - ) + claude_desktop_model_mappings: claude_desktop_model_mappings.as_ref(), + }) } #[tauri::command] @@ -2812,7 +2824,7 @@ fn claude_desktop_config_paths(_home: &Path) -> Vec { let local = env::var_os("LOCALAPPDATA") .map(PathBuf::from) .unwrap_or_else(|| _home.join("AppData/Local")); - return claude_desktop_config_paths_from_local_app_data(&local); + claude_desktop_config_paths_from_local_app_data(&local) } #[cfg(target_os = "linux")] let (normal, threep) = { @@ -3230,7 +3242,7 @@ fn find_codex_app_target(home: &Path) -> Option { fn find_claude_desktop_target(home: &Path) -> Option { #[cfg(target_os = "windows")] { - return find_windows_claude_desktop_target(home); + find_windows_claude_desktop_target(home) } #[cfg(not(target_os = "windows"))] find_claude_desktop_executable(home).map(ClaudeDesktopTarget::Application) @@ -3239,9 +3251,9 @@ fn find_claude_desktop_target(home: &Path) -> Option { fn read_claude_desktop_version(home: &Path) -> Option { #[cfg(target_os = "windows")] { - return read_windows_claude_desktop_store_version().or_else(|| { + read_windows_claude_desktop_store_version().or_else(|| { find_claude_desktop_executable(home).and_then(|path| read_agent_version(&path)) - }); + }) } #[cfg(not(target_os = "windows"))] find_claude_desktop_executable(home).and_then(|path| read_agent_version(&path)) @@ -6542,33 +6554,48 @@ fn reset_agent_configuration_to_default( model: &str, codex_catalog: Option<&str>, ) -> Result { - reset_agent_configuration_to_default_with_oauth( + reset_agent_configuration_to_default_with_oauth(AgentDefaultConfiguration { client, home, port, api_key, model, - &[AgentModelOption { + models: &[AgentModelOption { name: model.to_string(), alias: None, }], codex_catalog, - false, - None, - ) + oauth_configuration: false, + claude_desktop_model_mappings: None, + }) +} + +struct AgentDefaultConfiguration<'a> { + client: AgentClient, + home: &'a Path, + port: u16, + api_key: &'a str, + model: &'a str, + models: &'a [AgentModelOption], + codex_catalog: Option<&'a str>, + oauth_configuration: bool, + claude_desktop_model_mappings: Option<&'a ClaudeDesktopModelMappings>, } fn reset_agent_configuration_to_default_with_oauth( - client: AgentClient, - home: &Path, - port: u16, - api_key: &str, - model: &str, - models: &[AgentModelOption], - codex_catalog: Option<&str>, - oauth_configuration: bool, - claude_desktop_model_mappings: Option<&ClaudeDesktopModelMappings>, + request: AgentDefaultConfiguration<'_>, ) -> Result { + let AgentDefaultConfiguration { + client, + home, + port, + api_key, + model, + models, + codex_catalog, + oauth_configuration, + claude_desktop_model_mappings, + } = request; let paths = agent_config_paths(client, home); let contents = fresh_agent_contents_with_oauth( client, @@ -7752,16 +7779,25 @@ fn validate_portable_update_manifest(manifest: &PortableUpdateManifest) -> Resul .get(key) .ok_or_else(|| format!("软件更新清单缺少 {key}"))?; validate_portable_update_asset(asset)?; - let expected_name = format!( + 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 expected_url = format!( + let legacy_package_url = format!( "{APP_RELEASE_DOWNLOAD_PREFIX}v{}/{}", manifest.version.trim().trim_start_matches('v'), - expected_name + legacy_package_name ); - if asset.url != expected_url { + if asset.url != full_package_url && asset.url != legacy_package_url { return Err(format!("软件更新资产名称与 {key} 不匹配")); } } @@ -7913,10 +7949,10 @@ async fn download_and_stage_portable_app_update( task.message = Some("正在准备应用更新".to_string()); }); let staging_dir = work_dir.join("staging"); - let package_manifest = extract_portable_update_archive(&archive_path, &staging_dir)?; - if normalize_version(&package_manifest.version) != normalize_version(&pending.version) - || package_manifest.platform != "windows" - || package_manifest.arch != pending.arch + let package = extract_portable_update_archive(&archive_path, &staging_dir)?; + if normalize_version(&package.manifest.version) != normalize_version(&pending.version) + || package.manifest.platform != "windows" + || package.manifest.arch != pending.arch { return Err("应用更新包版本或架构不匹配".to_string()); } @@ -7927,6 +7963,10 @@ async fn download_and_stage_portable_app_update( .parent() .ok_or_else(|| "当前程序路径没有父目录".to_string())?; preflight_portable_update_directory(app_dir)?; + let core_archive_name = match package.core_archive_name { + Some(name) => name, + None => stage_current_portable_core_payload(app_dir, &staging_dir, &pending.arch)?, + }; let helper_path = work_dir.join("EasyCLIProxyAPI-updater.exe"); fs::copy(¤t_exe, &helper_path) .map_err(|error| format!("准备应用更新助手失败: {error}"))?; @@ -7939,6 +7979,12 @@ async fn download_and_stage_portable_app_update( staged_manifest: staging_dir.join(PORTABLE_APP_MANIFEST_FILE), backup_exe: app_dir.join(".EasyCLIProxyAPI.exe.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(), @@ -8071,23 +8117,33 @@ fn format_byte_count(bytes: u64) -> String { fn extract_portable_update_archive( archive_path: &Path, staging_dir: &Path, -) -> Result { +) -> Result { fs::create_dir_all(staging_dir) .map_err(|error| format!("创建应用更新暂存目录失败: {error}"))?; let file = File::open(archive_path).map_err(|error| format!("打开应用更新包失败: {error}"))?; let mut archive = ZipArchive::new(file).map_err(|error| format!("读取应用更新 ZIP 失败: {error}"))?; - if archive.len() != 2 { - return Err("应用更新包必须只包含程序和便携版标识".to_string()); - } + let archive_names = archive + .file_names() + .map(|name| name.replace('\\', "/")) + .collect::>(); + let legacy_layout = archive_names.len() == 2 + && archive_names.iter().any(|name| name == PORTABLE_APP_BINARY) + && archive_names + .iter() + .any(|name| name == PORTABLE_APP_MANIFEST_FILE); + 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 index in 0..archive.len() { let mut entry = archive .by_index(index) .map_err(|error| format!("读取应用更新 ZIP 条目失败: {error}"))?; let name = entry.name().replace('\\', "/"); - if entry.is_dir() || entry.enclosed_name().is_none() { + if entry.enclosed_name().is_none() { return Err("应用更新包包含不安全路径".to_string()); } if entry @@ -8096,7 +8152,32 @@ fn extract_portable_update_archive( { return Err("应用更新包不能包含符号链接".to_string()); } - let destination = match name.as_str() { + if entry.is_dir() { + continue; + } + regular_file_count = regular_file_count.saturating_add(1); + let relative; + let relative_name; + if legacy_layout { + relative = Vec::new(); + relative_name = name.clone(); + } else { + let mut components = name.split('/'); + let root = components + .next() + .filter(|value| !value.is_empty()) + .ok_or_else(|| "应用更新包缺少顶层目录".to_string())?; + relative = components.collect::>(); + if relative.is_empty() || relative.iter().any(|value| value.is_empty()) { + return Err("应用更新包目录结构无效".to_string()); + } + if package_root.as_deref().is_some_and(|value| value != root) { + return Err("应用更新包必须只包含一个顶层目录".to_string()); + } + package_root.get_or_insert_with(|| root.to_string()); + relative_name = relative.join("/"); + } + let destination = match relative_name.as_str() { PORTABLE_APP_BINARY if !seen_binary => { if entry.size() == 0 || entry.size() > 256 * 1024 * 1024 { return Err("应用更新程序大小异常".to_string()); @@ -8111,6 +8192,32 @@ fn extract_portable_update_archive( seen_manifest = true; staging_dir.join(PORTABLE_APP_MANIFEST_FILE) } + CORE_VERSION_FILE if !seen_core_version => { + if entry.size() == 0 || entry.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(".zip") + || filename.contains("_no-plugin") + || entry.size() == 0 + || entry.size() > 512 * 1024 * 1024 + { + return Err("应用更新包内置内核压缩包无效".to_string()); + } + core_archive_name = Some(filename.to_string()); + 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!("应用更新包包含未知文件: {name}")), }; let mut output = File::create(&destination) @@ -8121,7 +8228,12 @@ fn extract_portable_update_archive( .flush() .map_err(|error| format!("保存应用更新暂存文件失败: {error}"))?; } - if !seen_binary || !seen_manifest { + let required_files_present = if legacy_layout { + regular_file_count == 2 && seen_binary && seen_manifest + } else { + regular_file_count == 4 && seen_binary && seen_manifest && seen_core_version + }; + if !required_files_present { return Err("应用更新包缺少必要文件".to_string()); } let manifest = fs::read_to_string(staging_dir.join(PORTABLE_APP_MANIFEST_FILE)) @@ -8134,11 +8246,79 @@ fn extract_portable_update_archive( { return Err("应用更新标识无效".to_string()); } - Ok(manifest) + if !legacy_layout { + let expected_root = format!( + "EasyCLIProxyAPI-v{}-Windows-{}", + 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}_windows_{}.zip", 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)] +fn stage_current_portable_core_payload( + app_dir: &Path, + staging_dir: &Path, + arch: &str, +) -> Result { + let current_core_version = app_dir.join(CORE_VERSION_FILE); + let core_version = fs::read_to_string(¤t_core_version) + .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 core_archive_name = format!("CLIProxyAPI_{core_version}_windows_{arch}.zip"); + let current_core_archive = app_dir.join("cpa-core").join(&core_archive_name); + if !current_core_archive.is_file() { + return Err(format!( + "当前便携版缺少内置内核压缩包 {core_archive_name},无法兼容旧版更新包" + )); + } + let staged_core_dir = staging_dir.join("cpa-core"); + fs::create_dir_all(&staged_core_dir) + .map_err(|error| format!("创建旧版更新兼容暂存目录失败: {error}"))?; + fs::copy(¤t_core_version, staging_dir.join(CORE_VERSION_FILE)) + .map_err(|error| format!("暂存当前内核版本文件失败: {error}"))?; + fs::copy( + current_core_archive, + staged_core_dir.join(&core_archive_name), + ) + .map_err(|error| format!("暂存当前内置内核压缩包失败: {error}"))?; + Ok(core_archive_name) } #[cfg(windows)] 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()); + } let probe = app_dir.join(format!( ".easycliproxy-update-write-test-{}", std::process::id() @@ -8146,6 +8326,14 @@ fn preflight_portable_update_directory(app_dir: &Path) -> Result<(), String> { fs::write(&probe, b"update-write-test") .map_err(|error| format!("应用目录不可写,无法自动升级: {error}"))?; fs::remove_file(&probe).map_err(|error| format!("清理应用更新写入测试失败: {error}"))?; + let core_probe = app_dir.join("cpa-core").join(format!( + ".easycliproxy-update-write-test-{}", + std::process::id() + )); + fs::write(&core_probe, b"update-write-test") + .map_err(|error| format!("内置内核目录不可写,无法自动升级: {error}"))?; + fs::remove_file(&core_probe) + .map_err(|error| format!("清理内置内核更新写入测试失败: {error}"))?; Ok(()) } @@ -14335,9 +14523,24 @@ fn validate_portable_update_descriptor( || descriptor.current_manifest != app_dir.join(PORTABLE_APP_MANIFEST_FILE) || descriptor.backup_exe != app_dir.join(".EasyCLIProxyAPI.exe.update-backup") || 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") { return Err("应用更新目标路径无效".to_string()); } + let core_archive_name = descriptor + .staged_core_archive + .file_name() + .ok_or_else(|| "应用更新内置内核文件名无效".to_string())?; + if descriptor.target_core_archive != app_dir.join("cpa-core").join(core_archive_name) + || !core_archive_name + .to_string_lossy() + .starts_with("CLIProxyAPI_") + || !core_archive_name.to_string_lossy().ends_with(".zip") + || descriptor.install_core_archive == descriptor.target_core_archive.is_file() + { + return Err("应用更新内置内核目标路径无效".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()) @@ -14361,7 +14564,12 @@ fn validate_portable_update_descriptor( { return Err("应用更新描述路径越界".to_string()); } - for staged in [&descriptor.staged_exe, &descriptor.staged_manifest] { + for staged in [ + &descriptor.staged_exe, + &descriptor.staged_manifest, + &descriptor.staged_core_version, + &descriptor.staged_core_archive, + ] { let canonical = fs::canonicalize(staged) .map_err(|error| format!("读取应用更新暂存文件失败: {error}"))?; if !canonical.starts_with(&canonical_work_dir) { @@ -14378,6 +14586,14 @@ fn validate_portable_update_descriptor( .work_dir .join("staging") .join(PORTABLE_APP_MANIFEST_FILE) + || descriptor.staged_core_version + != descriptor.work_dir.join("staging").join(CORE_VERSION_FILE) + || descriptor.staged_core_archive + != descriptor + .work_dir + .join("staging") + .join("cpa-core") + .join(core_archive_name) { return Err("应用更新暂存路径无效".to_string()); } @@ -14386,7 +14602,10 @@ fn validate_portable_update_descriptor( #[cfg(windows)] fn restore_portable_update_backup(descriptor: &PortableUpdateDescriptor) -> Result<(), String> { - if !descriptor.backup_exe.is_file() || !descriptor.backup_manifest.is_file() { + if !descriptor.backup_exe.is_file() + || !descriptor.backup_manifest.is_file() + || !descriptor.backup_core_version.is_file() + { return Err("应用更新备份不完整,无法回滚".to_string()); } if descriptor.current_exe.exists() { @@ -14401,6 +14620,19 @@ fn restore_portable_update_backup(descriptor: &PortableUpdateDescriptor) -> Resu } fs::rename(&descriptor.backup_manifest, &descriptor.current_manifest) .map_err(|error| format!("恢复旧版便携版标识失败: {error}"))?; + if descriptor.current_core_version.exists() { + fs::remove_file(&descriptor.current_core_version) + .map_err(|error| format!("移除新版内核版本文件失败: {error}"))?; + } + fs::rename( + &descriptor.backup_core_version, + &descriptor.current_core_version, + ) + .map_err(|error| format!("恢复旧版内核版本文件失败: {error}"))?; + if descriptor.install_core_archive && descriptor.target_core_archive.exists() { + fs::remove_file(&descriptor.target_core_archive) + .map_err(|error| format!("移除新版内置内核压缩包失败: {error}"))?; + } Ok(()) } @@ -14412,39 +14644,81 @@ fn replace_portable_update_files(descriptor: &PortableUpdateDescriptor) -> Resul .ok_or_else(|| "应用更新目标路径无效".to_string())?; let replacement_exe = app_dir.join(".EasyCLIProxyAPI.exe.update-new"); 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"); let _ = fs::remove_file(&replacement_exe); let _ = fs::remove_file(&replacement_manifest); + let _ = fs::remove_file(&replacement_core_version); + let _ = fs::remove_file(&replacement_core_archive); fs::copy(&descriptor.staged_exe, &replacement_exe) .map_err(|error| format!("准备新版应用程序失败: {error}"))?; if let Err(error) = fs::copy(&descriptor.staged_manifest, &replacement_manifest) { let _ = fs::remove_file(&replacement_exe); return Err(format!("准备新版便携版标识失败: {error}")); } + if let Err(error) = fs::copy(&descriptor.staged_core_version, &replacement_core_version) { + let _ = fs::remove_file(&replacement_exe); + let _ = fs::remove_file(&replacement_manifest); + return Err(format!("准备新版内核版本文件失败: {error}")); + } + if descriptor.install_core_archive { + if let Err(error) = fs::copy(&descriptor.staged_core_archive, &replacement_core_archive) { + let _ = fs::remove_file(&replacement_exe); + let _ = fs::remove_file(&replacement_manifest); + let _ = fs::remove_file(&replacement_core_version); + return Err(format!("准备新版内置内核压缩包失败: {error}")); + } + } let _ = fs::remove_file(&descriptor.backup_exe); let _ = fs::remove_file(&descriptor.backup_manifest); + let _ = fs::remove_file(&descriptor.backup_core_version); if let Err(error) = fs::rename(&descriptor.current_exe, &descriptor.backup_exe) { let _ = fs::remove_file(&replacement_exe); let _ = fs::remove_file(&replacement_manifest); + let _ = fs::remove_file(&replacement_core_version); + let _ = fs::remove_file(&replacement_core_archive); return Err(format!("备份旧版应用失败: {error}")); } if let Err(error) = fs::rename(&descriptor.current_manifest, &descriptor.backup_manifest) { let _ = fs::rename(&descriptor.backup_exe, &descriptor.current_exe); let _ = fs::remove_file(&replacement_exe); let _ = fs::remove_file(&replacement_manifest); + let _ = fs::remove_file(&replacement_core_version); + let _ = fs::remove_file(&replacement_core_archive); return Err(format!("备份便携版标识失败: {error}")); } + if let Err(error) = fs::rename( + &descriptor.current_core_version, + &descriptor.backup_core_version, + ) { + let _ = fs::rename(&descriptor.backup_manifest, &descriptor.current_manifest); + let _ = fs::rename(&descriptor.backup_exe, &descriptor.current_exe); + let _ = fs::remove_file(&replacement_exe); + let _ = fs::remove_file(&replacement_manifest); + let _ = fs::remove_file(&replacement_core_version); + let _ = fs::remove_file(&replacement_core_archive); + return Err(format!("备份旧版内核版本文件失败: {error}")); + } let replace_result = (|| -> Result<(), String> { fs::rename(&replacement_exe, &descriptor.current_exe) .map_err(|error| format!("替换应用程序失败: {error}"))?; fs::rename(&replacement_manifest, &descriptor.current_manifest) .map_err(|error| format!("替换便携版标识失败: {error}"))?; + fs::rename(&replacement_core_version, &descriptor.current_core_version) + .map_err(|error| format!("替换内核版本文件失败: {error}"))?; + if descriptor.install_core_archive { + fs::rename(&replacement_core_archive, &descriptor.target_core_archive) + .map_err(|error| format!("安装新版内置内核压缩包失败: {error}"))?; + } Ok(()) })(); if let Err(error) = replace_result { let _ = fs::remove_file(&replacement_exe); let _ = fs::remove_file(&replacement_manifest); + let _ = fs::remove_file(&replacement_core_version); + let _ = fs::remove_file(&replacement_core_archive); restore_portable_update_backup(descriptor) .map_err(|rollback| format!("{error};{rollback}"))?; return Err(error); @@ -14452,6 +14726,48 @@ fn replace_portable_update_files(descriptor: &PortableUpdateDescriptor) -> Resul Ok(()) } +#[cfg(windows)] +fn cleanup_superseded_bundled_core_archives(descriptor: &PortableUpdateDescriptor) { + let Some(core_dir) = descriptor.target_core_archive.parent() else { + return; + }; + let Some(current_name) = descriptor + .target_core_archive + .file_name() + .and_then(|value| value.to_str()) + else { + return; + }; + let Ok(entries) = fs::read_dir(core_dir) else { + return; + }; + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + if path.is_file() + && name != current_name + && name.starts_with("CLIProxyAPI_") + && name.ends_with(".zip") + && !name.contains("_no-plugin") + { + if let Err(error) = fs::remove_file(&path) { + eprintln!( + "清理旧版内置内核压缩包失败 {}: {error}", + path_to_string(&path) + ); + } + } + } +} + +#[cfg(windows)] +fn cleanup_portable_update_payload(descriptor_path: &Path, descriptor: &PortableUpdateDescriptor) { + let _ = fs::remove_file(descriptor.work_dir.join("update.zip")); + 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)] fn run_portable_update_helper(descriptor_path: &Path) -> Result<(), String> { let descriptor = serde_json::from_slice::( @@ -14465,6 +14781,7 @@ fn run_portable_update_helper(descriptor_path: &Path) -> Result<(), String> { let mut rollback = Command::new(&descriptor.current_exe); configure_background_command(&mut rollback); let _ = rollback.spawn(); + cleanup_portable_update_payload(descriptor_path, &descriptor); return Err(error); } @@ -14484,6 +14801,7 @@ fn run_portable_update_helper(descriptor_path: &Path) -> Result<(), String> { let mut rollback = Command::new(&descriptor.current_exe); configure_background_command(&mut rollback); let _ = rollback.spawn(); + cleanup_portable_update_payload(descriptor_path, &descriptor); return Err(format!("启动新版应用失败: {error}")); } }; @@ -14508,8 +14826,9 @@ fn run_portable_update_helper(descriptor_path: &Path) -> Result<(), String> { if confirmed { let _ = fs::remove_file(&descriptor.backup_exe); let _ = fs::remove_file(&descriptor.backup_manifest); - let _ = fs::remove_file(&descriptor.ack_path); - let _ = fs::remove_file(descriptor_path); + let _ = fs::remove_file(&descriptor.backup_core_version); + cleanup_superseded_bundled_core_archives(&descriptor); + cleanup_portable_update_payload(descriptor_path, &descriptor); return Ok(()); } @@ -14519,6 +14838,7 @@ fn run_portable_update_helper(descriptor_path: &Path) -> Result<(), String> { let mut rollback = Command::new(&descriptor.current_exe); configure_background_command(&mut rollback); let _ = rollback.spawn(); + cleanup_portable_update_payload(descriptor_path, &descriptor); Err(format!( "新版应用 {} 未能在 60 秒内完成启动确认,已回滚", descriptor.target_version @@ -18051,6 +18371,15 @@ custom_option = "keep-original" } fn portable_update_test_asset(version: &str, arch: &str) -> PortableUpdateAsset { + let name = format!("EasyCLIProxyAPI-v{version}-Windows-{arch}.zip"); + PortableUpdateAsset { + url: format!("{APP_RELEASE_DOWNLOAD_PREFIX}v{version}/{name}"), + sha256: "ab".repeat(32), + size_bytes: 1024, + } + } + + fn portable_update_test_legacy_asset(version: &str, arch: &str) -> PortableUpdateAsset { let name = format!("EasyCLIProxyAPI-update-v{version}-Windows-{arch}.zip"); PortableUpdateAsset { url: format!("{APP_RELEASE_DOWNLOAD_PREFIX}v{version}/{name}"), @@ -18087,6 +18416,17 @@ custom_option = "keep-original" 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 missing_arch = portable_update_test_manifest("1.2.3"); missing_arch.assets.remove("windows-aarch64"); assert!(validate_portable_update_manifest(&missing_arch).is_err()); @@ -18104,9 +18444,8 @@ custom_option = "keep-original" 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-update-v1.2.3-Windows-amd64.zip" - ); + mismatched_tag.assets.get_mut("windows-amd64").unwrap().url = + format!("{APP_RELEASE_DOWNLOAD_PREFIX}v9.9.9/EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip"); assert!(validate_portable_update_manifest(&mismatched_tag).is_err()); } @@ -18175,27 +18514,65 @@ custom_option = "keep-original" #[cfg(windows)] #[test] - fn portable_update_zip_accepts_only_the_two_expected_regular_files() { + fn portable_update_zip_accepts_the_complete_release_package() { let root = agent_test_home("portable-zip"); let valid_zip = root.join("valid.zip"); let manifest = br#"{"schemaVersion":1,"application":"EasyCLIProxyAPI","version":"1.2.3","platform":"windows","arch":"amd64","autoUpdate":true}"#; write_portable_update_zip( &valid_zip, &[ - (PORTABLE_APP_BINARY, b"new executable", None), - (PORTABLE_APP_MANIFEST_FILE, manifest, None), + ( + "EasyCLIProxyAPI-v1.2.3-Windows-amd64/EasyCLIProxyAPI.exe", + b"new executable", + None, + ), + ( + "EasyCLIProxyAPI-v1.2.3-Windows-amd64/portable-app.json", + manifest, + None, + ), + ( + "EasyCLIProxyAPI-v1.2.3-Windows-amd64/core-version.txt", + b"7.2.109\n", + None, + ), + ( + "EasyCLIProxyAPI-v1.2.3-Windows-amd64/cpa-core/CLIProxyAPI_7.2.109_windows_amd64.zip", + b"core archive", + None, + ), ], ); let extracted = extract_portable_update_archive(&valid_zip, &root.join("valid")).unwrap(); - assert_eq!(extracted.version, "1.2.3"); - assert!(extracted.auto_update); + assert_eq!(extracted.manifest.version, "1.2.3"); + assert!(extracted.manifest.auto_update); + assert_eq!( + extracted.core_archive_name, + Some("CLIProxyAPI_7.2.109_windows_amd64.zip".to_string()) + ); + + let legacy_zip = root.join("legacy.zip"); + write_portable_update_zip( + &legacy_zip, + &[ + (PORTABLE_APP_BINARY, b"legacy executable", None), + (PORTABLE_APP_MANIFEST_FILE, manifest, None), + ], + ); + let legacy = extract_portable_update_archive(&legacy_zip, &root.join("legacy")).unwrap(); + assert_eq!(legacy.manifest.version, "1.2.3"); + assert!(legacy.core_archive_name.is_none()); let traversal_zip = root.join("traversal.zip"); write_portable_update_zip( &traversal_zip, &[ ("../EasyCLIProxyAPI.exe", b"malicious", None), - (PORTABLE_APP_MANIFEST_FILE, manifest, None), + ( + "EasyCLIProxyAPI-v1.2.3-Windows-amd64/portable-app.json", + manifest, + None, + ), ], ); assert!(extract_portable_update_archive(&traversal_zip, &root.join("traversal")).is_err()); @@ -18208,13 +18585,16 @@ custom_option = "keep-original" let mut archive = zip::ZipWriter::new(file); archive .add_symlink( - PORTABLE_APP_BINARY, + "EasyCLIProxyAPI-v1.2.3-Windows-amd64/EasyCLIProxyAPI.exe", "target.exe", SimpleFileOptions::default(), ) .unwrap(); archive - .start_file(PORTABLE_APP_MANIFEST_FILE, SimpleFileOptions::default()) + .start_file( + "EasyCLIProxyAPI-v1.2.3-Windows-amd64/portable-app.json", + SimpleFileOptions::default(), + ) .unwrap(); archive.write_all(manifest).unwrap(); archive.finish().unwrap(); @@ -18232,13 +18612,31 @@ custom_option = "keep-original" let staging = work_dir.join("staging"); fs::create_dir_all(&app_dir).unwrap(); fs::create_dir_all(&staging).unwrap(); + fs::create_dir_all(app_dir.join("cpa-core")).unwrap(); + fs::create_dir_all(staging.join("cpa-core")).unwrap(); fs::write(app_dir.join(PORTABLE_APP_BINARY), b"old exe").unwrap(); fs::write(app_dir.join(PORTABLE_APP_MANIFEST_FILE), b"old manifest").unwrap(); + fs::write(app_dir.join(CORE_VERSION_FILE), b"7.2.100").unwrap(); + fs::write( + app_dir + .join("cpa-core") + .join("CLIProxyAPI_7.2.100_windows_amd64.zip"), + b"old core archive", + ) + .unwrap(); fs::write(app_dir.join(GUI_CONFIG_FILE), b"user config").unwrap(); fs::create_dir_all(app_dir.join(OAUTH_DIR_NAME)).unwrap(); fs::write(app_dir.join(OAUTH_DIR_NAME).join("account.json"), b"oauth").unwrap(); fs::write(staging.join(PORTABLE_APP_BINARY), b"new exe").unwrap(); fs::write(staging.join(PORTABLE_APP_MANIFEST_FILE), b"new manifest").unwrap(); + fs::write(staging.join(CORE_VERSION_FILE), b"7.2.109").unwrap(); + fs::write( + staging + .join("cpa-core") + .join("CLIProxyAPI_7.2.109_windows_amd64.zip"), + b"new core archive", + ) + .unwrap(); let descriptor = PortableUpdateDescriptor { parent_pid: 1, current_exe: app_dir.join(PORTABLE_APP_BINARY), @@ -18247,6 +18645,16 @@ custom_option = "keep-original" staged_manifest: staging.join(PORTABLE_APP_MANIFEST_FILE), backup_exe: app_dir.join(".EasyCLIProxyAPI.exe.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.join(CORE_VERSION_FILE), + backup_core_version: app_dir.join(".core-version.txt.update-backup"), + staged_core_archive: staging + .join("cpa-core") + .join("CLIProxyAPI_7.2.109_windows_amd64.zip"), + target_core_archive: app_dir + .join("cpa-core") + .join("CLIProxyAPI_7.2.109_windows_amd64.zip"), + install_core_archive: true, ack_path: work_dir.join("update-started.ack"), work_dir, target_version: "1.2.3".to_string(), @@ -18258,6 +18666,14 @@ custom_option = "keep-original" fs::read(&descriptor.current_manifest).unwrap(), b"new manifest" ); + assert_eq!( + fs::read(&descriptor.current_core_version).unwrap(), + b"7.2.109" + ); + assert_eq!( + fs::read(&descriptor.target_core_archive).unwrap(), + b"new core archive" + ); assert_eq!( fs::read(app_dir.join(GUI_CONFIG_FILE)).unwrap(), b"user config" @@ -18273,6 +18689,11 @@ custom_option = "keep-original" fs::read(&descriptor.current_manifest).unwrap(), b"old manifest" ); + assert_eq!( + fs::read(&descriptor.current_core_version).unwrap(), + b"7.2.100" + ); + assert!(!descriptor.target_core_archive.exists()); fs::remove_dir_all(root).unwrap(); } diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index b278fea..9b7c611 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -13,6 +13,7 @@ export const jaOverrides = { 'app.nav.kernel': 'コア', 'app.nav.config': '詳細設定', 'app.nav.thinkingAliases': 'モデルエイリアス', + 'app.nav.speedAliases': '速度エイリアス', 'app.nav.oauth': 'OAuth', 'app.nav.api': 'API 接続', 'app.nav.authFiles': '認証ファイル', @@ -239,6 +240,20 @@ export const jaOverrides = { 'aliases.empty.description': '左側でモデルとレベルを選択して作成できます', 'aliases.unboundEffort': 'レベル未設定', 'aliases.delete': '{alias} を削除', + 'speedAliases.created': '{alias} を作成し、速度階層を Fast に固定しました', + 'speedAliases.deleted': '{alias} を削除しました', + 'speedAliases.deleteConfirm': '速度エイリアス「{alias}」を削除しますか?\n対応する Fast 上書きルールも削除されます。', + 'speedAliases.create.title': '速度エイリアスを作成', + 'speedAliases.create.description': '常に Fast リクエスト階層を使用するエイリアスを作成します', + 'speedAliases.fast.title': 'Fast', + 'speedAliases.aliasName.example': '例:{model}-fast', + 'speedAliases.creating': '作成中', + 'speedAliases.create': 'Fast エイリアスを作成', + 'speedAliases.createdList.title': '作成済み速度エイリアス', + 'speedAliases.createdList.description': '各エイリアスには元のモデル、プロバイダー、固定 Fast 階層が保持されます', + 'speedAliases.empty.title': '速度エイリアスはまだありません', + 'speedAliases.empty.description': '左側でモデルを選択して Fast エイリアスを作成できます', + 'speedAliases.delete': '{alias} を削除', 'config.routing.roundRobin': 'ラウンドロビン', 'config.routing.fillFirst': '優先充填', 'config.tabs.label': '詳細設定ページ', @@ -435,6 +450,7 @@ export const jaOverrides = { 'agents.error.modelsLoading': 'モデル一覧を読み込み中です。しばらくしてから再試行してください', 'agents.error.noModels': '選択可能なモデルがないため、設定変更を適用できません', 'agents.error.selectionGone': '選択中のモデルは利用可能一覧にありません。更新後に選び直してください', + 'agents.error.mappingSelectionGone': 'Claude Desktop のモデルマッピングに利用できないモデルがあります。モデルを更新して選び直してください', 'agents.error.applyFirst': '選択したモデルはまだ適用されていません。先に「設定変更を適用」をクリックしてください', 'agents.status.detecting': '検出中', 'agents.status.unsupported': 'プラットフォーム非対応', @@ -530,6 +546,11 @@ export const jaOverrides = { 'agents.model.firstSelection': '利用可能なモデル {count} 件。初回は先頭を選択します', 'agents.modify.title': '設定変更', 'agents.modify.oauthConfiguration': 'OAuth 設定方式', + 'agents.claudeDesktopMapping.title': 'Claude Desktop モデル → 上流モデル', + 'agents.claudeDesktopMapping.description': '各 Claude Desktop モデルが実際に呼び出す上流モデルを選択します。', + 'agents.claudeDesktopMapping.opus': 'Claude Opus 5 のマッピング先', + 'agents.claudeDesktopMapping.sonnet': 'Claude Sonnet 4.6 のマッピング先', + 'agents.claudeDesktopMapping.haiku': 'Claude Haiku 4.5 のマッピング先', 'agents.oauthLoginRequired.title': 'Codex へのログインが必要です', 'agents.oauthLoginRequired.enableDescription': '有効な Codex のログイン認証情報が見つからないため、OAuth 設定方式を有効にできません。Codex アプリで ChatGPT アカウントにログインしてください。Codex アプリでログインを開始できない場合は、先に', 'agents.oauthLoginRequired.enableClearConfiguration': '設定を消去', diff --git a/tests/portableUpdateManifest.test.ts b/tests/portableUpdateManifest.test.ts index 722d9ce..86fd619 100644 --- a/tests/portableUpdateManifest.test.ts +++ b/tests/portableUpdateManifest.test.ts @@ -3,19 +3,22 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, test } from 'bun:test'; -import { generatePortableUpdateManifest } from '../scripts/generate-portable-update-manifest.mjs'; +import { + generatePortableUpdateManifest, + LEGACY_UPDATE_BRIDGE_VERSION, +} from '../scripts/generate-portable-update-manifest.mjs'; describe('Windows 便携更新清单', () => { test('URL、大小和哈希与实际上传资产一致', async () => { const root = await mkdtemp(join(tmpdir(), 'easycli-manifest-test-')); try { const payloads = { - amd64: Buffer.from('amd64 portable updater'), - aarch64: Buffer.from('aarch64 portable updater'), + amd64: Buffer.from('amd64 full portable package'), + aarch64: Buffer.from('aarch64 full portable package'), }; for (const [arch, contents] of Object.entries(payloads)) { await writeFile( - join(root, `EasyCLIProxyAPI-update-v1.2.3-Windows-${arch}.zip`), + join(root, `EasyCLIProxyAPI-v1.2.3-Windows-${arch}.zip`), contents, ); } @@ -34,7 +37,7 @@ describe('Windows 便携更新清单', () => { for (const arch of ['amd64', 'aarch64'] as const) { const asset = manifest.assets[`windows-${arch}`]; expect(asset.url).toBe( - `https://github.com/router-for-me/EasyCLIProxyAPI/releases/download/v1.2.3/EasyCLIProxyAPI-update-v1.2.3-Windows-${arch}.zip`, + `https://github.com/router-for-me/EasyCLIProxyAPI/releases/download/v1.2.3/EasyCLIProxyAPI-v1.2.3-Windows-${arch}.zip`, ); expect(asset.sizeBytes).toBe(payloads[arch].byteLength); expect(asset.sha256).toBe(createHash('sha256').update(payloads[arch]).digest('hex')); @@ -48,7 +51,7 @@ describe('Windows 便携更新清单', () => { const root = await mkdtemp(join(tmpdir(), 'easycli-manifest-missing-')); try { await writeFile( - join(root, 'EasyCLIProxyAPI-update-v1.2.3-Windows-amd64.zip'), + join(root, 'EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip'), 'amd64', ); await expect(generatePortableUpdateManifest({ @@ -61,4 +64,32 @@ describe('Windows 便携更新清单', () => { await rm(root, { recursive: true, force: true }); } }); + + test('v0.2.10 使用一次性旧版桥接资产,后续版本恢复完整包', async () => { + const root = await mkdtemp(join(tmpdir(), 'easycli-manifest-bridge-')); + try { + for (const arch of ['amd64', 'aarch64']) { + await writeFile( + join(root, `EasyCLIProxyAPI-update-v${LEGACY_UPDATE_BRIDGE_VERSION}-Windows-${arch}.zip`), + `legacy bridge ${arch}`, + ); + } + const manifest = await generatePortableUpdateManifest({ + directory: root, + output: join(root, 'portable-update-windows.json'), + repository: 'router-for-me/EasyCLIProxyAPI', + tag: `v${LEGACY_UPDATE_BRIDGE_VERSION}`, + publishedAt: '2026-08-02T00:00:00.000Z', + }); + + expect(manifest.assets['windows-amd64'].url).toEndWith( + '/EasyCLIProxyAPI-update-v0.2.10-Windows-amd64.zip', + ); + expect(manifest.assets['windows-aarch64'].url).toEndWith( + '/EasyCLIProxyAPI-update-v0.2.10-Windows-aarch64.zip', + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); });