diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 205a4676..52a20757 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -268,6 +268,8 @@ pub struct GeneralSettings { pub power_save_mode: bool, /// File › Rename Project… (project layer): the name shown for the project; empty = its folder name. pub project_name: String, + /// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`. + pub update_source: String, } impl Default for GeneralSettings { @@ -282,6 +284,7 @@ impl Default for GeneralSettings { date_format: DateFormatSettings::default(), power_save_mode: false, project_name: String::new(), + update_source: "auto".into(), } } } diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index ad0f8c11..9994abe0 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -330,36 +330,74 @@ fn update_endpoint_env() -> Option { /// tauri.conf.json, or the `OPENIDEA_UPDATE_ENDPOINT` environment variable. Empty by /// default, and "Check for Updates…" is hidden then. pub fn updates_configured(app: &AppHandle) -> bool { - let in_config = app - .config() + !config_endpoints(app).is_empty() || update_endpoint_env().is_some() +} + +/// Where updates are downloaded from (System Settings › Updates). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum UpdateSource { + /// The GitHub releases named in tauri.conf.json. + #[default] + Github, + /// The CNB mirror of the same releases, for networks where GitHub is slow. + Cnb, +} + +const CNB_ENDPOINT: &str = "https://cnb.cool/l0ng-ai/openidea/-/releases/latest/download/latest.json"; + +fn config_endpoints(app: &AppHandle) -> Vec { + app.config() .plugins .0 .get("updater") .and_then(|u| u.get("endpoints")) .and_then(|e| e.as_array()) - .is_some_and(|e| !e.is_empty()); - in_config || update_endpoint_env().is_some() + .map(|e| e.iter().filter_map(|u| u.as_str().map(String::from)).collect()) + .unwrap_or_default() } -fn updater(app: &AppHandle) -> IdeResult> { +/// The endpoints tried in order: `source` first, the other source when it fails or has +/// no manifest. Bundles are signed, so either source's manifest and downloads verify. +/// No official endpoint (a build without updates) means no mirror either. +fn source_endpoints(official: Vec, source: UpdateSource) -> Vec { + if official.is_empty() { + return official; + } + let mirror = vec![CNB_ENDPOINT.to_string()]; + match source { + UpdateSource::Github => [official, mirror].concat(), + UpdateSource::Cnb => [mirror, official].concat(), + } +} + +fn updater(app: &AppHandle, source: UpdateSource) -> IdeResult> { use tauri_plugin_updater::UpdaterExt; if !updates_configured(app) { return Ok(None); } - let env = update_endpoint_env(); + let endpoints = match update_endpoint_env() { + Some(url) => vec![url], + None => source_endpoints(config_endpoints(app), source), + }; + let endpoints = endpoints + .iter() + .map(|u| u.parse().map_err(|e| IdeError::invalid(format!("update endpoint {u}: {e}")))) + .collect::>>()?; let proxy = ide_core::proxy_settings::proxy(); - let mut builder = app.updater_builder().configure_client(move |b| ide_http::client::with_proxy(b, &proxy)); - if let Some(url) = env { - let url = url.parse().map_err(|e| IdeError::invalid(format!("OPENIDEA_UPDATE_ENDPOINT: {e}")))?; - builder = builder.endpoints(vec![url]).map_err(|e| IdeError::invalid(e.to_string()))?; - } - builder.build().map(Some).map_err(|e| IdeError::unavailable(e.to_string())) + app.updater_builder() + .configure_client(move |b| ide_http::client::with_proxy(b, &proxy)) + .endpoints(endpoints) + .map_err(|e| IdeError::invalid(e.to_string()))? + .build() + .map(Some) + .map_err(|e| IdeError::unavailable(e.to_string())) } #[tauri::command] #[specta::specta] -pub async fn app_check_update(app: AppHandle) -> IdeResult { - let Some(updater) = updater(&app)? else { +pub async fn app_check_update(app: AppHandle, source: UpdateSource) -> IdeResult { + let Some(updater) = updater(&app, source)? else { return Ok(UpdateCheck { enabled: false, update: None }); }; let update = updater.check().await.map_err(|e| IdeError::unavailable(e.to_string()))?; @@ -379,12 +417,12 @@ pub async fn app_check_update(app: AppHandle) -> IdeResult { /// not reported: the user didn't ask for this check. #[tauri::command] #[specta::specta] -pub async fn app_startup_update_check(app: AppHandle) -> Option { +pub async fn app_startup_update_check(app: AppHandle, source: UpdateSource) -> Option { static DONE: AtomicBool = AtomicBool::new(false); if cfg!(debug_assertions) || DONE.swap(true, Ordering::SeqCst) { return None; } - match app_check_update(app).await { + match app_check_update(app, source).await { Ok(check) => check.update, Err(e) => { tracing::warn!("startup update check failed: {e}"); @@ -452,6 +490,7 @@ impl Drop for InstallingGuard { pub async fn app_install_update( app: AppHandle, state: State<'_, AppState>, + source: UpdateSource, request: RequestId, on_progress: Channel, ) -> IdeResult<()> { @@ -459,7 +498,7 @@ pub async fn app_install_update( 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 updater = updater(&app, source)?.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()), @@ -522,6 +561,15 @@ mod tests { assert!(ProgressThrottle::default().chunk(1, Some(0)).is_some()); } + #[test] + fn chosen_source_is_tried_first_and_the_other_after_it() { + let github = "https://github.com/l0ng-ai/openidea/releases/latest/download/latest.json".to_string(); + assert_eq!(source_endpoints(vec![github.clone()], UpdateSource::Github), [github.clone(), CNB_ENDPOINT.into()]); + assert_eq!(source_endpoints(vec![github.clone()], UpdateSource::Cnb), [CNB_ENDPOINT.into(), github]); + // A build without an update endpoint stays without one. + assert!(source_endpoints(Vec::new(), UpdateSource::Cnb).is_empty()); + } + #[test] fn only_one_install_at_a_time() { let first = InstallingGuard::acquire().expect("first install starts"); diff --git a/src/bindings.ts b/src/bindings.ts index 9082906d..73c45b57 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -910,24 +910,24 @@ export const commands = { menuSetDisabled: (ids: string[]) => __TAURI_INVOKE("menu_set_disabled", { ids }), appLogLocation: () => typedError(__TAURI_INVOKE("app_log_location")), appBuildInfo: () => __TAURI_INVOKE("app_build_info"), - appCheckUpdate: () => typedError(__TAURI_INVOKE("app_check_update")), + appCheckUpdate: (source: UpdateSource) => typedError(__TAURI_INVOKE("app_check_update", { source })), /** * The background check made once per process when the first window starts; * later calls (other windows) and debug builds get `None`. Failures are logged, * not reported: the user didn't ask for this check. */ - appStartupUpdateCheck: () => __TAURI_INVOKE<{ + appStartupUpdateCheck: (source: UpdateSource) => __TAURI_INVOKE<{ version: string, currentVersion: string, notes: string | null, date: string | null, -} | null>("app_startup_update_check"), +} | null>("app_startup_update_check", { source }), /** * 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: (request: RequestId, onProgress: Channel) => typedError(__TAURI_INVOKE("app_install_update", { request, onProgress })), + appInstallUpdate: (source: UpdateSource, request: RequestId, onProgress: Channel) => typedError(__TAURI_INVOKE("app_install_update", { source, 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 })), @@ -3866,6 +3866,8 @@ export type GeneralSettings = { powerSaveMode?: boolean, /** File › Rename Project… (project layer): the name shown for the project; empty = its folder name. */ projectName?: string, + /** Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`. */ + updateSource?: string, }; /** A repository of the signed-in account (owned, collaborator, or organization member). */ @@ -7598,6 +7600,13 @@ export type UpdateProgress = /** Download done; the new version is being written over this one. */ { type: "installing" }; +/** Where updates are downloaded from (System Settings › Updates). */ +export type UpdateSource = +/** The GitHub releases named in tauri.conf.json. */ +"github" | +/** The CNB mirror of the same releases, for networks where GitHub is slow. */ +"cnb"; + 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 index 1a4a71f8..28326a0e 100644 --- a/src/features/app/__tests__/updates.test.ts +++ b/src/features/app/__tests__/updates.test.ts @@ -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() { let install: ReturnType>>; let check: ReturnType>>; 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(); 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({ diff --git a/src/features/app/updates.tsx b/src/features/app/updates.tsx index e0451b85..4d10d291 100644 --- a/src/features/app/updates.tsx +++ b/src/features/app/updates.tsx @@ -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 = { 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 | null = null; let installing: Promise | 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 { 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 { +export function installUpdate(version: string, source = updateSource()): Promise { 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; diff --git a/src/features/settings/model/behaviorSchema.ts b/src/features/settings/model/behaviorSchema.ts index bfcd7194..bd21bfd7 100644 --- a/src/features/settings/model/behaviorSchema.ts +++ b/src/features/settings/model/behaviorSchema.ts @@ -50,8 +50,11 @@ export interface DateFormatValid { pretty: boolean; } +export type UpdateSourceSetting = "auto" | "github" | "cnb"; + export interface GeneralExtras { openProjectIn: OpenProjectIn; + updateSource: UpdateSourceSetting; defaultProjectDirectory: string; saveOnIdle: boolean; idleSeconds: number; @@ -100,6 +103,7 @@ export const DEFAULT_APPEARANCE_EXTRAS: AppearanceExtras = { export const DEFAULT_GENERAL_EXTRAS: GeneralExtras = { openProjectIn: "ask", + updateSource: "auto", defaultProjectDirectory: "", saveOnIdle: false, idleSeconds: 15, @@ -160,6 +164,7 @@ export function validateGeneralExtras(raw: unknown): GeneralExtras { const df = obj(g.dateFormat); return { openProjectIn: oneOf(g.openProjectIn, ["ask", "new", "same", "attach"] as const, d.openProjectIn), + updateSource: oneOf(g.updateSource, ["auto", "github", "cnb"] as const, d.updateSource), defaultProjectDirectory: typeof g.defaultProjectDirectory === "string" ? g.defaultProjectDirectory : "", saveOnIdle: bool(g.saveOnIdle, d.saveOnIdle), idleSeconds: int(g.idleSeconds, d.idleSeconds, IDLE_LIMITS), diff --git a/src/features/settings/pages/behavior.tsx b/src/features/settings/pages/behavior.tsx index e77441ed..09dddb15 100644 --- a/src/features/settings/pages/behavior.tsx +++ b/src/features/settings/pages/behavior.tsx @@ -26,6 +26,7 @@ import { type NotificationDisplay, type NotificationGroupValid, type OpenProjectIn, + type UpdateSourceSetting, } from "../model/behaviorSchema"; import { ACCENT_PRESETS, LIMITS, type ThemeId } from "../model/schema"; import type { PageSpec } from "./spec"; @@ -381,6 +382,12 @@ const OPEN_IN: Array<{ value: OpenProjectIn; label: string }> = [ { value: "ask", label: "Ask" }, ]; +const UPDATE_SOURCES: SelectOption[] = [ + { value: "auto", label: "Automatic" }, + { value: "github", label: "GitHub" }, + { value: "cnb", label: "CNB (China mirror)" }, +]; + function OpenProjectInRow() { const [value, set] = useSetting("general.openProjectIn"); const name = useId(); @@ -724,6 +731,18 @@ export const BEHAVIOR_PAGES: PageSpec[] = [ }, ], }, + { + title: "Updates", + options: [ + { + kind: "select", + key: "general.updateSource", + label: "Update source", + hint: "Where updates are downloaded from. If it can't be reached, the other source is tried.", + options: UPDATE_SOURCES, + }, + ], + }, { title: "Exit", options: [{ kind: "toggle", key: "general.confirmExit", label: "Confirm before exiting the IDE" }], diff --git a/src/platform/clientSettings.ts b/src/platform/clientSettings.ts index 742e797d..9fa3364b 100644 --- a/src/platform/clientSettings.ts +++ b/src/platform/clientSettings.ts @@ -11,6 +11,7 @@ export const CLIENT_SETTINGS_KEYS = [ "general.reopenLastProject", "general.confirmExit", "general.openProjectIn", + "general.updateSource", "general.dateFormat", "editor.fontFamily", "editor.fontSize", diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index 18b22917..c650dea4 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -81,6 +81,7 @@ "app.update.progressUnknown": "{done} MB", "app.update.restart": "Restart", "app.update.retry": "Retry", + "app.update.retryWith": "Retry with {source}", "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/text/en.json b/src/platform/locales/text/en.json index 79e65738..66c7ef7e 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -37,5 +37,10 @@ "e.g. /var/www/html": "e.g. /var/www/html", "Listening for Xdebug on port {port}": "Listening for Xdebug on port {port}", "Port {port} is already in use: another IDE or debug session may be listening on it. Stop it or choose another port.": "Port {port} is already in use: another IDE or debug session may be listening on it. Stop it or choose another port.", - "Unable to load image: {path}": "Unable to load image: {path}" + "Unable to load image: {path}": "Unable to load image: {path}", + "Updates": "Updates", + "Update source": "Update source", + "Where updates are downloaded from. If it can't be reached, the other source is tried.": "Where updates are downloaded from. If it can't be reached, the other source is tried.", + "Automatic": "Automatic", + "CNB (China mirror)": "CNB (China mirror)" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 765651a7..c7053ec3 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6377,5 +6377,9 @@ "Force push anyway": "仍然强制推送", "Stage and Retry": "暂存并重试", "Show Files": "显示文件", - "Unable to load image: {path}": "无法加载图片:{path}" + "Unable to load image: {path}": "无法加载图片:{path}", + "Updates": "更新", + "Update source": "更新源", + "Where updates are downloaded from. If it can't be reached, the other source is tried.": "从哪里下载更新。无法连接时会改用另一个源。", + "CNB (China mirror)": "CNB(国内镜像)" } diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index e135cf93..b6b026d5 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -94,6 +94,7 @@ const UNTRANSLATED = new Set([ "oxfmt", "Ruff", "IntelliJ IDEA", + "GitHub", "PHPStan", // Docker run target placeholder values "compose.yaml", diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index 6cf81d31..4a67b7ec 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -81,6 +81,7 @@ "app.update.progressUnknown": "{done} MB", "app.update.restart": "重启", "app.update.retry": "重试", + "app.update.retryWith": "改用 {source} 重试", "app.window.desktopOnly": "多窗口需要桌面应用", "app.window.openFailed": "无法打开 {name}", "app.window.pickFileTitle": "打开文件",