diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 037d19d..68cb42b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -190,8 +190,8 @@ jobs: throw new Error(`cpa-core must contain exactly one archive; found: ${entries.map((entry) => entry.name).join(', ')}`); } NODE - - name: Prepare v0.2.10 legacy updater bridge - if: matrix.os == 'windows' && steps.package.outputs.version == '0.2.10' + - name: Prepare Windows legacy updater payload + if: matrix.os == 'windows' 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 "0.2.10" -or + $marker.version -ne "${{ steps.package.outputs.version }}" -or $marker.platform -ne "windows" -or $marker.arch -ne "${{ matrix.arch }}" -or $marker.autoUpdate -ne $true) { - throw "v0.2.10 legacy updater bridge does not match the Windows target" + throw "Legacy updater payload does not match the Windows target" } - name: Build signed macOS DMG if: matrix.os == 'darwin' @@ -341,8 +341,6 @@ 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" Add-Type -AssemblyName System.IO.Compression.FileSystem @@ -360,20 +358,18 @@ jobs: } 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() + $updateArchive = "EasyCLIProxyAPI-update-v${{ steps.package.outputs.version }}-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 "Legacy updater ZIP contains unexpected entries: $($updateEntries -join ', ')" } + } finally { + $updateZip.Dispose() } - uses: actions/upload-artifact@v7 with: @@ -382,10 +378,10 @@ jobs: if-no-files-found: error retention-days: 14 - uses: actions/upload-artifact@v7 - if: matrix.os == 'windows' && steps.package.outputs.version == '0.2.10' + if: matrix.os == 'windows' with: - name: legacy-updater-bridge-windows-${{ matrix.arch }} - path: EasyCLIProxyAPI-update-v0.2.10-Windows-${{ matrix.arch }}.zip + name: portable-updater-windows-${{ matrix.arch }} + path: EasyCLIProxyAPI-update-v${{ steps.package.outputs.version }}-Windows-${{ matrix.arch }}.zip if-no-files-found: error retention-days: 14 publish: diff --git a/README.ja.md b/README.ja.md index a5127c6..095d9d5 100644 --- a/README.ja.md +++ b/README.ja.md @@ -119,7 +119,7 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で ## アップグレード -v0.2.10 は一度限りの移行用リリースです。完全版 Windows ZIP と旧クライアント互換の `update` ZIP を同時に公開し、v0.2.6~v0.2.9 からアプリ内で v0.2.10 へ更新できます。v0.2.11 以降は完全版 ZIP のみを公開し、v0.2.10 へ移行済みのクライアントは途中のバージョンを個別に導入せず直接アップグレードできます。 +すべての Windows リリースで、完全版 ZIP と旧クライアント互換の `update` ZIP を同時に公開します。まだ移行できていない旧クライアントでもアプリ内更新を利用でき、新しいクライアントでは内蔵 core も更新できる完全版パッケージを使用します。 v0.2.5 以前を使用している場合は、一度だけ手動で移行してください。EasyCLIProxyAPI を終了し、使用中のアーキテクチャに対応する最新版の完全版 Windows ZIP をダウンロードして、その最上位ディレクトリの内容を既存のインストール先へコピーし、同名ファイルを上書きします。既存のディレクトリを先に削除しないでください。`config.toml`、`oauth`、`cpa-core/config.yaml` などのユーザーデータは保持されます。新しいバージョンを起動した後は、以降のリリースでアプリ内自動更新を利用できます。 diff --git a/README.md b/README.md index b9f17df..3a629fa 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ previous configuration, and launch an available desktop or CLI entry point. ## 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. +Every Windows release publishes both the complete ZIP and the legacy `update` ZIP. This keeps in-app updates available to older clients that have not migrated yet, while newer clients use the complete package so the bundled core can be updated too. 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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 1d9509e..2551e4d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -116,7 +116,7 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址 ## 升级 -v0.2.10 是一次性桥接版本:它会同时发布完整 Windows ZIP 和兼容旧客户端的 `update` ZIP,使 v0.2.6–v0.2.9 可以通过应用内更新升级到 v0.2.10。v0.2.11 起只发布完整 ZIP;已升级到 v0.2.10 的客户端可以识别完整包并跨过多个中间版本直接升级。 +每个 Windows 版本都会同时发布完整 ZIP 和兼容旧客户端的 `update` ZIP。这样尚未及时迁移的旧客户端仍可使用应用内更新;新版客户端则使用完整包,同时更新内置 core。 如果当前版本是 v0.2.5 或更早版本,请执行一次手动迁移:退出 EasyCLIProxyAPI,下载最新版对应架构的完整 Windows ZIP,将 ZIP 顶层目录内的内容复制到现有安装目录并覆盖同名文件。不要先删除现有安装目录;`config.toml`、`oauth` 和 `cpa-core/config.yaml` 等用户数据会被保留。启动新版后,后续版本即可继续使用应用内自动升级。 diff --git a/scripts/generate-portable-update-manifest.d.mts b/scripts/generate-portable-update-manifest.d.mts index 2daf8d5..739c082 100644 --- a/scripts/generate-portable-update-manifest.d.mts +++ b/scripts/generate-portable-update-manifest.d.mts @@ -4,14 +4,13 @@ export type PortableUpdateManifestAsset = { sizeBytes: number; }; -export const LEGACY_UPDATE_BRIDGE_VERSION: string; - export type PortableUpdateManifest = { schemaVersion: number; version: string; publishedAt: string; releaseUrl: string; assets: Record; + fullAssets: Record; }; export function generatePortableUpdateManifest(options: { diff --git a/scripts/generate-portable-update-manifest.mjs b/scripts/generate-portable-update-manifest.mjs index 0a62cb5..b3a205d 100644 --- a/scripts/generate-portable-update-manifest.mjs +++ b/scripts/generate-portable-update-manifest.mjs @@ -3,8 +3,6 @@ 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, @@ -31,21 +29,23 @@ export async function generatePortableUpdateManifest({ } const assets = {}; - const useLegacyBridgeAssets = version === LEGACY_UPDATE_BRIDGE_VERSION; + const fullAssets = {}; for (const arch of ['amd64', 'aarch64']) { - 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 release asset is empty or not a file: ${filename}`); + for (const [collection, filename] of [ + [assets, `EasyCLIProxyAPI-update-${tag}-Windows-${arch}.zip`], + [fullAssets, `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 release asset is empty or not a file: ${filename}`); + } + collection[`windows-${arch}`] = { + url: `https://github.com/${resolvedRepository}/releases/download/${tag}/${filename}`, + sha256: createHash('sha256').update(contents).digest('hex'), + sizeBytes: metadata.size, + }; } - assets[`windows-${arch}`] = { - url: `https://github.com/${resolvedRepository}/releases/download/${tag}/${filename}`, - sha256: createHash('sha256').update(contents).digest('hex'), - sizeBytes: metadata.size, - }; } const manifest = { @@ -54,6 +54,7 @@ export async function generatePortableUpdateManifest({ publishedAt, releaseUrl: `https://github.com/${resolvedRepository}/releases/tag/${tag}`, assets, + fullAssets, }; await writeFile(resolvedOutput, `${JSON.stringify(manifest, null, 2)}\n`); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 11ddcad..5349aba 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -353,6 +353,8 @@ struct PortableUpdateManifest { published_at: String, release_url: String, assets: std::collections::HashMap, + #[serde(default)] + full_assets: Option>, } #[derive(Clone, Debug, Deserialize)] @@ -10045,9 +10047,8 @@ async fn check_app_update( let current_version = normalize_version(env!("CARGO_PKG_VERSION")); let update_available = is_app_update_available(¤t_version, &latest_version)?; let target = portable_update_target(); - let asset = target - .and_then(|(key, _)| manifest.assets.get(key)) - .cloned(); + let asset_catalog = manifest.full_assets.as_ref().unwrap_or(&manifest.assets); + let asset = target.and_then(|(key, _)| asset_catalog.get(key)).cloned(); let portable_support = target .map(|(_, arch)| validate_local_portable_app_manifest(arch)) .transpose()?; @@ -10179,6 +10180,31 @@ fn validate_portable_update_manifest(manifest: &PortableUpdateManifest) -> Resul return Err(format!("软件更新资产名称与 {key} 不匹配")); } } + if let Some(full_assets) = &manifest.full_assets { + if full_assets.len() != 2 { + return Err( + "Full update asset catalog must contain both Windows architectures".to_string(), + ); + } + for (key, arch) in [("windows-amd64", "amd64"), ("windows-aarch64", "aarch64")] { + let asset = full_assets + .get(key) + .ok_or_else(|| format!("Full update asset catalog is missing {key}"))?; + validate_portable_update_asset(asset)?; + let package_name = format!( + "EasyCLIProxyAPI-v{}-Windows-{arch}.zip", + manifest.version.trim().trim_start_matches('v') + ); + let expected_url = format!( + "{APP_RELEASE_DOWNLOAD_PREFIX}v{}/{}", + manifest.version.trim().trim_start_matches('v'), + package_name + ); + if asset.url != expected_url { + return Err(format!("Full update asset name does not match {key}")); + } + } + } Ok(()) } @@ -22406,6 +22432,7 @@ custom_option = "keep-original" ] .into_iter() .collect(), + full_assets: None, } } @@ -22425,6 +22452,35 @@ custom_option = "keep-original" ); assert!(validate_portable_update_manifest(&legacy_manifest).is_ok()); + let mut dual_manifest = portable_update_test_manifest("1.2.3"); + dual_manifest.assets = [ + ( + "windows-amd64".to_string(), + portable_update_test_legacy_asset("1.2.3", "amd64"), + ), + ( + "windows-aarch64".to_string(), + portable_update_test_legacy_asset("1.2.3", "aarch64"), + ), + ] + .into_iter() + .collect(); + dual_manifest.full_assets = Some( + [ + ( + "windows-amd64".to_string(), + portable_update_test_asset("1.2.3", "amd64"), + ), + ( + "windows-aarch64".to_string(), + portable_update_test_asset("1.2.3", "aarch64"), + ), + ] + .into_iter() + .collect(), + ); + assert!(validate_portable_update_manifest(&dual_manifest).is_ok()); + let 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()); diff --git a/tests/portableUpdateManifest.test.ts b/tests/portableUpdateManifest.test.ts index 86fd619..a4de083 100644 --- a/tests/portableUpdateManifest.test.ts +++ b/tests/portableUpdateManifest.test.ts @@ -5,7 +5,6 @@ import { join } from 'node:path'; import { describe, expect, test } from 'bun:test'; import { generatePortableUpdateManifest, - LEGACY_UPDATE_BRIDGE_VERSION, } from '../scripts/generate-portable-update-manifest.mjs'; describe('Windows 便携更新清单', () => { @@ -17,6 +16,10 @@ describe('Windows 便携更新清单', () => { 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`), + Buffer.from(`${arch} legacy update package`), + ); await writeFile( join(root, `EasyCLIProxyAPI-v1.2.3-Windows-${arch}.zip`), contents, @@ -37,10 +40,14 @@ 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`, + ); + const fullAsset = manifest.fullAssets[`windows-${arch}`]; + expect(fullAsset.url).toBe( `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')); + expect(fullAsset.sizeBytes).toBe(payloads[arch].byteLength); + expect(fullAsset.sha256).toBe(createHash('sha256').update(payloads[arch]).digest('hex')); } } finally { await rm(root, { recursive: true, force: true }); @@ -51,7 +58,7 @@ describe('Windows 便携更新清单', () => { const root = await mkdtemp(join(tmpdir(), 'easycli-manifest-missing-')); try { await writeFile( - join(root, 'EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip'), + join(root, 'EasyCLIProxyAPI-update-v1.2.3-Windows-amd64.zip'), 'amd64', ); await expect(generatePortableUpdateManifest({ @@ -65,28 +72,38 @@ describe('Windows 便携更新清单', () => { } }); - test('v0.2.10 使用一次性旧版桥接资产,后续版本恢复完整包', async () => { - const root = await mkdtemp(join(tmpdir(), 'easycli-manifest-bridge-')); + test('所有版本同时保留旧版更新包和完整包', async () => { + const root = await mkdtemp(join(tmpdir(), 'easycli-manifest-dual-')); try { for (const arch of ['amd64', 'aarch64']) { await writeFile( - join(root, `EasyCLIProxyAPI-update-v${LEGACY_UPDATE_BRIDGE_VERSION}-Windows-${arch}.zip`), - `legacy bridge ${arch}`, + join(root, `EasyCLIProxyAPI-update-v1.2.3-Windows-${arch}.zip`), + `legacy update ${arch}`, + ); + await writeFile( + join(root, `EasyCLIProxyAPI-v1.2.3-Windows-${arch}.zip`), + `full package ${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}`, + tag: 'v1.2.3', publishedAt: '2026-08-02T00:00:00.000Z', }); expect(manifest.assets['windows-amd64'].url).toEndWith( - '/EasyCLIProxyAPI-update-v0.2.10-Windows-amd64.zip', + '/EasyCLIProxyAPI-update-v1.2.3-Windows-amd64.zip', ); expect(manifest.assets['windows-aarch64'].url).toEndWith( - '/EasyCLIProxyAPI-update-v0.2.10-Windows-aarch64.zip', + '/EasyCLIProxyAPI-update-v1.2.3-Windows-aarch64.zip', + ); + expect(manifest.fullAssets['windows-amd64'].url).toEndWith( + '/EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip', + ); + expect(manifest.fullAssets['windows-aarch64'].url).toEndWith( + '/EasyCLIProxyAPI-v1.2.3-Windows-aarch64.zip', ); } finally { await rm(root, { recursive: true, force: true });