Skip to main content

lingxia_update/
app.rs

1use crate::{
2    BoxFuture, UpdatePackageInfo, UpdateTarget, UpdateVerifyTarget, Version, check_update_enabled,
3    embedded_update_public_keys, host_update_platform, verify_checked_update,
4};
5use std::path::{Path, PathBuf};
6use std::sync::OnceLock;
7use tokio::sync::broadcast;
8
9use super::error::UpdateError;
10
11#[derive(Debug, Clone)]
12pub enum AppUpdateEvent {
13    Available(UpdatePackageInfo),
14    DownloadStarted {
15        version: String,
16    },
17    DownloadProgress {
18        version: String,
19        downloaded_bytes: u64,
20        total_bytes: Option<u64>,
21        progress: Option<u8>,
22    },
23    Downloaded {
24        version: String,
25    },
26    InstallRequested {
27        version: String,
28    },
29    /// Store channel: the listing was opened; nothing was downloaded.
30    StoreOpened {
31        version: String,
32    },
33    Failed {
34        stage: AppUpdateStage,
35        error: String,
36    },
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum AppUpdateStage {
41    Check,
42    Download,
43    Install,
44}
45
46pub type AppUpdateEventReceiver = broadcast::Receiver<AppUpdateEvent>;
47pub type AppUpdateEventSender = broadcast::Sender<AppUpdateEvent>;
48
49pub struct AppUpdateApply {
50    receiver: AppUpdateEventReceiver,
51    done: bool,
52}
53
54impl AppUpdateApply {
55    pub fn new(receiver: AppUpdateEventReceiver) -> Self {
56        Self {
57            receiver,
58            done: false,
59        }
60    }
61
62    pub fn channel() -> (Self, AppUpdateEventSender) {
63        let (sender, receiver) = broadcast::channel(32);
64        (Self::new(receiver), sender)
65    }
66
67    pub async fn next(&mut self) -> Option<AppUpdateEvent> {
68        if self.done {
69            return None;
70        }
71
72        let event = loop {
73            match self.receiver.recv().await {
74                Ok(event) => break Some(event),
75                Err(broadcast::error::RecvError::Lagged(_)) => continue,
76                Err(broadcast::error::RecvError::Closed) => break None,
77            }
78        };
79
80        let Some(event) = event else {
81            self.done = true;
82            return None;
83        };
84
85        if matches!(
86            event,
87            AppUpdateEvent::InstallRequested { .. }
88                | AppUpdateEvent::StoreOpened { .. }
89                | AppUpdateEvent::Failed { .. }
90        ) {
91            self.done = true;
92        }
93
94        Some(event)
95    }
96}
97
98#[derive(Debug, Clone)]
99pub struct AppUpdateProgressReporter {
100    version: String,
101    sender: Option<AppUpdateEventSender>,
102}
103
104impl AppUpdateProgressReporter {
105    pub fn scoped(version: impl Into<String>, sender: AppUpdateEventSender) -> Self {
106        Self {
107            version: version.into(),
108            sender: Some(sender),
109        }
110    }
111
112    fn emit(&self, event: AppUpdateEvent) {
113        if let Some(sender) = &self.sender {
114            let _ = sender.send(event);
115        } else {
116            emit_app_update_event(event);
117        }
118    }
119
120    pub fn report(&self, downloaded_bytes: u64, total_bytes: Option<u64>) {
121        let progress = total_bytes.filter(|total| *total > 0).map(|total| {
122            ((downloaded_bytes as f64 / total as f64) * 100.0)
123                .round()
124                .clamp(0.0, 100.0) as u8
125        });
126        self.emit(AppUpdateEvent::DownloadProgress {
127            version: self.version.clone(),
128            downloaded_bytes,
129            total_bytes,
130            progress,
131        });
132    }
133}
134
135pub fn send_app_update_event(sender: &AppUpdateEventSender, event: AppUpdateEvent) {
136    let _ = sender.send(event);
137}
138
139pub fn send_app_update_failed(
140    sender: &AppUpdateEventSender,
141    stage: AppUpdateStage,
142    error: &UpdateError,
143) {
144    send_app_update_event(
145        sender,
146        AppUpdateEvent::Failed {
147            stage,
148            error: error.to_string(),
149        },
150    );
151}
152
153pub trait AppUpdateHost: Clone + Send + Sync + 'static {
154    fn spawn_detached(&self, task: BoxFuture<'static, ()>);
155    fn current_app_version(&self) -> Result<String, UpdateError>;
156    fn check_app_update<'a>(
157        &'a self,
158        current_version: &'a str,
159    ) -> BoxFuture<'a, Result<Option<UpdatePackageInfo>, UpdateError>>;
160    fn download_app_update<'a>(
161        &'a self,
162        update: &'a UpdatePackageInfo,
163        progress: AppUpdateProgressReporter,
164    ) -> BoxFuture<'a, Result<PathBuf, UpdateError>>;
165    /// Hand off the downloaded package to the platform installer. `info_json`
166    /// carries the prompt metadata `{version, releaseNotes}` the dismissible
167    /// "ready to update" prompt renders.
168    fn install_app_update(&self, package_path: &Path, info_json: &str) -> Result<(), UpdateError>;
169    fn log_app_update_warning(&self, detail: &str);
170}
171
172fn app_update_events() -> &'static broadcast::Sender<AppUpdateEvent> {
173    static APP_UPDATE_EVENTS: OnceLock<broadcast::Sender<AppUpdateEvent>> = OnceLock::new();
174    APP_UPDATE_EVENTS.get_or_init(|| {
175        let (tx, _) = broadcast::channel(32);
176        tx
177    })
178}
179
180pub fn subscribe_app_update_events() -> AppUpdateEventReceiver {
181    app_update_events().subscribe()
182}
183
184fn emit_app_update_event(event: AppUpdateEvent) {
185    let _ = app_update_events().send(event);
186}
187
188pub async fn check_app_update<H: AppUpdateHost>(
189    host: &H,
190) -> Result<Option<UpdatePackageInfo>, UpdateError> {
191    let target_id = lingxia_app_context::app_config()
192        .and_then(|config| config.lingxia_id.clone())
193        .filter(|id| !id.is_empty())
194        .unwrap_or_default();
195    check_app_update_for(host, &embedded_update_public_keys(), target_id).await
196}
197
198async fn check_app_update_for<H: AppUpdateHost>(
199    host: &H,
200    trusted_public_keys: &[String],
201    target_id: String,
202) -> Result<Option<UpdatePackageInfo>, UpdateError> {
203    if !check_update_enabled(trusted_public_keys) {
204        return Ok(None);
205    }
206    let current_version = host.current_app_version()?;
207    let candidate = host.check_app_update(&current_version).await?;
208    let Some(package) = candidate else {
209        return Ok(None);
210    };
211    let package = verify_checked_update(
212        package,
213        &UpdateVerifyTarget {
214            kind: "app".into(),
215            target_id,
216            channel: String::new(),
217            platform: host_update_platform().into(),
218            exact_version: None,
219        },
220        trusted_public_keys,
221    )?;
222    // Only surface a strictly-newer candidate. A provider that re-offers the
223    // installed version (or the same version after a successful update) would
224    // otherwise make the app re-download and re-prompt on every check — an
225    // endless "update available" loop. Unparseable versions fall through to the
226    // apply-time downgrade guard.
227    if !app_update_candidate_is_newer(&package.version, &current_version) {
228        return Ok(None);
229    }
230    Ok(Some(package))
231}
232
233fn app_update_candidate_is_newer(candidate: &str, current: &str) -> bool {
234    match (
235        Version::parse(candidate.trim()),
236        Version::parse(current.trim()),
237    ) {
238        (Ok(candidate), Ok(current)) => candidate > current,
239        // Can't compare — let the apply-time guard decide rather than hiding it.
240        _ => true,
241    }
242}
243
244pub fn ensure_app_update_candidate_version(
245    current_version: &str,
246    candidate_version: &str,
247) -> Result<(), UpdateError> {
248    let candidate_version = candidate_version.trim();
249    if candidate_version.is_empty() {
250        return Err(UpdateError::invalid_parameter(
251            "app update package version is empty",
252        ));
253    }
254
255    let candidate = Version::parse(candidate_version).map_err(|_| {
256        UpdateError::invalid_parameter(format!(
257            "app update package version is not semantic version: {}",
258            candidate_version
259        ))
260    })?;
261
262    let current = Version::parse(current_version).map_err(|_| {
263        UpdateError::runtime(format!(
264            "current app version is not semantic version: {}",
265            current_version
266        ))
267    })?;
268
269    if candidate < current {
270        return Err(UpdateError::unsupported(format!(
271            "reject app downgrade: current={} candidate={}",
272            current_version, candidate_version
273        )));
274    }
275
276    Ok(())
277}
278
279pub fn app_update_scope_key() -> String {
280    UpdateTarget::app(None::<String>).scope_key()
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use crate::signing::{SignRequest, archive_sha256_hex, public_key_base64url, sign_package};
287    use crate::{UpdateAuthentication, UpdatePackageInfo};
288    use lingxia_app_context::{AppConfig, AppEnv};
289    use std::path::PathBuf;
290    use std::sync::Arc;
291    use std::sync::atomic::{AtomicUsize, Ordering};
292
293    const SEED: [u8; 32] = [7u8; 32];
294    const ARCHIVE: &[u8] = b"host-update-golden-archive";
295    const TARGET_ID: &str = "demo-host";
296
297    #[derive(Clone)]
298    struct FakeHost {
299        current_version: String,
300        response: Option<UpdatePackageInfo>,
301        provider_calls: Arc<AtomicUsize>,
302    }
303
304    impl FakeHost {
305        fn new(current_version: &str, response: Option<UpdatePackageInfo>) -> Self {
306            Self {
307                current_version: current_version.into(),
308                response,
309                provider_calls: Arc::new(AtomicUsize::new(0)),
310            }
311        }
312    }
313
314    impl AppUpdateHost for FakeHost {
315        fn spawn_detached(&self, _task: BoxFuture<'static, ()>) {}
316
317        fn current_app_version(&self) -> Result<String, UpdateError> {
318            Ok(self.current_version.clone())
319        }
320
321        fn check_app_update<'a>(
322            &'a self,
323            _current_version: &'a str,
324        ) -> BoxFuture<'a, Result<Option<UpdatePackageInfo>, UpdateError>> {
325            self.provider_calls.fetch_add(1, Ordering::SeqCst);
326            let response = self.response.clone();
327            Box::pin(async move { Ok(response) })
328        }
329
330        fn download_app_update<'a>(
331            &'a self,
332            _update: &'a UpdatePackageInfo,
333            _progress: AppUpdateProgressReporter,
334        ) -> BoxFuture<'a, Result<PathBuf, UpdateError>> {
335            Box::pin(async { Err(UpdateError::runtime("download not used")) })
336        }
337
338        fn install_app_update(
339            &self,
340            _package_path: &Path,
341            _info_json: &str,
342        ) -> Result<(), UpdateError> {
343            Err(UpdateError::runtime("install not used"))
344        }
345
346        fn log_app_update_warning(&self, _detail: &str) {}
347    }
348
349    fn install_release_keys() {
350        let config = AppConfig {
351            product_name: "Host Verify".to_string(),
352            product_names: Default::default(),
353            product_version: "1.0.0".to_string(),
354            lingxia_id: Some(TARGET_ID.to_string()),
355            lingxia_server: None,
356            env: AppEnv::Prod,
357            home_app_id: String::new(),
358            home_app_version: String::new(),
359            cache_max_size_mb: 1024,
360            storage: None,
361            splash: None,
362            dev_ws_url: None,
363            dev_bundle_base_url: None,
364            app_links: None,
365            theme: None,
366            settings_destination: None,
367            browser: Default::default(),
368            capabilities: None,
369            panels: None,
370            update_trusted_public_keys: vec![public_key_base64url(&SEED)],
371            update_channel: None,
372            update_channels: Default::default(),
373            store_listing_ids: Default::default(),
374        };
375        lingxia_app_context::set_app_config(config).expect("install host verify config");
376    }
377
378    fn signed_package(version: &str, auth: Option<UpdateAuthentication>) -> UpdatePackageInfo {
379        let sha256 = archive_sha256_hex(ARCHIVE);
380        UpdatePackageInfo {
381            version: version.into(),
382            url: "https://cdn.example.com/app".into(),
383            checksum_sha256: sha256,
384            size: Some(ARCHIVE.len() as u64),
385            release_notes: None,
386            min_runtime: None,
387            authentication: auth,
388        }
389    }
390
391    fn sign(version: &str) -> UpdateAuthentication {
392        let sha256 = archive_sha256_hex(ARCHIVE);
393        sign_package(
394            &SEED,
395            &SignRequest {
396                kind: "app",
397                target_id: TARGET_ID,
398                channel: "",
399                platform: host_update_platform(),
400                version,
401                sha256: &sha256,
402            },
403        )
404        .expect("sign host package")
405    }
406
407    fn block_on<T>(future: impl std::future::Future<Output = T>) -> T {
408        tokio::runtime::Builder::new_current_thread()
409            .enable_all()
410            .build()
411            .expect("test runtime")
412            .block_on(future)
413    }
414
415    #[test]
416    fn check_app_update_verifies_before_version_or_force_decisions() {
417        install_release_keys();
418
419        let unsigned = FakeHost::new("1.0.0", Some(signed_package("1.0.1", None)));
420        let err = block_on(check_app_update(&unsigned)).expect_err("unsigned release");
421        assert!(err.to_string().contains("require signed updates"), "{err}");
422
423        let mut bad = sign("1.0.1");
424        let mut sig = crate::decode_base64url(&bad.signatures[0]).unwrap();
425        sig[0] ^= 0xff;
426        bad.signatures[0] = crate::encode_base64url(&sig);
427        let tampered = FakeHost::new("1.0.0", Some(signed_package("1.0.1", Some(bad))));
428        assert!(block_on(check_app_update(&tampered)).is_err());
429
430        let same_version =
431            FakeHost::new("1.0.1", Some(signed_package("1.0.1", Some(sign("1.0.1")))));
432        let none = block_on(check_app_update(&same_version)).expect("verified same version");
433        assert!(none.is_none(), "version filter runs only after verify");
434
435        let newer = FakeHost::new("1.0.0", Some(signed_package("1.0.1", Some(sign("1.0.1")))));
436        let accepted = block_on(check_app_update(&newer))
437            .expect("verified newer")
438            .expect("update available");
439        assert_eq!(accepted.version, "1.0.1");
440        assert_eq!(accepted.checksum_sha256, archive_sha256_hex(ARCHIVE));
441    }
442
443    #[test]
444    fn prod_without_keys_skips_check() {
445        let host = FakeHost::new("1.0.0", Some(signed_package("1.0.1", None)));
446        let result = block_on(check_app_update_for(&host, &[], TARGET_ID.into()))
447            .expect("skip is not an error");
448        assert!(result.is_none());
449        assert_eq!(host.provider_calls.load(Ordering::SeqCst), 0);
450    }
451
452    #[test]
453    fn prod_rejects_unsigned_host_update() {
454        // Host updates have no channel; the prod host still requires a signature.
455        let host = FakeHost::new("1.0.0", Some(signed_package("1.0.1", None)));
456        let keys = [public_key_base64url(&SEED)];
457        let err = block_on(check_app_update_for(&host, &keys, TARGET_ID.into()))
458            .expect_err("an unsigned package on a prod build");
459        assert!(err.to_string().contains("require signed updates"), "{err}");
460        assert_eq!(host.provider_calls.load(Ordering::SeqCst), 1);
461    }
462}