Skip to main content

futu_core/localization/
remote.rs

1use std::path::PathBuf;
2use std::time::Duration;
3
4use reqwest::Url;
5use serde::{Deserialize, Serialize};
6
7use super::{
8    LanguageId, LanguagePackFile, LanguagePackInstallOptions, LanguagePackKind,
9    LanguagePackManifest, install_language_pack_files, language_pack_sha256_hex,
10    record_language_pack_update_error,
11};
12
13const MAX_REMOTE_PACK_BYTES: u64 = 5 * 1024 * 1024;
14
15#[derive(Debug, Clone)]
16pub struct LanguagePackRemoteUpdateOptions {
17    pub endpoint: String,
18    pub cache_root: PathBuf,
19    pub timeout_ms: u64,
20    pub lang_filter: Option<String>,
21}
22
23#[derive(Debug, Clone)]
24pub struct LanguagePackAutoUpdateOptions {
25    pub enabled: bool,
26    pub endpoint: Option<String>,
27    pub cache_root: PathBuf,
28    pub timeout_ms: u64,
29    pub lang_filter: Option<String>,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
33#[serde(rename_all = "snake_case")]
34pub enum LanguagePackAutoUpdateState {
35    DisabledByConfig,
36    EnabledNoEndpoint,
37    Updated,
38    DisabledAfterError,
39}
40
41impl LanguagePackAutoUpdateState {
42    pub const fn as_str(self) -> &'static str {
43        match self {
44            Self::DisabledByConfig => "disabled_by_config",
45            Self::EnabledNoEndpoint => "enabled_no_endpoint",
46            Self::Updated => "updated",
47            Self::DisabledAfterError => "disabled_after_error",
48        }
49    }
50}
51
52#[derive(Debug, Clone, Serialize)]
53pub struct LanguagePackAutoUpdateOutcome {
54    pub state: LanguagePackAutoUpdateState,
55    pub pack_version: Option<String>,
56    pub error: Option<String>,
57}
58
59#[derive(Debug, Deserialize)]
60struct RemoteManifest {
61    pack_version: String,
62    #[serde(default = "default_remote_source")]
63    source: String,
64    #[serde(default)]
65    profile: Option<String>,
66    entries: Vec<RemoteEntry>,
67}
68
69#[derive(Debug, Deserialize)]
70struct RemoteEntry {
71    lang: String,
72    kind: LanguagePackKind,
73    path: String,
74    sha256: String,
75    #[serde(default)]
76    bytes: Option<u64>,
77}
78
79pub async fn auto_update_language_pack(
80    options: LanguagePackAutoUpdateOptions,
81) -> LanguagePackAutoUpdateOutcome {
82    if !options.enabled {
83        return LanguagePackAutoUpdateOutcome {
84            state: LanguagePackAutoUpdateState::DisabledByConfig,
85            pack_version: None,
86            error: None,
87        };
88    }
89
90    let Some(endpoint) = options
91        .endpoint
92        .as_deref()
93        .map(str::trim)
94        .filter(|endpoint| !endpoint.is_empty())
95    else {
96        return LanguagePackAutoUpdateOutcome {
97            state: LanguagePackAutoUpdateState::EnabledNoEndpoint,
98            pack_version: None,
99            error: None,
100        };
101    };
102
103    match update_language_pack_from_endpoint(LanguagePackRemoteUpdateOptions {
104        endpoint: endpoint.to_string(),
105        cache_root: options.cache_root.clone(),
106        timeout_ms: options.timeout_ms,
107        lang_filter: options.lang_filter,
108    })
109    .await
110    {
111        Ok(manifest) => LanguagePackAutoUpdateOutcome {
112            state: LanguagePackAutoUpdateState::Updated,
113            pack_version: Some(manifest.pack_version),
114            error: None,
115        },
116        Err(error) => {
117            let error = format!("language-pack auto update failed: {error}");
118            let _ = record_language_pack_update_error(&options.cache_root, &error);
119            LanguagePackAutoUpdateOutcome {
120                state: LanguagePackAutoUpdateState::DisabledAfterError,
121                pack_version: None,
122                error: Some(error),
123            }
124        }
125    }
126}
127
128pub async fn update_language_pack_from_endpoint(
129    options: LanguagePackRemoteUpdateOptions,
130) -> Result<LanguagePackManifest, String> {
131    let manifest_url = language_pack_manifest_url(&options.endpoint)?;
132    ensure_allowed_language_pack_remote_scheme(&manifest_url)?;
133    let langs = parse_lang_filter(options.lang_filter.as_deref())?;
134    let client = reqwest::Client::builder()
135        .timeout(Duration::from_millis(options.timeout_ms))
136        .build()
137        .map_err(|error| format!("build language-pack HTTP client: {error}"))?;
138    let manifest = fetch_remote_manifest(&client, manifest_url.clone()).await?;
139    let files = fetch_remote_files(&client, &manifest_url, &manifest, langs.as_deref()).await?;
140    install_language_pack_files(
141        files,
142        LanguagePackInstallOptions {
143            cache_root: options.cache_root,
144            pack_version: manifest.pack_version,
145            source: manifest.source,
146            profile: manifest.profile,
147        },
148    )
149}
150
151pub fn language_pack_manifest_url(endpoint: &str) -> Result<Url, String> {
152    let mut url = Url::parse(endpoint)
153        .map_err(|error| format!("parse language-pack endpoint URL: {error}"))?;
154    if url.path().is_empty() || url.path().ends_with('/') {
155        url = url
156            .join("manifest.json")
157            .map_err(|error| format!("append manifest.json to endpoint URL: {error}"))?;
158    }
159    Ok(url)
160}
161
162pub fn ensure_allowed_language_pack_remote_scheme(url: &Url) -> Result<(), String> {
163    if url.scheme() == "https" {
164        return Ok(());
165    }
166    let host = url.host_str().unwrap_or_default();
167    if url.scheme() == "http" && matches!(host, "127.0.0.1" | "localhost" | "::1") {
168        return Ok(());
169    }
170    Err(
171        "language-pack update requires HTTPS endpoint; http is allowed only for localhost tests"
172            .to_string(),
173    )
174}
175
176async fn fetch_remote_manifest(
177    client: &reqwest::Client,
178    url: Url,
179) -> Result<RemoteManifest, String> {
180    let resp = client
181        .get(url.clone())
182        .send()
183        .await
184        .map_err(|error| format!("fetch language-pack manifest {url}: {error}"))?;
185    let status = resp.status();
186    if !status.is_success() {
187        return Err(format!("language-pack manifest HTTP status {status}"));
188    }
189    let bytes = resp
190        .bytes()
191        .await
192        .map_err(|error| format!("read language-pack manifest {url}: {error}"))?;
193    serde_json::from_slice(&bytes)
194        .map_err(|error| format!("parse language-pack manifest JSON: {error}"))
195}
196
197async fn fetch_remote_files(
198    client: &reqwest::Client,
199    manifest_url: &Url,
200    manifest: &RemoteManifest,
201    lang_filter: Option<&[LanguageId]>,
202) -> Result<Vec<LanguagePackFile>, String> {
203    let mut files = Vec::new();
204    for entry in &manifest.entries {
205        let Some(language) = LanguageId::from_cache_name(&entry.lang) else {
206            continue;
207        };
208        if let Some(filter) = lang_filter
209            && !filter.contains(&language)
210        {
211            continue;
212        }
213        let url = manifest_url
214            .join(&entry.path)
215            .map_err(|error| format!("resolve language-pack entry path {}: {error}", entry.path))?;
216        ensure_allowed_language_pack_remote_scheme(&url)?;
217        let bytes = fetch_remote_file(client, url, entry.bytes).await?;
218        let actual = language_pack_sha256_hex(&bytes);
219        if actual != entry.sha256 {
220            return Err(format!(
221                "language-pack checksum mismatch for {} {}: expected {}, got {}",
222                entry.lang,
223                entry.kind.as_str(),
224                entry.sha256,
225                actual
226            ));
227        }
228        files.push(LanguagePackFile {
229            language,
230            kind: entry.kind,
231            bytes,
232        });
233    }
234    if files.is_empty() {
235        return Err("language-pack manifest yielded no supported entries".to_string());
236    }
237    Ok(files)
238}
239
240async fn fetch_remote_file(
241    client: &reqwest::Client,
242    url: Url,
243    expected_bytes: Option<u64>,
244) -> Result<Vec<u8>, String> {
245    let resp = client
246        .get(url.clone())
247        .send()
248        .await
249        .map_err(|error| format!("fetch language-pack file {url}: {error}"))?;
250    let status = resp.status();
251    if !status.is_success() {
252        return Err(format!("language-pack file {url} HTTP status {status}"));
253    }
254    if let Some(len) = resp.content_length()
255        && len > MAX_REMOTE_PACK_BYTES
256    {
257        return Err(format!("language-pack file {url} too large: {len} bytes"));
258    }
259    let bytes = resp
260        .bytes()
261        .await
262        .map_err(|error| format!("read language-pack file {url}: {error}"))?;
263    if bytes.len() as u64 > MAX_REMOTE_PACK_BYTES {
264        return Err(format!(
265            "language-pack file {url} too large after download: {} bytes",
266            bytes.len()
267        ));
268    }
269    if let Some(expected) = expected_bytes
270        && bytes.len() as u64 != expected
271    {
272        return Err(format!(
273            "language-pack file {url} byte size mismatch: expected {expected}, got {}",
274            bytes.len()
275        ));
276    }
277    Ok(bytes.to_vec())
278}
279
280fn parse_lang_filter(value: Option<&str>) -> Result<Option<Vec<LanguageId>>, String> {
281    let Some(value) = value else {
282        return Ok(None);
283    };
284    let mut langs = Vec::new();
285    for token in value
286        .split(',')
287        .map(str::trim)
288        .filter(|token| !token.is_empty())
289    {
290        let Some(language) = LanguageId::from_cache_name(token) else {
291            return Err(format!(
292                "unsupported language `{token}`; supported values: en, zh_cn"
293            ));
294        };
295        langs.push(language);
296    }
297    langs.sort();
298    langs.dedup();
299    Ok(Some(langs))
300}
301
302fn default_remote_source() -> String {
303    "remote".to_string()
304}