futu_core/
update_check.rs1use std::time::Duration;
2
3use anyhow::{Context, Result, bail};
4use serde::{Deserialize, Serialize};
5
6use crate::diagnostic_text::update_check_unavailable_action;
7
8pub 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}