chore: bump version to 0.39.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
coso
2026-01-10 13:13:10 +08:00
co-authored by Claude Opus 4.5
parent cb1c478920
commit b2289e4f16
24 changed files with 1642 additions and 6 deletions
+1
View File
@@ -26,3 +26,4 @@ __pycache__/
# Kiro
.kiro/
.history
docs/prd/
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.38.0",
"version": "0.39.0",
"type": "module",
"repository": {
"type": "git",
+1 -1
View File
@@ -3917,7 +3917,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.38.0"
version = "0.39.0"
dependencies = [
"anyhow",
"arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "proxycast"
version = "0.38.0"
version = "0.39.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://schemas.tauri.app/config/2/capability",
"identifier": "default",
"description": "Default capabilities for ProxyCast",
"windows": ["main", "screenshot-chat"],
"windows": ["main", "screenshot-chat", "update-notification"],
"permissions": [
"core:default",
"core:webview:default",
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for ProxyCast","local":true,"windows":["main","screenshot-chat"],"permissions":["core:default","core:webview:default","core:webview:allow-webview-close","core:webview:allow-webview-position","core:webview:allow-webview-size","core:webview:allow-set-webview-position","core:webview:allow-set-webview-size","core:window:default","core:window:allow-close","core:window:allow-show","core:window:allow-hide","core:window:allow-set-focus","core:window:allow-center","core:window:allow-start-dragging","shell:allow-open","shell:allow-spawn","shell:allow-execute","shell:allow-kill","shell:allow-stdin-write","dialog:default","global-shortcut:default","global-shortcut:allow-is-registered","global-shortcut:allow-register","global-shortcut:allow-unregister",{"identifier":"shell:allow-execute","allow":[{"args":true,"name":"binaries/aster-server","sidecar":true}]},{"identifier":"shell:allow-spawn","allow":[{"args":true,"name":"binaries/aster-server","sidecar":true}]}]}}
{"default":{"identifier":"default","description":"Default capabilities for ProxyCast","local":true,"windows":["main","screenshot-chat","update-notification"],"permissions":["core:default","core:webview:default","core:webview:allow-webview-close","core:webview:allow-webview-position","core:webview:allow-webview-size","core:webview:allow-set-webview-position","core:webview:allow-set-webview-size","core:window:default","core:window:allow-close","core:window:allow-show","core:window:allow-hide","core:window:allow-set-focus","core:window:allow-center","core:window:allow-start-dragging","shell:allow-open","shell:allow-spawn","shell:allow-execute","shell:allow-kill","shell:allow-stdin-write","dialog:default","global-shortcut:default","global-shortcut:allow-is-registered","global-shortcut:allow-register","global-shortcut:allow-unregister",{"identifier":"shell:allow-execute","allow":[{"args":true,"name":"binaries/aster-server","sidecar":true}]},{"identifier":"shell:allow-spawn","allow":[{"args":true,"name":"binaries/aster-server","sidecar":true}]}]}}
+6
View File
@@ -37,6 +37,7 @@ use crate::services::api_key_provider_service::ApiKeyProviderService;
use crate::services::provider_pool_service::ProviderPoolService;
use crate::services::skill_service::SkillService;
use crate::services::token_cache_service::TokenCacheService;
use crate::services::update_check_service::UpdateCheckServiceState;
use crate::telemetry;
use super::types::{AppState, LogState, TokenCacheServiceState};
@@ -146,6 +147,7 @@ pub struct AppStates {
pub global_config_manager: GlobalConfigManagerState,
pub terminal_manager: TerminalManagerState,
pub webview_manager: WebviewManagerWrapper,
pub update_check_service: UpdateCheckServiceState,
// 用于 setup hook 的共享实例
pub shared_stats: Arc<parking_lot::RwLock<telemetry::StatsAggregator>>,
pub shared_tokens: Arc<parking_lot::RwLock<telemetry::TokenTracker>>,
@@ -233,6 +235,9 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
let webview_manager_state =
WebviewManagerWrapper(Arc::new(RwLock::new(WebviewManagerState::new())));
// 初始化更新检查服务
let update_check_service_state = UpdateCheckServiceState::new();
// 初始化全局配置管理器
let config_path = ConfigManager::default_config_path();
let global_config_manager = GlobalConfigManager::new(config.clone(), config_path);
@@ -277,6 +282,7 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
global_config_manager: global_config_manager_state,
terminal_manager: terminal_manager_state,
webview_manager: webview_manager_state,
update_check_service: update_check_service_state,
shared_stats,
shared_tokens,
shared_logger,
+23
View File
@@ -75,6 +75,7 @@ pub fn run() {
global_config_manager: global_config_manager_state,
terminal_manager: terminal_manager_state,
webview_manager: webview_manager_state,
update_check_service: update_check_service_state,
shared_stats,
shared_tokens,
shared_logger,
@@ -93,6 +94,7 @@ pub fn run() {
let shared_logger_clone = shared_logger.clone();
let flow_monitor_clone = flow_monitor.clone();
let flow_interceptor_clone = flow_interceptor.clone();
let update_check_service_clone = update_check_service_state.0.clone();
let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
@@ -154,6 +156,7 @@ pub fn run() {
.manage(global_config_manager_state)
.manage(terminal_manager_state)
.manage(webview_manager_state)
.manage(update_check_service_state)
.on_window_event(move |window, event| {
// 处理窗口关闭事件
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
@@ -563,6 +566,18 @@ pub fn run() {
}
}
});
// 启动后台更新检查任务
let app_handle_for_update = app.handle().clone();
let update_service_for_task = update_check_service_clone.clone();
tauri::async_runtime::spawn(async move {
crate::commands::update_cmd::start_background_update_check(
app_handle_for_update,
update_service_for_task,
).await;
});
tracing::info!("[启动] 后台更新检查任务已启动");
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -1153,6 +1168,14 @@ pub fn run() {
commands::screenshot_cmd::send_screenshot_chat,
commands::screenshot_cmd::close_screenshot_chat_window,
commands::screenshot_cmd::read_image_as_base64,
// Update Check commands
commands::update_cmd::check_update,
commands::update_cmd::get_update_check_settings,
commands::update_cmd::set_update_check_settings,
commands::update_cmd::skip_update_version,
commands::update_cmd::update_last_check_timestamp,
commands::update_cmd::close_update_window,
commands::update_cmd::test_update_window,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+1
View File
@@ -30,6 +30,7 @@ pub mod switch_cmd;
pub mod telemetry_cmd;
pub mod terminal_cmd;
pub mod tray_cmd;
pub mod update_cmd;
pub mod usage_cmd;
pub mod websocket_cmd;
pub mod webview_cmd;
+230
View File
@@ -0,0 +1,230 @@
//! 更新检查命令模块
//!
//! 提供自动更新检查相关的 Tauri 命令
//!
//! input: 前端调用请求
//! output: 更新信息、配置操作结果
//! pos: commands 层,被前端调用
use crate::app::AppState;
use crate::config;
use crate::services::update_check_service::{
UpdateCheckService, UpdateCheckServiceState, UpdateInfo,
};
use crate::services::update_window;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::{AppHandle, Manager, State};
use tokio::sync::RwLock;
/// 更新检查配置(前端可见)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateCheckSettings {
pub enabled: bool,
pub check_interval_hours: u32,
pub show_notification: bool,
pub last_check_timestamp: u64,
pub skipped_version: Option<String>,
}
/// 手动检查更新
#[tauri::command]
pub async fn check_update(
update_service: State<'_, UpdateCheckServiceState>,
) -> Result<UpdateInfo, String> {
let service = update_service.0.read().await;
Ok(service.check_for_updates().await)
}
/// 获取更新检查配置
#[tauri::command]
pub async fn get_update_check_settings(
app_state: State<'_, AppState>,
) -> Result<UpdateCheckSettings, String> {
let state = app_state.read().await;
let update_config = &state.config.experimental.update_check;
Ok(UpdateCheckSettings {
enabled: update_config.enabled,
check_interval_hours: update_config.check_interval_hours,
show_notification: update_config.show_notification,
last_check_timestamp: update_config.last_check_timestamp,
skipped_version: update_config.skipped_version.clone(),
})
}
/// 更新检查配置
#[tauri::command]
pub async fn set_update_check_settings(
app_state: State<'_, AppState>,
settings: UpdateCheckSettings,
) -> Result<(), String> {
let mut state = app_state.write().await;
let update_config = &mut state.config.experimental.update_check;
update_config.enabled = settings.enabled;
update_config.check_interval_hours = settings.check_interval_hours;
update_config.show_notification = settings.show_notification;
update_config.skipped_version = settings.skipped_version;
config::save_config(&state.config).map_err(|e| format!("保存配置失败: {}", e))
}
/// 跳过指定版本
#[tauri::command]
pub async fn skip_update_version(
app_handle: AppHandle,
app_state: State<'_, AppState>,
version: String,
) -> Result<(), String> {
let mut state = app_state.write().await;
state.config.experimental.update_check.skipped_version = Some(version);
config::save_config(&state.config).map_err(|e| format!("保存配置失败: {}", e))?;
// 关闭更新窗口
let _ = update_window::close_update_window(&app_handle);
Ok(())
}
/// 关闭更新提醒窗口
#[tauri::command]
pub fn close_update_window(app_handle: AppHandle) -> Result<(), String> {
update_window::close_update_window(&app_handle).map_err(|e| format!("关闭更新窗口失败: {}", e))
}
/// 测试更新提醒窗口(仅开发环境使用)
#[tauri::command]
pub fn test_update_window(app_handle: AppHandle) -> Result<(), String> {
let current_version = env!("CARGO_PKG_VERSION");
let test_info = UpdateInfo {
current_version: current_version.to_string(),
latest_version: Some("0.99.0".to_string()),
has_update: true,
download_url: Some(format!(
"https://github.com/aiclientproxy/proxycast/releases/tag/v0.99.0"
)),
release_notes_url: None,
checked_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
error: None,
};
update_window::open_update_window(&app_handle, &test_info)
.map_err(|e| format!("打开更新窗口失败: {}", e))
}
/// 更新上次检查时间
#[tauri::command]
pub async fn update_last_check_timestamp(app_state: State<'_, AppState>) -> Result<u64, String> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut state = app_state.write().await;
state.config.experimental.update_check.last_check_timestamp = now;
config::save_config(&state.config).map_err(|e| format!("保存配置失败: {}", e))?;
Ok(now)
}
/// 启动后台更新检查任务
///
/// 在应用启动时调用,根据配置定期检查更新
pub async fn start_background_update_check(
app_handle: tauri::AppHandle,
update_service: Arc<RwLock<UpdateCheckService>>,
) {
let app_handle_clone = app_handle.clone();
tokio::spawn(async move {
// 延迟 30 秒后开始第一次检查,避免影响启动性能
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
loop {
// 获取当前配置
let (enabled, interval_hours, show_notification, last_check, skipped_version) = {
if let Some(app_state) = app_handle_clone.try_state::<AppState>() {
let state = app_state.read().await;
let update_config = &state.config.experimental.update_check;
(
update_config.enabled,
update_config.check_interval_hours,
update_config.show_notification,
update_config.last_check_timestamp,
update_config.skipped_version.clone(),
)
} else {
(true, 24, true, 0, None)
}
};
if !enabled {
// 如果禁用了自动检查,每小时检查一次配置是否变化
tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await;
continue;
}
// 检查是否需要执行更新检查
let service = update_service.read().await;
let last_result = service.get_state().await.last_result;
let latest_version = last_result
.as_ref()
.and_then(|r| r.latest_version.as_deref());
if UpdateCheckService::should_check(
last_check,
interval_hours,
skipped_version.as_deref(),
latest_version,
) {
drop(service);
// 执行更新检查
let service = update_service.read().await;
let result = service.check_for_updates().await;
tracing::info!(
"[更新检查] 当前版本: {}, 最新版本: {:?}, 有更新: {}",
result.current_version,
result.latest_version,
result.has_update
);
// 更新检查时间
if let Some(app_state) = app_handle_clone.try_state::<AppState>() {
let mut state = app_state.write().await;
state.config.experimental.update_check.last_check_timestamp = result.checked_at;
let _ = config::save_config(&state.config);
}
// 如果有更新且启用了通知,打开独立的更新提醒窗口
if result.has_update && show_notification {
// 检查是否跳过了此版本
let should_notify = result.latest_version.as_ref().map_or(true, |latest| {
skipped_version
.as_ref()
.map_or(true, |skipped| skipped != latest)
});
if should_notify {
// 打开独立的更新提醒窗口
if let Err(e) =
update_window::open_update_window(&app_handle_clone, &result)
{
tracing::error!("[更新检查] 打开更新窗口失败: {}", e);
}
}
}
}
// 每小时检查一次是否需要执行更新检查
tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await;
}
});
}
+50
View File
@@ -404,6 +404,52 @@ fn default_screenshot_shortcut() -> String {
"CommandOrControl+Alt+Q".to_string()
}
/// 自动更新检查配置
///
/// 配置自动检查更新的行为,符合 macOS/Windows 平台规范
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UpdateCheckConfig {
/// 是否启用自动检查更新
#[serde(default = "default_update_check_enabled")]
pub enabled: bool,
/// 检查间隔(小时),默认 24 小时
#[serde(default = "default_check_interval_hours")]
pub check_interval_hours: u32,
/// 是否显示系统通知
#[serde(default = "default_show_notification")]
pub show_notification: bool,
/// 上次检查时间(Unix 时间戳,秒)
#[serde(default)]
pub last_check_timestamp: u64,
/// 已跳过的版本(用户选择"跳过此版本")
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skipped_version: Option<String>,
}
fn default_update_check_enabled() -> bool {
true
}
fn default_check_interval_hours() -> u32 {
24
}
fn default_show_notification() -> bool {
true
}
impl Default for UpdateCheckConfig {
fn default() -> Self {
Self {
enabled: default_update_check_enabled(),
check_interval_hours: default_check_interval_hours(),
show_notification: default_show_notification(),
last_check_timestamp: 0,
skipped_version: None,
}
}
}
impl Default for ScreenshotChatConfig {
fn default() -> Self {
Self {
@@ -421,6 +467,9 @@ pub struct ExperimentalFeatures {
/// 截图对话功能配置
#[serde(default)]
pub screenshot_chat: ScreenshotChatConfig,
/// 自动更新检查配置
#[serde(default)]
pub update_check: UpdateCheckConfig,
}
impl NativeAgentConfig {
@@ -1522,6 +1571,7 @@ mod unit_tests {
enabled: true,
shortcut: "CommandOrControl+Alt+X".to_string(),
},
..Default::default()
};
let yaml = serde_yaml::to_string(&config).unwrap();
+1
View File
@@ -119,6 +119,7 @@ mod tests {
enabled: true,
shortcut: "CommandOrControl+Shift+S".to_string(),
},
..Default::default()
};
assert!(is_screenshot_chat_enabled(&config));
}
+2
View File
@@ -27,6 +27,8 @@
- `kiro_event_service.rs` - Kiro 事件服务
- `machine_id_service.rs` - 机器 ID 服务
- `model_registry_service.rs` - 模型注册表服务
- `update_check_service.rs` - 自动更新检查服务(每日检查、系统通知)
- `update_window.rs` - 更新提醒独立窗口管理
## 更新提醒
+2
View File
@@ -14,4 +14,6 @@ pub mod skill_service;
pub mod switch;
pub mod sysinfo_service;
pub mod token_cache_service;
pub mod update_check_service;
pub mod update_window;
pub mod usage_service;
@@ -0,0 +1,350 @@
//! 自动更新检查服务
//!
//! 提供每日自动检查更新功能,支持:
//! - 定时检查(可配置间隔)
//! - 系统原生通知(macOS/Windows)
//! - 用户可控(启用/禁用、跳过版本)
//!
//! ## 平台适配
//! - macOS: 使用 NSUserNotification / UNUserNotificationCenter
//! - Windows: 使用 Windows Toast Notification
//!
//! ## 使用示例
//! ```rust,ignore
//! let service = UpdateCheckService::new();
//! service.start_background_check(app_handle).await;
//! ```
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
/// 更新检查结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateInfo {
/// 当前版本
pub current_version: String,
/// 最新版本
pub latest_version: Option<String>,
/// 是否有更新
pub has_update: bool,
/// 下载链接
pub download_url: Option<String>,
/// 发布说明链接
pub release_notes_url: Option<String>,
/// 检查时间(Unix 时间戳)
pub checked_at: u64,
/// 错误信息
pub error: Option<String>,
}
/// 更新检查服务状态
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateCheckState {
/// 是否正在检查
pub is_checking: bool,
/// 最后一次检查结果
pub last_result: Option<UpdateInfo>,
/// 下次检查时间(Unix 时间戳)
pub next_check_at: Option<u64>,
}
impl Default for UpdateCheckState {
fn default() -> Self {
Self {
is_checking: false,
last_result: None,
next_check_at: None,
}
}
}
/// 更新检查服务
pub struct UpdateCheckService {
state: Arc<RwLock<UpdateCheckState>>,
github_api_url: String,
}
impl UpdateCheckService {
const CURRENT_VERSION: &'static str = env!("CARGO_PKG_VERSION");
const DEFAULT_GITHUB_API_URL: &'static str =
"https://api.github.com/repos/aiclientproxy/proxycast/releases/latest";
pub fn new() -> Self {
Self {
state: Arc::new(RwLock::new(UpdateCheckState::default())),
github_api_url: Self::DEFAULT_GITHUB_API_URL.to_string(),
}
}
/// 获取当前状态
pub async fn get_state(&self) -> UpdateCheckState {
self.state.read().await.clone()
}
/// 检查是否需要执行更新检查
pub fn should_check(
last_check_timestamp: u64,
check_interval_hours: u32,
skipped_version: Option<&str>,
latest_version: Option<&str>,
) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let interval_secs = (check_interval_hours as u64) * 3600;
// 检查时间间隔
if now < last_check_timestamp + interval_secs {
return false;
}
// 检查是否跳过了当前最新版本
if let (Some(skipped), Some(latest)) = (skipped_version, latest_version) {
if skipped == latest {
return false;
}
}
true
}
/// 执行更新检查
pub async fn check_for_updates(&self) -> UpdateInfo {
{
let mut state = self.state.write().await;
state.is_checking = true;
}
let result = self.do_check().await;
{
let mut state = self.state.write().await;
state.is_checking = false;
state.last_result = Some(result.clone());
}
result
}
async fn do_check(&self) -> UpdateInfo {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build();
let client = match client {
Ok(c) => c,
Err(e) => {
return UpdateInfo {
current_version: Self::CURRENT_VERSION.to_string(),
latest_version: None,
has_update: false,
download_url: None,
release_notes_url: None,
checked_at: now,
error: Some(format!("创建 HTTP 客户端失败: {}", e)),
};
}
};
match client
.get(&self.github_api_url)
.header("User-Agent", "ProxyCast")
.header("Accept", "application/vnd.github.v3+json")
.send()
.await
{
Ok(response) => {
if response.status().is_success() {
match response.json::<serde_json::Value>().await {
Ok(data) => {
let latest_version = data["tag_name"]
.as_str()
.unwrap_or("")
.trim_start_matches('v')
.to_string();
let download_url = data["html_url"].as_str().map(|s| s.to_string());
let release_notes_url = download_url.clone();
let has_update =
Self::version_compare(Self::CURRENT_VERSION, &latest_version);
UpdateInfo {
current_version: Self::CURRENT_VERSION.to_string(),
latest_version: Some(latest_version),
has_update,
download_url,
release_notes_url,
checked_at: now,
error: None,
}
}
Err(e) => UpdateInfo {
current_version: Self::CURRENT_VERSION.to_string(),
latest_version: None,
has_update: false,
download_url: None,
release_notes_url: None,
checked_at: now,
error: Some(format!("解析响应失败: {}", e)),
},
}
} else {
UpdateInfo {
current_version: Self::CURRENT_VERSION.to_string(),
latest_version: None,
has_update: false,
download_url: None,
release_notes_url: None,
checked_at: now,
error: Some(format!("GitHub API 请求失败: {}", response.status())),
}
}
}
Err(e) => UpdateInfo {
current_version: Self::CURRENT_VERSION.to_string(),
latest_version: None,
has_update: false,
download_url: None,
release_notes_url: None,
checked_at: now,
error: Some(format!("网络请求失败: {}", e)),
},
}
}
/// 版本比较:返回 true 如果 latest > current
fn version_compare(current: &str, latest: &str) -> bool {
let current = current.trim_start_matches('v');
let latest = latest.trim_start_matches('v');
let current_parts: Vec<u32> = current.split('.').filter_map(|s| s.parse().ok()).collect();
let latest_parts: Vec<u32> = latest.split('.').filter_map(|s| s.parse().ok()).collect();
let max_len = current_parts.len().max(latest_parts.len());
for i in 0..max_len {
let current_part = current_parts.get(i).unwrap_or(&0);
let latest_part = latest_parts.get(i).unwrap_or(&0);
if latest_part > current_part {
return true;
} else if latest_part < current_part {
return false;
}
}
false
}
}
impl Default for UpdateCheckService {
fn default() -> Self {
Self::new()
}
}
/// 发送系统通知(跨平台)
///
/// 使用 Tauri 的通知 API 发送原生系统通知
#[cfg(feature = "notification")]
pub async fn send_update_notification(
app_handle: &tauri::AppHandle,
update_info: &UpdateInfo,
) -> Result<(), String> {
use tauri::api::notification::Notification;
if !update_info.has_update {
return Ok(());
}
let latest = update_info.latest_version.as_deref().unwrap_or("未知版本");
Notification::new(&app_handle.config().tauri.bundle.identifier)
.title("ProxyCast 有新版本可用")
.body(&format!(
"新版本 {} 已发布,当前版本 {}",
latest, update_info.current_version
))
.show()
.map_err(|e| format!("发送通知失败: {}", e))
}
/// 更新检查服务状态包装器(用于 Tauri 状态管理)
pub struct UpdateCheckServiceState(pub Arc<RwLock<UpdateCheckService>>);
impl UpdateCheckServiceState {
pub fn new() -> Self {
Self(Arc::new(RwLock::new(UpdateCheckService::new())))
}
}
impl Default for UpdateCheckServiceState {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version_compare() {
assert!(UpdateCheckService::version_compare("0.14.0", "0.14.1"));
assert!(UpdateCheckService::version_compare("0.14.0", "0.15.0"));
assert!(UpdateCheckService::version_compare("0.14.0", "1.0.0"));
assert!(UpdateCheckService::version_compare("0.38.0", "0.39.0"));
assert!(!UpdateCheckService::version_compare("0.14.1", "0.14.0"));
assert!(!UpdateCheckService::version_compare("0.14.0", "0.14.0"));
assert!(!UpdateCheckService::version_compare("1.0.0", "0.14.0"));
}
#[test]
fn test_should_check() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
// 从未检查过,应该检查
assert!(UpdateCheckService::should_check(0, 24, None, None));
// 刚检查过,不应该检查
assert!(!UpdateCheckService::should_check(now, 24, None, None));
// 超过间隔,应该检查
let old_timestamp = now - 25 * 3600;
assert!(UpdateCheckService::should_check(
old_timestamp,
24,
None,
None
));
// 跳过了当前最新版本,不应该检查
assert!(!UpdateCheckService::should_check(
0,
24,
Some("1.0.0"),
Some("1.0.0")
));
// 跳过了旧版本,应该检查
assert!(UpdateCheckService::should_check(
0,
24,
Some("0.9.0"),
Some("1.0.0")
));
}
}
+231
View File
@@ -0,0 +1,231 @@
//! 更新提醒窗口管理
//!
//! 提供独立的更新提醒悬浮窗口,与主应用窗口分离显示。
//! 参考 screenshot/window.rs 的实现模式。
//!
//! input: UpdateInfo(更新信息)
//! output: 独立的更新提醒窗口
//! pos: services 层,被 update_cmd 调用
use mouse_position::mouse_position::Mouse;
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
use tracing::{debug, info};
#[cfg(target_os = "macos")]
use cocoa::appkit::{NSColor, NSWindow};
#[cfg(target_os = "macos")]
use cocoa::base::{id, nil};
use super::update_check_service::UpdateInfo;
/// 窗口错误类型
#[derive(Debug, thiserror::Error)]
pub enum UpdateWindowError {
#[error("窗口创建失败: {0}")]
CreateFailed(String),
#[error("窗口操作失败: {0}")]
OperationFailed(String),
}
/// 更新提醒窗口标签
const UPDATE_WINDOW_LABEL: &str = "update-notification";
/// 窗口尺寸
const WINDOW_WIDTH: f64 = 340.0;
const WINDOW_HEIGHT: f64 = 280.0;
/// 距离屏幕顶部的边距
const TOP_MARGIN: f64 = 80.0;
/// 获取鼠标所在的显示器
fn get_monitor_at_cursor(app: &AppHandle) -> Option<tauri::Monitor> {
let (cursor_x, cursor_y) = match Mouse::get_mouse_position() {
Mouse::Position { x, y } => {
debug!("鼠标位置: ({}, {})", x, y);
(x as f64, y as f64)
}
Mouse::Error => {
debug!("无法获取鼠标位置");
return None;
}
};
let monitors = match app.available_monitors() {
Ok(monitors) => monitors,
Err(e) => {
debug!("无法获取显示器列表: {}", e);
return None;
}
};
for monitor in monitors {
let pos = monitor.position();
let size = monitor.size();
let left = pos.x as f64;
let top = pos.y as f64;
let right = left + size.width as f64;
let bottom = top + size.height as f64;
if cursor_x >= left && cursor_x < right && cursor_y >= top && cursor_y < bottom {
debug!(
"鼠标在显示器: {:?}, 位置: ({}, {}), 尺寸: {}x{}",
monitor.name(),
pos.x,
pos.y,
size.width,
size.height
);
return Some(monitor);
}
}
debug!("未找到鼠标所在的显示器");
None
}
/// 计算窗口位置(鼠标所在屏幕的顶部居中)
fn calculate_window_position(app: &AppHandle) -> (f64, f64) {
// 优先获取鼠标所在的显示器,否则使用主显示器
let monitor = get_monitor_at_cursor(app).or_else(|| app.primary_monitor().ok().flatten());
if let Some(monitor) = monitor {
let screen_pos = monitor.position();
let screen_size = monitor.size();
let scale_factor = monitor.scale_factor();
// 物理像素转换为逻辑像素
let screen_width = screen_size.width as f64 / scale_factor;
let screen_x = screen_pos.x as f64 / scale_factor;
let screen_y = screen_pos.y as f64 / scale_factor;
// 顶部居中定位
let x = screen_x + (screen_width - WINDOW_WIDTH) / 2.0;
let y = screen_y + TOP_MARGIN;
debug!(
"更新窗口位置: 屏幕({}, {}), 尺寸: {}x?, 窗口位置: ({}, {})",
screen_x, screen_y, screen_width, x, y
);
return (x, y);
}
// 默认位置
debug!("无法获取显示器信息,使用默认位置");
(800.0, 400.0)
}
/// 打开更新提醒窗口
///
/// 如果窗口已存在,则更新内容并显示;否则创建新窗口
pub fn open_update_window(
app: &AppHandle,
update_info: &UpdateInfo,
) -> Result<(), UpdateWindowError> {
info!("打开更新提醒窗口");
// 构建窗口 URL,包含更新信息参数
let url = format!(
"/update-notification?current={}&latest={}&download_url={}",
urlencoding::encode(&update_info.current_version),
urlencoding::encode(update_info.latest_version.as_deref().unwrap_or("")),
urlencoding::encode(update_info.download_url.as_deref().unwrap_or(""))
);
debug!("更新窗口 URL: {}", url);
// 检查窗口是否已存在
if let Some(window) = app.get_webview_window(UPDATE_WINDOW_LABEL) {
info!("更新窗口已存在,导航到新 URL 并显示");
let (x, y) = calculate_window_position(app);
use tauri::LogicalPosition;
let _ = window.set_position(LogicalPosition::new(x, y));
// 使用 JavaScript 导航到新的 URL
let js = format!("window.location.href = '{}';", url);
window
.eval(&js)
.map_err(|e| UpdateWindowError::OperationFailed(format!("导航失败: {}", e)))?;
window
.show()
.map_err(|e| UpdateWindowError::OperationFailed(format!("显示窗口失败: {}", e)))?;
window
.set_focus()
.map_err(|e| UpdateWindowError::OperationFailed(format!("聚焦窗口失败: {}", e)))?;
return Ok(());
}
// 窗口不存在,动态创建
info!("动态创建更新提醒窗口");
let (x, y) = calculate_window_position(app);
let window = WebviewWindowBuilder::new(app, UPDATE_WINDOW_LABEL, WebviewUrl::App(url.into()))
.inner_size(WINDOW_WIDTH, WINDOW_HEIGHT)
.position(x, y)
.decorations(false)
.always_on_top(true)
.skip_taskbar(true)
.visible(true)
.focused(true)
.transparent(true)
.resizable(false)
.build()
.map_err(|e| UpdateWindowError::CreateFailed(format!("{}", e)))?;
// macOS: 设置窗口背景透明
#[cfg(target_os = "macos")]
{
use objc::{msg_send, sel, sel_impl};
if let Ok(ns_win) = window.ns_window() {
unsafe {
let ns_window = ns_win as id;
let clear_color = NSColor::clearColor(nil);
ns_window.setBackgroundColor_(clear_color);
let _: () = msg_send![ns_window, setOpaque: false];
let _: () = msg_send![ns_window, setHasShadow: false];
}
}
}
info!("更新提醒窗口创建成功: {}", UPDATE_WINDOW_LABEL);
Ok(())
}
/// 关闭更新提醒窗口
pub fn close_update_window(app: &AppHandle) -> Result<(), UpdateWindowError> {
info!("关闭更新提醒窗口");
if let Some(window) = app.get_webview_window(UPDATE_WINDOW_LABEL) {
window
.close()
.map_err(|e| UpdateWindowError::OperationFailed(format!("关闭窗口失败: {}", e)))?;
info!("更新提醒窗口已关闭");
} else {
debug!("更新提醒窗口不存在,无需关闭");
}
Ok(())
}
/// 检查更新窗口是否打开
pub fn is_update_window_open(app: &AppHandle) -> bool {
app.get_webview_window(UPDATE_WINDOW_LABEL)
.map(|w| w.is_visible().unwrap_or(false))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_window_label() {
assert_eq!(UPDATE_WINDOW_LABEL, "update-notification");
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyCast",
"version": "0.38.0",
"version": "0.39.0",
"identifier": "com.proxycast.app",
"build": {
"beforeDevCommand": "npm run dev",
+7
View File
@@ -5,12 +5,14 @@
import App from "./App";
import { ScreenshotChatPage } from "./pages/screenshot-chat";
import { UpdateNotificationPage } from "./pages/update-notification";
import { Toaster } from "./components/ui/sonner";
/**
* 根据 URL 路径渲染对应的组件
*
* - /screenshot-chat: 截图对话悬浮窗口(独立 Tauri 窗口)
* - /update-notification: 更新提醒悬浮窗口(独立 Tauri 窗口)
* - 其他: 主应用
*/
export function RootRouter() {
@@ -21,6 +23,11 @@ export function RootRouter() {
return <ScreenshotChatPage />;
}
// 更新提醒悬浮窗口路由
if (pathname === "/update-notification") {
return <UpdateNotificationPage />;
}
// 默认渲染主应用
return (
<>
@@ -17,6 +17,7 @@ import {
ExperimentalFeatures,
} from "@/hooks/useTauri";
import { ShortcutSettings } from "@/components/screenshot-chat/ShortcutSettings";
import { UpdateCheckSettings } from "./UpdateNotification";
// ============================================================
// 组件
@@ -257,6 +258,11 @@ export function ExperimentalSettings() {
)}
</div>
{/* 自动更新检查设置 */}
<div className="rounded-lg border p-4">
<UpdateCheckSettings />
</div>
{/* 更多实验功能占位 */}
<div className="rounded-lg border border-dashed p-4 text-center">
<p className="text-sm text-muted-foreground">更多实验功能即将推出...</p>
+32
View File
@@ -0,0 +1,32 @@
# settings
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
## 架构说明
设置页面组件集合,提供应用配置界面。采用分组设计,每个设置类别独立为一个组件。
## 文件索引
| 文件 | 说明 |
|------|------|
| `index.ts` | 模块导出入口 |
| `SettingsPage.tsx` | 设置页面主容器,管理标签页切换 |
| `GeneralSettings.tsx` | 通用设置(主题、语言等) |
| `ProxySettings.tsx` | 代理服务器设置 |
| `DirectorySettings.tsx` | 目录路径设置 |
| `TlsSettings.tsx` | TLS/SSL 证书设置 |
| `QuotaSettings.tsx` | 配额管理设置 |
| `ConnectionsSettings.tsx` | 连接管理设置 |
| `RemoteManagementSettings.tsx` | 远程管理设置 |
| `DeveloperSettings.tsx` | 开发者选项设置 |
| `ExperimentalSettings.tsx` | 实验室功能设置(截图对话、自动更新检查等) |
| `ExtensionsSettings.tsx` | 扩展管理设置 |
| `AboutSection.tsx` | 关于页面(版本信息、更新检查) |
| `LanguageSelector.tsx` | 语言选择器组件 |
| `UpdateNotification.tsx` | 更新提醒弹窗和更新检查设置组件 |
| `GeneralSettings.test.ts` | 通用设置测试文件 |
## 更新提醒
任何文件变更后,请更新此文档和相关的上级文档。
@@ -0,0 +1,214 @@
/**
* @file UpdateNotification.tsx
* @description 更新检查设置组件
*
* 用于在设置页面中配置自动更新检查行为。
* 更新提醒弹窗已移至独立窗口 (src/pages/update-notification.tsx)。
*
* input: 用户配置操作
* output: 更新检查设置 UI
* pos: components/settings 层
*/
import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Bug } from "lucide-react";
interface UpdateCheckConfig {
enabled: boolean;
check_interval_hours: number;
show_notification: boolean;
last_check_timestamp: number;
skipped_version: string | null;
}
/**
* 更新检查设置组件
* 用于在设置页面中配置自动更新检查行为
*/
export function UpdateCheckSettings() {
const [settings, setSettings] = useState<UpdateCheckConfig>({
enabled: true,
check_interval_hours: 24,
show_notification: true,
last_check_timestamp: 0,
skipped_version: null,
});
const [loading, setLoading] = useState(true);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
const result = await invoke<UpdateCheckConfig>(
"get_update_check_settings",
);
setSettings(result);
} catch (error) {
console.error("加载更新检查设置失败:", error);
} finally {
setLoading(false);
}
};
const saveSettings = async (newSettings: UpdateCheckConfig) => {
try {
await invoke("set_update_check_settings", { settings: newSettings });
setSettings(newSettings);
} catch (error) {
console.error("保存更新检查设置失败:", error);
}
};
const handleToggleEnabled = () => {
saveSettings({ ...settings, enabled: !settings.enabled });
};
const handleToggleNotification = () => {
saveSettings({
...settings,
show_notification: !settings.show_notification,
});
};
const handleIntervalChange = (hours: number) => {
saveSettings({ ...settings, check_interval_hours: hours });
};
const handleClearSkipped = () => {
saveSettings({ ...settings, skipped_version: null });
};
if (loading) {
return (
<div className="p-4 rounded-lg border animate-pulse">
<div className="h-4 bg-muted rounded w-1/3 mb-2"></div>
<div className="h-3 bg-muted rounded w-2/3"></div>
</div>
);
}
return (
<div className="space-y-4">
<h3 className="text-sm font-medium">自动更新检查</h3>
<div className="space-y-3">
{/* 启用自动检查 */}
<div className="flex items-center justify-between p-3 rounded-lg border">
<div>
<div className="text-sm font-medium">自动检查更新</div>
<div className="text-xs text-muted-foreground">
定期检查是否有新版本可用
</div>
</div>
<button
onClick={handleToggleEnabled}
className={`relative w-11 h-6 rounded-full transition-colors ${
settings.enabled ? "bg-blue-600" : "bg-muted"
}`}
>
<span
className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${
settings.enabled ? "translate-x-5" : ""
}`}
/>
</button>
</div>
{/* 显示通知 */}
<div className="flex items-center justify-between p-3 rounded-lg border">
<div>
<div className="text-sm font-medium">显示更新通知</div>
<div className="text-xs text-muted-foreground">
发现新版本时显示弹窗提醒
</div>
</div>
<button
onClick={handleToggleNotification}
disabled={!settings.enabled}
className={`relative w-11 h-6 rounded-full transition-colors disabled:opacity-50 ${
settings.show_notification ? "bg-blue-600" : "bg-muted"
}`}
>
<span
className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${
settings.show_notification ? "translate-x-5" : ""
}`}
/>
</button>
</div>
{/* 检查间隔 */}
<div className="p-3 rounded-lg border">
<div className="text-sm font-medium mb-2">检查间隔</div>
<div className="flex gap-2">
{[12, 24, 48, 168].map((hours) => (
<button
key={hours}
onClick={() => handleIntervalChange(hours)}
disabled={!settings.enabled}
className={`px-3 py-1.5 rounded-md text-xs transition-colors disabled:opacity-50 ${
settings.check_interval_hours === hours
? "bg-blue-600 text-white"
: "bg-muted hover:bg-muted/80"
}`}
>
{hours === 168 ? "每周" : `${hours}小时`}
</button>
))}
</div>
</div>
{/* 已跳过的版本 */}
{settings.skipped_version && (
<div className="flex items-center justify-between p-3 rounded-lg border bg-muted/30">
<div>
<div className="text-sm">已跳过版本</div>
<div className="text-xs text-muted-foreground font-mono">
{settings.skipped_version}
</div>
</div>
<button
onClick={handleClearSkipped}
className="px-3 py-1 rounded-md text-xs bg-muted hover:bg-muted/80 transition-colors"
>
清除
</button>
</div>
)}
{/* 上次检查时间 */}
{settings.last_check_timestamp > 0 && (
<div className="text-xs text-muted-foreground">
上次检查:{" "}
{new Date(settings.last_check_timestamp * 1000).toLocaleString()}
</div>
)}
{/* 测试按钮(仅开发环境) */}
{import.meta.env.DEV && (
<button
onClick={async () => {
try {
await invoke("test_update_window");
} catch (error) {
console.error("测试更新弹窗失败:", error);
}
}}
className="flex items-center justify-center gap-1.5 w-full px-3 py-1.5 rounded-lg border border-dashed border-orange-400 text-orange-600 text-xs hover:bg-orange-50 dark:hover:bg-orange-900/20 transition-colors"
>
<Bug className="h-3.5 w-3.5" />
测试更新弹窗
</button>
)}
</div>
</div>
);
}
// 保持向后兼容的导出(已废弃,返回 null)
export function UpdateNotification() {
return null;
}
+2
View File
@@ -11,6 +11,8 @@
- `FlowMonitorPage.tsx` - LLM 流量监控页面
- `screenshot-chat.tsx` - 截图对话悬浮窗口页面(独立 Tauri 窗口)
- `update-notification.tsx` - 更新提醒悬浮窗口页面(独立 Tauri 窗口)
- `update-notification.css` - 更新提醒窗口样式
- `index.ts` - 页面导出入口
## 更新提醒
+269
View File
@@ -0,0 +1,269 @@
/**
* @file update-notification.css
* @description 更新提醒独立窗口样式
*
* 独立窗口的样式,支持透明背景和圆角卡片设计。
*/
.update-container {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
padding: 16px;
box-sizing: border-box;
/* 允许拖动窗口 */
-webkit-app-region: drag;
}
.update-card {
width: 100%;
max-width: 320px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 16px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.12),
0 2px 8px rgba(0, 0, 0, 0.08);
overflow: hidden;
/* 卡片内容不可拖动 */
-webkit-app-region: no-drag;
}
/* 深色模式 */
@media (prefers-color-scheme: dark) {
.update-card {
background: rgba(30, 30, 30, 0.95);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.4),
0 2px 8px rgba(0, 0, 0, 0.3);
}
}
.update-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 16px 12px;
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.1),
rgba(147, 51, 234, 0.1)
);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
cursor: grab;
}
.update-header:active {
cursor: grabbing;
}
@media (prefers-color-scheme: dark) {
.update-header {
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.15),
rgba(147, 51, 234, 0.15)
);
border-bottom-color: rgba(255, 255, 255, 0.06);
}
}
.update-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
font-weight: 600;
color: #1a1a1a;
}
@media (prefers-color-scheme: dark) {
.update-title {
color: #f0f0f0;
}
}
.update-logo {
width: 28px;
height: 28px;
object-fit: contain;
}
.update-close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
border-radius: 6px;
color: #666;
cursor: pointer;
transition: all 0.15s ease;
}
.update-close-btn:hover {
background: rgba(0, 0, 0, 0.08);
color: #333;
}
@media (prefers-color-scheme: dark) {
.update-close-btn {
color: #999;
}
.update-close-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
}
.update-content {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.update-version-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.update-label {
font-size: 13px;
color: #666;
}
@media (prefers-color-scheme: dark) {
.update-label {
color: #999;
}
}
.update-value {
font-size: 13px;
font-family:
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
color: #1a1a1a;
}
@media (prefers-color-scheme: dark) {
.update-value {
color: #f0f0f0;
}
}
.update-value.update-new {
color: #16a34a;
font-weight: 500;
}
.update-actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 4px;
}
.update-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 16px;
border: none;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.update-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.update-btn-primary {
background: #3b82f6;
color: white;
}
.update-btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.update-btn-secondary {
flex: 1;
background: transparent;
border: 1px solid rgba(0, 0, 0, 0.12);
color: #333;
padding: 8px 12px;
}
.update-btn-secondary:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.04);
}
@media (prefers-color-scheme: dark) {
.update-btn-secondary {
border-color: rgba(255, 255, 255, 0.12);
color: #e0e0e0;
}
.update-btn-secondary:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.06);
}
}
.update-btn-row {
display: flex;
gap: 8px;
}
.update-link {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px;
background: transparent;
border: none;
font-size: 12px;
color: #666;
cursor: pointer;
transition: color 0.15s ease;
}
.update-link:hover {
color: #3b82f6;
}
@media (prefers-color-scheme: dark) {
.update-link {
color: #999;
}
.update-link:hover {
color: #60a5fa;
}
}
/* 动画 */
@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
.animate-bounce {
animation: bounce 0.6s ease infinite;
}
+209
View File
@@ -0,0 +1,209 @@
/**
* @file update-notification.tsx
* @description 更新提醒独立窗口页面
*
* 独立于主应用的更新提醒悬浮窗口,显示版本信息和更新操作。
* 参考 screenshot-chat.tsx 的实现模式。
*
* input: URL 参数(current, latest, download_url)
* output: 更新提醒 UI
* pos: pages 层,独立 Tauri 窗口
*/
import { useEffect, useState, useCallback, type MouseEvent } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { invoke } from "@tauri-apps/api/core";
import { open as shellOpen } from "@tauri-apps/plugin-shell";
import { Download, X, Clock, SkipForward, ExternalLink } from "lucide-react";
import "./update-notification.css";
interface UpdateParams {
currentVersion: string;
latestVersion: string;
downloadUrl: string;
}
function getUpdateParamsFromUrl(): UpdateParams {
const params = new URLSearchParams(window.location.search);
return {
currentVersion: params.get("current") || "",
latestVersion: params.get("latest") || "",
downloadUrl: params.get("download_url") || "",
};
}
export function UpdateNotificationPage() {
const [params, setParams] = useState<UpdateParams>({
currentVersion: "",
latestVersion: "",
downloadUrl: "",
});
const [downloading, setDownloading] = useState(false);
useEffect(() => {
setParams(getUpdateParamsFromUrl());
}, []);
// 关闭窗口
const handleClose = useCallback(async () => {
try {
await invoke("close_update_window");
} catch (err) {
console.error("关闭窗口失败:", err);
// 备用方案:直接关闭
await getCurrentWindow().close();
}
}, []);
// ESC 关闭窗口
useEffect(() => {
const handleKeyDown = async (e: KeyboardEvent) => {
if (e.key === "Escape") {
await handleClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleClose]);
// 开始拖动窗口
const handleStartDrag = useCallback(async (e: MouseEvent) => {
if (e.button !== 0) return;
try {
await getCurrentWindow().startDragging();
} catch (err) {
console.error("拖动窗口失败:", err);
}
}, []);
// 立即更新
const handleDownload = async () => {
setDownloading(true);
try {
await invoke("download_update");
// download_update 成功后会自动关闭窗口并启动安装程序
} catch (error) {
console.error("下载更新失败:", error);
// 如果下载失败,尝试打开浏览器
if (params.downloadUrl) {
try {
await shellOpen(params.downloadUrl);
await handleClose();
} catch {
window.open(params.downloadUrl, "_blank");
}
}
} finally {
setDownloading(false);
}
};
// 稍后提醒
const handleLater = async () => {
await handleClose();
};
// 跳过此版本
const handleSkipVersion = async () => {
if (params.latestVersion) {
try {
await invoke("skip_update_version", {
version: params.latestVersion,
});
await handleClose();
} catch (error) {
console.error("跳过版本失败:", error);
}
}
};
// 在浏览器中打开
const handleOpenInBrowser = async () => {
if (params.downloadUrl) {
try {
await shellOpen(params.downloadUrl);
} catch (error) {
console.error("打开浏览器失败:", error);
// 备用方案
window.open(params.downloadUrl, "_blank");
}
}
};
return (
<div className="update-container">
<div className="update-card">
{/* 头部 - 可拖动区域 */}
<div className="update-header" onMouseDown={handleStartDrag}>
<div className="update-title">
<img src="/logo.png" alt="ProxyCast" className="update-logo" />
<span>发现新版本</span>
</div>
<button
onClick={handleClose}
className="update-close-btn"
title="关闭 (ESC)"
onMouseDown={(e) => e.stopPropagation()}
>
<X size={16} />
</button>
</div>
{/* 内容 */}
<div className="update-content">
<div className="update-version-row">
<span className="update-label">当前版本</span>
<span className="update-value">{params.currentVersion}</span>
</div>
<div className="update-version-row">
<span className="update-label">最新版本</span>
<span className="update-value update-new">
{params.latestVersion}
</span>
</div>
{/* 操作按钮 */}
<div className="update-actions">
<button
onClick={handleDownload}
disabled={downloading}
className="update-btn update-btn-primary"
>
<Download
size={16}
className={downloading ? "animate-bounce" : ""}
/>
{downloading ? "下载中..." : "立即更新"}
</button>
<div className="update-btn-row">
<button
onClick={handleLater}
className="update-btn update-btn-secondary"
>
<Clock size={14} />
稍后提醒
</button>
<button
onClick={handleSkipVersion}
className="update-btn update-btn-secondary"
>
<SkipForward size={14} />
跳过此版本
</button>
</div>
{params.downloadUrl && (
<button onClick={handleOpenInBrowser} className="update-link">
<ExternalLink size={12} />
在浏览器中查看
</button>
)}
</div>
</div>
</div>
</div>
);
}
export default UpdateNotificationPage;