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:
1 parent
879ebd2dc7
commit
462766d0d2
17 files changed
+2116
-197
No files matched your search
@@ -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();
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
"title": "EasyCLIProxyAPI",
|
||||
"width": 1200,
|
||||
"height": 810,
|
||||
"minWidth": 900,
|
||||
"minWidth": 640,
|
||||
"minHeight": 600
|
||||
}
|
||||
],
|
||||
|
||||
+51
-13
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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': 'ダウンロードをキャンセル中',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user