fix(app): Show update check and download progress as background tasks (#171)

Installing an update gave no feedback: app_install_update discarded the download progress callbacks and downloaded the whole package silently before restarting, the UI had no pending state, and repeated clicks started parallel downloads. Manual Check for Updates also showed nothing while checking.

The update now runs as a status-bar background task: "Checking for updates…", then "Downloading OpenIDEA x.y.z" with size and percentage (streamed over a Channel, one event per percent) and Cancel until the download completes, then "Installing…". A running check or install is shared, so clicks from the notification, the notification log, Help › Check for Updates, the toolbar menu, the welcome screen or Search Everywhere join it; the backend allows one install at a time. Failures show an error notification with Retry. After installing, the app restarts through every window's unsaved-changes check and leaves an "installed" notification with Restart in case a window cancels it. Signature verification, endpoints and updater configuration are unchanged.

Fixes #156

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-29 07:48:24 +08:00
1 джерело 3764628eb3
коміт 64c0f7a79d
8 files changed
+500 -74

No files matched your search

+136 -15
Переглянути файл
@@ -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<UpdateCheck> {
#[tauri::command]
#[specta::specta]
pub async fn app_startup_update_check(app: AppHandle) -> Option<UpdateInfo> {
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<UpdateInfo> {
}
}
/// 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<f64> },
/// 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<u64>,
}
impl ProgressThrottle {
const UNKNOWN_STEP: u64 = 256 * 1024;
fn chunk(&mut self, len: usize, total: Option<u64>) -> Option<UpdateProgress> {
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<Self> {
(!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<UpdateProgress>,
) -> 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<UpdateProgress>) -> Option<(f64, Option<f64>)> {
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());
}
}
+11 -4
Переглянути файл
@@ -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<null, IdeError>(__TAURI_INVOKE("app_install_update")),
appInstallUpdate: (request: RequestId, onProgress: Channel<UpdateProgress>) => typedError<null, IdeError>(__TAURI_INVOKE("app_install_update", { request, onProgress })),
debugExceptionFilters: () => typedError<SessionExceptionFilters[], IdeError>(__TAURI_INVOKE("debug_exception_filters")),
debugSetExceptionBreakpoints: (choices: { [key in string]: boolean }) => typedError<null, IdeError>(__TAURI_INVOKE("debug_set_exception_breakpoints", { choices })),
debugSetVariable: (session: DebugSessionId, container: number, name: string, evaluateName: string | null, frameId: number | null, value: string) => typedError<Variable, IdeError>(__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,
+174
Переглянути файл
@@ -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<T> = { status: "ok"; data: T } | { status: "error"; error: { kind: string; message: string } };
function deferred<T>() {
let resolve!: (v: T) => void;
const promise = new Promise<T>((r) => (resolve = r));
return { promise, resolve };
}
let install: ReturnType<typeof deferred<Result<null>>>;
let check: ReturnType<typeof deferred<Result<UpdateCheck>>>;
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<typeof import("@/platform/ipc")>();
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<NotificationService["notify"]>[0];
let notes: Note[];
let expired: string[];
const task = (id: string) => useTasks.getState().tasks.find((t) => t.id === id);
const ok = <T>(data: T): Result<T> => ({ 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<UpdateCheck>,
"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);
});
});
+2 -54
Переглянути файл
@@ -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<void> {
}
}
/** Manual update check. Does nothing over the network unless an endpoint is configured. */
export async function checkForUpdates(): Promise<void> {
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<void> {
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
+2 -1
Переглянути файл
@@ -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";
+157
Переглянути файл
@@ -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<void> | null = null;
let installing: Promise<void> | null = null;
/** Manual update check. Does nothing over the network unless an endpoint is configured. */
export function checkForUpdates(): Promise<void> {
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<void> {
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<void> {
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<UpdateProgress>();
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();
}
+9
Переглянути файл
@@ -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",
+9
Переглянути файл
@@ -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": "打开文件",