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