дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
* feat(updates): choose the update source, with a CNB mirror System Settings › Updates › Update source: Automatic, GitHub or CNB (China mirror). Automatic uses CNB for the Chinese UI and GitHub otherwise. The chosen source is tried first and the other one when it fails or has no manifest, for the check, the startup check and the install. * feat(updates): offer the other update source after a failed download A failed or cancelled update download now offers "Retry with CNB" (or "Retry with GitHub"): it switches the update source and downloads again from there. Checks need no such action: they already try both sources. * fix(updates): keep GitHub untranslated in the text catalog
234 рядки
8.5 KiB
TypeScript
234 рядки
8.5 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 { resolveLocale, t } from "@/platform/i18n";
|
||
import {
|
||
call,
|
||
commands,
|
||
isCancelled,
|
||
isTauri,
|
||
nextRequestId,
|
||
type UpdateInfo,
|
||
type UpdateProgress,
|
||
type UpdateSource,
|
||
} 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);
|
||
}
|
||
|
||
/** System Settings › Updates › Update source; `auto` picks the CNB mirror for the Chinese UI. */
|
||
export function updateSource(): UpdateSource {
|
||
const { settings } = useProject.getState();
|
||
const chosen = settings?.general?.updateSource;
|
||
if (chosen === "github" || chosen === "cnb") return chosen;
|
||
return resolveLocale(settings?.appearance?.locale) === "zh" ? "cnb" : "github";
|
||
}
|
||
|
||
const SOURCE_NAMES: Record<UpdateSource, string> = { github: "GitHub", cnb: "CNB" };
|
||
|
||
/** Makes `source` the update source; the retry it precedes uses it even if saving fails. */
|
||
async function chooseSource(source: UpdateSource) {
|
||
try {
|
||
useProject.setState({
|
||
settings: await call(commands.settingsSet("global", "general.updateSource", JSON.stringify(source))),
|
||
});
|
||
} catch {
|
||
/* the choice still applies to this retry */
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Offered once a download failed or was cancelled (likely too slow): the same install from
|
||
* the other source. A check never needs it, since it already falls back to the other source.
|
||
*/
|
||
function otherSourceAction(version: string, source: UpdateSource) {
|
||
const other: UpdateSource = source === "cnb" ? "github" : "cnb";
|
||
return {
|
||
label: t("app.update.retryWith", { source: SOURCE_NAMES[other] }),
|
||
run: () => void chooseSource(other).then(() => installUpdate(version, other)),
|
||
};
|
||
}
|
||
|
||
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(updateSource())), 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(updateSource()).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, source = updateSource()): Promise<void> {
|
||
if (!isTauri()) return Promise.resolve();
|
||
installing ??= runInstall(version, source).finally(() => (installing = null));
|
||
return installing;
|
||
}
|
||
|
||
async function runInstall(version: string, source: UpdateSource) {
|
||
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(source, request, channel));
|
||
} catch (e) {
|
||
if (isCancelled(e)) {
|
||
notifications().notify({
|
||
level: "info",
|
||
title: t("app.update.cancelled"),
|
||
key: UPDATE_KEY,
|
||
actions: [otherSourceAction(version, source)],
|
||
});
|
||
} 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, source) },
|
||
otherSourceAction(version, source),
|
||
],
|
||
});
|
||
}
|
||
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();
|
||
}
|