diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index c54585183..2806e366b 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -3,10 +3,12 @@ use crate::menu::{self, MenuConfig, MenuState}; use crate::state::AppState; use crate::windows::{self, Launch, OpenOutcome, WindowsState}; -use ide_core::{IdeError, IdeResult}; +use ide_core::{IdeError, IdeResult, RequestId}; use serde::{Deserialize, Serialize}; use specta::Type; use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use tauri::ipc::Channel; use tauri::{AppHandle, Manager, State, WebviewWindow}; /// Opens `path` in a new window (`new_window`) or tells the caller to open it @@ -325,7 +327,6 @@ pub async fn app_check_update(app: AppHandle) -> IdeResult { #[tauri::command] #[specta::specta] pub async fn app_startup_update_check(app: AppHandle) -> Option { - use std::sync::atomic::{AtomicBool, Ordering}; static DONE: AtomicBool = AtomicBool::new(false); if cfg!(debug_assertions) || DONE.swap(true, Ordering::SeqCst) { return None; @@ -339,20 +340,140 @@ pub async fn app_startup_update_check(app: AppHandle) -> Option { } } -/// Downloads and installs the available update, then quits through every -/// window's unsaved-changes check and relaunches. If a window cancels the quit, -/// the update applies on the next start. +/// Progress of [`app_install_update`], streamed to the UI's background task. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum UpdateProgress { + /// Bytes received so far; `total` when the server sends a length. + Downloading { downloaded: f64, total: Option }, + /// Download done; the new version is being written over this one. + Installing, +} + +/// Limits download events to one per whole percent (or per 256 KiB without a length). +#[derive(Default)] +struct ProgressThrottle { + downloaded: u64, + last_step: Option, +} + +impl ProgressThrottle { + const UNKNOWN_STEP: u64 = 256 * 1024; + + fn chunk(&mut self, len: usize, total: Option) -> Option { + self.downloaded += len as u64; + let step = match total { + Some(t) if t > 0 => self.downloaded.min(t) * 100 / t, + _ => self.downloaded / Self::UNKNOWN_STEP, + }; + if self.last_step == Some(step) { + return None; + } + self.last_step = Some(step); + Some(UpdateProgress::Downloading { downloaded: self.downloaded as f64, total: total.map(|t| t as f64) }) + } +} + +/// Set while an update is being downloaded or installed (by any window). +static INSTALLING: AtomicBool = AtomicBool::new(false); + +struct InstallingGuard; + +impl InstallingGuard { + fn acquire() -> Option { + (!INSTALLING.swap(true, Ordering::SeqCst)).then_some(Self) + } +} + +impl Drop for InstallingGuard { + fn drop(&mut self) { + INSTALLING.store(false, Ordering::SeqCst); + } +} + +/// Downloads and installs the available update, streaming progress to `on_progress`. +/// Cancellable through `request` until the download completes; the UI then restarts +/// with `app_restart`. One install runs at a time. #[tauri::command] #[specta::specta] -pub async fn app_install_update(app: AppHandle, windows: State<'_, WindowsState>) -> IdeResult<()> { - let updater = updater(&app)?.ok_or_else(|| IdeError::unavailable("updates are not configured"))?; - let update = updater - .check() - .await - .map_err(|e| IdeError::unavailable(e.to_string()))? +pub async fn app_install_update( + app: AppHandle, + state: State<'_, AppState>, + request: RequestId, + on_progress: Channel, +) -> IdeResult<()> { + let _guard = + InstallingGuard::acquire().ok_or_else(|| IdeError::unavailable("an update is already being installed"))?; + let token = state.host.begin_request(request); + let result = async { + let updater = updater(&app)?.ok_or_else(|| IdeError::unavailable("updates are not configured"))?; + let update = tokio::select! { + r = updater.check() => r.map_err(|e| IdeError::unavailable(e.to_string()))?, + _ = token.cancelled() => return Err(IdeError::cancelled()), + } .ok_or_else(|| IdeError::not_found("no update available"))?; - update.download_and_install(|_, _| {}, || {}).await.map_err(|e| IdeError::internal(e.to_string()))?; - windows.set_restart(); - app_quit(app, windows); - Ok(()) + let mut throttle = ProgressThrottle::default(); + let progress = on_progress.clone(); + let download = update.download( + move |len, total| { + if let Some(p) = throttle.chunk(len, total) { + let _ = progress.send(p); + } + }, + || {}, + ); + let bytes = tokio::select! { + r = download => r.map_err(|e| IdeError::unavailable(e.to_string()))?, + _ = token.cancelled() => return Err(IdeError::cancelled()), + }; + let _ = on_progress.send(UpdateProgress::Installing); + tauri::async_runtime::spawn_blocking(move || update.install(bytes)) + .await + .map_err(|e| IdeError::internal(e.to_string()))? + .map_err(|e| IdeError::internal(e.to_string())) + } + .await; + state.host.end_request(request); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + fn downloading(p: Option) -> Option<(f64, Option)> { + match p? { + UpdateProgress::Downloading { downloaded, total } => Some((downloaded, total)), + UpdateProgress::Installing => None, + } + } + + #[test] + fn throttle_reports_each_whole_percent_once() { + let mut t = ProgressThrottle::default(); + assert_eq!(downloading(t.chunk(1, Some(1000))), Some((1.0, Some(1000.0)))); + assert_eq!(downloading(t.chunk(5, Some(1000))), None); + assert_eq!(downloading(t.chunk(4, Some(1000))), Some((10.0, Some(1000.0)))); + let reported = (0..99).filter(|_| t.chunk(10, Some(1000)).is_some()).count(); + assert_eq!(reported, 99); + assert_eq!(t.downloaded, 1000); + } + + #[test] + fn throttle_without_length_reports_per_step() { + let mut t = ProgressThrottle::default(); + assert!(t.chunk(10, None).is_some()); + assert!(t.chunk(1024, None).is_none()); + assert_eq!(downloading(t.chunk(256 * 1024, None)), Some(((256 * 1024 + 1034) as f64, None))); + // A zero length is treated as unknown, not divided by. + assert!(ProgressThrottle::default().chunk(1, Some(0)).is_some()); + } + + #[test] + fn only_one_install_at_a_time() { + let first = InstallingGuard::acquire().expect("first install starts"); + assert!(InstallingGuard::acquire().is_none()); + drop(first); + assert!(InstallingGuard::acquire().is_some()); + } } diff --git a/src/bindings.ts b/src/bindings.ts index e9701963f..165f2a14f 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -658,11 +658,11 @@ export const commands = { date: string | null, } | null>("app_startup_update_check"), /** - * Downloads and installs the available update, then quits through every - * window's unsaved-changes check and relaunches. If a window cancels the quit, - * the update applies on the next start. + * Downloads and installs the available update, streaming progress to `on_progress`. + * Cancellable through `request` until the download completes; the UI then restarts + * with `app_restart`. One install runs at a time. */ - appInstallUpdate: () => typedError(__TAURI_INVOKE("app_install_update")), + appInstallUpdate: (request: RequestId, onProgress: Channel) => typedError(__TAURI_INVOKE("app_install_update", { request, onProgress })), debugExceptionFilters: () => typedError(__TAURI_INVOKE("debug_exception_filters")), debugSetExceptionBreakpoints: (choices: { [key in string]: boolean }) => typedError(__TAURI_INVOKE("debug_set_exception_breakpoints", { choices })), debugSetVariable: (session: DebugSessionId, container: number, name: string, evaluateName: string | null, frameId: number | null, value: string) => typedError(__TAURI_INVOKE("debug_set_variable", { session, container, name, evaluateName, frameId, value })), @@ -5657,6 +5657,13 @@ export type UpdateInfo = { date: string | null, }; +/** Progress of [`app_install_update`], streamed to the UI's background task. */ +export type UpdateProgress = +/** Bytes received so far; `total` when the server sends a length. */ +{ type: "downloading"; downloaded: number | null; total: number | null } | +/** Download done; the new version is being written over this one. */ +{ type: "installing" }; + export type Variable = { name: string, value: string, diff --git a/src/features/app/__tests__/updates.test.ts b/src/features/app/__tests__/updates.test.ts new file mode 100644 index 000000000..483c832c5 --- /dev/null +++ b/src/features/app/__tests__/updates.test.ts @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { UpdateCheck, UpdateProgress } from "@/bindings"; +import type { NotificationService } from "@/workbench/contributions"; + +vi.mock("@tauri-apps/api/core", () => ({ + Channel: class { + onmessage: (p: unknown) => void = () => {}; + }, +})); + +type Channel = { onmessage: (p: UpdateProgress) => void }; +type Result = { status: "ok"; data: T } | { status: "error"; error: { kind: string; message: string } }; + +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +} + +let install: ReturnType>>; +let check: ReturnType>>; +let channel: Channel | null; +const appInstallUpdate = vi.fn((_request: number, ch: Channel) => { + channel = ch; + return install.promise; +}); +const appCheckUpdate = vi.fn(() => check.promise); +const appRestart = vi.fn(async () => null); +const cancelRequest = vi.fn(async () => null); +const appBuildInfo = vi.fn(async () => ({ version: "0.1.21" })); + +vi.mock("@/platform/ipc", async (orig) => { + const actual = await orig(); + return { + ...actual, + isTauri: () => true, + commands: { ...actual.commands, appInstallUpdate, appCheckUpdate, appRestart, cancelRequest, appBuildInfo }, + }; +}); + +const { checkForUpdates, installUpdate, notifyUpdate, progressText, CHECK_TASK_ID, INSTALL_TASK_ID, UPDATE_KEY } = + await import("../updates"); +const { provideNotificationService } = await import("@/workbench/contributions"); +const { useTasks } = await import("@/workbench/tasks"); + +type Note = Parameters[0]; +let notes: Note[]; +let expired: string[]; + +const task = (id: string) => useTasks.getState().tasks.find((t) => t.id === id); +const ok = (data: T): Result => ({ status: "ok", data }); +const tick = () => new Promise((r) => setTimeout(r, 0)); + +beforeEach(() => { + notes = []; + expired = []; + channel = null; + install = deferred(); + check = deferred(); + provideNotificationService({ notify: (n) => void notes.push(n), expire: (k) => void expired.push(k) }); + useTasks.setState({ tasks: [] }); +}); + +afterEach(() => vi.clearAllMocks()); + +describe("Install and Restart", () => { + it("runs as a background task with download progress, then restarts", async () => { + const done = installUpdate("0.1.22"); + expect(expired).toEqual([UPDATE_KEY]); + expect(task(INSTALL_TASK_ID)).toMatchObject({ title: "Downloading OpenIDEA 0.1.22", fraction: null, done: false }); + expect(task(INSTALL_TASK_ID)?.cancel).toBeTypeOf("function"); + + channel!.onmessage({ type: "downloading", downloaded: 21 * 1024 * 1024, total: 84 * 1024 * 1024 }); + expect(task(INSTALL_TASK_ID)).toMatchObject({ fraction: 0.25, message: "21.0 MB of 84.0 MB" }); + channel!.onmessage({ type: "installing" }); + expect(task(INSTALL_TASK_ID)).toMatchObject({ fraction: null, message: "Installing…", cancel: undefined }); + + install.resolve(ok(null)); + await done; + expect(task(INSTALL_TASK_ID)?.done).toBe(true); + expect(appRestart).toHaveBeenCalledTimes(1); + const installed = notes.at(-1)!; + expect(installed).toMatchObject({ title: "OpenIDEA 0.1.22 is installed", key: UPDATE_KEY, sticky: true }); + installed.actions![0]!.run(); + expect(appRestart).toHaveBeenCalledTimes(2); + }); + + 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 }); + const action = notes[0]!.actions![0]!; + action.run(); + action.run(); + void installUpdate("0.1.22"); + expect(appInstallUpdate).toHaveBeenCalledTimes(1); + + // A check while installing doesn't offer the install again. + const checked = checkForUpdates(); + check.resolve( + ok({ enabled: true, update: { version: "0.1.22", currentVersion: "0.1.21", notes: null, date: null } }), + ); + await checked; + expect(notes).toHaveLength(1); + + install.resolve(ok(null)); + await tick(); + action.run(); + expect(appInstallUpdate).toHaveBeenCalledTimes(2); + }); + + it("reports a failure with Retry, and stays quiet when cancelled", async () => { + const failed = installUpdate("0.1.22"); + install.resolve({ status: "error", error: { kind: "unavailable", message: "connection reset" } }); + await failed; + expect(appRestart).not.toHaveBeenCalled(); + expect(task(INSTALL_TASK_ID)?.done).toBe(true); + expect(notes.at(-1)).toMatchObject({ + level: "error", + title: "Could not install the update", + body: "connection reset", + }); + expect(notes.at(-1)!.actions![0]!.label).toBe("Retry"); + + notes = []; + install = deferred(); + const cancelled = installUpdate("0.1.22"); + task(INSTALL_TASK_ID)!.cancel!(); + expect(cancelRequest).toHaveBeenCalledWith(appInstallUpdate.mock.calls.at(-1)![0]); + install.resolve({ status: "error", error: { kind: "cancelled", message: "cancelled" } }); + await cancelled; + expect(notes).toEqual([]); + expect(appRestart).not.toHaveBeenCalled(); + }); +}); + +describe("Check for Updates", () => { + it("shows a background task while checking and joins a running check", async () => { + const first = checkForUpdates(); + void checkForUpdates(); + expect(appCheckUpdate).toHaveBeenCalledTimes(1); + expect(task(CHECK_TASK_ID)).toMatchObject({ title: "Checking for updates…", done: false }); + check.resolve(ok({ enabled: true, update: null })); + await first; + expect(task(CHECK_TASK_ID)?.done).toBe(true); + expect(notes.at(-1)).toMatchObject({ title: "OpenIDEA 0.1.21 is up to date" }); + }); + + it.each([ + [ok({ enabled: false, update: null }), "Automatic updates are not configured"], + [ + ok({ enabled: true, update: { version: "0.1.22", currentVersion: "0.1.21", notes: null, date: null } }), + "OpenIDEA 0.1.22 is available", + ], + [ + { status: "error", error: { kind: "unavailable", message: "offline" } } as Result, + "Update check failed", + ], + ])("always ends in a notification (%#)", async (result, title) => { + const done = checkForUpdates(); + check.resolve(result); + await done; + expect(notes.at(-1)?.title).toBe(title); + expect(task(CHECK_TASK_ID)?.done).toBe(true); + }); +}); + +describe("progressText", () => { + it("formats known and unknown lengths", () => { + expect(progressText({ type: "downloading", downloaded: 1024 * 1024, total: null })).toEqual({ + message: "1.0 MB", + fraction: null, + }); + expect(progressText({ type: "downloading", downloaded: 10, total: 5 }).fraction).toBe(1); + }); +}); diff --git a/src/features/app/appActions.ts b/src/features/app/appActions.ts index e6a953752..19d7e4ce7 100644 --- a/src/features/app/appActions.ts +++ b/src/features/app/appActions.ts @@ -2,12 +2,13 @@ import { redo, undo } from "@codemirror/commands"; import { EditorView } from "@codemirror/view"; import type { Action } from "@/platform/actions"; import { t } from "@/platform/i18n"; -import { call, commands, isTauri, type UpdateInfo } from "@/platform/ipc"; +import { call, commands, isTauri } from "@/platform/ipc"; import { notifications } from "@/workbench/contributions"; import { showAbout } from "./AboutDialog"; import { quitApp } from "./closeGuard"; import { guessOs } from "./host"; import { showKeymapReference } from "./KeymapReference"; +import { checkForUpdates } from "./updates"; function errorText(e: unknown) { return e instanceof Error ? e.message : String(e); @@ -24,59 +25,6 @@ export async function showLog(): Promise { } } -/** Manual update check. Does nothing over the network unless an endpoint is configured. */ -export async function checkForUpdates(): Promise { - if (!isTauri()) return; - try { - const r = await call(commands.appCheckUpdate()); - if (!r.enabled) { - notifications().notify({ - level: "info", - title: t("app.update.disabled"), - body: t("app.update.disabledBody"), - key: "app.update", - }); - } else if (!r.update) { - const info = await commands.appBuildInfo(); - notifications().notify({ - level: "info", - title: t("app.update.none", { version: info.version }), - key: "app.update", - }); - } else { - notifyUpdate(r.update); - } - } catch (e) { - notifications().notify({ level: "error", title: t("app.update.failed"), body: errorText(e), key: "app.update" }); - } -} - -/** 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().catch(() => null); - if (update) notifyUpdate(update); -} - -function notifyUpdate(update: UpdateInfo) { - notifications().notify({ - level: "info", - title: t("app.update.available", { version: update.version }), - body: update.notes ?? undefined, - sticky: true, - key: "app.update", - actions: [ - { - label: t("app.update.install"), - run: () => - void call(commands.appInstallUpdate()).catch((e) => - notifications().notify({ level: "error", title: t("app.update.installFailed"), body: errorText(e) }), - ), - }, - ], - }); -} - /** * Edit menu items on Windows/Linux (macOS uses native roles). They act on the * focused element like the native commands would. Undo / Redo in an editor go diff --git a/src/features/app/index.ts b/src/features/app/index.ts index a5cb87d25..27dbe5e2e 100644 --- a/src/features/app/index.ts +++ b/src/features/app/index.ts @@ -4,7 +4,8 @@ */ import { registerOverlay, registerStatusItem } from "@/workbench/contributions"; import { AboutOverlay } from "./AboutDialog"; -import { appActions, checkForUpdatesOnStartup, updateActions } from "./appActions"; +import { appActions, updateActions } from "./appActions"; +import { checkForUpdatesOnStartup } from "./updates"; import { installCloseGuard } from "./closeGuard"; import { InvalidateCachesOverlay, registerInvalidateCachesActions } from "./InvalidateCaches"; import { buildInfo } from "./host"; diff --git a/src/features/app/updates.ts b/src/features/app/updates.ts new file mode 100644 index 000000000..2833bd21d --- /dev/null +++ b/src/features/app/updates.ts @@ -0,0 +1,157 @@ +/** + * Update check and install. Both run as background tasks in the status bar: the + * check while the request is out, the install with download progress and Cancel. + * Every entry point (Help menu, toolbar gear, welcome screen, Search Everywhere, + * the "is available" notification and its log entry) goes through these, so a + * repeated click joins the running check or install instead of starting another. + */ +import { Channel } from "@tauri-apps/api/core"; +import { t } from "@/platform/i18n"; +import { + call, + commands, + isCancelled, + isTauri, + nextRequestId, + type UpdateInfo, + type UpdateProgress, +} from "@/platform/ipc"; +import { notifications } from "@/workbench/contributions"; +import { upsertTask } from "@/workbench/tasks"; + +export const UPDATE_KEY = "app.update"; +export const CHECK_TASK_ID = "app.update.check"; +export const INSTALL_TASK_ID = "app.update.install"; + +function errorText(e: unknown) { + return e instanceof Error ? e.message : String(e); +} + +let checking: Promise | null = null; +let installing: Promise | null = null; + +/** Manual update check. Does nothing over the network unless an endpoint is configured. */ +export function checkForUpdates(): Promise { + if (!isTauri()) return Promise.resolve(); + checking ??= runCheck().finally(() => (checking = null)); + return checking; +} + +async function runCheck() { + const title = t("app.update.checking"); + upsertTask({ id: CHECK_TASK_ID, title, message: null, fraction: null, done: false }); + try { + const r = await call(commands.appCheckUpdate()); + if (!r.enabled) { + notifications().notify({ + level: "info", + title: t("app.update.disabled"), + body: t("app.update.disabledBody"), + key: UPDATE_KEY, + }); + } else if (!r.update) { + const info = await commands.appBuildInfo(); + notifications().notify({ + level: "info", + title: t("app.update.none", { version: info.version }), + key: UPDATE_KEY, + }); + } else if (!installing) { + notifyUpdate(r.update); + } + } catch (e) { + notifications().notify({ level: "error", title: t("app.update.failed"), body: errorText(e), key: UPDATE_KEY }); + } finally { + upsertTask({ id: CHECK_TASK_ID, title, message: null, fraction: 1, done: true }); + } +} + +/** 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().catch(() => null); + if (update) notifyUpdate(update); +} + +export function notifyUpdate(update: UpdateInfo) { + notifications().notify({ + level: "info", + title: t("app.update.available", { version: update.version }), + body: update.notes ?? undefined, + sticky: true, + key: UPDATE_KEY, + actions: [{ label: t("app.update.install"), run: () => void installUpdate(update.version) }], + }); +} + +function megabytes(bytes: number) { + return (bytes / (1024 * 1024)).toFixed(1); +} + +/** Download progress as the task's message and fraction. */ +export function progressText(p: UpdateProgress): { message: string; fraction: number | null } { + if (p.type === "installing") return { message: t("app.update.installingMessage"), fraction: null }; + const done = p.downloaded ?? 0; + if (p.total) { + return { + message: t("app.update.progress", { done: megabytes(done), total: megabytes(p.total) }), + fraction: Math.min(1, done / p.total), + }; + } + return { message: t("app.update.progressUnknown", { done: megabytes(done) }), fraction: null }; +} + +/** Downloads and installs `version`, then restarts. A second call while one runs joins it. */ +export function installUpdate(version: string): Promise { + if (!isTauri()) return Promise.resolve(); + installing ??= runInstall(version).finally(() => (installing = null)); + return installing; +} + +async function runInstall(version: string) { + const title = t("app.update.downloading", { version }); + const request = nextRequestId(); + const cancel = () => void commands.cancelRequest(request).catch(() => {}); + const channel = new Channel(); + channel.onmessage = (p) => { + const { message, fraction } = progressText(p); + upsertTask({ + id: INSTALL_TASK_ID, + title, + message, + fraction, + done: false, + cancel: p.type === "installing" ? undefined : cancel, + }); + }; + // The notification's action is spent: progress lives in the status bar from here. + notifications().expire?.(UPDATE_KEY); + upsertTask({ id: INSTALL_TASK_ID, title, message: null, fraction: null, done: false, cancel }); + try { + await call(commands.appInstallUpdate(request, channel)); + } catch (e) { + if (!isCancelled(e)) { + notifications().notify({ + level: "error", + title: t("app.update.installFailed"), + body: errorText(e), + key: UPDATE_KEY, + actions: [{ label: t("app.update.retry"), run: () => void installUpdate(version) }], + }); + } + return; + } finally { + upsertTask({ id: INSTALL_TASK_ID, title, message: null, fraction: 1, done: true }); + } + const restart = () => void commands.appRestart(); + // Stays if a window cancels the restart (unsaved changes): the update applies on the next start. + notifications().notify({ + level: "info", + title: t("app.update.installed", { version }), + body: t("app.update.installedBody"), + sticky: true, + key: UPDATE_KEY, + actions: [{ label: t("app.update.restart"), run: restart }], + }); + restart(); +} diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index 7034ea9e2..46e70d276 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -56,12 +56,21 @@ "app.unsaved.saveFailed": "Could not save all files", "app.unsaved.title": "Unsaved Changes", "app.update.available": "OpenIDEA {version} is available", + "app.update.checking": "Checking for updates…", "app.update.disabled": "Automatic updates are not configured", "app.update.disabledBody": "Set an update endpoint (OPENIDEA_UPDATE_ENDPOINT or plugins.updater.endpoints) to enable update checks.", + "app.update.downloading": "Downloading OpenIDEA {version}", "app.update.failed": "Update check failed", "app.update.install": "Install and Restart", "app.update.installFailed": "Could not install the update", + "app.update.installed": "OpenIDEA {version} is installed", + "app.update.installedBody": "It is used after OpenIDEA restarts.", + "app.update.installingMessage": "Installing…", "app.update.none": "OpenIDEA {version} is up to date", + "app.update.progress": "{done} MB of {total} MB", + "app.update.progressUnknown": "{done} MB", + "app.update.restart": "Restart", + "app.update.retry": "Retry", "app.window.desktopOnly": "Multiple windows require the desktop app", "app.window.openFailed": "Cannot open {name}", "app.window.pickFileTitle": "Open File", diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index 38e55b6d6..ff0064a35 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -56,12 +56,21 @@ "app.unsaved.saveFailed": "无法保存所有文件", "app.unsaved.title": "未保存的更改", "app.update.available": "OpenIDEA {version} 可用", + "app.update.checking": "正在检查更新…", "app.update.disabled": "未配置自动更新", "app.update.disabledBody": "设置更新地址(OPENIDEA_UPDATE_ENDPOINT 或 plugins.updater.endpoints)以启用更新检查。", + "app.update.downloading": "正在下载 OpenIDEA {version}", "app.update.failed": "检查更新失败", "app.update.install": "安装并重启", "app.update.installFailed": "无法安装更新", + "app.update.installed": "OpenIDEA {version} 已安装", + "app.update.installedBody": "重启 OpenIDEA 后生效。", + "app.update.installingMessage": "正在安装…", "app.update.none": "OpenIDEA {version} 已是最新版本", + "app.update.progress": "{done} MB / {total} MB", + "app.update.progressUnknown": "{done} MB", + "app.update.restart": "重启", + "app.update.retry": "重试", "app.window.desktopOnly": "多窗口需要桌面应用", "app.window.openFailed": "无法打开 {name}", "app.window.pickFileTitle": "打开文件",