feat(updater): add app update checks and refine the kernel dashboard
This commit is contained in:
1 parent
4e8a02fdfc
commit
d1833e63ad
7 files changed
+247
-44
No files matched your search
Generated
+1
@@ -439,6 +439,7 @@ dependencies = [
|
||||
"libc",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_norway",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(¤t_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");
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"label": "main",
|
||||
"title": "Easy_CLIProxyAPI",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"height": 810,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user