feat(update): support Linux and macOS auto-updates
This commit is contained in:
1 parent
906f68f566
commit
6d09a6490d
13 files changed
+1126
-154
No files matched your search
@@ -293,6 +293,16 @@ jobs:
|
||||
COPYFILE_DISABLE=1 tar -czf "$signed_archive" -C "$signed_core_dir" .
|
||||
mv "$signed_archive" "$core_archive"
|
||||
|
||||
cp "release/${{ steps.package.outputs.stem }}/portable-app.json" portable-app.json
|
||||
node -e '
|
||||
const marker = require("./portable-app.json");
|
||||
if (marker.schemaVersion !== 1 || marker.application !== "EasyCLIProxyAPI"
|
||||
|| marker.version !== process.argv[1] || marker.platform !== "darwin"
|
||||
|| marker.arch !== process.argv[2] || marker.autoUpdate !== true) {
|
||||
throw new Error("macOS auto-update marker does not match the release target");
|
||||
}
|
||||
' "${{ steps.package.outputs.version }}" "${{ matrix.arch }}"
|
||||
|
||||
# Build the notarized app with Tauri, then create the DMG with hdiutil.
|
||||
# Tauri's generated create-dmg script is unreliable on GitHub's macOS runners.
|
||||
env -u APPLE_CERTIFICATE -u APPLE_CERTIFICATE_PASSWORD \
|
||||
@@ -331,6 +341,7 @@ jobs:
|
||||
resources_dir="$app_path/Contents/Resources"
|
||||
test -d "$app_path"
|
||||
test -s "$resources_dir/core-version.txt"
|
||||
test -s "$resources_dir/portable-app.json"
|
||||
core_count="$(find "$resources_dir/cpa-core" -maxdepth 1 -type f -name 'CLIProxyAPI_*.tar.gz' | wc -l | tr -d '[:space:]')"
|
||||
if [ "$core_count" -ne 1 ]; then
|
||||
echo "macOS app must contain exactly one bundled core archive" >&2
|
||||
@@ -425,17 +436,21 @@ jobs:
|
||||
with:
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Generate Windows portable update manifest
|
||||
- name: Generate portable update manifests
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.version.outputs.tag }}
|
||||
run: >-
|
||||
node scripts/manifest.mjs
|
||||
--directory artifacts
|
||||
--output artifacts/portable-update-windows.json
|
||||
--repository "${{ github.repository }}"
|
||||
--gitcode-repository "${{ vars.GITCODE_GUI_REPOSITORY }}"
|
||||
--tag "$RELEASE_TAG"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for platform in windows linux darwin; do
|
||||
node scripts/manifest.mjs \
|
||||
--directory artifacts \
|
||||
--output "artifacts/portable-update-${platform}.json" \
|
||||
--platform "$platform" \
|
||||
--repository "${{ github.repository }}" \
|
||||
--gitcode-repository "${{ vars.GITCODE_GUI_REPOSITORY }}" \
|
||||
--tag "$RELEASE_TAG"
|
||||
done
|
||||
- name: Publish release
|
||||
shell: bash
|
||||
env:
|
||||
|
||||
@@ -4,6 +4,7 @@ src-tauri/gen/
|
||||
src-tauri/target/
|
||||
bin-work/
|
||||
cpa-core/
|
||||
/portable-app.json
|
||||
|
||||
agent_docs/
|
||||
|
||||
|
||||
@@ -121,6 +121,10 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で
|
||||
|
||||
すべての Windows リリースで、完全版 ZIP と旧クライアント互換の `update` ZIP を同時に公開します。まだ移行できていない旧クライアントでもアプリ内更新を利用でき、新しいクライアントでは内蔵 core も更新できる完全版パッケージを使用します。
|
||||
|
||||
現在の Windows、Linux、macOS リリースはアプリ内自動更新に対応しています。Linux では実行時データを保持したままポータブルアプリのファイルを置き換え、macOS では署名済みアプリバンドル全体を置き換えます。新しいバージョンの起動確認に失敗した場合は自動的にロールバックします。インストール先は現在のユーザーが書き込める必要があります。
|
||||
|
||||
既存の Linux および macOS インストールでは、クロスプラットフォーム自動更新マーカーを含むリリースへ一度手動で更新する必要があります。そのリリースを起動した後は、以降の更新をアプリ内で実行できます。
|
||||
|
||||
v0.2.5 以前を使用している場合は、一度だけ手動で移行してください。EasyCLIProxyAPI を終了し、使用中のアーキテクチャに対応する最新版の完全版 Windows ZIP をダウンロードして、その最上位ディレクトリの内容を既存のインストール先へコピーし、同名ファイルを上書きします。既存のディレクトリを先に削除しないでください。`config.toml`、`oauth`、`cpa-core/config.yaml` などのユーザーデータは保持されます。新しいバージョンを起動した後は、以降のリリースでアプリ内自動更新を利用できます。
|
||||
|
||||
## 対応プラットフォーム
|
||||
|
||||
@@ -123,6 +123,10 @@ previous configuration.
|
||||
|
||||
Every Windows release publishes both the complete ZIP and the legacy `update` ZIP. This keeps in-app updates available to older clients that have not migrated yet, while newer clients use the complete package so the bundled core can be updated too.
|
||||
|
||||
Current Windows, Linux, and macOS release packages support in-app automatic updates. Linux replaces the portable application files while preserving runtime data, and macOS replaces the signed application bundle. Each platform waits for the new version to confirm a successful launch and rolls back automatically if startup fails. The installation directory must be writable by the current user.
|
||||
|
||||
Existing Linux and macOS installations need one manual upgrade to a release that includes the cross-platform auto-update marker. In-app updates are available after that release has been launched once.
|
||||
|
||||
If you are running v0.2.5 or earlier, perform one manual migration: exit EasyCLIProxyAPI, download the latest complete Windows ZIP for your architecture, then copy the contents of its top-level directory over the existing installation directory. Do not delete the existing directory first; user data such as `config.toml`, `oauth`, and `cpa-core/config.yaml` will remain in place. After launching the new version, later releases can use in-app automatic updates.
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
@@ -118,6 +118,10 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址
|
||||
|
||||
每个 Windows 版本都会同时发布完整 ZIP 和兼容旧客户端的 `update` ZIP。这样尚未及时迁移的旧客户端仍可使用应用内更新;新版客户端则使用完整包,同时更新内置 core。
|
||||
|
||||
当前 Windows、Linux 和 macOS 发行包都支持应用内自动升级。Linux 会替换便携版程序文件并保留运行数据;macOS 会整体替换已签名的应用包。各平台都会等待新版完成启动确认,启动失败时自动回滚。安装目录必须允许当前用户写入。
|
||||
|
||||
现有 Linux 和 macOS 安装需要先手动升级一次,安装带有跨平台自动更新标识的版本;成功启动该版本后,后续即可使用应用内自动升级。
|
||||
|
||||
如果当前版本是 v0.2.5 或更早版本,请执行一次手动迁移:退出 EasyCLIProxyAPI,下载最新版对应架构的完整 Windows ZIP,将 ZIP 顶层目录内的内容复制到现有安装目录并覆盖同名文件。不要先删除现有安装目录;`config.toml`、`oauth` 和 `cpa-core/config.yaml` 等用户数据会被保留。启动新版后,后续版本即可继续使用应用内自动升级。
|
||||
|
||||
## 支持的平台
|
||||
|
||||
+23
-8
@@ -7,13 +7,22 @@ import { validateAppVersion } from './version.mjs';
|
||||
export async function generatePortableUpdateManifest({
|
||||
directory,
|
||||
output,
|
||||
platform = 'windows',
|
||||
repository,
|
||||
gitcodeRepository,
|
||||
tag: rawTag,
|
||||
publishedAt = new Date().toISOString(),
|
||||
}) {
|
||||
const resolvedDirectory = resolve(directory ?? 'artifacts');
|
||||
const resolvedOutput = resolve(output ?? join(resolvedDirectory, 'portable-update-windows.json'));
|
||||
const platformSpecs = {
|
||||
windows: { display: 'Windows', suffix: 'zip', legacy: true },
|
||||
linux: { display: 'Linux', suffix: 'tar.gz', legacy: false },
|
||||
darwin: { display: 'Darwin', suffix: 'dmg', legacy: false },
|
||||
};
|
||||
const normalizedPlatform = String(platform).trim().toLowerCase();
|
||||
const platformSpec = platformSpecs[normalizedPlatform];
|
||||
if (!platformSpec) throw new Error(`Unsupported update platform: ${platform}`);
|
||||
const resolvedOutput = resolve(output ?? join(resolvedDirectory, `portable-update-${normalizedPlatform}.json`));
|
||||
const resolvedRepository = repository ?? 'router-for-me/EasyCLIProxyAPI';
|
||||
const resolvedGitcodeRepository = String(gitcodeRepository ?? '').trim();
|
||||
const normalizedRawTag = String(rawTag ?? '').trim();
|
||||
@@ -35,10 +44,14 @@ export async function generatePortableUpdateManifest({
|
||||
const assets = {};
|
||||
const fullAssets = {};
|
||||
for (const arch of ['amd64', 'aarch64']) {
|
||||
for (const [collection, filename] of [
|
||||
[assets, `EasyCLIProxyAPI-update-${tag}-Windows-${arch}.zip`],
|
||||
[fullAssets, `EasyCLIProxyAPI-${tag}-Windows-${arch}.zip`],
|
||||
]) {
|
||||
const fullFilename = `EasyCLIProxyAPI-${tag}-${platformSpec.display}-${arch}.${platformSpec.suffix}`;
|
||||
const candidates = platformSpec.legacy
|
||||
? [
|
||||
[assets, `EasyCLIProxyAPI-update-${tag}-Windows-${arch}.zip`],
|
||||
[fullAssets, fullFilename],
|
||||
]
|
||||
: [[assets, fullFilename]];
|
||||
for (const [collection, filename] of candidates) {
|
||||
const path = join(resolvedDirectory, filename);
|
||||
const [contents, metadata] = await Promise.all([readFile(path), stat(path)]);
|
||||
if (!metadata.isFile() || metadata.size === 0) {
|
||||
@@ -56,7 +69,7 @@ export async function generatePortableUpdateManifest({
|
||||
filename,
|
||||
)];
|
||||
}
|
||||
collection[`windows-${arch}`] = asset;
|
||||
collection[`${normalizedPlatform}-${arch}`] = asset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +79,7 @@ export async function generatePortableUpdateManifest({
|
||||
publishedAt,
|
||||
releaseUrl: `https://github.com/${resolvedRepository}/releases/tag/${tag}`,
|
||||
assets,
|
||||
fullAssets,
|
||||
...(platformSpec.legacy ? { fullAssets } : {}),
|
||||
};
|
||||
|
||||
await writeFile(resolvedOutput, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
@@ -85,10 +98,12 @@ async function main() {
|
||||
args.set(process.argv[index], process.argv[index + 1]);
|
||||
}
|
||||
const directory = resolve(args.get('--directory') ?? 'artifacts');
|
||||
const output = resolve(args.get('--output') ?? join(directory, 'portable-update-windows.json'));
|
||||
const platform = args.get('--platform') ?? 'windows';
|
||||
const output = resolve(args.get('--output') ?? join(directory, `portable-update-${platform}.json`));
|
||||
const manifest = await generatePortableUpdateManifest({
|
||||
directory,
|
||||
output,
|
||||
platform,
|
||||
repository: args.get('--repository'),
|
||||
gitcodeRepository: args.get('--gitcode-repository'),
|
||||
tag: args.get('--tag'),
|
||||
|
||||
@@ -69,7 +69,7 @@ await writeFile(join(output, 'portable-app.json'), `${JSON.stringify({
|
||||
version: appVersion,
|
||||
platform: targetOS,
|
||||
arch: targetArch,
|
||||
autoUpdate: targetOS === 'windows',
|
||||
autoUpdate: ['windows', 'linux', 'darwin'].includes(targetOS),
|
||||
}, null, 2)}\n`);
|
||||
|
||||
const coreOutput = join(output, 'cpa-core');
|
||||
|
||||
@@ -37,7 +37,7 @@ yaml-serde-edit = { version = "0.1", default-features = false }
|
||||
yaml-edit = "0.2"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
|
||||
+845
-93
File diff suppressed because it is too large.
Load diff
+30
-4
@@ -73,10 +73,20 @@ const RELEASE_PAGE_URL: &str = "https://github.com/router-for-me/CLIProxyAPI/rel
|
||||
const RELEASE_ATOM_URL: &str = "https://github.com/router-for-me/CLIProxyAPI/releases.atom";
|
||||
const RELEASE_DOWNLOAD_PREFIX: &str =
|
||||
"https://github.com/router-for-me/CLIProxyAPI/releases/download/";
|
||||
#[cfg(windows)]
|
||||
const APP_UPDATE_MANIFEST_URL: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest/download/portable-update-windows.json";
|
||||
#[cfg(target_os = "linux")]
|
||||
const APP_UPDATE_MANIFEST_URL: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest/download/portable-update-linux.json";
|
||||
#[cfg(target_os = "macos")]
|
||||
const APP_UPDATE_MANIFEST_URL: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest/download/portable-update-darwin.json";
|
||||
const APP_RELEASE_DOWNLOAD_PREFIX: &str =
|
||||
"https://github.com/router-for-me/EasyCLIProxyAPI/releases/download/";
|
||||
#[cfg(windows)]
|
||||
const APP_UPDATE_MANIFEST_NAME: &str = "portable-update-windows.json";
|
||||
#[cfg(target_os = "linux")]
|
||||
const APP_UPDATE_MANIFEST_NAME: &str = "portable-update-linux.json";
|
||||
#[cfg(target_os = "macos")]
|
||||
const APP_UPDATE_MANIFEST_NAME: &str = "portable-update-darwin.json";
|
||||
const CODEX_MODEL_CATALOG_URL: &str = "https://raw.githubusercontent.com/router-for-me/EasyCLIProxyAPI/main/src-tauri/resources/codex_models/model-catalog.json";
|
||||
const CODEX_MODEL_CATALOG_OVERRIDE_DIR: &str = "codex_models";
|
||||
const CODEX_MODEL_CATALOG_SOURCE_FILE: &str = "model-catalog.json";
|
||||
@@ -85,6 +95,8 @@ const APP_UPDATE_PROGRESS_EVENT: &str = "app-update-progress";
|
||||
const PORTABLE_APP_MANIFEST_FILE: &str = "portable-app.json";
|
||||
#[cfg(windows)]
|
||||
const PORTABLE_APP_BINARY: &str = "EasyCLIProxyAPI.exe";
|
||||
#[cfg(target_os = "linux")]
|
||||
const PORTABLE_APP_BINARY: &str = "EasyCLIProxyAPI";
|
||||
const CORE_INSTALL_PROGRESS_EVENT: &str = "core-install-progress";
|
||||
const CORE_STATUS_EVENT: &str = "core-status-changed";
|
||||
const CONFIG_FILES_CHANGED_EVENT: &str = "config-files-changed";
|
||||
@@ -449,13 +461,13 @@ impl Default for AppUpdateTask {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
struct PortablePackagePayload {
|
||||
manifest: PortableAppManifest,
|
||||
core_archive_name: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PortableUpdateDescriptor {
|
||||
@@ -477,6 +489,20 @@ struct PortableUpdateDescriptor {
|
||||
target_version: String,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MacosUpdateDescriptor {
|
||||
parent_pid: u32,
|
||||
current_app: PathBuf,
|
||||
staged_app: PathBuf,
|
||||
backup_app: PathBuf,
|
||||
executable_relative_path: PathBuf,
|
||||
ack_path: PathBuf,
|
||||
work_dir: PathBuf,
|
||||
target_version: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BundledCoreInfo {
|
||||
@@ -1215,7 +1241,7 @@ impl AppUpdateState {
|
||||
Ok(pending)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
|
||||
fn update_task<F>(&self, update: F) -> AppUpdateTask
|
||||
where
|
||||
F: FnOnce(&mut AppUpdateTask),
|
||||
@@ -1575,7 +1601,7 @@ struct GithubAsset {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
#[cfg(windows)]
|
||||
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
|
||||
{
|
||||
let mut args = env::args_os();
|
||||
while let Some(argument) = args.next() {
|
||||
|
||||
@@ -25,7 +25,8 @@ fn app_update_comparison_uses_semantic_versions() {
|
||||
}
|
||||
|
||||
fn portable_update_test_asset(version: &str, arch: &str) -> PortableUpdateAsset {
|
||||
let name = format!("EasyCLIProxyAPI-v{version}-Windows-{arch}.zip");
|
||||
let (_, display, suffix) = portable_update_asset_platform().unwrap();
|
||||
let name = format!("EasyCLIProxyAPI-v{version}-{display}-{arch}.{suffix}");
|
||||
PortableUpdateAsset {
|
||||
url: format!("{APP_RELEASE_DOWNLOAD_PREFIX}v{version}/{name}"),
|
||||
fallback_urls: Vec::new(),
|
||||
@@ -34,6 +35,7 @@ fn portable_update_test_asset(version: &str, arch: &str) -> PortableUpdateAsset
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn portable_update_test_legacy_asset(version: &str, arch: &str) -> PortableUpdateAsset {
|
||||
let name = format!("EasyCLIProxyAPI-update-v{version}-Windows-{arch}.zip");
|
||||
PortableUpdateAsset {
|
||||
@@ -45,6 +47,7 @@ fn portable_update_test_legacy_asset(version: &str, arch: &str) -> PortableUpdat
|
||||
}
|
||||
|
||||
fn portable_update_test_manifest(version: &str) -> PortableUpdateManifest {
|
||||
let (platform, _, _) = portable_update_asset_platform().unwrap();
|
||||
PortableUpdateManifest {
|
||||
schema_version: 1,
|
||||
version: version.to_string(),
|
||||
@@ -54,11 +57,11 @@ fn portable_update_test_manifest(version: &str) -> PortableUpdateManifest {
|
||||
),
|
||||
assets: [
|
||||
(
|
||||
"windows-amd64".to_string(),
|
||||
format!("{platform}-amd64"),
|
||||
portable_update_test_asset(version, "amd64"),
|
||||
),
|
||||
(
|
||||
"windows-aarch64".to_string(),
|
||||
format!("{platform}-aarch64"),
|
||||
portable_update_test_asset(version, "aarch64"),
|
||||
),
|
||||
]
|
||||
@@ -73,48 +76,52 @@ fn portable_update_manifest_requires_both_matching_github_assets() {
|
||||
let manifest = portable_update_test_manifest("1.2.3");
|
||||
assert!(validate_portable_update_manifest(&manifest).is_ok());
|
||||
|
||||
let mut legacy_manifest = portable_update_test_manifest("1.2.3");
|
||||
legacy_manifest.assets.insert(
|
||||
"windows-amd64".to_string(),
|
||||
portable_update_test_legacy_asset("1.2.3", "amd64"),
|
||||
);
|
||||
legacy_manifest.assets.insert(
|
||||
"windows-aarch64".to_string(),
|
||||
portable_update_test_legacy_asset("1.2.3", "aarch64"),
|
||||
);
|
||||
assert!(validate_portable_update_manifest(&legacy_manifest).is_ok());
|
||||
|
||||
let mut dual_manifest = portable_update_test_manifest("1.2.3");
|
||||
dual_manifest.assets = [
|
||||
(
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut legacy_manifest = portable_update_test_manifest("1.2.3");
|
||||
legacy_manifest.assets.insert(
|
||||
"windows-amd64".to_string(),
|
||||
portable_update_test_legacy_asset("1.2.3", "amd64"),
|
||||
),
|
||||
(
|
||||
);
|
||||
legacy_manifest.assets.insert(
|
||||
"windows-aarch64".to_string(),
|
||||
portable_update_test_legacy_asset("1.2.3", "aarch64"),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
dual_manifest.full_assets = Some(
|
||||
[
|
||||
);
|
||||
assert!(validate_portable_update_manifest(&legacy_manifest).is_ok());
|
||||
|
||||
let mut dual_manifest = portable_update_test_manifest("1.2.3");
|
||||
dual_manifest.assets = [
|
||||
(
|
||||
"windows-amd64".to_string(),
|
||||
portable_update_test_asset("1.2.3", "amd64"),
|
||||
portable_update_test_legacy_asset("1.2.3", "amd64"),
|
||||
),
|
||||
(
|
||||
"windows-aarch64".to_string(),
|
||||
portable_update_test_asset("1.2.3", "aarch64"),
|
||||
portable_update_test_legacy_asset("1.2.3", "aarch64"),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
);
|
||||
assert!(validate_portable_update_manifest(&dual_manifest).is_ok());
|
||||
.collect();
|
||||
dual_manifest.full_assets = Some(
|
||||
[
|
||||
(
|
||||
"windows-amd64".to_string(),
|
||||
portable_update_test_asset("1.2.3", "amd64"),
|
||||
),
|
||||
(
|
||||
"windows-aarch64".to_string(),
|
||||
portable_update_test_asset("1.2.3", "aarch64"),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
);
|
||||
assert!(validate_portable_update_manifest(&dual_manifest).is_ok());
|
||||
}
|
||||
|
||||
let (platform, _, _) = portable_update_asset_platform().unwrap();
|
||||
let mut missing_arch = portable_update_test_manifest("1.2.3");
|
||||
missing_arch.assets.remove("windows-aarch64");
|
||||
missing_arch.assets.remove(&format!("{platform}-aarch64"));
|
||||
assert!(validate_portable_update_manifest(&missing_arch).is_err());
|
||||
|
||||
let mut invalid_timestamp = portable_update_test_manifest("1.2.3");
|
||||
@@ -124,37 +131,44 @@ fn portable_update_manifest_requires_both_matching_github_assets() {
|
||||
let mut foreign_host = portable_update_test_manifest("1.2.3");
|
||||
foreign_host
|
||||
.assets
|
||||
.get_mut("windows-amd64")
|
||||
.get_mut(&format!("{platform}-amd64"))
|
||||
.unwrap()
|
||||
.url = "https://github.com.example.invalid/router-for-me/EasyCLIProxyAPI/releases/download/v1.2.3/update.zip".to_string();
|
||||
assert!(validate_portable_update_manifest(&foreign_host).is_err());
|
||||
|
||||
let mut mismatched_tag = portable_update_test_manifest("1.2.3");
|
||||
mismatched_tag.assets.get_mut("windows-amd64").unwrap().url =
|
||||
format!("{APP_RELEASE_DOWNLOAD_PREFIX}v9.9.9/EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip");
|
||||
let (_, display, suffix) = portable_update_asset_platform().unwrap();
|
||||
mismatched_tag
|
||||
.assets
|
||||
.get_mut(&format!("{platform}-amd64"))
|
||||
.unwrap()
|
||||
.url = format!(
|
||||
"{APP_RELEASE_DOWNLOAD_PREFIX}v9.9.9/EasyCLIProxyAPI-v1.2.3-{display}-amd64.{suffix}"
|
||||
);
|
||||
assert!(validate_portable_update_manifest(&mismatched_tag).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_update_asset_accepts_only_the_configured_gitcode_fallback() {
|
||||
let mut asset = portable_update_test_asset("1.2.3", "amd64");
|
||||
let filename = "EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip";
|
||||
let (_, display, suffix) = portable_update_asset_platform().unwrap();
|
||||
let filename = format!("EasyCLIProxyAPI-v1.2.3-{display}-amd64.{suffix}");
|
||||
asset.fallback_urls = vec![gitcode_release_attachment_url(
|
||||
"mirror-owner/EasyCLIProxyAPI",
|
||||
"v1.2.3",
|
||||
filename,
|
||||
&filename,
|
||||
)];
|
||||
assert!(validate_portable_update_asset_fallbacks_for_repository(
|
||||
&asset,
|
||||
"v1.2.3",
|
||||
&[filename],
|
||||
&[&filename],
|
||||
Some("mirror-owner/EasyCLIProxyAPI"),
|
||||
)
|
||||
.is_ok());
|
||||
assert!(validate_portable_update_asset_fallbacks_for_repository(
|
||||
&asset,
|
||||
"v1.2.3",
|
||||
&[filename],
|
||||
&[&filename],
|
||||
Some("another-owner/EasyCLIProxyAPI"),
|
||||
)
|
||||
.is_err());
|
||||
@@ -408,6 +422,106 @@ fn portable_update_replacement_preserves_user_data_and_can_roll_back() {
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn portable_update_tar_gz_accepts_the_complete_linux_release() {
|
||||
use flate2::{write::GzEncoder, Compression};
|
||||
use tar::{Builder, Header};
|
||||
|
||||
let root = agent_test_home("portable-linux-tar");
|
||||
let archive_path = root.join("update.tar.gz");
|
||||
let file = File::create(&archive_path).unwrap();
|
||||
let encoder = GzEncoder::new(file, Compression::default());
|
||||
let mut archive = Builder::new(encoder);
|
||||
let package_root = "EasyCLIProxyAPI-v1.2.3-Linux-amd64";
|
||||
let manifest = br#"{"schemaVersion":1,"application":"EasyCLIProxyAPI","version":"1.2.3","platform":"linux","arch":"amd64","autoUpdate":true}"#;
|
||||
for (path, contents, mode) in [
|
||||
(
|
||||
format!("{package_root}/EasyCLIProxyAPI"),
|
||||
b"binary".as_slice(),
|
||||
0o755,
|
||||
),
|
||||
(
|
||||
format!("{package_root}/portable-app.json"),
|
||||
manifest.as_slice(),
|
||||
0o644,
|
||||
),
|
||||
(
|
||||
format!("{package_root}/core-version.txt"),
|
||||
b"7.2.109\n".as_slice(),
|
||||
0o644,
|
||||
),
|
||||
(
|
||||
format!("{package_root}/cpa-core/CLIProxyAPI_7.2.109_linux_amd64.tar.gz"),
|
||||
b"core archive".as_slice(),
|
||||
0o644,
|
||||
),
|
||||
] {
|
||||
let mut header = Header::new_gnu();
|
||||
header.set_size(contents.len() as u64);
|
||||
header.set_mode(mode);
|
||||
header.set_cksum();
|
||||
archive.append_data(&mut header, path, contents).unwrap();
|
||||
}
|
||||
archive.into_inner().unwrap().finish().unwrap();
|
||||
|
||||
let staging = root.join("staging");
|
||||
let package = extract_portable_update_tar_gz(&archive_path, &staging).unwrap();
|
||||
assert_eq!(package.manifest.platform, "linux");
|
||||
assert_eq!(package.manifest.arch, "amd64");
|
||||
assert_eq!(
|
||||
package.core_archive_name.as_deref(),
|
||||
Some("CLIProxyAPI_7.2.109_linux_amd64.tar.gz")
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(staging.join(PORTABLE_APP_BINARY)).unwrap(),
|
||||
b"binary"
|
||||
);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn macos_update_descriptor_is_confined_to_the_app_and_temp_directories() {
|
||||
let root = agent_test_home("portable-macos-descriptor");
|
||||
let work_dir = root.join("EasyCLIProxyAPI-update-1.2.3-1-1");
|
||||
let current_app = root.join("Applications").join("EasyCLIProxyAPI.app");
|
||||
let executable_relative_path = PathBuf::from("Contents/MacOS/cpa-gui");
|
||||
let current_exe = current_app.join(&executable_relative_path);
|
||||
let staged_app = work_dir.join("staging").join("EasyCLIProxyAPI.app");
|
||||
let staged_exe = staged_app.join(&executable_relative_path);
|
||||
fs::create_dir_all(current_exe.parent().unwrap()).unwrap();
|
||||
fs::create_dir_all(staged_exe.parent().unwrap()).unwrap();
|
||||
fs::write(¤t_exe, b"old").unwrap();
|
||||
fs::write(&staged_exe, b"new").unwrap();
|
||||
let descriptor = MacosUpdateDescriptor {
|
||||
parent_pid: 1,
|
||||
current_app: current_app.clone(),
|
||||
staged_app,
|
||||
backup_app: current_app
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join(".EasyCLIProxyAPI.app.update-backup"),
|
||||
executable_relative_path,
|
||||
ack_path: work_dir.join("update-started.ack"),
|
||||
work_dir: work_dir.clone(),
|
||||
target_version: "1.2.3".to_string(),
|
||||
};
|
||||
fs::create_dir_all(&work_dir).unwrap();
|
||||
let descriptor_path = work_dir.join("update-descriptor.json");
|
||||
fs::write(
|
||||
&descriptor_path,
|
||||
serde_json::to_vec_pretty(&descriptor).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(validate_macos_update_descriptor(&descriptor_path, &descriptor).is_ok());
|
||||
assert_eq!(
|
||||
macos_application_bundle_from_executable(¤t_exe).unwrap(),
|
||||
current_app
|
||||
);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_release_uses_official_asset_names_and_urls() {
|
||||
let release = release_from_tag("7.2.80");
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"bundle": {
|
||||
"resources": {
|
||||
"../core-version.txt": "core-version.txt",
|
||||
"../cpa-core/CLIProxyAPI_*": "cpa-core/"
|
||||
"../cpa-core/CLIProxyAPI_*": "cpa-core/",
|
||||
"../portable-app.json": "portable-app.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,3 +117,39 @@ describe('Windows 便携更新清单', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('跨平台便携更新清单', () => {
|
||||
test.each([
|
||||
['linux', 'Linux', 'tar.gz'],
|
||||
['darwin', 'Darwin', 'dmg'],
|
||||
] as const)('%s 清单直接引用完整发布包', async (platform, display, suffix) => {
|
||||
const root = await mkdtemp(join(tmpdir(), `easycli-${platform}-manifest-test-`));
|
||||
try {
|
||||
for (const arch of ['amd64', 'aarch64']) {
|
||||
await writeFile(
|
||||
join(root, `EasyCLIProxyAPI-v1.2.3-${display}-${arch}.${suffix}`),
|
||||
`${platform} ${arch} full package`,
|
||||
);
|
||||
}
|
||||
const output = join(root, `portable-update-${platform}.json`);
|
||||
const manifest = await generatePortableUpdateManifest({
|
||||
directory: root,
|
||||
output,
|
||||
platform,
|
||||
repository: 'router-for-me/EasyCLIProxyAPI',
|
||||
tag: 'v1.2.3',
|
||||
publishedAt: '2026-08-10T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(manifest.fullAssets).toBeUndefined();
|
||||
for (const arch of ['amd64', 'aarch64']) {
|
||||
expect(manifest.assets[`${platform}-${arch}`].url).toEndWith(
|
||||
`/EasyCLIProxyAPI-v1.2.3-${display}-${arch}.${suffix}`,
|
||||
);
|
||||
}
|
||||
expect(JSON.parse(await readFile(output, 'utf8'))).toEqual(manifest);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user