From b2289e4f16b69e4a757830e7b32b80f477a6f409 Mon Sep 17 00:00:00 2001 From: coso Date: Sat, 10 Jan 2026 13:13:10 +0800 Subject: [PATCH] chore: bump version to 0.39.0 Co-Authored-By: Claude Opus 4.5 --- .gitignore | 1 + package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/capabilities/default.json | 2 +- src-tauri/gen/schemas/capabilities.json | 2 +- src-tauri/src/app/bootstrap.rs | 6 + src-tauri/src/app/runner.rs | 23 ++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/update_cmd.rs | 230 ++++++++++++ src-tauri/src/config/types.rs | 50 +++ src-tauri/src/screenshot/config.rs | 1 + src-tauri/src/services/README.md | 2 + src-tauri/src/services/mod.rs | 2 + .../src/services/update_check_service.rs | 350 ++++++++++++++++++ src-tauri/src/services/update_window.rs | 231 ++++++++++++ src-tauri/tauri.conf.json | 2 +- src/RootRouter.tsx | 7 + .../settings/ExperimentalSettings.tsx | 6 + src/components/settings/README.md | 32 ++ .../settings/UpdateNotification.tsx | 214 +++++++++++ src/pages/README.md | 2 + src/pages/update-notification.css | 269 ++++++++++++++ src/pages/update-notification.tsx | 209 +++++++++++ 24 files changed, 1642 insertions(+), 6 deletions(-) create mode 100644 src-tauri/src/commands/update_cmd.rs create mode 100644 src-tauri/src/services/update_check_service.rs create mode 100644 src-tauri/src/services/update_window.rs create mode 100644 src/components/settings/README.md create mode 100644 src/components/settings/UpdateNotification.tsx create mode 100644 src/pages/update-notification.css create mode 100644 src/pages/update-notification.tsx diff --git a/.gitignore b/.gitignore index b21d06050..f3e067348 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ __pycache__/ # Kiro .kiro/ .history +docs/prd/ \ No newline at end of file diff --git a/package.json b/package.json index 491a9a590..81174b086 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.38.0", + "version": "0.39.0", "type": "module", "repository": { "type": "git", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cb89c8183..dc6cc886c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3917,7 +3917,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.38.0" +version = "0.39.0" dependencies = [ "anyhow", "arboard", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3db4d7319..77f49af87 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 89de19dc7..78e2e2134 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -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", diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 832c14fb7..cd5a2628c 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -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}]}]}} \ No newline at end of file +{"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}]}]}} \ No newline at end of file diff --git a/src-tauri/src/app/bootstrap.rs b/src-tauri/src/app/bootstrap.rs index 236a5dcc0..18bff1549 100644 --- a/src-tauri/src/app/bootstrap.rs +++ b/src-tauri/src/app/bootstrap.rs @@ -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>, pub shared_tokens: Arc>, @@ -233,6 +235,9 @@ pub fn init_states(config: &Config) -> Result { 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 { 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, diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 6565fd842..2f8d7cbb2 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -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"); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a2905c590..4fcd1b055 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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; diff --git a/src-tauri/src/commands/update_cmd.rs b/src-tauri/src/commands/update_cmd.rs new file mode 100644 index 000000000..9f7f2908c --- /dev/null +++ b/src-tauri/src/commands/update_cmd.rs @@ -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, +} + +/// 手动检查更新 +#[tauri::command] +pub async fn check_update( + update_service: State<'_, UpdateCheckServiceState>, +) -> Result { + 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 { + 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 { + 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>, +) { + 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::() { + 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::() { + 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; + } + }); +} diff --git a/src-tauri/src/config/types.rs b/src-tauri/src/config/types.rs index d0c73531b..311b49416 100644 --- a/src-tauri/src/config/types.rs +++ b/src-tauri/src/config/types.rs @@ -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, +} + +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(); diff --git a/src-tauri/src/screenshot/config.rs b/src-tauri/src/screenshot/config.rs index 866ecc4a7..461042931 100644 --- a/src-tauri/src/screenshot/config.rs +++ b/src-tauri/src/screenshot/config.rs @@ -119,6 +119,7 @@ mod tests { enabled: true, shortcut: "CommandOrControl+Shift+S".to_string(), }, + ..Default::default() }; assert!(is_screenshot_chat_enabled(&config)); } diff --git a/src-tauri/src/services/README.md b/src-tauri/src/services/README.md index 4fa54aaca..b948ef84b 100644 --- a/src-tauri/src/services/README.md +++ b/src-tauri/src/services/README.md @@ -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` - 更新提醒独立窗口管理 ## 更新提醒 diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 785c7de0c..8df69d58d 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -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; diff --git a/src-tauri/src/services/update_check_service.rs b/src-tauri/src/services/update_check_service.rs new file mode 100644 index 000000000..ac06cdc36 --- /dev/null +++ b/src-tauri/src/services/update_check_service.rs @@ -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, + /// 是否有更新 + pub has_update: bool, + /// 下载链接 + pub download_url: Option, + /// 发布说明链接 + pub release_notes_url: Option, + /// 检查时间(Unix 时间戳) + pub checked_at: u64, + /// 错误信息 + pub error: Option, +} + +/// 更新检查服务状态 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateCheckState { + /// 是否正在检查 + pub is_checking: bool, + /// 最后一次检查结果 + pub last_result: Option, + /// 下次检查时间(Unix 时间戳) + pub next_check_at: Option, +} + +impl Default for UpdateCheckState { + fn default() -> Self { + Self { + is_checking: false, + last_result: None, + next_check_at: None, + } + } +} + +/// 更新检查服务 +pub struct UpdateCheckService { + state: Arc>, + 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::().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 = current.split('.').filter_map(|s| s.parse().ok()).collect(); + let latest_parts: Vec = 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>); + +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") + )); + } +} diff --git a/src-tauri/src/services/update_window.rs b/src-tauri/src/services/update_window.rs new file mode 100644 index 000000000..8de30f5c3 --- /dev/null +++ b/src-tauri/src/services/update_window.rs @@ -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 { + 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"); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 376add6d3..89aea6377 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/RootRouter.tsx b/src/RootRouter.tsx index 4391e89fb..97db45555 100644 --- a/src/RootRouter.tsx +++ b/src/RootRouter.tsx @@ -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 ; } + // 更新提醒悬浮窗口路由 + if (pathname === "/update-notification") { + return ; + } + // 默认渲染主应用 return ( <> diff --git a/src/components/settings/ExperimentalSettings.tsx b/src/components/settings/ExperimentalSettings.tsx index 91c72d1f4..e2d9065c2 100644 --- a/src/components/settings/ExperimentalSettings.tsx +++ b/src/components/settings/ExperimentalSettings.tsx @@ -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() { )} + {/* 自动更新检查设置 */} +
+ +
+ {/* 更多实验功能占位 */}

