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:
1 parent
88baeab512
commit
365356969e
9 files changed
+595
-80
No files matched your search
@@ -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: >-
|
||||
|
||||
@@ -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 では、次のリリースパッケージをビルドします。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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(¤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<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(¤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::<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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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': '設定を消去',
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user