Файли
openidea/src/features/app/updates.tsx
T
youngtseup c50d212a1d fix(app): keep update progress visible on the welcome screen (#951)
Check for Updates on the welcome screen showed nothing while the request was out: the check ran only as a status-bar task, and the welcome screen has no status bar. Leaving a project also dropped every notification, including a running update download.

A check started with no project or LightEdit window open now shows its progress in a balloon (keyed like the result, so the result replaces it); project windows keep it in the status bar. A project switch clears only project notifications and keeps the app-wide update ones.

Co-authored-by: youngtseup <[email protected]>
2026-11-03 14:38:19 +08:00

192 рядки
6.9 KiB
TypeScript

/**
* 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.
* Their balloons show the progress too (the welcome screen has no status bar).
* 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 { useProject } from "@/platform/project";
import { notifications } from "@/workbench/contributions";
import { upsertTask } from "@/workbench/tasks";
import { UpdateBalloonBody } from "./UpdateBalloonBody";
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");
// The check only reads: cancelling drops its answer (the request finishes on its own).
let abandon = () => {};
const abandoned = new Promise<"cancelled">((resolve) => (abandon = () => resolve("cancelled")));
upsertTask({ id: CHECK_TASK_ID, title, message: null, fraction: null, done: false, cancel: () => abandon() });
// No status bar (the welcome screen): the balloon is the only place the check can show.
const { project, lightEdit } = useProject.getState();
if (!installing && !project && !lightEdit)
notifications().notify({
level: "info",
title,
body: <UpdateBalloonBody task={CHECK_TASK_ID} />,
key: UPDATE_KEY,
});
try {
const r = await Promise.race([call(commands.appCheckUpdate()), abandoned]);
if (r === "cancelled") return;
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>();
const balloon = (actions?: Array<{ label: string; run: () => void }>) =>
notifications().notify({
level: "info",
title,
body: <UpdateBalloonBody task={INSTALL_TASK_ID} />,
sticky: true,
key: UPDATE_KEY,
actions,
});
let installingShown = false;
channel.onmessage = (p) => {
const { message, fraction } = progressText(p);
if (p.type === "installing" && !installingShown) {
installingShown = true;
balloon();
}
upsertTask({
id: INSTALL_TASK_ID,
title,
message,
fraction,
done: false,
cancel: p.type === "installing" ? undefined : cancel,
// Replacing the app happens in one step that must not be cut short.
uncancellable: p.type === "installing" ? t("app.update.installUncancellable") : undefined,
});
};
upsertTask({ id: INSTALL_TASK_ID, title, message: null, fraction: null, done: false, cancel });
// Replaces the notification whose action is now spent.
balloon([{ label: t("app.update.cancel"), run: cancel }]);
try {
await call(commands.appInstallUpdate(request, channel));
} catch (e) {
if (isCancelled(e)) {
notifications().notify({ level: "info", title: t("app.update.cancelled"), key: UPDATE_KEY });
} else {
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();
}