feat(updater): migrate Windows updates to full release packages

- add a v0.2.10 legacy updater bridge
- support both legacy and full update archives
- stop publishing dedicated update packages after v0.2.10
- preserve user data during full-package upgrades
This commit is contained in:
lzt404 committed 2026-08-02 00:45:45 +08:00
1 parent 88baeab512
commit 365356969e
9 files changed
+595 -80

No files matched your search

+32 -15
View File
@@ -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: >-
+6
View File
@@ -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 では、次のリリースパッケージをビルドします。
+6
View File
@@ -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:
+6
View File
@@ -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 会构建以下发行包:
@@ -4,6 +4,8 @@ export type PortableUpdateManifestAsset = {
sizeBytes: number;
};
export const LEGACY_UPDATE_BRIDGE_VERSION: string;
export type PortableUpdateManifest = {
schemaVersion: number;
version: string;
@@ -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}`,
+478 -57
View File
@@ -398,6 +398,12 @@ impl Default for AppUpdateTask {
}
}
#[cfg(windows)]
struct PortablePackagePayload {
manifest: PortableAppManifest,
core_archive_name: Option<String>,
}
#[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<PathBuf> {
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<CodexAppTarget> {
fn find_claude_desktop_target(home: &Path) -> Option<ClaudeDesktopTarget> {
#[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<ClaudeDesktopTarget> {
fn read_claude_desktop_version(home: &Path) -> Option<String> {
#[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<AgentConfigActionResult, String> {
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<AgentConfigActionResult, String> {
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(&current_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<PortableAppManifest, String> {
) -> Result<PortablePackagePayload, String> {
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::<Vec<_>>();
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::<String>;
let mut seen_binary = false;
let mut seen_manifest = false;
let mut seen_core_version = false;
let mut core_archive_name = None::<String>;
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::<Vec<_>>();
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<String, String> {
let current_core_version = app_dir.join(CORE_VERSION_FILE);
let core_version = fs::read_to_string(&current_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(&current_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::<PortableUpdateDescriptor>(
@@ -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();
}
+21
View File
@@ -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': '設定を消去',
+37 -6
View File
@@ -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 });
}
});
});