From 023c3f36c23fc6cca3acce9f9aad5da264f0b8ab Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:13:47 +0800 Subject: [PATCH] feat(app): check for updates while the IDE stays open (#2329) * feat(app): check for updates periodically * test(app): cover update commands in Restricted Mode --- crates/ide-core/src/event.rs | 6 + crates/ide-core/src/lib.rs | 1 + crates/ide-core/src/settings.rs | 2 + crates/ide-core/src/updates.rs | 151 +++++++++++++++ crates/ide-host/src/trust.rs | 4 +- crates/ide-host/tests/trust.rs | 4 +- e2e/updates.spec.ts | 52 ++++++ src-tauri/src/commands/app.rs | 4 + src-tauri/src/commands/mod.rs | 4 +- src-tauri/src/commands/window.rs | 78 ++++---- src-tauri/src/lib.rs | 2 + src-tauri/src/state.rs | 2 + src-tauri/src/updates.rs | 176 ++++++++++++++++++ src/bindings.ts | 31 ++- src/features/app/__tests__/updates.test.ts | 67 +++++-- src/features/app/index.ts | 4 +- src/features/app/updates.tsx | 34 +++- .../settings/__tests__/behavior.test.ts | 6 + src/features/settings/model/behaviorSchema.ts | 3 + src/features/settings/pages/behavior.tsx | 19 -- src/features/settings/pages/registry.tsx | 13 +- src/features/settings/pages/updates.tsx | 87 +++++++++ src/platform/clientSettings.ts | 1 + src/platform/locales/text/en.json | 6 + src/platform/locales/text/zh.json | 6 + 25 files changed, 654 insertions(+), 109 deletions(-) create mode 100644 crates/ide-core/src/updates.rs create mode 100644 e2e/updates.spec.ts create mode 100644 src-tauri/src/updates.rs create mode 100644 src/features/settings/pages/updates.tsx diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index 3e786e418..30f43857e 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -148,6 +148,12 @@ pub enum IdeEvent { SettingsChanged { key: String, }, + UpdateStatusChanged { + status: crate::updates::UpdateStatus, + }, + UpdateAvailable { + update: crate::updates::UpdateInfo, + }, /// The project's source / test / resource / excluded roots changed (build files /// changed or folders were marked); the UI re-reads them with `project_roots`. ProjectRootsChanged { diff --git a/crates/ide-core/src/lib.rs b/crates/ide-core/src/lib.rs index 24a925368..a3906733a 100644 --- a/crates/ide-core/src/lib.rs +++ b/crates/ide-core/src/lib.rs @@ -27,6 +27,7 @@ pub mod settings; pub mod task; pub mod text; pub mod tool_path; +pub mod updates; pub mod version_managers; pub mod workflow_settings; pub mod wsl; diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index bf27e06d9..5a936b165 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -292,6 +292,7 @@ pub struct GeneralSettings { pub project_name: String, /// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`. pub update_source: String, + pub auto_check_updates: bool, } impl Default for GeneralSettings { @@ -307,6 +308,7 @@ impl Default for GeneralSettings { power_save_mode: false, project_name: String::new(), update_source: "auto".into(), + auto_check_updates: true, } } } diff --git a/crates/ide-core/src/updates.rs b/crates/ide-core/src/updates.rs new file mode 100644 index 000000000..ae1679f39 --- /dev/null +++ b/crates/ide-core/src/updates.rs @@ -0,0 +1,151 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::HashSet; +use std::time::{Duration, Instant}; + +pub const CHECK_INTERVAL: Duration = Duration::from_secs(4 * 60 * 60); + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum UpdateSource { + #[default] + Github, + Cnb, +} + +impl UpdateSource { + pub fn resolve(preference: &str, locale: &str, system_locale: &str) -> Self { + match preference { + "github" => return Self::Github, + "cnb" => return Self::Cnb, + _ => {} + } + let locale = locale.trim(); + let locale = if matches!(locale, "" | "auto" | "system") { system_locale } else { locale }; + if locale.split(['-', '_']).next().is_some_and(|base| base.eq_ignore_ascii_case("zh")) { + Self::Cnb + } else { + Self::Github + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct UpdateInfo { + pub version: String, + pub current_version: String, + pub notes: Option, + pub date: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct UpdateCheck { + pub enabled: bool, + pub update: Option, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct UpdateStatus { + /// Unix milliseconds of the last successful check in this process. + pub last_checked: Option, + pub checking: bool, +} + +/// One schedule and announcement history for the desktop process, independent of its windows. +#[derive(Default)] +pub struct UpdatePolicy { + attempted: Option, + pub status: UpdateStatus, + announced: HashSet, +} + +impl UpdatePolicy { + pub fn due_in(&self, now: Instant) -> Duration { + self.attempted.map_or(Duration::ZERO, |at| CHECK_INTERVAL.saturating_sub(now.saturating_duration_since(at))) + } + + pub fn begin(&mut self, now: Instant) { + self.attempted = Some(now); + self.status.checking = true; + } + + pub fn finish(&mut self, checked_at: Option) { + self.status.checking = false; + if let Some(at) = checked_at { + self.status.last_checked = Some(at); + } + } + + pub fn announced(&self, version: &str) -> bool { + self.announced.contains(version) + } + + /// Manual checks always show their result, but also count as an announcement. + pub fn announce(&mut self, version: &str) -> bool { + self.announced.insert(version.to_owned()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn automatic_and_manual_checks_resolve_the_same_preferred_source() { + for (preference, locale, system, expected) in [ + ("auto", "zh-CN", "en-US", UpdateSource::Cnb), + ("auto", " ZH_CN ", "en-US", UpdateSource::Cnb), + ("auto", "system", "zh-CN", UpdateSource::Cnb), + ("auto", "auto", "zh-TW", UpdateSource::Cnb), + ("auto", "", "zh", UpdateSource::Cnb), + ("auto", "en", "zh-CN", UpdateSource::Github), + ("github", "zh", "zh-CN", UpdateSource::Github), + ("cnb", "en", "en-US", UpdateSource::Cnb), + ] { + assert_eq!(UpdateSource::resolve(preference, locale, system), expected); + } + } + + #[test] + fn checks_at_startup_and_every_four_hours_even_after_failure() { + let mut policy = UpdatePolicy::default(); + let now = Instant::now(); + assert_eq!(policy.due_in(now), Duration::ZERO); + policy.begin(now); + assert!(policy.status.checking); + policy.finish(None); + assert_eq!(policy.status.last_checked, None); + assert_eq!(policy.due_in(now + CHECK_INTERVAL - Duration::from_secs(1)), Duration::from_secs(1)); + assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::ZERO); + policy.begin(now + CHECK_INTERVAL); + policy.finish(Some(1234.0)); + assert_eq!(policy.status.last_checked, Some(1234.0)); + assert!(!policy.status.checking); + assert_eq!(policy.due_in(now + CHECK_INTERVAL), CHECK_INTERVAL); + } + + #[test] + fn manual_checks_reset_the_schedule_without_losing_last_success_on_failure() { + let mut policy = UpdatePolicy::default(); + let now = Instant::now(); + policy.begin(now); + policy.finish(Some(1000.0)); + policy.begin(now + Duration::from_secs(60)); + policy.finish(None); + assert_eq!(policy.status.last_checked, Some(1000.0)); + assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::from_secs(60)); + } + + #[test] + fn announcements_are_per_version_across_windows_sources_and_manual_checks() { + let mut policy = UpdatePolicy::default(); + assert!(policy.announce("0.1.40")); + assert!(!policy.announce("0.1.40")); + assert!(policy.announce("0.1.41")); + assert!(!policy.announce("0.1.40")); + assert!(UpdatePolicy::default().announce("0.1.40")); + } +} diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 603dbc77d..a2c5ba409 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -221,7 +221,9 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "app_cli_uninstall" | "app_log_location" | "app_check_update" - | "app_startup_update_check" + | "app_update_window_ready" + | "app_update_status" + | "app_update_announcement" | "app_install_update" | "caches_schedule_invalidation" | "clear_caches" diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs index 753dfac6a..5ef72d707 100644 --- a/crates/ide-host/tests/trust.rs +++ b/crates/ide-host/tests/trust.rs @@ -38,7 +38,9 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project( "app_cli_uninstall", "app_log_location", "app_check_update", - "app_startup_update_check", + "app_update_window_ready", + "app_update_status", + "app_update_announcement", "app_install_update", "caches_schedule_invalidation", "clear_caches", diff --git a/e2e/updates.spec.ts b/e2e/updates.spec.ts new file mode 100644 index 000000000..bf004fcb0 --- /dev/null +++ b/e2e/updates.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from "./fixtures"; + +test("Updates shows check status and keeps manual checks available when automatic checks are off", async ({ + workbench: page, +}) => { + await page.evaluate(async () => { + const ipcPath = "/src/platform/ipc.ts"; + const actionsPath = "/src/platform/actions.ts"; + const { commands } = await import(/* @vite-ignore */ ipcPath); + const { registerAction, executeAction } = await import(/* @vite-ignore */ actionsPath); + Object.assign(commands, { + appBuildInfo: async () => ({ version: "0.1.39", updates: true }), + appUpdateStatus: async () => ({ status: "ok", data: { lastChecked: null, checking: false } }), + }); + registerAction({ + id: "app.checkForUpdates", + title: "Check for Updates…", + run: () => document.body.setAttribute("data-update-checked", "true"), + }); + await executeAction("file.settings", "general"); + }); + const dialog = page.getByRole("dialog", { name: "Settings", exact: true }); + await expect(dialog).toBeVisible(); + await dialog.getByRole("textbox", { name: "Search settings" }).fill("update"); + await dialog.getByRole("treeitem", { name: "Updates", exact: true }).click(); + await expect(dialog.getByText("0.1.39", { exact: true })).toBeVisible(); + await expect(dialog.getByText("Never", { exact: true })).toBeVisible(); + const automatic = dialog.getByRole("checkbox", { name: "Check for updates automatically" }); + await expect(automatic).toBeChecked(); + await automatic.uncheck(); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + await expect(automatic).not.toBeChecked(); + await dialog.getByRole("button", { name: "Check Now", exact: true }).click(); + await expect(page.locator("body")).toHaveAttribute("data-update-checked", "true"); +}); + +test("builds without update endpoints hide Updates from settings and search", async ({ workbench: page }) => { + await page.evaluate(async () => { + const ipcPath = "/src/platform/ipc.ts"; + const actionsPath = "/src/platform/actions.ts"; + const { commands } = await import(/* @vite-ignore */ ipcPath); + commands.appBuildInfo = async () => ({ version: "0.1.39", updates: false }); + const { executeAction } = await import(/* @vite-ignore */ actionsPath); + await executeAction("file.settings", "general"); + }); + const dialog = page.getByRole("dialog", { name: "Settings", exact: true }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole("treeitem", { name: "Updates", exact: true })).toHaveCount(0); + await expect(dialog.getByText("Update source", { exact: true })).toHaveCount(0); + await dialog.getByRole("textbox", { name: "Search settings" }).fill("update"); + await expect(dialog.getByRole("treeitem", { name: "Updates", exact: true })).toHaveCount(0); +}); diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index a7283a85b..50f4a9efc 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -184,6 +184,9 @@ pub fn settings_set( if key == "mcpServer" || key.starts_with("mcpServer.") { crate::commands::mcp::apply(&state); } + if !applied.project_scope { + state.updates.wake.notify_one(); + } // A project value concerns only that project's window (and its settings window). let event = ide_core::IdeEvent::SettingsChanged { key }; if applied.project_scope { @@ -238,6 +241,7 @@ pub async fn settings_import(window: Window, state: State<'_, AppState>, path: P crate::commands::ssh::apply_ssh_settings(&state); crate::commands::mcp::apply(&state); state.host.apply_proxy(); + state.updates.wake.notify_one(); state.sink.emit(ide_core::IdeEvent::SettingsChanged { key: String::new() }); let settings = ide_host::ops::project::settings(&state.host, window.label()); Ok(SettingsImport { settings, storage }) diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 7c33cdfa1..6e7863d19 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -461,7 +461,9 @@ pub fn builder() -> tauri_specta::Builder { window::app_cli_uninstall, window::app_build_info, window::app_check_update, - window::app_startup_update_check, + window::app_update_window_ready, + window::app_update_status, + window::app_update_announcement, window::app_install_update, debug::debug_exception_filters, debug::debug_set_exception_breakpoints, diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 8d7b703d8..d8f1dbf4c 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -384,24 +384,10 @@ pub fn app_build_info(app: AppHandle) -> BuildInfo { } } -#[derive(Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct UpdateInfo { - pub version: String, - pub current_version: String, - pub notes: Option, - pub date: Option, -} +use ide_core::updates::UpdateInfo; +pub use ide_core::updates::{UpdateCheck, UpdateSource, UpdateStatus}; -#[derive(Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct UpdateCheck { - /// False when no update endpoint is configured (no request was made). - pub enabled: bool, - pub update: Option, -} - -fn update_endpoint_env() -> Option { +pub(crate) fn update_endpoint_env() -> Option { std::env::var("OPENIDEA_UPDATE_ENDPOINT").ok().filter(|s| !s.trim().is_empty()) } @@ -412,17 +398,6 @@ pub fn updates_configured(app: &AppHandle) -> bool { !config_endpoints(app).is_empty() || update_endpoint_env().is_some() } -/// Where updates are downloaded from (System Settings › Updates). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub enum UpdateSource { - /// The GitHub releases named in tauri.conf.json. - #[default] - Github, - /// The CNB mirror of the same releases, for networks where GitHub is slow. - Cnb, -} - const CNB_ENDPOINT: &str = "https://cnb.cool/l0ng-ai/openidea/-/releases/latest/download/latest.json"; fn config_endpoints(app: &AppHandle) -> Vec { @@ -476,10 +451,17 @@ fn updater(app: &AppHandle, source: UpdateSource) -> IdeResult IdeResult { - let Some(updater) = updater(&app, source)? else { + crate::updates::check(&app, Some(source)).await +} + +pub(crate) async fn check_update(app: &AppHandle, source: UpdateSource) -> IdeResult { + let Some(updater) = updater(app, source)? else { return Ok(UpdateCheck { enabled: false, update: None }); }; - let update = updater.check().await.map_err(|e| IdeError::unavailable(e.to_string()))?; + let update = tokio::time::timeout(std::time::Duration::from_secs(60), updater.check()) + .await + .map_err(|e| IdeError::unavailable(e.to_string()))? + .map_err(|e| IdeError::unavailable(e.to_string()))?; Ok(UpdateCheck { enabled: true, update: update.map(|u| UpdateInfo { @@ -491,23 +473,29 @@ pub async fn app_check_update(app: AppHandle, source: UpdateSource) -> IdeResult }) } -/// The background check made once per process when the first window starts; -/// later calls (other windows) and debug builds get `None`. Failures are logged, -/// not reported: the user didn't ask for this check. #[tauri::command] #[specta::specta] -pub async fn app_startup_update_check(app: AppHandle, source: UpdateSource) -> Option { - static DONE: AtomicBool = AtomicBool::new(false); - if cfg!(debug_assertions) || DONE.swap(true, Ordering::SeqCst) { - return None; - } - match app_check_update(app, source).await { - Ok(check) => check.update, - Err(e) => { - tracing::warn!("startup update check failed: {e}"); - None - } - } +pub fn app_update_window_ready( + app: AppHandle, + window: WebviewWindow, + ready: bool, + system_locale: String, +) -> IdeResult<()> { + crate::updates::window_ready(&app, window.label(), ready, system_locale); + Ok(()) +} + +#[tauri::command] +#[specta::specta] +pub fn app_update_status(state: State<'_, AppState>) -> IdeResult { + Ok(state.updates.state.lock().policy.status.clone()) +} + +#[tauri::command] +#[specta::specta] +pub fn app_update_announcement(state: State<'_, AppState>, version: String, automatic: bool) -> IdeResult { + let claimed = state.updates.state.lock().policy.announce(&version); + Ok(!automatic || claimed) } /// Progress of [`app_install_update`], streamed to the UI's background task. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 80e6712ea..0b71c43e6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -31,6 +31,7 @@ mod state; mod theme_frame; #[cfg(target_os = "macos")] mod titlebar; +mod updates; mod window_groups; #[cfg(any(windows, test))] mod window_work_area; @@ -240,6 +241,7 @@ pub fn run() { window_groups::destroyed(window.app_handle(), window.label()); windows::close_auxiliary_windows(window.app_handle(), window.label()); if let Some(state) = window.try_state::() { + state.updates.forget_window(window.label()); state.close_project(window.label()); state.plugins.forget_client(window.label()); } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 1cc55204e..155e4dd68 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -15,6 +15,7 @@ pub struct AppState { pub app: AppHandle, pub host: Arc, pub startup: Startup, + pub updates: crate::updates::Updates, pub recent: RecentProjects, /// Plugins (`ide_host::plugins`), shared by all windows. pub plugins: Arc, @@ -99,6 +100,7 @@ impl AppState { })); Ok(Self { recent: RecentProjects::load(&config_dir), + updates: Default::default(), askpass: start_askpass(&app), sink: global_sink, plugins: crate::commands::plugin::create_host(&app, &data_dir, &host)?, diff --git a/src-tauri/src/updates.rs b/src-tauri/src/updates.rs new file mode 100644 index 000000000..5f1eb6d6c --- /dev/null +++ b/src-tauri/src/updates.rs @@ -0,0 +1,176 @@ +//! Desktop updater coordination shared by all webviews. + +use crate::commands::window::{UpdateSource, check_update, update_endpoint_env, updates_configured}; +use crate::state::AppState; +use ide_core::updates::{UpdateCheck, UpdatePolicy}; +use ide_core::{IdeEvent, IdeResult}; +use parking_lot::Mutex; +use std::collections::HashSet; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; +use tauri::{AppHandle, Manager}; +use tokio::sync::Notify; + +#[derive(Default)] +pub struct Updates { + started: AtomicBool, + ready: Mutex>, + system_locale: Mutex, + pub wake: Notify, + gate: tokio::sync::Mutex<()>, + pub state: Mutex, +} + +#[derive(Default)] +pub struct CheckState { + pub policy: UpdatePolicy, + revision: u64, + result: Option>, +} + +impl Updates { + pub fn forget_window(&self, window: &str) { + self.ready.lock().remove(window); + } +} + +fn automatic_enabled(app: &AppHandle) -> bool { + updates_configured(app) && app.state::().global_settings.effective().general.auto_check_updates +} + +/// Called after an ordinary window has installed its event handlers. The task survives window closes and HMR. +pub fn window_ready(app: &AppHandle, window: &str, ready: bool, system_locale: String) { + if !ready { + app.state::().updates.forget_window(window); + return; + } + app.state::().updates.ready.lock().insert(window.to_owned()); + *app.state::().updates.system_locale.lock() = system_locale; + if !updates_configured(app) || (cfg!(debug_assertions) && update_endpoint_env().is_none()) { + return; + } + if app.state::().updates.started.swap(true, Ordering::SeqCst) { + announce_available(app); + return; + } + let app = app.clone(); + tauri::async_runtime::spawn(async move { + loop { + let state = app.state::(); + let wake = state.updates.wake.notified(); + let delay = automatic_enabled(&app).then(|| state.updates.state.lock().policy.due_in(Instant::now())); + if delay.is_some_and(|d| d.is_zero()) { + if let Err(error) = check(&app, None).await { + tracing::warn!(%error, "automatic update check failed"); + } + continue; + } + match delay { + Some(delay) => tokio::select! { + _ = tokio::time::sleep(delay) => {}, + _ = wake => {}, + }, + None => wake.await, + } + } + }); +} + +/// `None` is an automatic check; explicit sources are manual checks and always return their result. +pub async fn check(app: &AppHandle, source: Option) -> IdeResult { + let state = app.state::(); + let revision = state.updates.state.lock().revision; + let _gate = state.updates.gate.lock().await; + let automatic = source.is_none(); + { + let current = state.updates.state.lock(); + if !automatic + && current.revision != revision + && let Some(result) = ¤t.result + { + return result.clone(); + } + if automatic && (!automatic_enabled(app) || !current.policy.due_in(Instant::now()).is_zero()) { + return Ok(UpdateCheck { enabled: updates_configured(app), update: None }); + } + } + let settings = state.global_settings.effective(); + let source = source.unwrap_or_else(|| { + UpdateSource::resolve( + &settings.general.update_source, + &settings.appearance.locale, + &state.updates.system_locale.lock(), + ) + }); + state.updates.state.lock().policy.begin(Instant::now()); + emit_status(&state); + let result = check_update(app, source).await; + let checked_at = result + .as_ref() + .ok() + .filter(|check| check.enabled) + .map(|_| SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs_f64() * 1000.0); + { + let mut current = state.updates.state.lock(); + current.policy.finish(checked_at); + current.revision += 1; + current.result = Some(result.clone()); + } + emit_status(&state); + state.updates.wake.notify_one(); + if automatic { + announce_available(app); + } + result +} + +fn emit_status(state: &AppState) { + let status = state.updates.state.lock().policy.status.clone(); + state.sink.emit(IdeEvent::UpdateStatusChanged { status }); +} + +fn announce_available(app: &AppHandle) { + if !automatic_enabled(app) { + return; + } + let state = app.state::(); + let owner = { + let mut ready = state.updates.ready.lock(); + ready.retain(|label| app.get_webview_window(label).is_some()); + let focused = crate::windows::target_window(app); + ready_owner(focused.as_ref().map(|window| window.label()), &crate::windows::project_window_labels(app), &ready) + }; + let Some(owner) = owner else { return }; + let update = { + let current = state.updates.state.lock(); + let update = current.result.as_ref().and_then(|r| r.as_ref().ok()).and_then(|r| r.update.clone()); + update.filter(|update| !current.policy.announced(&update.version)) + }; + if let Some(update) = update { + state.window_sink(&owner).emit(IdeEvent::UpdateAvailable { update }); + } +} + +fn ready_owner(preferred: Option<&str>, live: &[String], ready: &HashSet) -> Option { + preferred + .map(AppState::owner_window) + .filter(|label| ready.contains(*label) && live.iter().any(|live| live.as_str() == *label)) + .map(str::to_owned) + .or_else(|| live.iter().find(|label| ready.contains(*label)).cloned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn notifications_only_target_live_owners_that_have_subscribed() { + let live = vec!["main".into(), "project-2".into()]; + let ready = HashSet::from(["main".into(), "closed-window".into()]); + for focused in [Some("project-2"), Some("settings-main"), Some("closed-window"), None] { + assert_eq!(ready_owner(focused, &live, &ready), Some("main".into())); + } + assert_eq!(ready_owner(Some("main"), &live, &HashSet::new()), None); + assert_eq!(ready_owner(Some("closed-window"), &[], &ready), None); + } +} diff --git a/src/bindings.ts b/src/bindings.ts index 5349a630a..d03e7e6bf 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1004,17 +1004,9 @@ export const commands = { appCliUninstall: () => typedError(__TAURI_INVOKE("app_cli_uninstall")), appBuildInfo: () => __TAURI_INVOKE("app_build_info"), appCheckUpdate: (source: UpdateSource) => typedError(__TAURI_INVOKE("app_check_update", { source })), - /** - * The background check made once per process when the first window starts; - * later calls (other windows) and debug builds get `None`. Failures are logged, - * not reported: the user didn't ask for this check. - */ - appStartupUpdateCheck: (source: UpdateSource) => __TAURI_INVOKE<{ - version: string, - currentVersion: string, - notes: string | null, - date: string | null, -} | null>("app_startup_update_check", { source }), + appUpdateWindowReady: (ready: boolean, systemLocale: string) => typedError(__TAURI_INVOKE("app_update_window_ready", { ready, systemLocale })), + appUpdateStatus: () => typedError(__TAURI_INVOKE("app_update_status")), + appUpdateAnnouncement: (version: string, automatic: boolean) => typedError(__TAURI_INVOKE("app_update_announcement", { version, automatic })), /** * Downloads and installs the available update, streaming progress to `on_progress`. * Cancellable through `request` until the download completes; the UI then restarts @@ -4396,6 +4388,7 @@ export type GeneralSettings = { projectName?: string, /** Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`. */ updateSource?: string, + autoCheckUpdates?: boolean, }; /** A repository of the signed-in account (owned, collaborator, or organization member). */ @@ -4871,7 +4864,7 @@ cancellable: boolean } | * A setting was written (any window); the windows it concerns (all for a * global value, the project's for a project value) re-read their effective settings. */ -{ type: "settingsChanged"; key: string } | +{ type: "settingsChanged"; key: string } | { type: "updateStatusChanged"; status: UpdateStatus } | { type: "updateAvailable"; update: UpdateInfo } | /** * The project's source / test / resource / excluded roots changed (build files * changed or folders were marked); the UI re-reads them with `project_roots`. @@ -8744,7 +8737,6 @@ export type UnityProjectInfo = { }; export type UpdateCheck = { - /** False when no update endpoint is configured (no request was made). */ enabled: boolean, update: UpdateInfo | null, }; @@ -8763,12 +8755,13 @@ export type UpdateProgress = /** Download done; the new version is being written over this one. */ { type: "installing" }; -/** Where updates are downloaded from (System Settings › Updates). */ -export type UpdateSource = -/** The GitHub releases named in tauri.conf.json. */ -"github" | -/** The CNB mirror of the same releases, for networks where GitHub is slow. */ -"cnb"; +export type UpdateSource = "github" | "cnb"; + +export type UpdateStatus = { + /** Unix milliseconds of the last successful check in this process. */ + lastChecked: number | null, + checking: boolean, +}; export type Variable = { name: string, diff --git a/src/features/app/__tests__/updates.test.ts b/src/features/app/__tests__/updates.test.ts index d6dce3e00..5c9b83943 100644 --- a/src/features/app/__tests__/updates.test.ts +++ b/src/features/app/__tests__/updates.test.ts @@ -25,7 +25,9 @@ const appInstallUpdate = vi.fn((_source: string, _request: number, ch: Channel) return install.promise; }); const appCheckUpdate = vi.fn(() => check.promise); -const appStartupUpdateCheck = vi.fn(async (): Promise => null); +const appUpdateWindowReady = vi.fn(async (_ready: boolean, _systemLocale: string) => ({ status: "ok", data: null })); +const appUpdateAnnouncement = vi.fn(async () => ({ status: "ok", data: true })); +const updateListeners = new Map void>(); const appRestart = vi.fn(async () => null); const cancelRequest = vi.fn(async () => null); const appBuildInfo = vi.fn(async () => ({ version: "0.1.21" })); @@ -36,11 +38,17 @@ vi.mock("@/platform/ipc", async (orig) => { return { ...actual, isTauri: () => true, + ideEventsReady: async () => {}, + onIdeEvent: (type: string, handler: (event: { update: UpdateInfo }) => void) => { + updateListeners.set(type, handler); + return () => updateListeners.delete(type); + }, commands: { ...actual.commands, appInstallUpdate, appCheckUpdate, - appStartupUpdateCheck, + appUpdateWindowReady, + appUpdateAnnouncement, appRestart, cancelRequest, appBuildInfo, @@ -51,7 +59,7 @@ vi.mock("@/platform/ipc", async (orig) => { const { checkForUpdates, - checkForUpdatesOnStartup, + installUpdateChecks, installUpdate, notifyUpdate, progressText, @@ -115,7 +123,7 @@ describe("Install and Restart", () => { }); it("joins the running install on repeated clicks (balloon, log entry, a second check)", async () => { - notifyUpdate({ version: "0.1.22", currentVersion: "0.1.21", notes: null, date: null }); + await notifyUpdate({ version: "0.1.22", currentVersion: "0.1.21", notes: null, date: null }); const action = notes[0]!.actions![0]!; action.run(); action.run(); @@ -212,6 +220,30 @@ describe("Check for Updates", () => { expect(task(CHECK_TASK_ID)?.done).toBe(true); }); + it("does not consume an announcement when a manual check is cancelled", async () => { + const done = checkForUpdates(); + task(CHECK_TASK_ID)!.cancel!(); + await done; + check.resolve( + ok({ enabled: true, update: { version: "0.1.22", currentVersion: "0.1.21", notes: null, date: null } }), + ); + await tick(); + expect(appUpdateAnnouncement).not.toHaveBeenCalled(); + await notifyUpdate({ version: "0.1.22", currentVersion: "0.1.21", notes: null, date: null }); + expect(appUpdateAnnouncement).toHaveBeenCalledWith("0.1.22", true); + expect(notes.at(-1)?.title).toBe("OpenIDEA 0.1.22 is available"); + }); + + it("suppresses an already announced automatic version but still answers a manual check", async () => { + const update = { version: "0.1.22", currentVersion: "0.1.21", notes: null, date: null }; + appUpdateAnnouncement.mockResolvedValueOnce({ status: "ok", data: false }); + await notifyUpdate(update); + expect(notes).toHaveLength(0); + await notifyUpdate(update, true); + expect(appUpdateAnnouncement).toHaveBeenLastCalledWith("0.1.22", false); + expect(notes.at(-1)).toMatchObject({ title: "OpenIDEA 0.1.22 is available", userInitiated: true }); + }); + it("shows the check in a balloon where there is no status bar (the welcome screen)", async () => { const done = checkForUpdates(); expect(notes[0]).toMatchObject({ level: "info", title: "Checking for updates…", key: UPDATE_KEY }); @@ -227,16 +259,25 @@ describe("Check for Updates", () => { await done; }); - it("leaves the startup check to the IDE Updates notification settings", async () => { - appStartupUpdateCheck.mockResolvedValueOnce({ - version: "0.1.22", - currentVersion: "0.1.21", - notes: null, - date: null, + it("starts the process scheduler after subscribing and leaves automatic results to notification settings", async () => { + const dispose = installUpdateChecks(); + await tick(); + expect(appUpdateWindowReady).toHaveBeenCalledTimes(1); + updateListeners.get("updateAvailable")!({ + update: { version: "0.1.22", currentVersion: "0.1.21", notes: null, date: null }, }); - await checkForUpdatesOnStartup(); - expect(notes.at(-1)).toMatchObject({ title: "OpenIDEA 0.1.22 is available" }); - expect(notes.at(-1)?.userInitiated).toBe(false); + await tick(); + expect(notes.at(-1)).toMatchObject({ title: "OpenIDEA 0.1.22 is available", userInitiated: false }); + dispose(); + expect(updateListeners.has("updateAvailable")).toBe(false); + }); + + it("does not start a schedule after its window has been disposed", async () => { + const dispose = installUpdateChecks(); + dispose(); + await tick(); + expect(appUpdateWindowReady).not.toHaveBeenCalledWith(true, expect.anything()); + expect(appUpdateWindowReady).toHaveBeenCalledWith(false, navigator.language); }); it('still pops up when IDE Updates is set to "No popup" (no other surface on the welcome screen)', async () => { diff --git a/src/features/app/index.ts b/src/features/app/index.ts index b70867fb3..1af15982f 100644 --- a/src/features/app/index.ts +++ b/src/features/app/index.ts @@ -5,7 +5,7 @@ import { registerOverlay, registerStatusItem } from "@/workbench/contributions"; import { AboutOverlay } from "./AboutDialog"; import { appActions, updateActions } from "./appActions"; -import { checkForUpdatesOnStartup } from "./updates"; +import { installUpdateChecks } from "./updates"; import { cliActions } from "./cliCommand"; import { installCloseGuard } from "./closeGuard"; import { TerminationOverlay } from "./termination"; @@ -60,7 +60,7 @@ export function activate(): () => void { void buildInfo().then((b) => { if (!b?.updates || disposed) return; disposers.push(registerLocalizedActions(updateActions)); - void checkForUpdatesOnStartup(); + disposers.push(installUpdateChecks()); }); return () => { disposed = true; diff --git a/src/features/app/updates.tsx b/src/features/app/updates.tsx index 1e25c881d..adcc1142a 100644 --- a/src/features/app/updates.tsx +++ b/src/features/app/updates.tsx @@ -13,6 +13,8 @@ import { commands, isCancelled, isTauri, + ideEventsReady, + onIdeEvent, nextRequestId, type UpdateInfo, type UpdateProgress, @@ -110,7 +112,7 @@ async function runCheck() { userInitiated: true, }); } else if (!installing) { - notifyUpdate(r.update, true); + await notifyUpdate(r.update, true); } } catch (e) { notifications().notify({ @@ -125,15 +127,33 @@ async function runCheck() { } } -/** Silent check once per app start; only an available update is shown. */ -export async function checkForUpdatesOnStartup(): Promise { - if (!isTauri()) return; - const update = await commands.appStartupUpdateCheck(updateSource()).catch(() => null); - if (update) notifyUpdate(update); +/** Start one backend schedule after this window can receive its notifications. */ +export function installUpdateChecks(): () => void { + let disposed = false; + const off = onIdeEvent("updateAvailable", ({ update }) => { + if (!installing) void notifyUpdate(update); + }); + void ideEventsReady().then(() => { + if (!disposed && isTauri()) + void call(commands.appUpdateWindowReady(true, navigator.language)).catch((e) => + console.warn("cannot start update checks", e), + ); + }); + return () => { + disposed = true; + off(); + if (isTauri()) void call(commands.appUpdateWindowReady(false, navigator.language)).catch(() => {}); + }; } /** `userInitiated`: the update came from a manual check, so it pops up regardless of the balloon settings. */ -export function notifyUpdate(update: UpdateInfo, userInitiated = false) { +export async function notifyUpdate(update: UpdateInfo, userInitiated = false): Promise { + if (isTauri()) { + const claimed = await call(commands.appUpdateAnnouncement(update.version, !userInitiated)).catch( + () => userInitiated, + ); + if (!claimed) return; + } notifications().notify({ level: "info", title: t("app.update.available", { version: update.version }), diff --git a/src/features/settings/__tests__/behavior.test.ts b/src/features/settings/__tests__/behavior.test.ts index f91a51b60..a3c854cce 100644 --- a/src/features/settings/__tests__/behavior.test.ts +++ b/src/features/settings/__tests__/behavior.test.ts @@ -6,6 +6,12 @@ import { validateSettings } from "../model/schema"; import { validateAppearanceExtras, validateNotifications } from "../model/behaviorSchema"; describe("appearance & behavior settings", () => { + it("defaults automatic update checks to on and respects an explicit opt-out", () => { + expect(validateSettings({}).general.autoCheckUpdates).toBe(true); + expect(validateSettings({ general: { autoCheckUpdates: false } }).general.autoCheckUpdates).toBe(false); + expect(validateSettings({ general: { autoCheckUpdates: "off" } }).general.autoCheckUpdates).toBe(true); + }); + it("steps zoom within 50–300%", () => { expect(nextZoom(100, 1)).toBe(110); expect(nextZoom(100, -1)).toBe(90); diff --git a/src/features/settings/model/behaviorSchema.ts b/src/features/settings/model/behaviorSchema.ts index fc81997ce..1e0cd1793 100644 --- a/src/features/settings/model/behaviorSchema.ts +++ b/src/features/settings/model/behaviorSchema.ts @@ -62,6 +62,7 @@ export type UpdateSourceSetting = "auto" | "github" | "cnb"; export interface GeneralExtras { openProjectIn: OpenProjectIn; updateSource: UpdateSourceSetting; + autoCheckUpdates: boolean; defaultProjectDirectory: string; saveOnIdle: boolean; idleSeconds: number; @@ -115,6 +116,7 @@ export const DEFAULT_APPEARANCE_EXTRAS: AppearanceExtras = { export const DEFAULT_GENERAL_EXTRAS: GeneralExtras = { openProjectIn: "ask", updateSource: "auto", + autoCheckUpdates: true, defaultProjectDirectory: "", saveOnIdle: false, idleSeconds: 15, @@ -180,6 +182,7 @@ export function validateGeneralExtras(raw: unknown): GeneralExtras { return { openProjectIn: oneOf(g.openProjectIn, ["ask", "new", "same", "attach"] as const, d.openProjectIn), updateSource: oneOf(g.updateSource, ["auto", "github", "cnb"] as const, d.updateSource), + autoCheckUpdates: bool(g.autoCheckUpdates, d.autoCheckUpdates), defaultProjectDirectory: typeof g.defaultProjectDirectory === "string" ? g.defaultProjectDirectory : "", saveOnIdle: bool(g.saveOnIdle, d.saveOnIdle), idleSeconds: int(g.idleSeconds, d.idleSeconds, IDLE_LIMITS), diff --git a/src/features/settings/pages/behavior.tsx b/src/features/settings/pages/behavior.tsx index 73a0e1c2b..389143545 100644 --- a/src/features/settings/pages/behavior.tsx +++ b/src/features/settings/pages/behavior.tsx @@ -26,7 +26,6 @@ import { type NotificationDisplay, type NotificationGroupValid, type OpenProjectIn, - type UpdateSourceSetting, } from "../model/behaviorSchema"; import { ACCENT_PRESETS, LIMITS, type ThemeId } from "../model/schema"; import type { PageSpec } from "./spec"; @@ -396,12 +395,6 @@ const OPEN_IN: Array<{ value: OpenProjectIn; label: string }> = [ { value: "ask", label: "Ask" }, ]; -const UPDATE_SOURCES: SelectOption[] = [ - { value: "auto", label: "Automatic" }, - { value: "github", label: "GitHub" }, - { value: "cnb", label: "CNB (China mirror)" }, -]; - function OpenProjectInRow() { const [value, set] = useSetting("general.openProjectIn"); const name = useId(); @@ -749,18 +742,6 @@ export const BEHAVIOR_PAGES: PageSpec[] = [ }, ], }, - { - title: "Updates", - options: [ - { - kind: "select", - key: "general.updateSource", - label: "Update source", - hint: "Where updates are downloaded from. If it can't be reached, the other source is tried.", - options: UPDATE_SOURCES, - }, - ], - }, { title: "Exit", options: [{ kind: "toggle", key: "general.confirmExit", label: "Confirm before exiting the IDE" }], diff --git a/src/features/settings/pages/registry.tsx b/src/features/settings/pages/registry.tsx index c8acbbbb8..6b41d4b95 100644 --- a/src/features/settings/pages/registry.tsx +++ b/src/features/settings/pages/registry.tsx @@ -1,4 +1,6 @@ /** Registers the built-in pages as ordinary settings-page contributions and indexes their labels. */ +import { commands } from "@/platform/ipc"; +import { UPDATES_PAGE } from "./updates"; import { onLanguageListChanged } from "@/platform/languageList"; import { registerSettingsPage, type SettingsPageContribution } from "@/workbench/contributions"; import { BEHAVIOR_PAGES } from "./behavior"; @@ -16,6 +18,7 @@ import { VCS_PAGES } from "./vcs"; const BUILTIN_PAGES = [ ...BEHAVIOR_PAGES, + UPDATES_PAGE, ...CORE_PAGES, ...EDITOR_PAGES, ...VCS_PAGES, @@ -55,9 +58,17 @@ function toContribution(spec: PageSpec): SettingsPageContribution { } export function registerBuiltinPages(): () => void { - const disposers = BUILTIN_PAGES.map((spec) => registerSettingsPage(toContribution(spec))); + const disposers = BUILTIN_PAGES.filter((spec) => spec !== UPDATES_PAGE).map((spec) => + registerSettingsPage(toContribution(spec)), + ); // Code Style › pages come from the backend's language list, which plugins change. let live = true; + void commands + .appBuildInfo() + .then((info) => { + if (live && info.updates) disposers.push(registerSettingsPage(toContribution(UPDATES_PAGE))); + }) + .catch(() => {}); let languagePages: (() => void)[] = []; let generation = 0; const loadLanguagePages = () => { diff --git a/src/features/settings/pages/updates.tsx b/src/features/settings/pages/updates.tsx new file mode 100644 index 000000000..818f996a2 --- /dev/null +++ b/src/features/settings/pages/updates.tsx @@ -0,0 +1,87 @@ +import { useEffect, useState } from "react"; +import { executeAction, useActions } from "@/platform/actions"; +import { formatClock, formatDay } from "@/platform/dateFormat"; +import { call, commands, onIdeEvent, type UpdateStatus } from "@/platform/ipc"; +import { useTx } from "@/platform/i18n"; +import { Button, T } from "@/ui"; +import { Row } from "../components/Form"; +import type { PageSpec } from "./spec"; + +function UpdateStatusRows() { + const tx = useTx(); + const [version, setVersion] = useState(null); + const [status, setStatus] = useState(null); + const canCheck = useActions((s) => s.actions.has("app.checkForUpdates")); + useEffect(() => { + let live = true; + let received = false; + const off = onIdeEvent("updateStatusChanged", ({ status }) => { + received = true; + if (live) setStatus(status); + }); + void commands + .appBuildInfo() + .then((info) => live && setVersion(info.version)) + .catch(() => {}); + void call(commands.appUpdateStatus()) + .then((status) => live && !received && setStatus(status)) + .catch(() => {}); + return () => { + live = false; + off(); + }; + }, []); + const date = status?.lastChecked == null ? null : new Date(status.lastChecked); + return ( + <> + + {version ?? tx("Loading…")} + + + {!status ? tx("Loading…") : date ? `${formatDay(date)} ${formatClock(date)}` : tx("Never")} + + + + ); +} + +export const UPDATES_PAGE: PageSpec = { + id: "general.updates", + title: "Updates", + order: 3, + keywords: ["update", "upgrade", "version", "automatic", "GitHub", "CNB"], + sections: [ + { + options: [ + { + kind: "custom", + label: "Current version", + keywords: ["Last checked", "Check Now"], + component: UpdateStatusRows, + }, + { + kind: "toggle", + key: "general.autoCheckUpdates", + label: "Check for updates automatically", + hint: "At startup and every 4 hours while OpenIDEA runs. Each new version is announced once per session.", + }, + { + kind: "select", + key: "general.updateSource", + label: "Update source", + hint: "Where updates are downloaded from. If it can't be reached, the other source is tried.", + options: [ + { value: "auto", label: "Automatic" }, + { value: "github", label: "GitHub" }, + { value: "cnb", label: "CNB (China mirror)" }, + ], + }, + ], + }, + ], +}; diff --git a/src/platform/clientSettings.ts b/src/platform/clientSettings.ts index 9fa3364b6..0c33aef46 100644 --- a/src/platform/clientSettings.ts +++ b/src/platform/clientSettings.ts @@ -12,6 +12,7 @@ export const CLIENT_SETTINGS_KEYS = [ "general.confirmExit", "general.openProjectIn", "general.updateSource", + "general.autoCheckUpdates", "general.dateFormat", "editor.fontFamily", "editor.fontSize", diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 974c288a0..85d721f45 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -168,6 +168,12 @@ "Unable to load image: {path}": "Unable to load image: {path}", "Updates": "Updates", "Update source": "Update source", + "Current version": "Current version", + "Last checked": "Last checked", + "Check Now": "Check Now", + "Check for updates automatically": "Check for updates automatically", + "At startup and every 4 hours while OpenIDEA runs. Each new version is announced once per session.": "At startup and every 4 hours while OpenIDEA runs. Each new version is announced once per session.", + "Checking for updates…": "Checking for updates…", "Where updates are downloaded from. If it can't be reached, the other source is tried.": "Where updates are downloaded from. If it can't be reached, the other source is tried.", "Automatic": "Automatic", "CNB (China mirror)": "CNB (China mirror)", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index af4e5ab51..38d34ef3e 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6637,6 +6637,12 @@ "Unable to load image: {path}": "无法加载图片:{path}", "Updates": "更新", "Update source": "更新源", + "Current version": "当前版本", + "Last checked": "上次检查", + "Check Now": "立即检查", + "Check for updates automatically": "自动检查更新", + "At startup and every 4 hours while OpenIDEA runs. Each new version is announced once per session.": "启动时及 OpenIDEA 运行期间每隔 4 小时检查一次,每次启动后同一新版本只提醒一次。", + "Checking for updates…": "正在检查更新…", "Where updates are downloaded from. If it can't be reached, the other source is tried.": "从哪里下载更新。无法连接时会改用另一个源。", "CNB (China mirror)": "CNB(国内镜像)", "Remote connection cancelled": "远程连接已取消",