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 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 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(¤t_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 if !app_update_candidate_is_newer(&package.version, ¤t_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 _ => 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 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}