From 266e4eeb034b20138da567e9f2210e91098dfc80 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:30:19 +0800 Subject: [PATCH] feat(updates): choose the update source, with a CNB mirror (#1673) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- crates/ide-core/src/settings.rs | 3 + src-tauri/src/commands/window.rs | 82 +++++++++++++++---- src/bindings.ts | 17 +++- src/features/app/__tests__/updates.test.ts | 82 +++++++++++++++++-- src/features/app/updates.tsx | 60 ++++++++++++-- src/features/settings/model/behaviorSchema.ts | 5 ++ src/features/settings/pages/behavior.tsx | 19 +++++ src/platform/clientSettings.ts | 1 + src/platform/locales/en.json | 1 + src/platform/locales/text/en.json | 7 +- src/platform/locales/text/zh.json | 6 +- src/platform/locales/textCatalog.test.ts | 1 + src/platform/locales/zh.json | 1 + 13 files changed, 246 insertions(+), 39 deletions(-) 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": "打开文件",