feat: split core views and add Windows portable auto-update

- split the core page into Home and Version Management
- add responsive layouts and navigation
- implement portable update checks, progress, verification, replacement, and rollback
- generate Windows update packages and release manifests
- add updater, navigation, and release workflow tests
This commit is contained in:
lzt404 committed 2026-07-24 15:36:58 +08:00
1 parent 879ebd2dc7
commit 462766d0d2
17 files changed
+2116 -197

No files matched your search

+54 -1
View File
@@ -153,6 +153,7 @@ jobs:
NODE
echo "stem=EasyCLIProxyAPI-${version}-${{ matrix.display_os }}-${{ matrix.arch }}" >> "$GITHUB_OUTPUT"
echo "version=${version#v}" >> "$GITHUB_OUTPUT"
- name: Install Linux dependencies
if: matrix.os == 'linux'
run: |
@@ -189,6 +190,23 @@ 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'
shell: pwsh
run: |
$updateDir = "release/update-${{ matrix.arch }}"
New-Item -ItemType Directory -Force -Path $updateDir | Out-Null
Copy-Item -LiteralPath "release/${{ steps.package.outputs.stem }}/EasyCLIProxyAPI.exe" -Destination $updateDir
Copy-Item -LiteralPath "release/${{ steps.package.outputs.stem }}/portable-app.json" -Destination $updateDir
$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.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"
}
- name: Build signed macOS DMG
if: matrix.os == 'darwin'
shell: bash
@@ -323,13 +341,35 @@ jobs:
- name: Archive Windows release
if: matrix.os == 'windows'
shell: pwsh
run: Compress-Archive -Path "release/${{ steps.package.outputs.stem }}" -DestinationPath "${{ steps.package.outputs.stem }}.zip"
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))
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 ', ')"
}
} finally {
$zip.Dispose()
}
- uses: actions/upload-artifact@v7
with:
name: ${{ steps.package.outputs.stem }}
path: ${{ steps.package.outputs.stem }}.${{ matrix.suffix }}
if-no-files-found: error
retention-days: 14
- uses: actions/upload-artifact@v7
if: matrix.os == 'windows'
with:
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:
name: Publish GitHub release
@@ -341,10 +381,23 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.ref }}
- uses: actions/download-artifact@v8
with:
path: artifacts
merge-multiple: true
- name: Generate Windows portable update manifest
shell: bash
env:
RELEASE_TAG: ${{ github.event.release.tag_name || (github.ref_type == 'tag' && github.ref_name) || inputs.release_tag }}
run: >-
node scripts/generate-portable-update-manifest.mjs
--directory artifacts
--output artifacts/portable-update-windows.json
--repository "${{ github.repository }}"
--tag "$RELEASE_TAG"
- name: Publish release
shell: bash
env:
@@ -0,0 +1,21 @@
export type PortableUpdateManifestAsset = {
url: string;
sha256: string;
sizeBytes: number;
};
export type PortableUpdateManifest = {
schemaVersion: number;
version: string;
publishedAt: string;
releaseUrl: string;
assets: Record<string, PortableUpdateManifestAsset>;
};
export function generatePortableUpdateManifest(options: {
directory?: string;
output?: string;
repository?: string;
tag?: string;
publishedAt?: string;
}): Promise<PortableUpdateManifest>;
@@ -0,0 +1,77 @@
import { createHash } from 'node:crypto';
import { readFile, stat, writeFile } from 'node:fs/promises';
import { basename, join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
export async function generatePortableUpdateManifest({
directory,
output,
repository,
tag: rawTag,
publishedAt = new Date().toISOString(),
}) {
const resolvedDirectory = resolve(directory ?? 'artifacts');
const resolvedOutput = resolve(output ?? join(resolvedDirectory, 'portable-update-windows.json'));
const resolvedRepository = repository ?? 'router-for-me/EasyCLIProxyAPI';
const normalizedRawTag = String(rawTag ?? '').trim();
const tag = normalizedRawTag.startsWith('v') ? normalizedRawTag : `v${normalizedRawTag}`;
const version = tag.slice(1);
const semverPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
if (!semverPattern.test(version)) {
throw new Error(`Invalid release tag: ${normalizedRawTag}`);
}
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(resolvedRepository)) {
throw new Error(`Invalid GitHub repository: ${resolvedRepository}`);
}
if (Number.isNaN(Date.parse(publishedAt))) {
throw new Error(`Invalid publishedAt: ${publishedAt}`);
}
const assets = {};
for (const arch of ['amd64', 'aarch64']) {
const filename = `EasyCLIProxyAPI-update-${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}`);
}
assets[`windows-${arch}`] = {
url: `https://github.com/${resolvedRepository}/releases/download/${tag}/${filename}`,
sha256: createHash('sha256').update(contents).digest('hex'),
sizeBytes: metadata.size,
};
}
const manifest = {
schemaVersion: 1,
version,
publishedAt,
releaseUrl: `https://github.com/${resolvedRepository}/releases/tag/${tag}`,
assets,
};
await writeFile(resolvedOutput, `${JSON.stringify(manifest, null, 2)}\n`);
return manifest;
}
async function main() {
const args = new Map();
for (let index = 2; index < process.argv.length; index += 2) {
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 manifest = await generatePortableUpdateManifest({
directory,
output,
repository: args.get('--repository'),
tag: args.get('--tag'),
});
console.log(`Generated ${basename(output)} for v${manifest.version}`);
}
const entryPoint = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : '';
if (import.meta.url === entryPoint) {
await main();
}
+14
View File
@@ -21,6 +21,12 @@ if (!existsSync(binary)) throw new Error(`GUI binary not found: ${binary}`);
const rawVersion = (await readFile(join(root, 'core-version.txt'), 'utf8')).trim();
if (!/^v?\d+(?:\.\d+)+$/.test(rawVersion)) throw new Error(`Invalid core-version.txt: ${rawVersion}`);
const version = rawVersion.replace(/^v/i, '');
const packageMetadata = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
const appVersion = String(packageMetadata.version ?? '').trim();
const semverPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
if (!semverPattern.test(appVersion)) {
throw new Error(`Invalid package.json version: ${appVersion}`);
}
const extension = targetOS === 'windows' ? 'zip' : 'tar.gz';
const assetName = `CLIProxyAPI_${version}_${targetOS}_${targetArch}.${extension}`;
const sourceDir = join(root, 'cpa-core');
@@ -59,6 +65,14 @@ await rm(legacyOutputBinary, { force: true });
await copyFile(binary, outputBinary);
if (targetOS !== 'windows') await chmod(outputBinary, 0o755);
await copyFile(join(root, 'core-version.txt'), join(output, 'core-version.txt'));
await writeFile(join(output, 'portable-app.json'), `${JSON.stringify({
schemaVersion: 1,
application: 'EasyCLIProxyAPI',
version: appVersion,
platform: targetOS,
arch: targetArch,
autoUpdate: targetOS === 'windows',
}, null, 2)}\n`);
const coreOutput = join(output, 'cpa-core');
await rm(coreOutput, { recursive: true, force: true });
+1217 -31
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -1420,6 +1420,7 @@ mod tests {
assert_eq!(busy_timeout, 5_000);
assert_eq!(foreign_keys, 1);
assert!(root.join(USAGE_DATABASE_FILE).is_file());
drop(connection);
fs::remove_dir_all(root).unwrap();
}
@@ -1441,6 +1442,7 @@ mod tests {
.unwrap();
assert_eq!(count, 1);
drop(connection);
fs::remove_dir_all(root).unwrap();
}
@@ -1492,6 +1494,7 @@ mod tests {
assert_eq!(count, 2);
assert_eq!(marker, "2");
drop(connection);
fs::remove_dir_all(root).unwrap();
}
@@ -1519,6 +1522,7 @@ mod tests {
assert_eq!(analysis.models[0].key, "gpt-b");
assert_eq!(events.total, 1);
assert_eq!(events.items[0].id, "request-2");
drop(connection);
fs::remove_dir_all(root).unwrap();
}
}
+1 -1
View File
@@ -16,7 +16,7 @@
"title": "EasyCLIProxyAPI",
"width": 1200,
"height": 810,
"minWidth": 900,
"minWidth": 640,
"minHeight": 600
}
],
+51 -13
View File
@@ -10,10 +10,12 @@ import {
Gauge,
GitFork,
History,
House,
Languages,
LogIn,
MessageCircle,
Network,
PackageOpen,
ServerCog,
Settings,
} from 'lucide-react';
@@ -29,15 +31,24 @@ import { AgentsPage } from './pages/AgentsPage';
import { ThinkingAliasesPage } from './pages/ThinkingAliasesPage';
import { UsageRecordsPage } from './pages/UsageRecordsPage';
import { languageOptions, useI18n } from './i18n';
import { AppUpdateDialog, AppUpdateProvider, useAppUpdate } from './appUpdate';
import { appUpdateIndicatorState } from './appUpdateModel';
import { canOpenAppPage, isAlwaysAvailablePage } from './navigation';
const CONTACT_URL = 'https://qm.qq.com/q/3queDaIG';
const pages = [
{
id: 'kernel',
labelKey: 'app.nav.kernel',
icon: ServerCog,
component: KernelPage,
id: 'home',
labelKey: 'app.nav.home',
icon: House,
component: HomePage,
},
{
id: 'versions',
labelKey: 'app.nav.versions',
icon: PackageOpen,
component: VersionManagementPage,
},
{
id: 'config',
@@ -97,17 +108,28 @@ type WindowsClosePrompt = {
error: string | null;
};
function HomePage() {
return <KernelPage view="home" />;
}
function VersionManagementPage() {
return <KernelPage view="versions" />;
}
function App() {
return (
<CoreRuntimeProvider>
<AppContent />
</CoreRuntimeProvider>
<AppUpdateProvider>
<CoreRuntimeProvider>
<AppContent />
</CoreRuntimeProvider>
</AppUpdateProvider>
);
}
function AppContent() {
const { locale, setLocale, t } = useI18n();
const [active, setActive] = useState<PageId>('kernel');
const { info: appUpdateInfo, hasUpdate, processing: appUpdateProcessing } = useAppUpdate();
const [active, setActive] = useState<PageId>('home');
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
const [windowsClosePrompt, setWindowsClosePrompt] = useState<WindowsClosePrompt | null>(null);
const closeDialogRef = useRef<HTMLElement>(null);
@@ -121,8 +143,8 @@ function AppContent() {
?? languageOptions[0];
useEffect(() => {
if (!coreRunning && active !== 'kernel') {
setActive('kernel');
if (!canOpenAppPage(active, coreRunning)) {
setActive('home');
}
}, [active, coreRunning]);
@@ -187,7 +209,7 @@ function AppContent() {
}, [windowsClosePrompt]);
const select = (pageId: PageId) => {
if (pageId !== 'kernel' && !coreRunning) {
if (!canOpenAppPage(pageId, coreRunning)) {
return;
}
setActive(pageId);
@@ -243,7 +265,10 @@ function AppContent() {
<nav className="nav-section" aria-label={t('app.navigation')}>
{pages.map((page) => {
const Icon = page.icon;
const locked = page.id !== 'kernel' && !coreRunning;
const locked = !canOpenAppPage(page.id, coreRunning);
const updateIndicator = page.id === 'versions'
? appUpdateIndicatorState(hasUpdate, appUpdateProcessing)
: null;
return (
<button
key={page.id}
@@ -257,6 +282,17 @@ function AppContent() {
>
<Icon size={17} aria-hidden="true" />
<span>{t(page.labelKey)}</span>
{updateIndicator ? (
<i
className={`nav-update-indicator ${updateIndicator}`}
title={updateIndicator === 'processing'
? t('appUpdate.progressTitle')
: t('appUpdate.badgeAvailable', { version: appUpdateInfo?.latestVersion ?? '' })}
aria-label={updateIndicator === 'processing'
? t('appUpdate.progressTitle')
: t('appUpdate.badgeAvailable', { version: appUpdateInfo?.latestVersion ?? '' })}
/>
) : null}
</button>
);
})}
@@ -326,7 +362,7 @@ function AppContent() {
<div className="workspace">
<main className="content">
{activePage.id === 'kernel' || coreRunning ? (
{isAlwaysAvailablePage(activePage.id) || coreRunning ? (
<ActivePage />
) : (
<CoreLockedPage />
@@ -392,6 +428,8 @@ function AppContent() {
</section>
</div>
) : null}
<AppUpdateDialog />
</>
);
}
+272
View File
@@ -0,0 +1,272 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { AlertCircle, Download, RefreshCw } from 'lucide-react';
import { useI18n } from './i18n';
export type AppUpdateInfo = {
currentVersion: string;
latestVersion: string;
updateAvailable: boolean;
releaseUrl: string;
autoUpdateSupported: boolean;
downloadSizeBytes: number | null;
unsupportedReason: string | null;
};
export type AppUpdatePhase =
| 'idle'
| 'checking'
| 'available'
| 'downloading'
| 'verifying'
| 'staging'
| 'waitingForExit'
| 'restarting'
| 'completed'
| 'cancelled'
| 'failed';
export type AppUpdateTask = {
running: boolean;
cancellable: boolean;
phase: AppUpdatePhase;
targetVersion: string | null;
downloadedBytes: number;
totalBytes: number | null;
percent: number | null;
message: string | null;
};
type AppUpdateContextValue = {
info: AppUpdateInfo | null;
task: AppUpdateTask;
error: string;
checking: boolean;
confirmOpen: boolean;
hasUpdate: boolean;
processing: boolean;
check: () => Promise<void>;
requestInstall: () => void;
dismissConfirm: () => void;
install: () => Promise<void>;
cancel: () => Promise<void>;
};
const idleTask: AppUpdateTask = {
running: false,
cancellable: false,
phase: 'idle',
targetVersion: null,
downloadedBytes: 0,
totalBytes: null,
percent: null,
message: null,
};
const AppUpdateContext = createContext<AppUpdateContextValue | null>(null);
export function AppUpdateProvider({ children }: { children: ReactNode }) {
const [info, setInfo] = useState<AppUpdateInfo | null>(null);
const [task, setTask] = useState<AppUpdateTask>(idleTask);
const [error, setError] = useState('');
const [checking, setChecking] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const startupCheckStarted = useRef(false);
const check = useCallback(async () => {
setChecking(true);
setError('');
try {
const result = await invoke<AppUpdateInfo>('check_app_update');
setInfo(result);
setTask((current) => (
current.running
? current
: {
...current,
phase: result.updateAvailable ? 'available' : 'idle',
targetVersion: result.updateAvailable ? result.latestVersion : null,
message: null,
}
));
} catch (nextError) {
setInfo(null);
setError(String(nextError));
} finally {
setChecking(false);
}
}, []);
useEffect(() => {
let disposed = false;
let stopListening: (() => void) | undefined;
void invoke<AppUpdateTask>('get_app_update_task')
.then((current) => {
if (!disposed) setTask(current);
})
.catch(() => undefined);
void listen<AppUpdateTask>('app-update-progress', (event) => {
if (disposed) return;
setTask(event.payload);
if (event.payload.phase === 'failed') {
setError(event.payload.message || 'Application update failed');
} else if (event.payload.phase !== 'cancelled') {
setError('');
}
}).then((stop) => {
if (disposed) stop();
else stopListening = stop;
});
if (!startupCheckStarted.current) {
startupCheckStarted.current = true;
void check();
}
return () => {
disposed = true;
stopListening?.();
};
}, []);
const install = useCallback(async () => {
setConfirmOpen(false);
setError('');
try {
await invoke('start_app_update');
} catch (nextError) {
setError(String(nextError));
}
}, []);
const cancel = useCallback(async () => {
try {
await invoke('cancel_app_update');
} catch (nextError) {
setError(String(nextError));
}
}, []);
const value = useMemo<AppUpdateContextValue>(() => ({
info,
task,
error,
checking,
confirmOpen,
hasUpdate: Boolean(info?.updateAvailable),
processing: task.running,
check,
requestInstall: () => setConfirmOpen(true),
dismissConfirm: () => setConfirmOpen(false),
install,
cancel,
}), [cancel, check, checking, confirmOpen, error, info, install, task]);
return <AppUpdateContext.Provider value={value}>{children}</AppUpdateContext.Provider>;
}
export function useAppUpdate() {
const context = useContext(AppUpdateContext);
if (!context) throw new Error('useAppUpdate must be used inside AppUpdateProvider');
return context;
}
export function AppUpdateDialog() {
const { t } = useI18n();
const {
info,
task,
error,
confirmOpen,
dismissConfirm,
install,
cancel,
} = useAppUpdate();
if (!confirmOpen && !task.running) return null;
const percent = task.percent ?? (
task.totalBytes && task.totalBytes > 0
? (task.downloadedBytes / task.totalBytes) * 100
: null
);
const phaseLabel = t(`appUpdate.phase.${task.phase}` as Parameters<typeof t>[0]);
return (
<div className="install-dialog-backdrop app-update-dialog-backdrop">
<section
className="install-dialog app-update-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="app-update-dialog-title"
>
<div className="install-dialog-heading">
<span>{t('appUpdate.eyebrow')}</span>
<h2 id="app-update-dialog-title">
{confirmOpen ? t('appUpdate.confirmTitle') : t('appUpdate.progressTitle')}
</h2>
</div>
{confirmOpen ? (
<>
<p className="app-update-confirm-copy">
{t('appUpdate.confirmDescription', { version: info?.latestVersion ?? '' })}
</p>
<div className="app-update-dialog-actions">
<button type="button" className="secondary-button" onClick={dismissConfirm}>
{t('common.cancel')}
</button>
<button type="button" className="primary-button" onClick={() => void install()}>
<Download size={15} aria-hidden="true" />
{t('appUpdate.installNow')}
</button>
</div>
</>
) : (
<>
<div className="install-dialog-phase">
<span>{t('kernel.dialog.phase')}</span>
<strong>{phaseLabel}</strong>
</div>
<div className={`install-progress-track ${percent === null ? 'unknown is-running' : ''}`}>
<span
className="install-progress-fill"
style={percent === null ? undefined : { width: `${Math.max(0, Math.min(100, percent))}%` }}
/>
</div>
<div className="install-progress-meta">
<strong>{percent === null ? t('kernel.dialog.unknownProgress') : `${percent.toFixed(1)}%`}</strong>
<span>{task.message || phaseLabel}</span>
</div>
{error ? (
<div className="install-dialog-message error" role="alert">
<AlertCircle size={15} aria-hidden="true" /> {error}
</div>
) : null}
<button
type="button"
className="danger-button"
disabled={!task.cancellable}
onClick={() => void cancel()}
>
{task.cancellable ? t('appUpdate.cancelDownload') : (
<><RefreshCw size={15} className="spin" aria-hidden="true" /> {phaseLabel}</>
)}
</button>
</>
)}
</section>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
export type AppUpdateIndicatorState = 'available' | 'processing' | null;
export function appUpdateIndicatorState(
hasUpdate: boolean,
processing: boolean,
): AppUpdateIndicatorState {
if (processing) return 'processing';
return hasUpdate ? 'available' : null;
}
+31
View File
@@ -4,6 +4,8 @@ export const jaOverrides = {
'app.language': '言語',
'app.desktopConsole': 'デスクトップコンソール',
'app.navigation': 'メインナビゲーション',
'app.nav.home': 'ホーム',
'app.nav.versions': 'バージョン管理',
'app.nav.kernel': 'コア',
'app.nav.config': '設定',
'app.nav.thinkingAliases': 'モデルエイリアス',
@@ -554,6 +556,35 @@ export const jaOverrides = {
'kernel.update.notYetChecked': 'まだ確認していません',
'kernel.update.latest': '最新バージョンです',
'kernel.update.available': '新しいバージョンをインストールできます',
'appUpdate.eyebrow': 'ソフトウェア更新',
'appUpdate.title': 'アプリのバージョン管理',
'appUpdate.current': '現在のバージョン',
'appUpdate.latest': '最新バージョン',
'appUpdate.status': '更新状態',
'appUpdate.portableReady': 'ポータブル版の自動更新に対応',
'appUpdate.manualOnly': 'このビルドは手動更新が必要です',
'appUpdate.upToDate': '最新バージョンです',
'appUpdate.available': 'バージョン {version} を利用できます',
'appUpdate.check': '更新を確認',
'appUpdate.checking': '確認中',
'appUpdate.installNow': '今すぐ更新',
'appUpdate.openRelease': 'ダウンロードページを開く',
'appUpdate.confirmTitle': 'アプリ更新をインストール',
'appUpdate.confirmDescription': '{version} をダウンロードしてインストールします。アプリは再起動し、管理中のコアは一時停止後に復帰します。',
'appUpdate.progressTitle': 'アプリを更新中',
'appUpdate.cancelDownload': 'ダウンロードをキャンセル',
'appUpdate.badgeAvailable': 'アプリの新しいバージョン {version} があります',
'appUpdate.phase.idle': '待機中',
'appUpdate.phase.checking': '更新を確認中',
'appUpdate.phase.available': '更新可能',
'appUpdate.phase.downloading': 'ダウンロード中',
'appUpdate.phase.verifying': '検証中',
'appUpdate.phase.staging': '更新を準備中',
'appUpdate.phase.waitingForExit': 'アプリの終了を待機中',
'appUpdate.phase.restarting': '再起動中',
'appUpdate.phase.completed': '更新完了',
'appUpdate.phase.cancelled': 'ダウンロードをキャンセルしました',
'appUpdate.phase.failed': '更新失敗',
'kernel.install.inProgress': 'インストール中',
'kernel.install.githubFailed': 'GitHub に接続できません。「オフラインインストール」を使用してください',
'kernel.install.titleCancelling': 'ダウンロードをキャンセル中',
+62
View File
@@ -7,6 +7,8 @@ export const zhCN = {
'app.language': '语言',
'app.desktopConsole': '桌面控制台',
'app.navigation': '主导航',
'app.nav.home': '首页',
'app.nav.versions': '版本管理',
'app.nav.kernel': '内核',
'app.nav.config': '配置',
'app.nav.thinkingAliases': '模型别名',
@@ -557,6 +559,35 @@ export const zhCN = {
'kernel.update.notYetChecked': '尚未检查',
'kernel.update.latest': '已是最新版本',
'kernel.update.available': '可安装新版本',
'appUpdate.eyebrow': '软件升级',
'appUpdate.title': '软件版本管理',
'appUpdate.current': '当前版本',
'appUpdate.latest': '最新版本',
'appUpdate.status': '升级状态',
'appUpdate.portableReady': '支持便携版自动升级',
'appUpdate.manualOnly': '当前版本需要手动更新',
'appUpdate.upToDate': '已是最新版本',
'appUpdate.available': '发现新版本 {version}',
'appUpdate.check': '检查更新',
'appUpdate.checking': '正在检查',
'appUpdate.installNow': '立即升级',
'appUpdate.openRelease': '打开下载页面',
'appUpdate.confirmTitle': '安装软件更新',
'appUpdate.confirmDescription': '将下载并安装 {version}。应用会自动重启,正在运行的托管内核会短暂停止并恢复。',
'appUpdate.progressTitle': '正在升级软件',
'appUpdate.cancelDownload': '取消下载',
'appUpdate.badgeAvailable': '发现软件新版本 {version}',
'appUpdate.phase.idle': '等待操作',
'appUpdate.phase.checking': '检查更新',
'appUpdate.phase.available': '可升级',
'appUpdate.phase.downloading': '正在下载',
'appUpdate.phase.verifying': '正在校验',
'appUpdate.phase.staging': '准备更新',
'appUpdate.phase.waitingForExit': '等待程序退出',
'appUpdate.phase.restarting': '正在重启',
'appUpdate.phase.completed': '升级完成',
'appUpdate.phase.cancelled': '下载已取消',
'appUpdate.phase.failed': '升级失败',
'kernel.install.inProgress': '正在安装',
'kernel.install.githubFailed': 'Github连接失败,请使用“离线安装”',
'kernel.install.titleCancelling': '正在取消下载',
@@ -680,6 +711,8 @@ export const en: Record<MessageKey, string> = {
'app.language': 'Language',
'app.desktopConsole': 'Desktop Console',
'app.navigation': 'Main navigation',
'app.nav.home': 'Home',
'app.nav.versions': 'Version Management',
'app.nav.kernel': 'Core',
'app.nav.config': 'Settings',
'app.nav.thinkingAliases': 'Model Aliases',
@@ -1230,6 +1263,35 @@ export const en: Record<MessageKey, string> = {
'kernel.update.notYetChecked': 'Not Checked Yet',
'kernel.update.latest': 'Up to Date',
'kernel.update.available': 'New Version Available',
'appUpdate.eyebrow': 'App Update',
'appUpdate.title': 'Application Version',
'appUpdate.current': 'Current Version',
'appUpdate.latest': 'Latest Version',
'appUpdate.status': 'Update Status',
'appUpdate.portableReady': 'Portable auto-update supported',
'appUpdate.manualOnly': 'This build requires a manual update',
'appUpdate.upToDate': 'Up to Date',
'appUpdate.available': 'Version {version} is available',
'appUpdate.check': 'Check for Updates',
'appUpdate.checking': 'Checking',
'appUpdate.installNow': 'Update Now',
'appUpdate.openRelease': 'Open Download Page',
'appUpdate.confirmTitle': 'Install Application Update',
'appUpdate.confirmDescription': 'Version {version} will be downloaded and installed. The app will restart, and a managed core will briefly stop and resume.',
'appUpdate.progressTitle': 'Updating Application',
'appUpdate.cancelDownload': 'Cancel Download',
'appUpdate.badgeAvailable': 'Application version {version} is available',
'appUpdate.phase.idle': 'Waiting',
'appUpdate.phase.checking': 'Checking',
'appUpdate.phase.available': 'Update Available',
'appUpdate.phase.downloading': 'Downloading',
'appUpdate.phase.verifying': 'Verifying',
'appUpdate.phase.staging': 'Preparing Update',
'appUpdate.phase.waitingForExit': 'Waiting for App to Exit',
'appUpdate.phase.restarting': 'Restarting',
'appUpdate.phase.completed': 'Update Complete',
'appUpdate.phase.cancelled': 'Download Canceled',
'appUpdate.phase.failed': 'Update Failed',
'kernel.install.inProgress': 'Installing',
'kernel.install.githubFailed': 'Cannot connect to GitHub. Use Offline Install.',
'kernel.install.titleCancelling': 'Canceling Download',
+9
View File
@@ -0,0 +1,9 @@
const alwaysAvailablePages = new Set(['home', 'versions']);
export function isAlwaysAvailablePage(pageId: string) {
return alwaysAvailablePages.has(pageId);
}
export function canOpenAppPage(pageId: string, coreRunning: boolean) {
return coreRunning || isAlwaysAvailablePage(pageId);
}
+96 -108
View File
@@ -1,8 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { getVersion } from '@tauri-apps/api/app';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { AlertCircle, Check, Copy, ExternalLink, Info, RefreshCw } from 'lucide-react';
import { AlertCircle, Check, Copy, ExternalLink, Info } from 'lucide-react';
import { type CoreStatus, useCoreRuntime } from '../coreRuntime';
import openaiIcon from '../assets/icons/openai-light.svg';
import claudeIcon from '../assets/icons/claude.svg';
@@ -10,6 +9,7 @@ import geminiIcon from '../assets/icons/gemini.svg';
import { clientApiProfiles, DEFAULT_CLIENT_API_KEY } from '../services/clientAccess';
import packageMetadata from '../../package.json';
import { useI18n } from '../i18n';
import { useAppUpdate } from '../appUpdate';
type CorePlatform = {
os: string;
@@ -57,31 +57,18 @@ type GuiSettings = {
runOnStartup: boolean;
};
type AppUpdateInfo = {
currentVersion: string;
latestVersion: string;
updateAvailable: boolean;
releaseUrl: string;
};
const APP_RELEASE_URL = 'https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest';
let latestAutoCheckStarted = false;
let cachedLatest: CoreLatest | null = null;
let cachedLatestError = '';
let latestCheckPromise: Promise<CoreLatest> | null = null;
let initialAppUpdateCheck: Promise<AppUpdateInfo> | null = null;
function displayAppVersion(version: string) {
const resolvedVersion = version.trim() || packageMetadata.version;
return resolvedVersion.startsWith('v') ? resolvedVersion : `v${resolvedVersion}`;
}
function requestInitialAppUpdate() {
initialAppUpdateCheck ??= invoke<AppUpdateInfo>('check_app_update');
return initialAppUpdateCheck;
}
function requestLatestCore() {
if (latestCheckPromise) {
return latestCheckPromise;
@@ -105,8 +92,18 @@ function requestLatestCore() {
return latestCheckPromise;
}
export function KernelPage() {
export type KernelView = 'home' | 'versions';
export function KernelPage({ view = 'home' }: { view?: KernelView }) {
const { t } = useI18n();
const {
info: appUpdate,
error: appUpdateError,
checking: checkingAppUpdate,
task: appUpdateTask,
check: checkAppUpdate,
requestInstall: requestAppUpdate,
} = useAppUpdate();
const {
status: coreStatus,
statusError,
@@ -114,10 +111,6 @@ export function KernelPage() {
publishStatus,
} = useCoreRuntime();
const [platform, setPlatform] = useState<CorePlatform | null>(null);
const [currentAppVersion, setCurrentAppVersion] = useState(() => displayAppVersion(packageMetadata.version));
const [appUpdate, setAppUpdate] = useState<AppUpdateInfo | null>(null);
const [appUpdateError, setAppUpdateError] = useState('');
const [checkingAppUpdate, setCheckingAppUpdate] = useState(true);
const [platformError, setPlatformError] = useState('');
const [latest, setLatest] = useState<CoreLatest | null>(cachedLatest);
const [latestError, setLatestError] = useState(cachedLatestError);
@@ -240,36 +233,6 @@ export function KernelPage() {
};
}, []);
useEffect(() => {
let disposed = false;
void getVersion()
.then((version) => {
if (!disposed) setCurrentAppVersion(displayAppVersion(version));
})
.catch((error) => console.warn('读取当前软件版本失败', error));
void requestInitialAppUpdate()
.then((info) => {
if (disposed) return;
setCurrentAppVersion(displayAppVersion(info.currentVersion));
setAppUpdate(info);
setAppUpdateError('');
})
.catch((error) => {
if (disposed) return;
console.warn('自动检查软件更新失败', error);
setAppUpdateError(String(error));
})
.finally(() => {
if (!disposed) setCheckingAppUpdate(false);
});
return () => {
disposed = true;
};
}, []);
useEffect(() => {
if (!installDialogOpen) {
return;
@@ -625,25 +588,11 @@ export function KernelPage() {
}
};
const checkAppUpdate = async () => {
setCheckingAppUpdate(true);
setAppUpdateError('');
try {
const info = await invoke<AppUpdateInfo>('check_app_update');
setCurrentAppVersion(displayAppVersion(info.currentVersion));
setAppUpdate(info);
} catch (error) {
setAppUpdateError(String(error));
} finally {
setCheckingAppUpdate(false);
}
};
const openAppUpdate = async () => {
const openAppRelease = async () => {
try {
await invoke('open_external_url', { url: appUpdate?.releaseUrl || APP_RELEASE_URL });
} catch (error) {
setAppUpdateError(t('kernel.error.openUpdate', { error: String(error) }));
showProcessNotice(t('kernel.error.openUpdate', { error: String(error) }), 'error');
}
};
@@ -680,12 +629,7 @@ export function KernelPage() {
: statusError
? t('common.detectionFailed')
: t('common.detecting');
const appUpdateLabel = appUpdateError
? t('kernel.update.failedRetry')
: appUpdate?.updateAvailable
? t('kernel.update.toVersion', { version: displayAppVersion(appUpdate.latestVersion) })
: '';
const appUpdateTone = appUpdateError ? 'error' : 'update';
const currentAppVersion = displayAppVersion(appUpdate?.currentVersion || packageMetadata.version);
const latestLabel = checkingLatest
? t('kernel.update.checking')
: latestVersion || (latestError ? t('kernel.update.failed') : t('kernel.update.notChecked'));
@@ -760,41 +704,9 @@ export function KernelPage() {
} as const;
return (
<section className="page kernel-page">
<div className="status-strip">
<div className="status-card status-card-primary">
<span className={`status-dot ${statusTone}`} />
<div>
<span>{t('kernel.overview.coreStatus')}</span>
<strong>{statusLabel}</strong>
</div>
</div>
<div className="status-card">
<span>{t('kernel.overview.appVersion')}</span>
<div className="software-version-line">
<strong>{currentAppVersion}</strong>
{appUpdateLabel ? (
<button
type="button"
className={`software-version-action ${appUpdateTone}`}
title={appUpdateError || t('kernel.update.openGithub')}
disabled={checkingAppUpdate}
onClick={() => void (appUpdateError ? checkAppUpdate() : openAppUpdate())}
>
{appUpdateError ? <RefreshCw size={11} aria-hidden="true" /> : null}
<span>{appUpdateLabel}</span>
{appUpdate?.updateAvailable && !appUpdateError ? <ExternalLink size={11} aria-hidden="true" /> : null}
</button>
) : null}
</div>
</div>
<div className="status-card">
<span>{t('kernel.overview.coreVersion')}</span>
<strong>{currentVersion || t('kernel.status.notInstalled')}</strong>
</div>
</div>
<div className="kernel-layout">
<section className={`page kernel-page ${view === 'home' ? 'home-page' : 'version-management-page'}`}>
<div className={view === 'home' ? 'kernel-layout home-layout' : 'kernel-layout version-management-layout'}>
{view === 'home' ? (
<div className="panel control-panel">
<div className="panel-heading">
<div>
@@ -899,7 +811,80 @@ export function KernelPage() {
</div>
</div>
) : null}
{view === 'versions' ? (
<div className="panel software-update-panel">
<div className="panel-heading">
<div>
<h2>{t('appUpdate.title')}</h2>
<p className={appUpdateError ? 'error' : appUpdate?.updateAvailable ? 'success' : ''}>
{appUpdateError
|| (appUpdate?.updateAvailable
? t('appUpdate.available', { version: displayAppVersion(appUpdate.latestVersion) })
: appUpdate
? t('appUpdate.upToDate')
: t('appUpdate.phase.checking'))}
</p>
</div>
<span className={`state-pill ${appUpdate?.updateAvailable ? 'update' : appUpdateError ? 'error' : 'success'}`}>
{appUpdate?.autoUpdateSupported ? t('appUpdate.portableReady') : t('appUpdate.manualOnly')}
</span>
</div>
<dl className="panel-detail-grid software-update-details">
<div className="panel-detail-row">
<dt>{t('appUpdate.current')}</dt>
<dd>{currentAppVersion}</dd>
</div>
<div className="panel-detail-row">
<dt>{t('appUpdate.latest')}</dt>
<dd>{appUpdate ? displayAppVersion(appUpdate.latestVersion) : t('common.detecting')}</dd>
</div>
<div className="panel-detail-row">
<dt>{t('appUpdate.status')}</dt>
<dd className={appUpdateError ? 'error' : appUpdate?.updateAvailable ? 'success' : ''}>
{appUpdateTask.running
? t(`appUpdate.phase.${appUpdateTask.phase}` as Parameters<typeof t>[0])
: appUpdateError
? t('kernel.update.failed')
: appUpdate?.updateAvailable
? t('appUpdate.available', { version: displayAppVersion(appUpdate.latestVersion) })
: appUpdate
? t('appUpdate.upToDate')
: t('appUpdate.phase.checking')}
</dd>
</div>
</dl>
<div className="button-row panel-action-row software-update-actions">
<button
type="button"
className="secondary-button"
disabled={checkingAppUpdate || appUpdateTask.running}
onClick={() => void checkAppUpdate()}
>
{checkingAppUpdate ? t('appUpdate.checking') : t('appUpdate.check')}
</button>
{appUpdate?.updateAvailable && appUpdate.autoUpdateSupported ? (
<button
type="button"
className="primary-button"
disabled={appUpdateTask.running}
onClick={requestAppUpdate}
>
{t('appUpdate.installNow')}
</button>
) : (
<button type="button" className="secondary-button" onClick={() => void openAppRelease()}>
{t('appUpdate.openRelease')} <ExternalLink size={14} aria-hidden="true" />
</button>
)}
</div>
</div>
) : null}
{view === 'versions' ? (
<div className="panel version-panel">
<div className="panel-heading">
<div className="version-heading-inline">
@@ -976,8 +961,10 @@ export function KernelPage() {
</div>
</div>
) : null}
</div>
{view === 'home' ? (
<section className="panel client-api-panel">
<div className="panel-heading client-api-heading">
<div>
@@ -1064,8 +1051,9 @@ export function KernelPage() {
))}
</div>
</section>
) : null}
{installDialogOpen && progress ? (
{view === 'versions' && installDialogOpen && progress ? (
<div className="install-dialog-backdrop">
<div
ref={installDialogRef}
+103 -43
View File
@@ -123,7 +123,7 @@ input:focus-visible {
.nav-section button {
display: grid;
grid-template-columns: 19px minmax(0, 1fr);
grid-template-columns: 19px minmax(0, 1fr) 10px;
align-items: center;
gap: 10px;
width: 100%;
@@ -147,6 +147,28 @@ input:focus-visible {
white-space: nowrap;
}
.nav-update-indicator {
display: block;
width: 8px;
height: 8px;
border-radius: 999px;
background: #c77b30;
box-shadow: 0 0 0 3px rgba(199, 123, 48, 0.14);
}
.nav-update-indicator.processing {
background: #4d78a8;
box-shadow: 0 0 0 3px rgba(77, 120, 168, 0.15);
animation: update-indicator-pulse 1.2s ease-in-out infinite;
}
@keyframes update-indicator-pulse {
50% {
opacity: 0.45;
transform: scale(0.78);
}
}
.nav-section button:hover {
background: #e8e5dc;
color: #2d2a26;
@@ -355,13 +377,22 @@ input:focus-visible {
color: #9f2d20;
}
.state-pill.update {
border-color: #dfbf91;
background: #fff7e9;
color: #8b5b21;
}
.content {
width: 100%;
padding: 24px 40px 42px;
container-type: inline-size;
container-name: workspace;
}
.page {
width: min(100%, 1180px);
margin-inline: auto;
}
.kernel-page,
@@ -545,9 +576,9 @@ input:focus-visible {
.kernel-layout {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: stretch;
gap: 12px;
grid-template-columns: minmax(0, 1fr);
align-items: start;
gap: 18px;
}
.config-workspace-grid {
@@ -559,11 +590,11 @@ input:focus-visible {
}
.control-panel {
grid-column: span 2;
grid-column: auto;
}
.version-panel {
grid-column: span 2;
grid-column: auto;
}
.version-heading-inline {
@@ -607,7 +638,7 @@ input:focus-visible {
.client-api-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
gap: 12px;
}
@@ -719,26 +750,25 @@ input:focus-visible {
.kernel-layout > .panel {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: 45px 232px 56px;
height: 375px;
grid-template-rows: repeat(3, auto);
}
.kernel-layout > .panel > .panel-heading {
min-height: 0;
height: 45px;
height: auto;
margin: 0;
padding-bottom: 16px;
}
.kernel-layout .panel-action-row {
height: 56px;
height: auto;
margin: 0;
padding-top: 18px;
}
.panel-detail-grid {
display: grid;
grid-template-rows: repeat(5, 40px);
grid-auto-rows: auto;
gap: 8px;
min-height: 0;
margin: 0;
@@ -810,11 +840,45 @@ input:focus-visible {
}
.control-action-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(120px, 100%), 1fr));
grid-auto-flow: row;
}
.version-action-row {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(120px, 100%), 1fr));
grid-auto-flow: row;
}
.software-update-details {
align-content: start;
}
.software-update-actions {
grid-template-columns: repeat(auto-fit, minmax(min(120px, 100%), 1fr));
grid-auto-flow: row;
}
.software-update-panel .panel-heading p {
white-space: normal;
}
@container workspace (min-width: 840px) {
.home-page {
grid-template-columns: minmax(320px, 0.8fr) minmax(0, 1.2fr);
align-items: start;
}
.home-page > .home-layout {
display: contents;
}
.home-page > .client-api-panel {
grid-column: 2;
}
.version-management-layout {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.info-list {
@@ -5039,6 +5103,29 @@ input:focus-visible {
height: 40px;
}
.app-update-dialog {
gap: 16px;
}
.app-update-confirm-copy {
margin: 0;
color: #5f5a53;
font-size: 13px;
line-height: 1.65;
}
.app-update-dialog-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.app-update-dialog .install-dialog-message {
display: flex;
align-items: center;
gap: 7px;
}
@keyframes install-progress-slide {
from {
transform: translateX(-120%);
@@ -5062,14 +5149,6 @@ input:focus-visible {
grid-template-columns: 1fr;
}
.client-api-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.client-api-card:last-child {
grid-column: 1 / -1;
}
.config-workspace-grid {
grid-template-columns: 1fr;
height: auto;
@@ -5139,12 +5218,12 @@ input:focus-visible {
.nav-section button {
width: auto;
min-width: 96px;
min-width: 116px;
text-align: center;
}
.nav-section button {
grid-template-columns: 17px minmax(0, 1fr);
grid-template-columns: 17px minmax(0, 1fr) 10px;
}
.sidebar-bottom {
@@ -5328,25 +5407,6 @@ input:focus-visible {
flex-direction: row;
}
.kernel-layout .panel-action-row {
grid-auto-flow: column;
}
.kernel-layout .control-action-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.kernel-layout .version-action-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-flow: row;
height: auto;
}
.kernel-layout > .version-panel {
grid-template-rows: auto 232px auto;
height: auto;
}
.management-header {
align-items: start;
flex-direction: column;
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, test } from 'bun:test';
import { appUpdateIndicatorState } from '../src/appUpdateModel';
import { canOpenAppPage, isAlwaysAvailablePage } from '../src/navigation';
describe('首页与版本管理导航', () => {
test('内核停止时仅首页和版本管理始终可进入', () => {
expect(isAlwaysAvailablePage('home')).toBe(true);
expect(isAlwaysAvailablePage('versions')).toBe(true);
expect(canOpenAppPage('home', false)).toBe(true);
expect(canOpenAppPage('versions', false)).toBe(true);
expect(canOpenAppPage('config', false)).toBe(false);
expect(canOpenAppPage('quota', false)).toBe(false);
});
test('内核运行后解锁其他功能页', () => {
expect(canOpenAppPage('config', true)).toBe(true);
expect(canOpenAppPage('agents', true)).toBe(true);
});
});
describe('软件更新导航提示点', () => {
test('有新版显示橙点,处理中的蓝点优先', () => {
expect(appUpdateIndicatorState(true, false)).toBe('available');
expect(appUpdateIndicatorState(true, true)).toBe('processing');
expect(appUpdateIndicatorState(false, true)).toBe('processing');
});
test('最新版或检查失败都不显示提示点', () => {
expect(appUpdateIndicatorState(false, false)).toBeNull();
});
});
+64
View File
@@ -0,0 +1,64 @@
import { createHash } from 'node:crypto';
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';
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'),
};
for (const [arch, contents] of Object.entries(payloads)) {
await writeFile(
join(root, `EasyCLIProxyAPI-update-v1.2.3-Windows-${arch}.zip`),
contents,
);
}
const output = join(root, 'portable-update-windows.json');
const manifest = await generatePortableUpdateManifest({
directory: root,
output,
repository: 'router-for-me/EasyCLIProxyAPI',
tag: 'v1.2.3',
publishedAt: '2026-07-24T00:00:00.000Z',
});
const saved = JSON.parse(await readFile(output, 'utf8'));
expect(saved).toEqual(manifest);
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`,
);
expect(asset.sizeBytes).toBe(payloads[arch].byteLength);
expect(asset.sha256).toBe(createHash('sha256').update(payloads[arch]).digest('hex'));
}
} finally {
await rm(root, { recursive: true, force: true });
}
});
test('缺少任一架构资产时拒绝生成清单', async () => {
const root = await mkdtemp(join(tmpdir(), 'easycli-manifest-missing-'));
try {
await writeFile(
join(root, 'EasyCLIProxyAPI-update-v1.2.3-Windows-amd64.zip'),
'amd64',
);
await expect(generatePortableUpdateManifest({
directory: root,
output: join(root, 'portable-update-windows.json'),
repository: 'router-for-me/EasyCLIProxyAPI',
tag: 'v1.2.3',
})).rejects.toThrow();
} finally {
await rm(root, { recursive: true, force: true });
}
});
});