From a42883f04f47dcf9bfc94c0086218ca870a01300 Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Mon, 2 Nov 2026 20:34:07 +0300 Subject: [PATCH] updater: check for updates through Hub Each channel now lists Hub's /hub/api/wizard/release endpoint before its GitHub manifest. The Hub check carries the session cookie and the usage parameters Hub turns into metrics: a random install_id stored in settings, process uptime, and first_run when no settings file existed before. first_run is resent until Hub answers once. --- Cargo.lock | 1 + crates/wizard-gui/src/lib.rs | 5 +- crates/wizard-gui/src/settings.rs | 21 +++- crates/wizard-gui/src/updater.rs | 164 ++++++++++++++++++++++++++++-- crates/wizard-gui/tauri.conf.json | 1 + crates/wizard-settings/Cargo.toml | 1 + crates/wizard-settings/src/lib.rs | 49 +++++++++ xtask/src/dev.rs | 4 +- xtask/src/main.rs | 1 + xtask/src/release.rs | 4 +- 10 files changed, 234 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f1eb783..812a0fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5542,6 +5542,7 @@ dependencies = [ "serde", "tempfile", "toml_edit 0.25.15+spec-1.1.0", + "uuid", ] [[package]] diff --git a/crates/wizard-gui/src/lib.rs b/crates/wizard-gui/src/lib.rs index e3663e1..0527e1d 100644 --- a/crates/wizard-gui/src/lib.rs +++ b/crates/wizard-gui/src/lib.rs @@ -99,7 +99,8 @@ pub fn run_application() -> Result<()> { .setup(move |app| { log::info!("----------"); log::info!("starting CoreInfra Wizard {}", app.package_info().version); - tauri::async_runtime::block_on(settings::initialize_state(app.handle()))?; + let first_run = + tauri::async_runtime::block_on(settings::initialize_state(app.handle()))?; hub_auth::initialize(app.handle()); hub_tokens::HubTokensManager::initialize(app.handle())?; #[cfg(any(target_os = "macos", target_os = "windows"))] @@ -130,7 +131,7 @@ pub fn run_application() -> Result<()> { if !minimized || !tray_enabled { request_main_window(app.handle()); } - updater::start(app.handle().clone()); + updater::start(app.handle().clone(), first_run)?; claude_desktop_install::start_background_checks(app.handle().clone()); Ok(()) }) diff --git a/crates/wizard-gui/src/settings.rs b/crates/wizard-gui/src/settings.rs index 8c16d47..ccda430 100644 --- a/crates/wizard-gui/src/settings.rs +++ b/crates/wizard-gui/src/settings.rs @@ -7,11 +7,24 @@ use wizard_settings::{self as settings, ApplicationSettings, Settings}; static SETTINGS_WRITE_LOCK: Mutex<()> = Mutex::new(()); -pub(crate) async fn initialize_state(app: &tauri::AppHandle) -> Result<()> { - let settings = tauri::async_runtime::spawn_blocking(|| { +/// Loads settings, assigning an `install_id` if they have none and saving it when possible. +/// Returns whether this is Wizard's first run on this machine: no settings file existed. +pub(crate) async fn initialize_state(app: &tauri::AppHandle) -> Result { + let (settings, first_run) = tauri::async_runtime::spawn_blocking(|| { // TODO: remove migration settings::migrate()?; - settings::load_from_file() + let first_run = !settings::paths::settings_path()? + .try_exists() + .context("failed to inspect Wizard settings file")?; + let mut settings = settings::load_from_file()?; + if !settings::is_install_id(&settings.install_id) { + settings.install_id = settings::new_install_id(); + // Settings that cannot be written (e.g. a symlinked file) must not block startup. + if let Err(error) = settings::save_to_file(&settings) { + log::warn!("failed to save install_id: {error:#}"); + } + } + anyhow::Ok((settings, first_run)) }) .await .context("settings initialization task failed")??; @@ -19,7 +32,7 @@ pub(crate) async fn initialize_state(app: &tauri::AppHandle) -> Result<()> { if !app.manage(RevisionSignal::new(settings)) { bail!("settings already initialized"); } - Ok(()) + Ok(first_run) } /// TODO: maybe update state when file changes diff --git a/crates/wizard-gui/src/updater.rs b/crates/wizard-gui/src/updater.rs index 0148087..dcbd5cd 100644 --- a/crates/wizard-gui/src/updater.rs +++ b/crates/wizard-gui/src/updater.rs @@ -1,10 +1,14 @@ use crate::revision_signal::{RevisionSignal, RevisionSnapshot}; use alloc::sync::Arc; -use anyhow::{Context as _, Result}; +use anyhow::{Context as _, Result, anyhow}; +use core::sync::atomic::{AtomicBool, Ordering}; use core::time::Duration; use serde::Serialize; +use std::time::Instant; use tauri::Manager as _; +use tauri::http::{HeaderValue, header::COOKIE}; use tauri_plugin_updater::{Update, UpdaterExt as _}; +use wizard_core::hub::{HUB_ORIGIN, Url}; const UPDATE_CHECK_INTERVAL: Duration = Duration::from_hours(1); const UPDATE_CHECK_TIMEOUT: Duration = Duration::from_secs(3); @@ -26,7 +30,50 @@ impl UpdateState { } } -pub(crate) fn start(app: tauri::AppHandle) { +/// The updater's configured endpoints, tried in order. Hub's endpoint also receives the session +/// and Wizard's usage parameters; the others are fallbacks that receive neither. +struct UpdateSources { + endpoints: Vec, + started_at: Instant, + /// Kept until Hub answers a check, so a failed first check reports it again. + first_run: AtomicBool, +} + +struct Endpoint { + url: Url, + hub: bool, +} + +impl UpdateSources { + fn from_config(app: &tauri::AppHandle, first_run: bool) -> Result { + let config = app + .config() + .plugins + .0 + .get("updater") + .cloned() + .context("updater is not configured")?; + let config: tauri_plugin_updater::Config = + serde_json::from_value(config).context("invalid updater configuration")?; + let hub_origin = Url::parse(HUB_ORIGIN)?.origin(); + let endpoints = config + .endpoints + .into_iter() + .map(|url| Endpoint { + hub: url.origin() == hub_origin, + url, + }) + .collect(); + Ok(Self { + endpoints, + started_at: Instant::now(), + first_run: AtomicBool::new(first_run), + }) + } +} + +pub(crate) fn start(app: tauri::AppHandle, first_run: bool) -> Result<()> { + app.manage(UpdateSources::from_config(&app, first_run)?); start_update(app.clone()); let state = Arc::clone(&app.state::>>()); @@ -36,6 +83,7 @@ pub(crate) fn start(app: tauri::AppHandle) { check_in_background(&app, &state).await; } }); + Ok(()) } #[tauri::command] @@ -169,16 +217,118 @@ async fn check_in_background(app: &tauri::AppHandle, state: &RevisionSignal Result> { - let updater = app + let sources = app.state::(); + let mut last_error = None; + for endpoint in &sources.endpoints { + let result = if endpoint.hub { + check_via_hub(app, &sources, endpoint.url.clone()).await + } else { + check(app, endpoint.url.clone(), None).await + }; + match result { + Ok(update) => return Ok(update), + Err(error) => { + log::warn!( + "update check via {} failed: {error:#}", + endpoint.url.host_str().unwrap_or_default() + ); + last_error = Some(error); + } + } + } + Err(last_error.unwrap_or_else(|| anyhow!("no update endpoints configured"))) + .context("Не удалось проверить обновления") +} + +async fn check_via_hub( + app: &tauri::AppHandle, + sources: &UpdateSources, + endpoint: Url, +) -> Result> { + let settings = crate::settings::get_state(app)?; + let endpoint = with_usage( + endpoint, + &settings.install_id, + sources.started_at.elapsed(), + sources.first_run.load(Ordering::Relaxed), + ); + let mut update = check(app, endpoint, session_cookie(&settings.hub_session)?).await?; + sources.first_run.store(false, Ordering::Relaxed); + if let Some(update) = &mut update { + // The bundle downloads from GitHub; the session is only for Hub. + update.headers.remove(COOKIE); + } + Ok(update) +} + +fn with_usage(mut endpoint: Url, install_id: &str, uptime: Duration, first_run: bool) -> Url { + { + let mut query = endpoint.query_pairs_mut(); + query + .append_pair("install_id", install_id) + .append_pair("uptime", &uptime.as_secs().to_string()); + if first_run { + query.append_pair("first_run", "1"); + } + } + endpoint +} + +fn session_cookie(session: &str) -> Result> { + if session.is_empty() { + return Ok(None); + } + let mut cookie = HeaderValue::from_str(&format!("session={session}")) + .map_err(|_| anyhow!("Hub session is not a valid cookie value"))?; + cookie.set_sensitive(true); + Ok(Some(cookie)) +} + +async fn check( + app: &tauri::AppHandle, + endpoint: Url, + session: Option, +) -> Result> { + let mut builder = app .updater_builder() // Client-level, so it also covers proxy CONNECT and the download. .configure_client(|client| client.user_agent(wizard_proxy::user_agent())) .timeout(UPDATE_CHECK_TIMEOUT) + .endpoints(vec![endpoint]) + .context("Не удалось подготовить проверку обновлений")?; + if let Some(session) = session { + builder = builder + .header(COOKIE, session) + .context("Не удалось подготовить проверку обновлений")?; + } + let updater = builder .build() .context("Не удалось подготовить проверку обновлений")?; + Ok(updater.check().await?) +} - updater - .check() - .await - .context("Не удалось проверить обновления") +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hub_endpoint_keeps_channel_and_adds_usage() { + let endpoint = Url::parse("https://hub.coreinfra.ai/hub/api/wizard/release?channel=dev") + .expect("valid endpoint"); + let url = with_usage( + endpoint.clone(), + "abcdefgh27", + Duration::from_secs(90), + true, + ); + assert_eq!( + url.as_str(), + "https://hub.coreinfra.ai/hub/api/wizard/release?channel=dev&install_id=abcdefgh27&uptime=90&first_run=1" + ); + let url = with_usage(endpoint, "abcdefgh27", Duration::from_millis(999), false); + assert_eq!( + url.query(), + Some("channel=dev&install_id=abcdefgh27&uptime=0") + ); + } } diff --git a/crates/wizard-gui/tauri.conf.json b/crates/wizard-gui/tauri.conf.json index bce71dc..58b4dbf 100644 --- a/crates/wizard-gui/tauri.conf.json +++ b/crates/wizard-gui/tauri.conf.json @@ -83,6 +83,7 @@ "plugins": { "updater": { "endpoints": [ + "https://hub.coreinfra.ai/hub/api/wizard/release?channel=stable", "https://github.com/CoreInfraAI/wizard/releases/latest/download/latest.json" ], "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU5NEY4MDg5RkNCQTAxOUMKUldTY0FicjhpWUJQNlhyVWNoUDVIYjkzNDlsTVdmTDFaWFNDc2tlTk80WGg0OVZMajh2Tms5TkkK" diff --git a/crates/wizard-settings/Cargo.toml b/crates/wizard-settings/Cargo.toml index 7030189..05064c0 100644 --- a/crates/wizard-settings/Cargo.toml +++ b/crates/wizard-settings/Cargo.toml @@ -17,3 +17,4 @@ dirs.workspace = true serde.workspace = true tempfile.workspace = true toml_edit = { workspace = true, features = ["serde"] } +uuid.workspace = true diff --git a/crates/wizard-settings/src/lib.rs b/crates/wizard-settings/src/lib.rs index a6d7ef5..e79a4a3 100644 --- a/crates/wizard-settings/src/lib.rs +++ b/crates/wizard-settings/src/lib.rs @@ -29,6 +29,9 @@ pub struct Settings { /// Selected Hub for listing and creating agent tokens; empty uses the personal hub. #[serde(skip_serializing_if = "String::is_empty")] pub active_hub_id: String, + /// Random id that tells Wizard installations apart in Hub update metrics. Not a secret. + #[serde(skip_serializing_if = "String::is_empty")] + pub install_id: String, /// Application lifecycle preferences, independent of proxy demand. pub application: ApplicationSettings, /// Kept last: it serializes as a TOML table, written after plain keys. @@ -76,6 +79,31 @@ impl Settings { } } +// Lowercase RFC 4648 base32; Hub accepts exactly this format. +const INSTALL_ID_ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567"; +const INSTALL_ID_LEN: usize = 10; + +/// 50 random bits as ten base32 characters. +#[must_use] +pub fn new_install_id() -> String { + // A v4 UUID keeps its version and variant bits above bit 61; the low 50 bits are random. + let bits = uuid::Uuid::new_v4().as_u128(); + (0..INSTALL_ID_LEN) + .map(|index| { + let digit = (bits >> (5 * index)) & 0x1f; + char::from(INSTALL_ID_ALPHABET[usize::try_from(digit).expect("5-bit digit")]) + }) + .collect() +} + +#[must_use] +pub fn is_install_id(value: &str) -> bool { + value.len() == INSTALL_ID_LEN + && value + .bytes() + .all(|byte| INSTALL_ID_ALPHABET.contains(&byte)) +} + /// Convert legacy per-app token strings to demand flags before GUI initialization. /// Reading settings stays strict. Never acquire credentials or start a service here. /// Preserve unrelated fields, including settings unknown to this version. @@ -211,6 +239,27 @@ mod tests { } } + #[test] + fn new_install_ids_match_hub_format() { + let first = new_install_id(); + assert!(is_install_id(&first), "{first:?}"); + assert_ne!(first, new_install_id()); + for invalid in ["", "abcdefgh2", "abcdefgh277", "ABCDEFGH27", "abcdefgh18"] { + assert!(!is_install_id(invalid), "{invalid:?}"); + } + } + + #[test] + fn install_id_round_trips() { + let settings = Settings { + install_id: "abcdefgh27".into(), + ..Settings::default() + }; + let text = toml_edit::ser::to_string_pretty(&settings).expect("serialize settings"); + let restored: Settings = toml_edit::de::from_str(&text).expect("read settings"); + assert_eq!(restored.install_id, "abcdefgh27"); + } + #[test] fn existing_settings_default_to_personal_hub() { let settings: Settings = toml_edit::de::from_str("hub_session = 'session'\n[last_paths]\n") diff --git a/xtask/src/dev.rs b/xtask/src/dev.rs index d8f0420..4e79fe8 100644 --- a/xtask/src/dev.rs +++ b/xtask/src/dev.rs @@ -1,5 +1,5 @@ use crate::utils::{Paths, clean_build_command, remove_path, require_success}; -use crate::{DEV_ENDPOINT, DEV_PUBLIC_KEY}; +use crate::{DEV_ENDPOINT, DEV_HUB_ENDPOINT, DEV_PUBLIC_KEY}; use anyhow::{Context as _, Result, bail}; use serde_json::json; use std::{fs, process::Command}; @@ -37,7 +37,7 @@ pub(crate) fn dev_app(paths: &Paths, reinstall: bool, console: bool, release: bo let config = json!({ "plugins": { "updater": { - "endpoints": [DEV_ENDPOINT], + "endpoints": [DEV_HUB_ENDPOINT, DEV_ENDPOINT], "pubkey": DEV_PUBLIC_KEY, }, }, diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 60992e7..798e5a6 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -9,6 +9,7 @@ mod dev_tag; mod release; mod utils; +const DEV_HUB_ENDPOINT: &str = "https://hub.coreinfra.ai/hub/api/wizard/release?channel=dev"; const DEV_ENDPOINT: &str = "https://coreinfraai.github.io/wizard/latest-dev.json"; const DEV_PUBLIC_KEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEY5QkI4Q0FEN0QxNTZDNkIKUldScmJCVjlyWXk3K2Q1VERmZ01oMzdVVDZPdG41VExtdkI5N3pHbXZYc3dxSEtPT0dHYTRaeksK"; diff --git a/xtask/src/release.rs b/xtask/src/release.rs index 1517cd7..4fc74b0 100644 --- a/xtask/src/release.rs +++ b/xtask/src/release.rs @@ -2,7 +2,7 @@ use crate::utils::{ Paths, clean_build_command, gh_api_bytes, gh_api_json, gh_release_upload, remove_path, require_success, required_env, stable_version, }; -use crate::{DEV_ENDPOINT, DEV_PUBLIC_KEY, apple, dev_tag}; +use crate::{DEV_ENDPOINT, DEV_HUB_ENDPOINT, DEV_PUBLIC_KEY, apple, dev_tag}; use anyhow::{Context as _, Result, bail}; use base64::{Engine as _, engine::general_purpose::STANDARD}; use core::fmt::Write as _; @@ -236,7 +236,7 @@ pub(crate) fn build( "bundle": { "createUpdaterArtifacts": true }, "plugins": { "updater": { - "endpoints": [DEV_ENDPOINT], + "endpoints": [DEV_HUB_ENDPOINT, DEV_ENDPOINT], "pubkey": DEV_PUBLIC_KEY, }, },