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
This commit is contained in:
l0ng-ai
2026-11-06 13:30:19 +08:00
committed by GitHub
parent c1aa092d75
commit 266e4eeb03
13 changed files with 246 additions and 39 deletions
+3
View File
@@ -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(),
}
}
}
+65 -17
View File
@@ -330,36 +330,74 @@ fn update_endpoint_env() -> Option<String> {
/// tauri.conf.json, or the `OPENIDEA_UPDATE_ENDPOINT` environment variable. Empty by
/// default, and "Check for Updates…" is hidden then.
pub fn updates_configured<R: tauri::Runtime>(app: &AppHandle<R>) -> 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<R: tauri::Runtime>(app: &AppHandle<R>) -> Vec<String> {
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<Option<tauri_plugin_updater::Updater>> {
/// 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<String>, source: UpdateSource) -> Vec<String> {
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<Option<tauri_plugin_updater::Updater>> {
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::<IdeResult<Vec<_>>>()?;
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<UpdateCheck> {
let Some(updater) = updater(&app)? else {
pub async fn app_check_update(app: AppHandle, source: UpdateSource) -> IdeResult<UpdateCheck> {
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<UpdateCheck> {
/// not reported: the user didn't ask for this check.
#[tauri::command]
#[specta::specta]
pub async fn app_startup_update_check(app: AppHandle) -> Option<UpdateInfo> {
pub async fn app_startup_update_check(app: AppHandle, source: UpdateSource) -> Option<UpdateInfo> {
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<UpdateProgress>,
) -> 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");
+13 -4
View File
@@ -910,24 +910,24 @@ export const commands = {
menuSetDisabled: (ids: string[]) => __TAURI_INVOKE<void>("menu_set_disabled", { ids }),
appLogLocation: () => typedError<LogLocation, IdeError>(__TAURI_INVOKE("app_log_location")),
appBuildInfo: () => __TAURI_INVOKE<BuildInfo>("app_build_info"),
appCheckUpdate: () => typedError<UpdateCheck, IdeError>(__TAURI_INVOKE("app_check_update")),
appCheckUpdate: (source: UpdateSource) => typedError<UpdateCheck, IdeError>(__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<UpdateProgress>) => typedError<null, IdeError>(__TAURI_INVOKE("app_install_update", { request, onProgress })),
appInstallUpdate: (source: UpdateSource, request: RequestId, onProgress: Channel<UpdateProgress>) => typedError<null, IdeError>(__TAURI_INVOKE("app_install_update", { source, request, onProgress })),
debugExceptionFilters: () => typedError<SessionExceptionFilters[], IdeError>(__TAURI_INVOKE("debug_exception_filters")),
debugSetExceptionBreakpoints: (choices: { [key in string]: boolean }) => typedError<null, IdeError>(__TAURI_INVOKE("debug_set_exception_breakpoints", { choices })),
debugSetVariable: (session: DebugSessionId, container: number, name: string, evaluateName: string | null, frameId: number | null, value: string) => typedError<Variable, IdeError>(__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,
+75 -7
View File
@@ -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
View File
@@ -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;
@@ -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),
+19
View File
@@ -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<UpdateSourceSetting>[] = [
{ value: "auto", label: "Automatic" },
{ value: "github", label: "GitHub" },
{ value: "cnb", label: "CNB (China mirror)" },
];
function OpenProjectInRow() {
const [value, set] = useSetting<OpenProjectIn>("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" }],
+1
View File
@@ -11,6 +11,7 @@ export const CLIENT_SETTINGS_KEYS = [
"general.reopenLastProject",
"general.confirmExit",
"general.openProjectIn",
"general.updateSource",
"general.dateFormat",
"editor.fontFamily",
"editor.fontSize",
+1
View File
@@ -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",
+6 -1
View File
@@ -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)"
}
+5 -1
View File
@@ -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(国内镜像)"
}
+1
View File
@@ -94,6 +94,7 @@ const UNTRANSLATED = new Set<string>([
"oxfmt",
"Ruff",
"IntelliJ IDEA",
"GitHub",
"PHPStan",
// Docker run target placeholder values
"compose.yaml",
+1
View File
@@ -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": "打开文件",