Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions crates/wizard-gui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))]
Expand Down Expand Up @@ -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(())
})
Expand Down
21 changes: 17 additions & 4 deletions crates/wizard-gui/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,32 @@ 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<bool> {
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")??;

if !app.manage(RevisionSignal::new(settings)) {
bail!("settings already initialized");
}
Ok(())
Ok(first_run)
}

/// TODO: maybe update state when file changes
Expand Down
164 changes: 157 additions & 7 deletions crates/wizard-gui/src/updater.rs
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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<Endpoint>,
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<Self> {
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::<Arc<RevisionSignal<UpdateState>>>());
Expand All @@ -36,6 +83,7 @@ pub(crate) fn start(app: tauri::AppHandle) {
check_in_background(&app, &state).await;
}
});
Ok(())
}

#[tauri::command]
Expand Down Expand Up @@ -169,16 +217,118 @@ async fn check_in_background(app: &tauri::AppHandle, state: &RevisionSignal<Upda
}

async fn find_update(app: &tauri::AppHandle) -> Result<Option<Update>> {
let updater = app
let sources = app.state::<UpdateSources>();
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<Option<Update>> {
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<Option<HeaderValue>> {
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<HeaderValue>,
) -> Result<Option<Update>> {
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")
);
}
}
1 change: 1 addition & 0 deletions crates/wizard-gui/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions crates/wizard-settings/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ dirs.workspace = true
serde.workspace = true
tempfile.workspace = true
toml_edit = { workspace = true, features = ["serde"] }
uuid.workspace = true
49 changes: 49 additions & 0 deletions crates/wizard-settings/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions xtask/src/dev.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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,
},
},
Expand Down
1 change: 1 addition & 0 deletions xtask/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
4 changes: 2 additions & 2 deletions xtask/src/release.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 _;
Expand Down Expand Up @@ -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,
},
},
Expand Down
Loading