feat(updater): add app update checks and refine the kernel dashboard

This commit is contained in:
lzt404 committed 2026-07-19 19:03:40 +08:00
1 parent 4e8a02fdfc
commit d1833e63ad
7 files changed
+247 -44

No files matched your search

+1
View File
@@ -439,6 +439,7 @@ dependencies = [
"libc",
"reqwest 0.12.28",
"rusqlite",
"semver",
"serde",
"serde_json",
"serde_norway",
+1
View File
@@ -16,6 +16,7 @@ futures-util = "0.3"
json5 = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
rusqlite = { version = "0.32.1", features = ["bundled"] }
semver = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_norway = "0.9"
+71
View File
@@ -26,6 +26,7 @@ const RELEASE_PAGE_URL: &str = "https://github.com/router-for-me/CLIProxyAPI/rel
const RELEASE_ATOM_URL: &str = "https://github.com/router-for-me/CLIProxyAPI/releases.atom";
const RELEASE_DOWNLOAD_PREFIX: &str =
"https://github.com/router-for-me/CLIProxyAPI/releases/download/";
const APP_RELEASE_PAGE_URL: &str = "https://github.com/lzt404/Easy_CLIProxyAPI/releases/latest";
const CORE_INSTALL_PROGRESS_EVENT: &str = "core-install-progress";
const CORE_STATUS_EVENT: &str = "core-status-changed";
const CORE_METADATA_FILE: &str = "cpa-gui-meta.json";
@@ -54,6 +55,11 @@ const USER_AGENT: &str = concat!(
env!("CARGO_PKG_VERSION"),
" (+https://github.com/router-for-me/CLIProxyAPI)"
);
const APP_USER_AGENT: &str = concat!(
"Easy_CLIProxyAPI/",
env!("CARGO_PKG_VERSION"),
" (+https://github.com/lzt404/Easy_CLIProxyAPI)"
);
static CORE_CONFIG_FILE_LOCK: Mutex<()> = Mutex::new(());
static AGENT_CONFIG_FILE_LOCK: Mutex<()> = Mutex::new(());
@@ -110,6 +116,15 @@ struct CoreLatest {
asset_name: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct AppUpdateInfo {
current_version: String,
latest_version: String,
update_available: bool,
release_url: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct BundledCoreInfo {
@@ -4629,6 +4644,45 @@ fn open_external_url(url: String) -> Result<(), String> {
open_external_url_inner(&url)
}
#[tauri::command]
async fn check_app_update() -> Result<AppUpdateInfo, String> {
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(8))
.read_timeout(Duration::from_secs(15))
.timeout(Duration::from_secs(20))
.build()
.map_err(|error| format!("创建版本检查客户端失败: {error}"))?;
let response = client
.get(APP_RELEASE_PAGE_URL)
.header(reqwest::header::ACCEPT, "text/html,application/xhtml+xml")
.header(reqwest::header::USER_AGENT, APP_USER_AGENT)
.send()
.await
.map_err(|error| format!("检查软件更新失败: {error}"))?;
let status = response.status();
let final_url = response.url().clone();
if !status.is_success() {
let body = response
.text()
.await
.map_err(|error| format!("读取软件更新响应失败: {error}"))?;
return Err(format_github_error(status.as_u16(), &body));
}
let latest_version = release_tag_from_url(&final_url)
.map(|version| normalize_version(&version))
.ok_or_else(|| "GitHub latest 页面没有返回正式版本标签".to_string())?;
let current_version = normalize_version(env!("CARGO_PKG_VERSION"));
let update_available = is_app_update_available(&current_version, &latest_version)?;
Ok(AppUpdateInfo {
current_version,
latest_version,
update_available,
release_url: APP_RELEASE_PAGE_URL.to_string(),
})
}
#[tauri::command]
async fn check_latest_core() -> Result<CoreLatest, String> {
let platform = current_core_platform()?;
@@ -5010,6 +5064,14 @@ fn release_tag_from_url(url: &reqwest::Url) -> Option<String> {
}
}
fn is_app_update_available(current: &str, latest: &str) -> Result<bool, String> {
let parse = |value: &str| {
semver::Version::parse(value.trim().trim_start_matches('v'))
.map_err(|error| format!("无法解析版本号 {value}: {error}"))
};
Ok(parse(latest)? > parse(current)?)
}
#[cfg(test)]
fn parse_release_assets(html: &str) -> Vec<GithubAsset> {
let mut assets = Vec::new();
@@ -8622,6 +8684,7 @@ fn main() {
get_oauth_status,
submit_oauth_callback,
open_external_url,
check_app_update,
check_latest_core,
detect_bundled_core,
install_core_version,
@@ -10214,6 +10277,14 @@ mod tests {
assert_eq!(release_tag_from_atom(xml).as_deref(), Some("v7.2.80"));
}
#[test]
fn app_update_comparison_uses_semantic_versions() {
assert!(is_app_update_available("v0.1.9", "v0.2.0").unwrap());
assert!(is_app_update_available("v0.2.0-beta.1", "v0.2.0").unwrap());
assert!(!is_app_update_available("v0.2.0", "v0.2.0").unwrap());
assert!(!is_app_update_available("v0.2.0", "v0.1.9").unwrap());
}
#[test]
fn synthetic_release_uses_official_asset_names_and_urls() {
let release = release_from_tag("7.2.80");
+1 -1
View File
@@ -15,7 +15,7 @@
"label": "main",
"title": "Easy_CLIProxyAPI",
"width": 1200,
"height": 800,
"height": 810,
"minWidth": 900,
"minHeight": 600
}
-4
View File
@@ -166,10 +166,6 @@ function AppContent() {
<span>联系我们</span>
<ExternalLink size={13} aria-hidden="true" />
</button>
<div className="sidebar-footer">
<span>Easy_CLIProxyAPI</span>
<strong>v0.1.9</strong>
</div>
</div>
</aside>
+113 -13
View File
@@ -1,12 +1,14 @@
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, Info } from 'lucide-react';
import { AlertCircle, Check, Copy, ExternalLink, Info, RefreshCw } 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 packageMetadata from '../../package.json';
type CorePlatform = {
os: string;
@@ -54,10 +56,30 @@ type GuiSettings = {
runOnStartup: boolean;
};
type AppUpdateInfo = {
currentVersion: string;
latestVersion: string;
updateAvailable: boolean;
releaseUrl: string;
};
const APP_RELEASE_URL = 'https://github.com/lzt404/Easy_CLIProxyAPI/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) {
@@ -90,6 +112,10 @@ 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);
@@ -212,6 +238,36 @@ 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;
@@ -565,12 +621,35 @@ 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 () => {
try {
await invoke('open_external_url', { url: appUpdate?.releaseUrl || APP_RELEASE_URL });
} catch (error) {
setAppUpdateError(`打开更新页面失败:${String(error)}`);
}
};
const latestVersion = latest?.version ?? '';
const currentVersion = coreStatus?.currentVersion ?? '';
const coreInstalled = Boolean(coreStatus?.installed);
const coreRunning = Boolean(coreStatus?.running);
const busy = checkingLatest || installing || processBusy;
const installDisabled = busy || Boolean(coreStatus?.running);
const offlineInstallDisabled = installing || processBusy || coreRunning;
const computedPercent =
progress?.percent ??
(progress?.total && progress.total > 0 ? (progress.downloaded / progress.total) * 100 : null);
@@ -597,6 +676,12 @@ export function KernelPage() {
: statusError
? '检测失败'
: '检测中';
const appUpdateLabel = appUpdateError
? '检查失败,重试'
: appUpdate?.updateAvailable
? `更新至 ${displayAppVersion(appUpdate.latestVersion)}`
: '';
const appUpdateTone = appUpdateError ? 'error' : 'update';
const latestLabel = checkingLatest
? '检查中'
: latestVersion || (latestError ? '检查失败' : '未检查');
@@ -609,17 +694,21 @@ export function KernelPage() {
: currentVersion === latestVersion
? '已是最新版本'
: '可安装新版本';
const platformLabel = platform ? `${platform.os} / ${platform.arch}` : platformError || '检测中';
const platformOsLabel = platform?.os || (platformError ? '检测失败' : '检测中');
const platformArchLabel = platform?.arch || (platformError ? '检测失败' : '检测中');
const installTaskRunning = Boolean(installing || progress?.running);
const offlineInstallRequired = Boolean(coreStatus && !coreInstalled && latestError);
const versionStatusLabel = installTaskRunning
? cancellingInstall
? '正在取消下载'
: progress?.phase || '正在安装'
: message || updateStateLabel;
: offlineInstallRequired
? 'Github连接失败,请使用“离线安装”'
: message || updateStateLabel;
const versionStatusTone: MessageType = installTaskRunning
? 'info'
: offlineInstallRequired
? 'error'
: message
? messageType
: latestError
@@ -677,17 +766,28 @@ export function KernelPage() {
</div>
</div>
<div className="status-card">
<span>当前版本</span>
<span>软件版本</span>
<div className="software-version-line">
<strong>{currentAppVersion}</strong>
{appUpdateLabel ? (
<button
type="button"
className={`software-version-action ${appUpdateTone}`}
title={appUpdateError || '打开 GitHub 最新版本页面'}
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>内核版本</span>
<strong>{currentVersion || '未安装'}</strong>
</div>
<div className="status-card">
<span>最新版本</span>
<strong>{latestLabel}</strong>
</div>
<div className="status-card">
<span>运行平台</span>
<strong>{platformLabel}</strong>
</div>
</div>
<div className="kernel-layout">
@@ -864,7 +964,7 @@ export function KernelPage() {
type="button"
className="primary-button"
title={(bundledCore?.assetName ?? bundledCoreError) || '当前发行包未包含内置内核'}
disabled={!bundledCore || installDisabled}
disabled={!bundledCore || offlineInstallDisabled}
onClick={() => void installBundledCore()}
>
离线安装
+60 -26
View File
@@ -222,19 +222,6 @@ input:focus-visible {
white-space: nowrap;
}
.sidebar-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #7a746c;
font-size: 12px;
}
.sidebar-footer strong {
color: #2d2a26;
}
.workspace {
min-width: 0;
}
@@ -336,16 +323,16 @@ input:focus-visible {
.status-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.status-card {
display: grid;
gap: 6px;
gap: 4px;
min-width: 0;
min-height: 84px;
padding: 16px;
min-height: 72px;
padding: 12px 14px;
border: 1px solid #e3e1db;
border-radius: 8px;
background: #fffdf8;
@@ -354,12 +341,12 @@ input:focus-visible {
.status-card-primary {
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 12px;
gap: 10px;
}
.status-card-primary > div {
display: grid;
gap: 6px;
gap: 4px;
min-width: 0;
}
@@ -374,12 +361,63 @@ input:focus-visible {
.status-card strong {
overflow: hidden;
color: #2d2a26;
font-size: 17px;
font-size: 16px;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.software-version-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.software-version-line > strong {
flex: none;
}
.software-version-action {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
min-width: 0;
max-width: 62%;
padding: 0;
border: 0;
background: transparent;
color: #817b73;
font-size: 10px;
font-weight: 600;
}
.software-version-action span {
color: inherit;
font-size: 10px;
font-weight: 600;
}
.software-version-action:hover:not(:disabled) span {
text-decoration: underline;
text-underline-offset: 2px;
}
.software-version-action:disabled {
cursor: wait;
opacity: 0.72;
}
.software-version-action.update {
color: #866b4d;
}
.software-version-action.error {
color: #8a625d;
}
.status-dot {
display: block;
width: 12px;
@@ -453,7 +491,7 @@ input:focus-visible {
}
.client-api-panel {
margin-top: 18px;
margin-top: 0;
}
.client-api-heading {
@@ -4819,7 +4857,7 @@ input:focus-visible {
}
.status-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.kernel-layout {
@@ -4911,10 +4949,6 @@ input:focus-visible {
grid-template-columns: 17px minmax(0, 1fr);
}
.sidebar-footer {
display: none;
}
.sidebar-bottom {
margin-top: 0;
padding-top: 0;