Skip to main content

futu_core/
update_check.rs

1use std::time::Duration;
2
3use anyhow::{Context, Result, bail};
4use serde::{Deserialize, Serialize};
5
6use crate::diagnostic_text::update_check_unavailable_action;
7
8/// Rust product-owned public manifest. This is not a backend/broker endpoint;
9/// `FUTU_UPDATE_CHECK_URL` remains the deployment override. Replace the
10/// literal if the Rust distribution authority moves.
11pub const DEFAULT_UPDATE_CHECK_URL: &str = "https://futuapi.com/version.json";
12const SUPPORTED_VERSION_MANIFEST_SCHEMA: u32 = 1;
13
14#[derive(Debug, Clone, Deserialize, Serialize)]
15pub struct VersionManifest {
16    pub schema_version: u32,
17    pub latest_version: String,
18    pub minimum_supported_version: Option<String>,
19    pub recommendation: Option<String>,
20    pub release_url: Option<String>,
21    pub changelog_url: Option<String>,
22    pub message: Option<String>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
26#[serde(rename_all = "kebab-case")]
27pub enum UpdateLevel {
28    UpToDate,
29    Info,
30    Recommended,
31    Critical,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
35pub struct UpdateDecision {
36    pub level: UpdateLevel,
37    pub current_version: String,
38    pub latest_version: String,
39    pub message: String,
40    pub action: String,
41}
42
43pub fn decide_update(current_version: &str, manifest: &VersionManifest) -> Result<UpdateDecision> {
44    if manifest.schema_version != SUPPORTED_VERSION_MANIFEST_SCHEMA {
45        bail!(
46            "unsupported version manifest schema_version={} (supported={SUPPORTED_VERSION_MANIFEST_SCHEMA}); {}",
47            manifest.schema_version,
48            update_check_unavailable_action().text_with_code()
49        );
50    }
51
52    let current = current_version.trim();
53    let latest = manifest.latest_version.trim();
54    let current_parts = parse_version_parts(current);
55    let latest_parts = parse_version_parts(latest);
56    let minimum_parts = manifest
57        .minimum_supported_version
58        .as_deref()
59        .and_then(parse_version_parts);
60    let current_below_minimum = current_parts
61        .as_ref()
62        .zip(minimum_parts.as_ref())
63        .is_some_and(|(current, minimum)| current < minimum);
64    let newer_available = current_parts
65        .as_ref()
66        .zip(latest_parts.as_ref())
67        .map_or_else(|| current != latest, |(current, latest)| current < latest);
68    let release_url = manifest
69        .release_url
70        .as_deref()
71        .unwrap_or("https://futuapi.com/download/");
72    let changelog_url = manifest
73        .changelog_url
74        .as_deref()
75        .unwrap_or("https://futuapi.com/changelog/");
76    let action = format!("下载: {release_url}; 变更记录: {changelog_url}");
77
78    if current_below_minimum {
79        let minimum = manifest
80            .minimum_supported_version
81            .as_deref()
82            .unwrap_or("<unknown>");
83        return Ok(UpdateDecision {
84            level: UpdateLevel::Critical,
85            current_version: current.to_string(),
86            latest_version: latest.to_string(),
87            message: format!(
88                "current version {current} is below minimum supported version {minimum}; latest is {latest}"
89            ),
90            action,
91        });
92    }
93    if !newer_available {
94        return Ok(UpdateDecision {
95            level: UpdateLevel::UpToDate,
96            current_version: current.to_string(),
97            latest_version: latest.to_string(),
98            message: format!("futu-opend-rs {current} is up to date"),
99            action: "无需升级".to_string(),
100        });
101    }
102
103    let level = match manifest
104        .recommendation
105        .as_deref()
106        .unwrap_or("info")
107        .trim()
108        .to_ascii_lowercase()
109        .as_str()
110    {
111        "critical" => UpdateLevel::Critical,
112        "recommended" => UpdateLevel::Recommended,
113        _ => UpdateLevel::Info,
114    };
115    let version_suffix = format!("latest={latest}, current={current}");
116    let default_message = format!("new futu-opend-rs version available: {version_suffix}");
117    let message = manifest
118        .message
119        .as_deref()
120        .map(|message| format!("{message} ({version_suffix})"))
121        .unwrap_or(default_message);
122    Ok(UpdateDecision {
123        level,
124        current_version: current.to_string(),
125        latest_version: latest.to_string(),
126        message,
127        action,
128    })
129}
130
131pub async fn check_for_update(
132    current_version: &str,
133    url: &str,
134    timeout: Duration,
135) -> Result<UpdateDecision> {
136    let client = crate::http_client::webpki_builder()
137        .timeout(timeout.max(Duration::from_millis(1)))
138        .build()
139        .context("build update-check HTTP client")?;
140    let manifest = fetch_version_manifest(&client, url).await?;
141    decide_update(current_version, &manifest)
142}
143
144pub fn resolve_update_check_url(explicit_url: Option<&str>) -> String {
145    let env_url = std::env::var("FUTU_UPDATE_CHECK_URL").ok();
146    resolve_update_check_url_from_sources(explicit_url, env_url.as_deref())
147}
148
149#[must_use]
150pub fn resolve_update_check_url_from_sources(
151    explicit_url: Option<&str>,
152    env_url: Option<&str>,
153) -> String {
154    if let Some(url) = explicit_url
155        && !url.trim().is_empty()
156    {
157        return url.trim().to_string();
158    }
159    if let Some(url) = env_url
160        && !url.trim().is_empty()
161    {
162        return url.trim().to_string();
163    }
164    DEFAULT_UPDATE_CHECK_URL.to_string()
165}
166
167async fn fetch_version_manifest(client: &reqwest::Client, url: &str) -> Result<VersionManifest> {
168    client
169        .get(url)
170        .send()
171        .await
172        .with_context(|| format!("GET {url} failed"))?
173        .error_for_status()
174        .with_context(|| {
175            format!(
176                "GET {url} returned non-success status; {}",
177                update_check_unavailable_action().text_with_code()
178            )
179        })?
180        .json::<VersionManifest>()
181        .await
182        .with_context(|| {
183            format!(
184                "parse {url} as version manifest; {}",
185                update_check_unavailable_action().text_with_code()
186            )
187        })
188}
189
190fn parse_version_parts(version: &str) -> Option<Vec<u64>> {
191    let normalized = version.trim().trim_start_matches('v');
192    let mut parts = Vec::new();
193    for part in normalized.split('.') {
194        if part.is_empty() || !part.chars().all(|ch| ch.is_ascii_digit()) {
195            return None;
196        }
197        parts.push(part.parse().ok()?);
198    }
199    if parts.is_empty() { None } else { Some(parts) }
200}