19 files changed
+2909
-328
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 });
|
||||
|
||||
+1672
-87
File diff suppressed because it is too large.
Load diff
@@ -1376,6 +1376,8 @@ mod tests {
|
||||
port: 8317,
|
||||
allow_lan: false,
|
||||
run_on_startup: false,
|
||||
window_width: None,
|
||||
window_height: None,
|
||||
auth_dir: String::new(),
|
||||
api_keys: Vec::new(),
|
||||
management_secret_key: "123456".to_string(),
|
||||
@@ -1420,6 +1422,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 +1444,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(count, 1);
|
||||
drop(connection);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
@@ -1492,6 +1496,7 @@ mod tests {
|
||||
|
||||
assert_eq!(count, 2);
|
||||
assert_eq!(marker, "2");
|
||||
drop(connection);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
@@ -1519,6 +1524,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
|
||||
}
|
||||
],
|
||||
|
||||
+62
-14
@@ -10,12 +10,15 @@ import {
|
||||
Gauge,
|
||||
GitFork,
|
||||
History,
|
||||
House,
|
||||
Languages,
|
||||
LogIn,
|
||||
MessageCircle,
|
||||
Network,
|
||||
PackageOpen,
|
||||
ServerCog,
|
||||
Settings,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import appLogo from './assets/logo.jpg';
|
||||
import { CoreRuntimeProvider, useCoreRuntime } from './coreRuntime';
|
||||
@@ -29,15 +32,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 +109,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 +144,8 @@ function AppContent() {
|
||||
?? languageOptions[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreRunning && active !== 'kernel') {
|
||||
setActive('kernel');
|
||||
if (!canOpenAppPage(active, coreRunning)) {
|
||||
setActive('home');
|
||||
}
|
||||
}, [active, coreRunning]);
|
||||
|
||||
@@ -187,7 +210,7 @@ function AppContent() {
|
||||
}, [windowsClosePrompt]);
|
||||
|
||||
const select = (pageId: PageId) => {
|
||||
if (pageId !== 'kernel' && !coreRunning) {
|
||||
if (!canOpenAppPage(pageId, coreRunning)) {
|
||||
return;
|
||||
}
|
||||
setActive(pageId);
|
||||
@@ -243,7 +266,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 +283,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 +363,7 @@ function AppContent() {
|
||||
|
||||
<div className="workspace">
|
||||
<main className="content">
|
||||
{activePage.id === 'kernel' || coreRunning ? (
|
||||
{isAlwaysAvailablePage(activePage.id) || coreRunning ? (
|
||||
<ActivePage />
|
||||
) : (
|
||||
<CoreLockedPage />
|
||||
@@ -351,8 +388,17 @@ function AppContent() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="close-dialog-dismiss"
|
||||
aria-label={t('common.cancel')}
|
||||
title={t('common.cancel')}
|
||||
disabled={windowsClosePrompt.resolvingAction !== null}
|
||||
onClick={() => setWindowsClosePrompt(null)}
|
||||
>
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
<div className="close-dialog-heading">
|
||||
<span>{t('app.close.eyebrow')}</span>
|
||||
<h2 id="close-dialog-title">{t('app.close.title')}</h2>
|
||||
</div>
|
||||
<p id="close-dialog-description">
|
||||
@@ -392,6 +438,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;
|
||||
}
|
||||
+38
-4
@@ -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': 'モデルエイリアス',
|
||||
@@ -17,7 +19,6 @@ export const jaOverrides = {
|
||||
'app.coreRequired.description': '設定、OAuth、API 接続などを使用するには CPA コアを起動してください。',
|
||||
'app.contact.title': 'QQ でお問い合わせ',
|
||||
'app.contact.label': 'お問い合わせ',
|
||||
'app.close.eyebrow': 'ウィンドウを閉じる',
|
||||
'app.close.title': '次の操作を選択してください',
|
||||
'app.close.description': 'アプリを終了するか、EasyCLIProxyAPI をシステムトレイで実行し続けることができます。',
|
||||
'app.close.minimizing': '最小化しています...',
|
||||
@@ -220,6 +221,7 @@ export const jaOverrides = {
|
||||
'config.error.duplicateKey': '同じ認証キーがすでに存在します',
|
||||
'config.error.remarkTooLong': 'メモは 80 文字以内で入力してください',
|
||||
'config.notice.keyAdded': '認証キーを追加しました',
|
||||
'config.notice.keyUpdated': '認証キーを更新しました',
|
||||
'config.notice.keyDeleted': '認証キーを削除しました',
|
||||
'config.notice.keyCopied': '認証キーをコピーしました',
|
||||
'config.notice.keyCopyFailed': '認証キーをコピーできませんでした',
|
||||
@@ -234,8 +236,8 @@ export const jaOverrides = {
|
||||
'config.keys.noRemark': 'メモなし',
|
||||
'config.keys.copy': '認証キーをコピー',
|
||||
'config.keys.copyNth': '{number} 番目の認証キーをコピー',
|
||||
'config.keys.builtInProtected': '組み込みキーは削除できません',
|
||||
'config.keys.builtIn': '組み込み',
|
||||
'config.keys.edit': '認証キーを編集',
|
||||
'config.keys.editNth': '{number} 番目の認証キーを編集',
|
||||
'config.keys.delete': '認証キーを削除',
|
||||
'config.keys.deleteNth': '{number} 番目の認証キーを削除',
|
||||
'config.keys.empty': '認証キーがありません',
|
||||
@@ -247,6 +249,7 @@ export const jaOverrides = {
|
||||
'config.plugins.enable': 'プラグインシステムを有効にする',
|
||||
'config.routing.title': 'ルーティング方式',
|
||||
'config.keys.addTitle': '認証キーを追加',
|
||||
'config.keys.editTitle': '認証キーを編集',
|
||||
'common.close': '閉じる',
|
||||
'config.keys.label': '認証キー',
|
||||
'config.keys.hide': 'キーを隠す',
|
||||
@@ -257,6 +260,7 @@ export const jaOverrides = {
|
||||
'config.keys.adding': '追加中',
|
||||
'common.add': '追加',
|
||||
'config.keys.deleteTitle': '認証キーを削除',
|
||||
'config.keys.deleteAllWarning': 'すべてのキーを削除すると、デフォルトですべてのキーが許可されます',
|
||||
'common.cancel': 'キャンセル',
|
||||
'common.deleting': '削除中',
|
||||
'common.delete': '削除',
|
||||
@@ -554,6 +558,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': 'ダウンロードをキャンセル中',
|
||||
@@ -602,7 +635,8 @@ export const jaOverrides = {
|
||||
'kernel.versions.reinstall': '再インストール',
|
||||
'kernel.versions.noBundled': 'このリリースには組み込みコアが含まれていません',
|
||||
'kernel.versions.offlineInstall': 'オフラインインストール',
|
||||
'kernel.access.defaultKey': '既定のキー:{key}',
|
||||
'kernel.access.firstKey': '最初のキー',
|
||||
'kernel.access.noConfiguredKey': 'キーが設定されていないため、現在はすべてのキーがデフォルトで許可されます',
|
||||
'kernel.access.connectable': '接続可能',
|
||||
'kernel.access.waiting': 'コアの起動を待っています',
|
||||
'kernel.access.localUrl': 'ローカル URL',
|
||||
|
||||
+76
-8
@@ -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': '模型别名',
|
||||
@@ -20,7 +22,6 @@ export const zhCN = {
|
||||
'app.coreRequired.description': '启动 CPA 内核后,才能使用配置、OAuth、API 接入等功能。',
|
||||
'app.contact.title': '通过 QQ 联系我们',
|
||||
'app.contact.label': '联系我们',
|
||||
'app.close.eyebrow': '关闭窗口',
|
||||
'app.close.title': '请选择后续操作',
|
||||
'app.close.description': '你可以退出程序,或让 EasyCLIProxyAPI 继续在系统托盘中运行。',
|
||||
'app.close.minimizing': '正在最小化...',
|
||||
@@ -223,6 +224,7 @@ export const zhCN = {
|
||||
'config.error.duplicateKey': '该鉴权密钥已经存在',
|
||||
'config.error.remarkTooLong': '备注不能超过 80 个字符',
|
||||
'config.notice.keyAdded': '鉴权密钥已新增',
|
||||
'config.notice.keyUpdated': '鉴权密钥已更新',
|
||||
'config.notice.keyDeleted': '鉴权密钥已删除',
|
||||
'config.notice.keyCopied': '鉴权密钥已复制',
|
||||
'config.notice.keyCopyFailed': '复制鉴权密钥失败',
|
||||
@@ -237,8 +239,8 @@ export const zhCN = {
|
||||
'config.keys.noRemark': '未填写备注',
|
||||
'config.keys.copy': '复制鉴权密钥',
|
||||
'config.keys.copyNth': '复制第 {number} 个鉴权密钥',
|
||||
'config.keys.builtInProtected': '内置密钥不能删除',
|
||||
'config.keys.builtIn': '内置',
|
||||
'config.keys.edit': '编辑鉴权密钥',
|
||||
'config.keys.editNth': '编辑第 {number} 个鉴权密钥',
|
||||
'config.keys.delete': '删除鉴权密钥',
|
||||
'config.keys.deleteNth': '删除第 {number} 个鉴权密钥',
|
||||
'config.keys.empty': '暂无鉴权密钥',
|
||||
@@ -250,6 +252,7 @@ export const zhCN = {
|
||||
'config.plugins.enable': '启用插件系统',
|
||||
'config.routing.title': '路由策略',
|
||||
'config.keys.addTitle': '新增鉴权密钥',
|
||||
'config.keys.editTitle': '编辑鉴权密钥',
|
||||
'common.close': '关闭',
|
||||
'config.keys.label': '鉴权密钥',
|
||||
'config.keys.hide': '隐藏密钥',
|
||||
@@ -260,6 +263,7 @@ export const zhCN = {
|
||||
'config.keys.adding': '正在新增',
|
||||
'common.add': '新增',
|
||||
'config.keys.deleteTitle': '删除鉴权密钥',
|
||||
'config.keys.deleteAllWarning': '删除所有密钥将会导致默认放行所有密钥',
|
||||
'common.cancel': '取消',
|
||||
'common.deleting': '正在删除',
|
||||
'common.delete': '删除',
|
||||
@@ -557,6 +561,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': '正在取消下载',
|
||||
@@ -605,7 +638,8 @@ export const zhCN = {
|
||||
'kernel.versions.reinstall': '重新安装',
|
||||
'kernel.versions.noBundled': '当前发行包未包含内置内核',
|
||||
'kernel.versions.offlineInstall': '离线安装',
|
||||
'kernel.access.defaultKey': '默认密钥:{key}',
|
||||
'kernel.access.firstKey': '第一个密钥',
|
||||
'kernel.access.noConfiguredKey': '未设置密钥,当前默认放行所有密钥',
|
||||
'kernel.access.connectable': '可连接',
|
||||
'kernel.access.waiting': '等待内核启动',
|
||||
'kernel.access.localUrl': '本机 URL',
|
||||
@@ -680,6 +714,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',
|
||||
@@ -693,7 +729,6 @@ export const en: Record<MessageKey, string> = {
|
||||
'app.coreRequired.description': 'Start the CPA core to use settings, OAuth, API access, and other features.',
|
||||
'app.contact.title': 'Contact us on QQ',
|
||||
'app.contact.label': 'Contact Us',
|
||||
'app.close.eyebrow': 'Close window',
|
||||
'app.close.title': 'What would you like to do?',
|
||||
'app.close.description': 'Exit the app, or keep EasyCLIProxyAPI running in the system tray.',
|
||||
'app.close.minimizing': 'Minimizing...',
|
||||
@@ -896,6 +931,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'config.error.duplicateKey': 'This authentication key already exists',
|
||||
'config.error.remarkTooLong': 'The remark cannot exceed 80 characters',
|
||||
'config.notice.keyAdded': 'Authentication key added',
|
||||
'config.notice.keyUpdated': 'Authentication key updated',
|
||||
'config.notice.keyDeleted': 'Authentication key deleted',
|
||||
'config.notice.keyCopied': 'Authentication key copied',
|
||||
'config.notice.keyCopyFailed': 'Failed to copy the authentication key',
|
||||
@@ -910,8 +946,8 @@ export const en: Record<MessageKey, string> = {
|
||||
'config.keys.noRemark': 'No remark',
|
||||
'config.keys.copy': 'Copy authentication key',
|
||||
'config.keys.copyNth': 'Copy authentication key {number}',
|
||||
'config.keys.builtInProtected': 'The built-in key cannot be deleted',
|
||||
'config.keys.builtIn': 'Built-in',
|
||||
'config.keys.edit': 'Edit authentication key',
|
||||
'config.keys.editNth': 'Edit authentication key {number}',
|
||||
'config.keys.delete': 'Delete authentication key',
|
||||
'config.keys.deleteNth': 'Delete authentication key {number}',
|
||||
'config.keys.empty': 'No authentication keys',
|
||||
@@ -923,6 +959,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'config.plugins.enable': 'Enable plugin system',
|
||||
'config.routing.title': 'Routing Strategy',
|
||||
'config.keys.addTitle': 'Add Authentication Key',
|
||||
'config.keys.editTitle': 'Edit Authentication Key',
|
||||
'common.close': 'Close',
|
||||
'config.keys.label': 'Authentication Key',
|
||||
'config.keys.hide': 'Hide key',
|
||||
@@ -933,6 +970,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'config.keys.adding': 'Adding',
|
||||
'common.add': 'Add',
|
||||
'config.keys.deleteTitle': 'Delete Authentication Key',
|
||||
'config.keys.deleteAllWarning': 'Deleting all keys will allow all keys by default',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.deleting': 'Deleting',
|
||||
'common.delete': 'Delete',
|
||||
@@ -1230,6 +1268,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',
|
||||
@@ -1278,7 +1345,8 @@ export const en: Record<MessageKey, string> = {
|
||||
'kernel.versions.reinstall': 'Reinstall',
|
||||
'kernel.versions.noBundled': 'This release does not include a bundled core',
|
||||
'kernel.versions.offlineInstall': 'Offline Install',
|
||||
'kernel.access.defaultKey': 'Default key: {key}',
|
||||
'kernel.access.firstKey': 'First key',
|
||||
'kernel.access.noConfiguredKey': 'No key is configured; all keys are currently allowed by default',
|
||||
'kernel.access.connectable': 'Connectable',
|
||||
'kernel.access.waiting': 'Waiting for core startup',
|
||||
'kernel.access.localUrl': 'Local URL',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+64
-34
@@ -7,6 +7,7 @@ import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
KeyRound,
|
||||
Pencil,
|
||||
Plus,
|
||||
Plug,
|
||||
RefreshCw,
|
||||
@@ -26,10 +27,9 @@ type CoreConfigSettings = {
|
||||
type CoreApiKey = {
|
||||
apiKey: string;
|
||||
remark: string;
|
||||
builtIn: boolean;
|
||||
};
|
||||
|
||||
type ConfigAction = 'add-key' | 'delete-key' | 'plugins' | 'routing' | null;
|
||||
type ConfigAction = 'add-key' | 'update-key' | 'delete-key' | 'plugins' | 'routing' | null;
|
||||
type NoticeTone = 'success' | 'error';
|
||||
|
||||
const ROUTING_OPTIONS = [
|
||||
@@ -44,6 +44,7 @@ export function ConfigPanelPage() {
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [busyAction, setBusyAction] = useState<ConfigAction>(null);
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
const [editingApiKey, setEditingApiKey] = useState<string | null>(null);
|
||||
const [deleteIndex, setDeleteIndex] = useState<number | null>(null);
|
||||
const [newApiKey, setNewApiKey] = useState('');
|
||||
const [newApiKeyRemark, setNewApiKeyRemark] = useState('');
|
||||
@@ -114,6 +115,7 @@ export function ConfigPanelPage() {
|
||||
};
|
||||
|
||||
const openAddDialog = () => {
|
||||
setEditingApiKey(null);
|
||||
setNewApiKey('');
|
||||
setNewApiKeyRemark('');
|
||||
setShowApiKey(false);
|
||||
@@ -121,11 +123,21 @@ export function ConfigPanelPage() {
|
||||
setAddDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEditDialog = (entry: CoreApiKey) => {
|
||||
setEditingApiKey(entry.apiKey);
|
||||
setNewApiKey(entry.apiKey);
|
||||
setNewApiKeyRemark(entry.remark);
|
||||
setShowApiKey(false);
|
||||
setFormError('');
|
||||
setAddDialogOpen(true);
|
||||
};
|
||||
|
||||
const closeAddDialog = () => {
|
||||
if (busyAction === 'add-key') {
|
||||
if (busyAction === 'add-key' || busyAction === 'update-key') {
|
||||
return;
|
||||
}
|
||||
setAddDialogOpen(false);
|
||||
setEditingApiKey(null);
|
||||
setFormError('');
|
||||
};
|
||||
|
||||
@@ -149,7 +161,7 @@ export function ConfigPanelPage() {
|
||||
setFormError(t('config.error.invalidKey'));
|
||||
return;
|
||||
}
|
||||
if (settings?.apiKeys.some((entry) => entry.apiKey === apiKey)) {
|
||||
if (settings?.apiKeys.some((entry) => entry.apiKey === apiKey && entry.apiKey !== editingApiKey)) {
|
||||
setFormError(t('config.error.duplicateKey'));
|
||||
return;
|
||||
}
|
||||
@@ -159,14 +171,16 @@ export function ConfigPanelPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const editing = editingApiKey !== null;
|
||||
const saved = await runMutation(
|
||||
'add-key',
|
||||
'add_core_api_key',
|
||||
{ apiKey, remark },
|
||||
t('config.notice.keyAdded'),
|
||||
editing ? 'update-key' : 'add-key',
|
||||
editing ? 'update_core_api_key' : 'add_core_api_key',
|
||||
editing ? { originalApiKey: editingApiKey, apiKey, remark } : { apiKey, remark },
|
||||
editing ? t('config.notice.keyUpdated') : t('config.notice.keyAdded'),
|
||||
);
|
||||
if (saved) {
|
||||
setAddDialogOpen(false);
|
||||
setEditingApiKey(null);
|
||||
setNewApiKey('');
|
||||
setNewApiKeyRemark('');
|
||||
}
|
||||
@@ -228,6 +242,8 @@ export function ConfigPanelPage() {
|
||||
const controlsDisabled = loading || settings === null || busyAction !== null;
|
||||
const selectedDeleteKey =
|
||||
deleteIndex === null ? '' : settings?.apiKeys[deleteIndex]?.apiKey || '';
|
||||
const deletingLastKey = deleteIndex !== null && settings?.apiKeys.length === 1;
|
||||
const keyMutationBusy = busyAction === 'add-key' || busyAction === 'update-key';
|
||||
|
||||
return (
|
||||
<section className="page config-page">
|
||||
@@ -302,22 +318,26 @@ export function ConfigPanelPage() {
|
||||
<Copy size={16} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
{entry.builtIn ? (
|
||||
<span className="config-protected-key" title={t('config.keys.builtInProtected')}>
|
||||
{t('config.keys.builtIn')}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button danger"
|
||||
onClick={() => setDeleteIndex(index)}
|
||||
disabled={controlsDisabled}
|
||||
title={t('config.keys.delete')}
|
||||
aria-label={t('config.keys.deleteNth', { number: index + 1 })}
|
||||
>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button quiet"
|
||||
onClick={() => openEditDialog(entry)}
|
||||
disabled={controlsDisabled}
|
||||
title={t('config.keys.edit')}
|
||||
aria-label={t('config.keys.editNth', { number: index + 1 })}
|
||||
>
|
||||
<Pencil size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button danger"
|
||||
onClick={() => setDeleteIndex(index)}
|
||||
disabled={controlsDisabled}
|
||||
title={t('config.keys.delete')}
|
||||
aria-label={t('config.keys.deleteNth', { number: index + 1 })}
|
||||
>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -409,13 +429,15 @@ export function ConfigPanelPage() {
|
||||
<div className="config-dialog-heading">
|
||||
<div>
|
||||
<KeyRound size={19} aria-hidden="true" />
|
||||
<h2 id="add-api-key-title">{t('config.keys.addTitle')}</h2>
|
||||
<h2 id="add-api-key-title">
|
||||
{editingApiKey === null ? t('config.keys.addTitle') : t('config.keys.editTitle')}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button quiet"
|
||||
onClick={closeAddDialog}
|
||||
disabled={busyAction === 'add-key'}
|
||||
disabled={keyMutationBusy}
|
||||
title={t('common.close')}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
@@ -434,7 +456,7 @@ export function ConfigPanelPage() {
|
||||
setNewApiKey(event.currentTarget.value);
|
||||
setFormError('');
|
||||
}}
|
||||
disabled={busyAction === 'add-key'}
|
||||
disabled={keyMutationBusy}
|
||||
aria-invalid={Boolean(formError)}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
@@ -442,7 +464,7 @@ export function ConfigPanelPage() {
|
||||
type="button"
|
||||
className="icon-button quiet"
|
||||
onClick={() => setShowApiKey((visible) => !visible)}
|
||||
disabled={busyAction === 'add-key'}
|
||||
disabled={keyMutationBusy}
|
||||
title={showApiKey ? t('config.keys.hide') : t('config.keys.show')}
|
||||
aria-label={showApiKey ? t('config.keys.hide') : t('config.keys.show')}
|
||||
>
|
||||
@@ -466,7 +488,7 @@ export function ConfigPanelPage() {
|
||||
setNewApiKeyRemark(event.currentTarget.value);
|
||||
setFormError('');
|
||||
}}
|
||||
disabled={busyAction === 'add-key'}
|
||||
disabled={keyMutationBusy}
|
||||
placeholder={t('config.keys.remarkPlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
@@ -480,14 +502,16 @@ export function ConfigPanelPage() {
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={generateApiKey}
|
||||
disabled={busyAction === 'add-key'}
|
||||
disabled={keyMutationBusy}
|
||||
>
|
||||
<Sparkles size={16} aria-hidden="true" />
|
||||
{t('config.keys.generate')}
|
||||
</button>
|
||||
<button type="submit" className="primary-button" disabled={busyAction === 'add-key'}>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
{busyAction === 'add-key' ? t('config.keys.adding') : t('common.add')}
|
||||
<button type="submit" className="primary-button" disabled={keyMutationBusy}>
|
||||
{editingApiKey === null ? <Plus size={16} aria-hidden="true" /> : <Check size={16} aria-hidden="true" />}
|
||||
{keyMutationBusy
|
||||
? editingApiKey === null ? t('config.keys.adding') : t('common.saving')
|
||||
: editingApiKey === null ? t('common.add') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -501,7 +525,7 @@ export function ConfigPanelPage() {
|
||||
}
|
||||
}}>
|
||||
<div
|
||||
className="config-dialog config-delete-dialog"
|
||||
className={`config-dialog config-delete-dialog ${deletingLastKey ? 'has-warning' : ''}`}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-api-key-title"
|
||||
@@ -513,6 +537,12 @@ export function ConfigPanelPage() {
|
||||
</div>
|
||||
</div>
|
||||
<code className="config-delete-key">{maskApiKey(selectedDeleteKey)}</code>
|
||||
{deletingLastKey ? (
|
||||
<div className="config-delete-warning">
|
||||
<AlertCircle size={17} aria-hidden="true" />
|
||||
<span>{t('config.keys.deleteAllWarning')}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="config-dialog-actions two-actions">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
+160
-108
@@ -1,15 +1,15 @@
|
||||
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, Eye, EyeOff, 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';
|
||||
import geminiIcon from '../assets/icons/gemini.svg';
|
||||
import { clientApiProfiles, DEFAULT_CLIENT_API_KEY } from '../services/clientAccess';
|
||||
import { clientApiProfiles } from '../services/clientAccess';
|
||||
import packageMetadata from '../../package.json';
|
||||
import { useI18n } from '../i18n';
|
||||
import { useAppUpdate } from '../appUpdate';
|
||||
|
||||
type CorePlatform = {
|
||||
os: string;
|
||||
@@ -57,11 +57,8 @@ type GuiSettings = {
|
||||
runOnStartup: boolean;
|
||||
};
|
||||
|
||||
type AppUpdateInfo = {
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
updateAvailable: boolean;
|
||||
releaseUrl: string;
|
||||
type CoreConfigSummary = {
|
||||
apiKeys: Array<{ apiKey: string }>;
|
||||
};
|
||||
|
||||
const APP_RELEASE_URL = 'https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest';
|
||||
@@ -70,18 +67,12 @@ 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 +96,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 +115,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);
|
||||
@@ -141,6 +138,9 @@ export function KernelPage() {
|
||||
const [installDialogOpen, setInstallDialogOpen] = useState(false);
|
||||
const [cancellingInstall, setCancellingInstall] = useState(false);
|
||||
const [copiedApiField, setCopiedApiField] = useState('');
|
||||
const [homeApiKey, setHomeApiKey] = useState<string | null | undefined>(undefined);
|
||||
const [homeApiKeyError, setHomeApiKeyError] = useState(false);
|
||||
const [showHomeApiKey, setShowHomeApiKey] = useState(false);
|
||||
const [lanIpv4, setLanIpv4] = useState<string | null>(null);
|
||||
const [lanIpChecked, setLanIpChecked] = useState(false);
|
||||
const installDialogRef = useRef<HTMLDivElement>(null);
|
||||
@@ -220,6 +220,9 @@ export function KernelPage() {
|
||||
loadBundledCore();
|
||||
loadInstallTask();
|
||||
loadGuiSettings();
|
||||
if (view === 'home') {
|
||||
void loadHomeApiKey();
|
||||
}
|
||||
|
||||
if (!latestAutoCheckStarted) {
|
||||
latestAutoCheckStarted = true;
|
||||
@@ -240,36 +243,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;
|
||||
@@ -371,6 +344,17 @@ export function KernelPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadHomeApiKey = async () => {
|
||||
try {
|
||||
const settings = await invoke<CoreConfigSummary>('get_core_config_settings');
|
||||
setHomeApiKey(settings.apiKeys[0]?.apiKey ?? null);
|
||||
setHomeApiKeyError(false);
|
||||
} catch {
|
||||
setHomeApiKey(undefined);
|
||||
setHomeApiKeyError(true);
|
||||
}
|
||||
};
|
||||
|
||||
const saveNetworkSettings = async (
|
||||
allowLan: boolean,
|
||||
port: number,
|
||||
@@ -625,25 +609,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 +650,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 +725,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 +832,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,13 +982,58 @@ export function KernelPage() {
|
||||
</div>
|
||||
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{view === 'home' ? (
|
||||
<section className="panel client-api-panel">
|
||||
<div className="panel-heading client-api-heading">
|
||||
<div>
|
||||
<h2>API URL</h2>
|
||||
<p>{t('kernel.access.defaultKey', { key: DEFAULT_CLIENT_API_KEY })}</p>
|
||||
{typeof homeApiKey === 'string' ? (
|
||||
<div className="client-api-key-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="client-api-default-key"
|
||||
aria-pressed={showHomeApiKey}
|
||||
title={showHomeApiKey ? t('config.keys.hide') : t('config.keys.show')}
|
||||
onClick={() => setShowHomeApiKey((visible) => !visible)}
|
||||
>
|
||||
<span>{t('kernel.access.firstKey')}</span>
|
||||
<code>{showHomeApiKey ? homeApiKey : '******'}</code>
|
||||
{showHomeApiKey ? (
|
||||
<EyeOff size={14} aria-hidden="true" />
|
||||
) : (
|
||||
<Eye size={14} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button quiet client-api-key-copy"
|
||||
title={t('config.keys.copy')}
|
||||
aria-label={t('config.keys.copy')}
|
||||
onClick={() =>
|
||||
void copyApiValue(
|
||||
homeApiKey,
|
||||
'home:api-key',
|
||||
t('config.notice.keyCopied'),
|
||||
)
|
||||
}
|
||||
>
|
||||
{copiedApiField === 'home:api-key' ? (
|
||||
<Check size={14} aria-hidden="true" />
|
||||
) : (
|
||||
<Copy size={14} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : homeApiKey === null ? (
|
||||
<p className="client-api-no-key">{t('kernel.access.noConfiguredKey')}</p>
|
||||
) : (
|
||||
<p className={homeApiKeyError ? 'error' : undefined}>
|
||||
{homeApiKeyError ? t('common.unavailable') : t('common.loading')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className={`state-pill ${coreRunning ? 'success' : ''}`}>
|
||||
{coreRunning ? t('kernel.access.connectable') : t('kernel.access.waiting')}
|
||||
@@ -1064,8 +1115,9 @@ export function KernelPage() {
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{installDialogOpen && progress ? (
|
||||
{view === 'versions' && installDialogOpen && progress ? (
|
||||
<div className="install-dialog-backdrop">
|
||||
<div
|
||||
ref={installDialogRef}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { getCurrentLocale, translate } from '../i18n';
|
||||
|
||||
export const DEFAULT_CLIENT_API_KEY = '123456';
|
||||
|
||||
export type ClientApiProfile = {
|
||||
id: 'openai' | 'claude' | 'gemini';
|
||||
name: string;
|
||||
|
||||
+279
-69
@@ -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 {
|
||||
@@ -605,16 +636,84 @@ input:focus-visible {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.client-api-key-actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.client-api-default-key {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
min-height: 19px;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #5f5a53;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.client-api-default-key span {
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-api-default-key code {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #2d2a26;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-api-default-key svg {
|
||||
flex: none;
|
||||
color: #7a746c;
|
||||
}
|
||||
|
||||
.client-api-default-key:hover span {
|
||||
color: #2d2a26;
|
||||
}
|
||||
|
||||
.client-api-key-copy {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-color: transparent !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.client-api-key-copy:hover:not(:disabled) {
|
||||
border-color: #d5d2cb !important;
|
||||
background: #f4f1ea !important;
|
||||
}
|
||||
|
||||
.client-api-no-key {
|
||||
color: #9f2d20 !important;
|
||||
}
|
||||
|
||||
.client-api-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.client-api-card {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid #e3e1db;
|
||||
@@ -673,6 +772,7 @@ input:focus-visible {
|
||||
|
||||
.client-api-values {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
@@ -719,26 +819,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 +909,95 @@ 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);
|
||||
grid-auto-rows: 1fr;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.home-page > .home-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
align-self: stretch;
|
||||
align-items: stretch;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.home-page > .client-api-panel {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.home-page > .home-layout > .control-panel,
|
||||
.home-page > .client-api-panel {
|
||||
display: grid;
|
||||
height: 100%;
|
||||
align-self: stretch;
|
||||
row-gap: clamp(12px, 2vh, 22px);
|
||||
}
|
||||
|
||||
.home-page > .home-layout > .control-panel {
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.home-page > .client-api-panel {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.home-page > .home-layout > .control-panel > .panel-heading,
|
||||
.home-page > .client-api-panel > .panel-heading,
|
||||
.version-management-layout > .panel > .panel-heading {
|
||||
min-height: 0;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.version-management-layout {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.version-management-layout > .panel {
|
||||
height: 100%;
|
||||
align-self: stretch;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
row-gap: clamp(12px, 2vh, 22px);
|
||||
}
|
||||
|
||||
.home-page > .home-layout > .control-panel > .panel-detail-grid,
|
||||
.version-management-layout > .panel > .panel-detail-grid,
|
||||
.home-page > .client-api-panel > .client-api-grid {
|
||||
align-content: space-evenly;
|
||||
}
|
||||
|
||||
.home-page > .home-layout > .control-panel > .panel-action-row,
|
||||
.version-management-layout > .panel > .panel-action-row {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.info-list {
|
||||
@@ -1129,7 +1312,7 @@ input:focus-visible {
|
||||
|
||||
.config-key-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 86px;
|
||||
grid-template-columns: minmax(0, 1fr) 112px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 66px;
|
||||
@@ -1183,24 +1366,6 @@ input:focus-visible {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.config-protected-key {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #c9c4ba;
|
||||
border-radius: 999px;
|
||||
background: #f4f1ea;
|
||||
color: #6d6760;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.config-protected-key {
|
||||
width: 40px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.config-key-details code {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -1213,7 +1378,7 @@ input:focus-visible {
|
||||
|
||||
.config-key-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 40px;
|
||||
grid-template-columns: repeat(3, 32px);
|
||||
justify-content: end;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1478,6 +1643,7 @@ input:focus-visible {
|
||||
}
|
||||
|
||||
.close-dialog {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
width: min(430px, 100%);
|
||||
@@ -1491,14 +1657,38 @@ input:focus-visible {
|
||||
|
||||
.close-dialog-heading {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding-right: 34px;
|
||||
}
|
||||
|
||||
.close-dialog-heading span {
|
||||
.close-dialog-dismiss {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #7a746c;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.close-dialog-dismiss:hover:not(:disabled) {
|
||||
border-color: #d5d2cb;
|
||||
background: #f0ede6;
|
||||
color: #2d2a26;
|
||||
}
|
||||
|
||||
.close-dialog-dismiss:focus-visible {
|
||||
outline: 3px solid rgba(66, 99, 127, 0.3);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.close-dialog-dismiss:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.close-dialog-heading h2 {
|
||||
@@ -1567,6 +1757,12 @@ input:focus-visible {
|
||||
height: 202px;
|
||||
}
|
||||
|
||||
.config-delete-dialog.has-warning {
|
||||
grid-template-rows: 40px 52px auto 40px;
|
||||
height: auto;
|
||||
min-height: 254px;
|
||||
}
|
||||
|
||||
.config-delete-key {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -1582,6 +1778,24 @@ input:focus-visible {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.config-delete-warning {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 9px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e2b6ae;
|
||||
border-radius: 6px;
|
||||
background: #fff1ee;
|
||||
color: #8f3025;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.config-delete-warning svg {
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.config-toast {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
@@ -5039,6 +5253,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 +5299,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 +5368,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 +5557,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