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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-09 19:13:47 +08:00
1 джерело 88a1e4161e
коміт 023c3f36c2
25 files changed
+654 -109

No files matched your search

+6
Переглянути файл
@@ -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 {
+1
Переглянути файл
@@ -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;
+2
Переглянути файл
@@ -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,
}
}
}
+151
Переглянути файл
@@ -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<String>,
pub date: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateCheck {
pub enabled: bool,
pub update: Option<UpdateInfo>,
}
#[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<f64>,
pub checking: bool,
}
/// One schedule and announcement history for the desktop process, independent of its windows.
#[derive(Default)]
pub struct UpdatePolicy {
attempted: Option<Instant>,
pub status: UpdateStatus,
announced: HashSet<String>,
}
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<f64>) {
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"));
}
}
+3 -1
Переглянути файл
@@ -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"
+3 -1
Переглянути файл
@@ -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",
+52
Переглянути файл
@@ -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);
});
+4
Переглянути файл
@@ -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 })
+3 -1
Переглянути файл
@@ -461,7 +461,9 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+33 -45
Переглянути файл
@@ -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<String>,
pub date: Option<String>,
}
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<UpdateInfo>,
}
fn update_endpoint_env() -> Option<String> {
pub(crate) fn update_endpoint_env() -> Option<String> {
std::env::var("OPENIDEA_UPDATE_ENDPOINT").ok().filter(|s| !s.trim().is_empty())
}
@@ -412,17 +398,6 @@ pub fn updates_configured<R: tauri::Runtime>(app: &AppHandle<R>) -> 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<R: tauri::Runtime>(app: &AppHandle<R>) -> Vec<String> {
@@ -476,10 +451,17 @@ fn updater(app: &AppHandle, source: UpdateSource) -> IdeResult<Option<tauri_plug
#[tauri::command]
#[specta::specta]
pub async fn app_check_update(app: AppHandle, source: UpdateSource) -> IdeResult<UpdateCheck> {
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<UpdateCheck> {
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<UpdateInfo> {
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<UpdateStatus> {
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<bool> {
let claimed = state.updates.state.lock().policy.announce(&version);
Ok(!automatic || claimed)
}
/// Progress of [`app_install_update`], streamed to the UI's background task.
+2
Переглянути файл
@@ -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::<AppState>() {
state.updates.forget_window(window.label());
state.close_project(window.label());
state.plugins.forget_client(window.label());
}
+2
Переглянути файл
@@ -15,6 +15,7 @@ pub struct AppState {
pub app: AppHandle,
pub host: Arc<Host>,
pub startup: Startup,
pub updates: crate::updates::Updates,
pub recent: RecentProjects,
/// Plugins (`ide_host::plugins`), shared by all windows.
pub plugins: Arc<ide_host::plugins::PluginHost>,
@@ -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)?,
+176
Переглянути файл
@@ -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<HashSet<String>>,
system_locale: Mutex<String>,
pub wake: Notify,
gate: tokio::sync::Mutex<()>,
pub state: Mutex<CheckState>,
}
#[derive(Default)]
pub struct CheckState {
pub policy: UpdatePolicy,
revision: u64,
result: Option<IdeResult<UpdateCheck>>,
}
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::<AppState>().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::<AppState>().updates.forget_window(window);
return;
}
app.state::<AppState>().updates.ready.lock().insert(window.to_owned());
*app.state::<AppState>().updates.system_locale.lock() = system_locale;
if !updates_configured(app) || (cfg!(debug_assertions) && update_endpoint_env().is_none()) {
return;
}
if app.state::<AppState>().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::<AppState>();
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<UpdateSource>) -> IdeResult<UpdateCheck> {
let state = app.state::<AppState>();
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) = &current.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::<AppState>();
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<String>) -> Option<String> {
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);
}
}
+12 -19
Переглянути файл
@@ -1004,17 +1004,9 @@ export const commands = {
appCliUninstall: () => typedError<IpcPath[], IdeError>(__TAURI_INVOKE("app_cli_uninstall")),
appBuildInfo: () => __TAURI_INVOKE<BuildInfo>("app_build_info"),
appCheckUpdate: (source: UpdateSource) => typedError<UpdateCheck, IdeError>(__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<null, IdeError>(__TAURI_INVOKE("app_update_window_ready", { ready, systemLocale })),
appUpdateStatus: () => typedError<UpdateStatus, IdeError>(__TAURI_INVOKE("app_update_status")),
appUpdateAnnouncement: (version: string, automatic: boolean) => typedError<boolean, IdeError>(__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,
+54 -13
Переглянути файл
@@ -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<UpdateInfo | null> => 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<string, (event: { update: UpdateInfo }) => 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 () => {
+2 -2
Переглянути файл
@@ -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;
+27 -7
Переглянути файл
@@ -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<void> {
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<void> {
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 }),
+6
Переглянути файл
@@ -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);
+3
Переглянути файл
@@ -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),
-19
Переглянути файл
@@ -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<UpdateSourceSetting>[] = [
{ value: "auto", label: "Automatic" },
{ value: "github", label: "GitHub" },
{ value: "cnb", label: "CNB (China mirror)" },
];
function OpenProjectInRow() {
const [value, set] = useSetting<OpenProjectIn>("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" }],
+12 -1
Переглянути файл
@@ -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 › <Language> 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 = () => {
+87
Переглянути файл
@@ -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<string | null>(null);
const [status, setStatus] = useState<UpdateStatus | null>(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 (
<>
<Row label="Current version">
<span>{version ?? tx("Loading…")}</span>
</Row>
<Row label="Last checked">
<span>{!status ? tx("Loading…") : date ? `${formatDay(date)} ${formatClock(date)}` : tx("Never")}</span>
<Button
disabled={!canCheck || !status || status.checking}
onClick={() => void executeAction("app.checkForUpdates")}
>
<T>{status?.checking ? "Checking for updates…" : "Check Now"}</T>
</Button>
</Row>
</>
);
}
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)" },
],
},
],
},
],
};
+1
Переглянути файл
@@ -12,6 +12,7 @@ export const CLIENT_SETTINGS_KEYS = [
"general.confirmExit",
"general.openProjectIn",
"general.updateSource",
"general.autoCheckUpdates",
"general.dateFormat",
"editor.fontFamily",
"editor.fontSize",
+6
Переглянути файл
@@ -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)",
+6
Переглянути файл
@@ -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": "远程连接已取消",