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