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

+65 -17
Переглянути файл
@@ -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");