дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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
Цей коміт міститься в:
1 джерело
88a1e4161e
коміт
023c3f36c2
25 files changed
+654
-109
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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) = ¤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::<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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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)" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -12,6 +12,7 @@ export const CLIENT_SETTINGS_KEYS = [
|
||||
"general.confirmExit",
|
||||
"general.openProjectIn",
|
||||
"general.updateSource",
|
||||
"general.autoCheckUpdates",
|
||||
"general.dateFormat",
|
||||
"editor.fontFamily",
|
||||
"editor.fontSize",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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": "远程连接已取消",
|
||||
|
||||
Reference in new issue
Заблокувати користувача