feat(updates): choose the update source, with a CNB mirror (#1673)

* 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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-06 13:30:19 +08:00
1 джерело c1aa092d75
коміт 266e4eeb03
13 files changed
+246 -39

No files matched your search

+75 -7
Переглянути файл
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { UpdateCheck, UpdateProgress } from "@/bindings";
import type { Settings, UpdateCheck, UpdateProgress } from "@/bindings";
import type { NotificationService } from "@/workbench/contributions";
vi.mock("@tauri-apps/api/core", () => ({
@@ -20,7 +20,7 @@ function deferred<T>() {
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) => {
const appInstallUpdate = vi.fn((_source: string, _request: number, ch: Channel) => {
channel = ch;
return install.promise;
});
@@ -28,18 +28,35 @@ 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" }));
const settingsSet = vi.fn(async () => ({ status: "ok", data: { general: {}, appearance: {} } }));
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 },
commands: {
...actual.commands,
appInstallUpdate,
appCheckUpdate,
appRestart,
cancelRequest,
appBuildInfo,
settingsSet,
},
};
});
const { checkForUpdates, installUpdate, notifyUpdate, progressText, CHECK_TASK_ID, INSTALL_TASK_ID, UPDATE_KEY } =
await import("../updates");
const {
checkForUpdates,
installUpdate,
notifyUpdate,
progressText,
updateSource,
CHECK_TASK_ID,
INSTALL_TASK_ID,
UPDATE_KEY,
} = await import("../updates");
const { provideNotificationService } = await import("@/workbench/contributions");
const { useProject } = await import("@/platform/project");
const { useTasks } = await import("@/workbench/tasks");
@@ -60,7 +77,7 @@ beforeEach(() => {
check = deferred();
provideNotificationService({ notify: (n) => void notes.push(n), expire: (k) => void expired.push(k) });
useTasks.setState({ tasks: [] });
useProject.setState({ project: null, lightEdit: false });
useProject.setState({ project: null, lightEdit: false, settings: null });
});
afterEach(() => vi.clearAllMocks());
@@ -128,12 +145,33 @@ describe("Install and Restart", () => {
install = deferred();
const cancelled = installUpdate("0.1.22");
task(INSTALL_TASK_ID)!.cancel!();
expect(cancelRequest).toHaveBeenCalledWith(appInstallUpdate.mock.calls.at(-1)![0]);
expect(cancelRequest).toHaveBeenCalledWith(appInstallUpdate.mock.calls.at(-1)![1]);
install.resolve({ status: "error", error: { kind: "cancelled", message: "cancelled" } });
await cancelled;
expect(notes.at(-1)).toMatchObject({ level: "info", title: "Update cancelled", key: UPDATE_KEY });
expect(appRestart).not.toHaveBeenCalled();
});
it.each([
["a failed", "unavailable", "github", "CNB", "cnb"],
["a failed", "unavailable", "cnb", "GitHub", "github"],
["a cancelled (likely slow)", "cancelled", "cnb", "GitHub", "github"],
])("offers %s download from %s again from the other source", async (_case, kind, from, label, to) => {
useProject.setState({ settings: { general: { updateSource: from }, appearance: {} } as unknown as Settings });
const first = installUpdate("0.1.22");
install.resolve({ status: "error", error: { kind, message: kind } });
await first;
const action = notes.at(-1)!.actions!.at(-1)!;
expect(action.label).toBe(`Retry with ${label}`);
install = deferred();
action.run();
await tick();
expect(settingsSet).toHaveBeenCalledWith("global", "general.updateSource", JSON.stringify(to));
expect(appInstallUpdate.mock.calls.at(-1)![0]).toBe(to);
install.resolve(ok(null));
await tick();
});
});
describe("Check for Updates", () => {
@@ -184,6 +222,36 @@ describe("Check for Updates", () => {
});
});
describe("updateSource", () => {
const withSettings = (updateSource: string | undefined, locale: string | undefined) =>
useProject.setState({ settings: { general: { updateSource }, appearance: { locale } } as unknown as Settings });
it.each([
["auto", "zh", "cnb"],
["auto", "zh-CN", "cnb"],
["auto", "en", "github"],
["auto", "ko", "github"],
[undefined, "zh", "cnb"],
["github", "zh", "github"],
["cnb", "en", "cnb"],
["bogus", "en", "github"],
])("%s with the %s UI uses %s", (setting, locale, expected) => {
withSettings(setting, locale);
expect(updateSource()).toBe(expected);
});
it("is passed to the check and the install", async () => {
withSettings("cnb", "en");
void checkForUpdates();
expect(appCheckUpdate).toHaveBeenCalledWith("cnb");
check.resolve(ok({ enabled: true, update: null }));
void installUpdate("0.1.22");
expect(appInstallUpdate.mock.calls.at(-1)![0]).toBe("cnb");
install.resolve(ok(null));
await tick();
});
});
describe("progressText", () => {
it("formats known and unknown lengths", () => {
expect(progressText({ type: "downloading", downloaded: 1024 * 1024, total: null })).toEqual({
+51 -9
Переглянути файл
@@ -7,7 +7,7 @@
* 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 { resolveLocale, t } from "@/platform/i18n";
import {
call,
commands,
@@ -16,6 +16,7 @@ import {
nextRequestId,
type UpdateInfo,
type UpdateProgress,
type UpdateSource,
} from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { notifications } from "@/workbench/contributions";
@@ -30,6 +31,39 @@ 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;
@@ -56,7 +90,7 @@ async function runCheck() {
key: UPDATE_KEY,
});
try {
const r = await Promise.race([call(commands.appCheckUpdate()), abandoned]);
const r = await Promise.race([call(commands.appCheckUpdate(updateSource())), abandoned]);
if (r === "cancelled") return;
if (!r.enabled) {
notifications().notify({
@@ -85,7 +119,7 @@ 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().catch(() => null);
const update = await commands.appStartupUpdateCheck(updateSource()).catch(() => null);
if (update) notifyUpdate(update);
}
@@ -118,13 +152,13 @@ export function progressText(p: UpdateProgress): { message: string; fraction: nu
}
/** Downloads and installs `version`, then restarts. A second call while one runs joins it. */
export function installUpdate(version: string): Promise<void> {
export function installUpdate(version: string, source = updateSource()): Promise<void> {
if (!isTauri()) return Promise.resolve();
installing ??= runInstall(version).finally(() => (installing = null));
installing ??= runInstall(version, source).finally(() => (installing = null));
return installing;
}
async function runInstall(version: string) {
async function runInstall(version: string, source: UpdateSource) {
const title = t("app.update.downloading", { version });
const request = nextRequestId();
const cancel = () => void commands.cancelRequest(request).catch(() => {});
@@ -160,17 +194,25 @@ async function runInstall(version: string) {
// Replaces the notification whose action is now spent.
balloon([{ label: t("app.update.cancel"), run: cancel }]);
try {
await call(commands.appInstallUpdate(request, channel));
await call(commands.appInstallUpdate(source, request, channel));
} catch (e) {
if (isCancelled(e)) {
notifications().notify({ level: "info", title: t("app.update.cancelled"), key: UPDATE_KEY });
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) }],
actions: [
{ label: t("app.update.retry"), run: () => void installUpdate(version, source) },
otherSourceAction(version, source),
],
});
}
return;