更多实验功能即将推出...

diff --git a/src/components/settings/README.md b/src/components/settings/README.md new file mode 100644 index 000000000..e057dc9b1 --- /dev/null +++ b/src/components/settings/README.md @@ -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` | 通用设置测试文件 | + +## 更新提醒 + +任何文件变更后,请更新此文档和相关的上级文档。 diff --git a/src/components/settings/UpdateNotification.tsx b/src/components/settings/UpdateNotification.tsx new file mode 100644 index 000000000..e7d01d16c --- /dev/null +++ b/src/components/settings/UpdateNotification.tsx @@ -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({ + 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( + "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 ( +
+
+
+
+ ); + } + + return ( +
+

自动更新检查

+ +
+ {/* 启用自动检查 */} +
+
+
自动检查更新
+
+ 定期检查是否有新版本可用 +
+
+ +
+ + {/* 显示通知 */} +
+
+
显示更新通知
+
+ 发现新版本时显示弹窗提醒 +
+
+ +
+ + {/* 检查间隔 */} +
+
检查间隔
+
+ {[12, 24, 48, 168].map((hours) => ( + + ))} +
+
+ + {/* 已跳过的版本 */} + {settings.skipped_version && ( +
+
+
已跳过版本
+
+ {settings.skipped_version} +
+
+ +
+ )} + + {/* 上次检查时间 */} + {settings.last_check_timestamp > 0 && ( +
+ 上次检查:{" "} + {new Date(settings.last_check_timestamp * 1000).toLocaleString()} +
+ )} + + {/* 测试按钮(仅开发环境) */} + {import.meta.env.DEV && ( + + )} +
+
+ ); +} + +// 保持向后兼容的导出(已废弃,返回 null) +export function UpdateNotification() { + return null; +} diff --git a/src/pages/README.md b/src/pages/README.md index 05fa60ed3..60b7e8e36 100644 --- a/src/pages/README.md +++ b/src/pages/README.md @@ -11,6 +11,8 @@ - `FlowMonitorPage.tsx` - LLM 流量监控页面 - `screenshot-chat.tsx` - 截图对话悬浮窗口页面(独立 Tauri 窗口) +- `update-notification.tsx` - 更新提醒悬浮窗口页面(独立 Tauri 窗口) +- `update-notification.css` - 更新提醒窗口样式 - `index.ts` - 页面导出入口 ## 更新提醒 diff --git a/src/pages/update-notification.css b/src/pages/update-notification.css new file mode 100644 index 000000000..d3f77b797 --- /dev/null +++ b/src/pages/update-notification.css @@ -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; +} diff --git a/src/pages/update-notification.tsx b/src/pages/update-notification.tsx new file mode 100644 index 000000000..17e87b9fc --- /dev/null +++ b/src/pages/update-notification.tsx @@ -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({ + 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 ( +
+
+ {/* 头部 - 可拖动区域 */} +
+
+ ProxyCast + 发现新版本 +
+ +
+ + {/* 内容 */} +
+
+ 当前版本 + {params.currentVersion} +
+
+ 最新版本 + + {params.latestVersion} + +
+ + {/* 操作按钮 */} +
+ + +
+ + +
+ + {params.downloadUrl && ( + + )} +
+
+
+
+ ); +} + +export default UpdateNotificationPage;