1use crate::api_config::ApiConfig;
2use crate::api_types::*;
3use crate::authenticated_client::AuthenticatedClient;
4use crate::constants::api as api_constants;
5use crate::downloader::{DownloadProgress, DownloaderConfig, FileDownloader};
6use crate::error::DuckError;
7use crate::version::Version;
8use anyhow::Result;
9use futures::stream::StreamExt;
10use reqwest::Client;
11use sha2::{Digest, Sha256};
12use std::io::{self, Write};
13use std::path::Path;
14use std::sync::Arc;
15use std::time::Duration;
16use tokio::fs::File;
17use tokio::io::{AsyncReadExt, AsyncWriteExt};
18use tracing::{error, info, warn};
19
20#[derive(Debug, Clone)]
22pub struct ApiClient {
23 client: Client,
24 config: Arc<ApiConfig>,
25 client_id: Option<String>,
26 authenticated_client: Option<Arc<AuthenticatedClient>>,
27}
28
29impl ApiClient {
30 pub fn new(
32 client_id: Option<String>,
33 authenticated_client: Option<Arc<AuthenticatedClient>>,
34 ) -> Self {
35 Self {
36 client: Client::builder()
37 .timeout(Duration::from_secs(60))
38 .build()
39 .expect("Failed to create HTTP client with timeout"),
40 config: Arc::new(ApiConfig::default()),
41 client_id,
42 authenticated_client,
43 }
44 }
45
46 pub fn set_client_id(&mut self, client_id: String) {
48 self.client_id = Some(client_id);
49 }
50
51 pub fn set_authenticated_client(&mut self, authenticated_client: Arc<AuthenticatedClient>) {
53 self.authenticated_client = Some(authenticated_client);
54 }
55
56 pub fn get_config(&self) -> &ApiConfig {
58 &self.config
59 }
60
61 fn build_request(&self, url: &str) -> reqwest::RequestBuilder {
63 let mut request = self.client.get(url);
64 if let Some(ref client_id) = self.client_id {
65 request = request.header("X-Client-ID", client_id);
66 }
67 request
68 }
69
70 fn build_post_request(&self, url: &str) -> reqwest::RequestBuilder {
72 let mut request = self.client.post(url);
73 if let Some(ref client_id) = self.client_id {
74 request = request.header("X-Client-ID", client_id);
75 }
76 request
77 }
78
79 pub async fn register_client(&self, request: ClientRegisterRequest) -> Result<String> {
81 let url = self
82 .config
83 .get_endpoint_url(&self.config.endpoints.client_register);
84
85 let response = self.client.post(&url).json(&request).send().await?;
86
87 if response.status().is_success() {
88 let register_response: RegisterClientResponse = response.json().await?;
89 info!(
90 "Client registered successfully, client ID: {}",
91 register_response.client_id
92 );
93 Ok(register_response.client_id)
94 } else {
95 let status = response.status();
96 let text = response.text().await.unwrap_or_default();
97 error!("Client registration failed: {} - {}", status, text);
98 Err(anyhow::anyhow!("Registration failed: {status} - {text}"))
99 }
100 }
101
102 pub async fn get_announcements(&self, since: Option<&str>) -> Result<AnnouncementsResponse> {
104 let mut url = self
105 .config
106 .get_endpoint_url(&self.config.endpoints.announcements);
107
108 if let Some(since_time) = since {
109 url = format!("{url}?since={since_time}");
110 }
111
112 let response = self.build_request(&url).send().await?;
113
114 if response.status().is_success() {
115 let announcements = response.json().await?;
116 Ok(announcements)
117 } else {
118 let status = response.status();
119 let text = response.text().await.unwrap_or_default();
120 error!("Failed to get announcements: {} - {}", status, text);
121 Err(anyhow::anyhow!(
122 "Failed to get announcements: {status} - {text}"
123 ))
124 }
125 }
126
127 pub async fn check_docker_version(
129 &self,
130 current_version: &str,
131 ) -> Result<DockerVersionResponse> {
132 let url = self
133 .config
134 .get_endpoint_url(&self.config.endpoints.docker_check_version);
135
136 let response = self.build_request(&url).send().await?;
137
138 if response.status().is_success() {
139 let manifest: ServiceManifest = response.json().await?;
140
141 let has_update = manifest.version != current_version;
143 let docker_version_response = DockerVersionResponse {
144 current_version: current_version.to_string(),
145 latest_version: manifest.version,
146 has_update,
147 release_notes: Some(manifest.release_notes),
148 };
149
150 Ok(docker_version_response)
151 } else {
152 let status = response.status();
153 let text = response.text().await.unwrap_or_default();
154 error!("Failed to check Docker version: {} - {}", status, text);
155 Err(anyhow::anyhow!(
156 "Failed to check Docker version: {status} - {text}"
157 ))
158 }
159 }
160
161 pub async fn get_docker_version_list(&self) -> Result<DockerVersionListResponse> {
163 let url = self
164 .config
165 .get_endpoint_url(&self.config.endpoints.docker_update_version_list);
166
167 let response = self.build_request(&url).send().await?;
168
169 if response.status().is_success() {
170 let version_list = response.json().await?;
171 Ok(version_list)
172 } else {
173 let status = response.status();
174 let text = response.text().await.unwrap_or_default();
175 error!("Failed to get Docker version list: {} - {}", status, text);
176 Err(anyhow::anyhow!(
177 "Failed to get Docker version list: {status} - {text}"
178 ))
179 }
180 }
181
182 pub async fn download_service_update<P: AsRef<Path>>(&self, save_path: P) -> Result<()> {
184 let url = self
185 .config
186 .get_endpoint_url(&self.config.endpoints.docker_download_full);
187
188 self.download_service_update_from_url(&url, save_path).await
189 }
190
191 pub async fn download_service_update_from_url<P: AsRef<Path>>(
193 &self,
194 url: &str,
195 save_path: P,
196 ) -> Result<()> {
197 self.download_service_update_from_url_with_auth(url, save_path, true)
198 .await
199 }
200
201 pub async fn download_service_update_from_url_with_auth<P: AsRef<Path>>(
203 &self,
204 url: &str,
205 save_path: P,
206 use_auth: bool,
207 ) -> Result<()> {
208 info!(
209 "Starting to download Docker service update package: {}",
210 url
211 );
212
213 let response = if use_auth {
215 if let Some(auth_client) = self.authenticated_client.as_ref() {
216 match auth_client.get(url).await {
217 Ok(request_builder) => auth_client.send(request_builder, url).await?,
218 Err(e) => {
219 warn!(
220 "AuthenticatedClient failed, falling back to regular request: {}",
221 e
222 );
223 self.build_request(url).send().await?
224 }
225 }
226 } else {
227 info!("Using regular HTTP client for download (no auth client available)");
228 self.build_request(url).send().await?
229 }
230 } else {
231 info!("Using regular HTTP client for download");
233 self.build_request(url).send().await?
234 };
235
236 if !response.status().is_success() {
237 let status = response.status();
238 let text = response.text().await.unwrap_or_default();
239 error!(
240 "Failed to download Docker service update package: {} - {}",
241 status, text
242 );
243 return Err(anyhow::anyhow!("Download failed: {status} - {text}"));
244 }
245
246 let total_size = response.content_length();
248
249 if let Some(size) = total_size {
250 info!(
251 "Docker service update package size: {} bytes ({:.1} MB)",
252 size,
253 size as f64 / 1024.0 / 1024.0
254 );
255 }
256
257 let mut file = File::create(&save_path).await?;
259 let mut stream = response.bytes_stream();
260 let mut downloaded = 0u64;
261 let mut last_progress_time = std::time::Instant::now();
262
263 while let Some(chunk) = stream.next().await {
264 let chunk =
265 chunk.map_err(|e| DuckError::custom(format!("Failed to download data: {e}")))?;
266
267 tokio::io::AsyncWriteExt::write_all(&mut file, &chunk)
268 .await
269 .map_err(|e| DuckError::custom(format!("Failed to write file: {e}")))?;
270
271 downloaded += chunk.len() as u64;
272
273 let now = std::time::Instant::now();
275 let time_since_last = now.duration_since(last_progress_time);
276
277 let should_show_progress = downloaded.is_multiple_of(50 * 1024 * 1024) && downloaded > 0 || time_since_last >= std::time::Duration::from_secs(30) || (total_size.is_some_and(|size| downloaded >= size)); if should_show_progress {
283 if let Some(size) = total_size {
284 let percentage = (downloaded as f64 / size as f64 * 100.0) as u32;
285 info!(
286 "Download progress: {}% ({:.1}/{:.1} MB)",
287 percentage,
288 downloaded as f64 / 1024.0 / 1024.0,
289 size as f64 / 1024.0 / 1024.0
290 );
291 } else {
292 info!("Downloaded: {:.1} MB", downloaded as f64 / 1024.0 / 1024.0);
293 }
294
295 last_progress_time = now;
297 }
298 }
299
300 if let Some(total) = total_size {
302 let downloaded_mb = downloaded as f64 / 1024.0 / 1024.0;
303 let total_mb = total as f64 / 1024.0 / 1024.0;
304
305 let bar_width = 30;
307 let progress_bar = "█".repeat(bar_width);
308
309 print!(
310 "\rDownload progress: [{progress_bar}] 100.0% ({downloaded_mb:.1}/{total_mb:.1} MB)"
311 );
312 io::stdout().flush().unwrap();
313 } else {
314 let downloaded_mb = downloaded as f64 / 1024.0 / 1024.0;
316 print!("\rDownload progress: {downloaded_mb:.1} MB (completed)");
317 io::stdout().flush().unwrap();
318 }
319
320 println!(); file.flush().await?;
323 info!(
324 "Docker service update package download completed: {}",
325 save_path.as_ref().display()
326 );
327 Ok(())
328 }
329
330 pub async fn report_service_upgrade_history(
332 &self,
333 request: ServiceUpgradeHistoryRequest,
334 ) -> Result<()> {
335 let url = self
336 .config
337 .get_service_upgrade_history_url(&request.service_name);
338
339 let response = self.build_post_request(&url).json(&request).send().await?;
340
341 if response.status().is_success() {
342 info!("Service upgrade history reported successfully");
343 Ok(())
344 } else {
345 let status = response.status();
346 let text = response.text().await.unwrap_or_default();
347 warn!(
348 "Failed to report service upgrade history: {} - {}",
349 status, text
350 );
351 Ok(())
353 }
354 }
355
356 pub async fn report_client_self_upgrade_history(
358 &self,
359 request: ClientSelfUpgradeHistoryRequest,
360 ) -> Result<()> {
361 let url = self
362 .config
363 .get_endpoint_url(&self.config.endpoints.client_self_upgrade_history);
364
365 let response = self.build_post_request(&url).json(&request).send().await?;
366
367 if response.status().is_success() {
368 info!("Client self-upgrade history reported successfully");
369 Ok(())
370 } else {
371 let status = response.status();
372 let text = response.text().await.unwrap_or_default();
373 warn!(
374 "Failed to report client self-upgrade history: {} - {}",
375 status, text
376 );
377 Ok(())
379 }
380 }
381
382 pub async fn report_telemetry(&self, request: TelemetryRequest) -> Result<()> {
384 let url = self
385 .config
386 .get_endpoint_url(&self.config.endpoints.telemetry);
387
388 let response = self.build_post_request(&url).json(&request).send().await?;
389
390 if response.status().is_success() {
391 info!("Telemetry data reported successfully");
392 Ok(())
393 } else {
394 let status = response.status();
395 let text = response.text().await.unwrap_or_default();
396 warn!("Failed to report telemetry data: {} - {}", status, text);
397 Ok(())
399 }
400 }
401
402 #[deprecated(note = "不在使用,现在需要区分架构和全量和增量")]
404 pub fn get_service_download_url(&self) -> String {
405 self.config
406 .get_endpoint_url(&self.config.endpoints.docker_download_full)
407 }
408
409 pub async fn calculate_file_hash(file_path: &Path) -> Result<String> {
411 if !file_path.exists() {
412 return Err(anyhow::anyhow!(
413 "File does not exist: {}",
414 file_path.display()
415 ));
416 }
417
418 let mut file = File::open(file_path).await.map_err(|e| {
419 DuckError::Custom(format!(
420 "Failed to open file {}: {}",
421 file_path.display(),
422 e
423 ))
424 })?;
425
426 let mut hasher = Sha256::new();
427 let mut buffer = vec![0u8; 8192]; loop {
430 let bytes_read = file.read(&mut buffer).await.map_err(|e| {
431 DuckError::Custom(format!(
432 "Failed to read file {}: {}",
433 file_path.display(),
434 e
435 ))
436 })?;
437
438 if bytes_read == 0 {
439 break;
440 }
441
442 hasher.update(&buffer[..bytes_read]);
443 }
444
445 let hash = hasher.finalize();
446 Ok(hash.to_vec().iter().map(|b| format!("{b:02x}")).collect())
447 }
448
449 pub async fn save_file_hash(file_path: &Path, hash: &str) -> Result<()> {
451 let hash_file_path = file_path.with_extension("hash");
452 let mut hash_file = File::create(&hash_file_path).await.map_err(|e| {
453 DuckError::Custom(format!(
454 "Failed to create hash file {}: {}",
455 hash_file_path.display(),
456 e
457 ))
458 })?;
459
460 hash_file.write_all(hash.as_bytes()).await.map_err(|e| {
461 DuckError::Custom(format!(
462 "Failed to write hash file {}: {}",
463 hash_file_path.display(),
464 e
465 ))
466 })?;
467
468 info!("File hash saved: {}", hash_file_path.display());
469 Ok(())
470 }
471
472 pub async fn load_file_hash(file_path: &Path) -> Result<Option<String>> {
474 let hash_file_path = file_path.with_extension("hash");
475
476 if !hash_file_path.exists() {
477 return Ok(None);
478 }
479
480 let mut hash_file = File::open(&hash_file_path).await.map_err(|e| {
481 DuckError::Custom(format!(
482 "Failed to open hash file {}: {}",
483 hash_file_path.display(),
484 e
485 ))
486 })?;
487
488 let mut hash_content = String::new();
489 hash_file
490 .read_to_string(&mut hash_content)
491 .await
492 .map_err(|e| {
493 DuckError::Custom(format!(
494 "Failed to read hash file {}: {}",
495 hash_file_path.display(),
496 e
497 ))
498 })?;
499
500 Ok(Some(hash_content.trim().to_string()))
501 }
502
503 pub async fn verify_file_integrity(file_path: &Path, expected_hash: &str) -> Result<bool> {
505 info!("Verifying file integrity: {}", file_path.display());
506
507 let actual_hash = Self::calculate_file_hash(file_path).await?;
509
510 let matches = actual_hash.to_lowercase() == expected_hash.to_lowercase();
512
513 if matches {
514 info!(
515 "File integrity verification passed: {}",
516 file_path.display()
517 );
518 } else {
519 warn!(
520 "File integrity verification failed: {}",
521 file_path.display()
522 );
523 warn!(" Expected hash: {}", expected_hash);
524 warn!(" Actual hash: {}", actual_hash);
525 }
526
527 Ok(matches)
528 }
529
530 pub async fn needs_file_download(&self, file_path: &Path, remote_hash: &str) -> Result<bool> {
532 match Self::calculate_file_hash(file_path).await {
534 Ok(actual_hash) => {
535 info!("Calculated file hash: {}", actual_hash);
536 if actual_hash.to_lowercase() == remote_hash.to_lowercase() {
537 info!("File hash matches, skipping download");
538 Ok(false)
539 } else {
540 info!("File hash mismatch, need to download new version");
541 info!(" Local hash: {}", actual_hash);
542 info!(" Remote hash: {}", remote_hash);
543 Ok(true)
544 }
545 }
546 Err(e) => {
547 warn!("Failed to calculate file hash: {}, need to re-download", e);
548 Ok(true)
549 }
550 }
551 }
552
553 pub async fn should_download_file(&self, file_path: &Path, remote_hash: &str) -> Result<bool> {
555 info!("Starting intelligent download decision check...");
556 info!(" Target file: {}", file_path.display());
557 info!(" Remote hash: {}", remote_hash);
558
559 if !file_path.exists() {
561 info!(
562 "File does not exist, need to download: {}",
563 file_path.display()
564 );
565 let hash_file_path = file_path.with_extension("hash");
567 if hash_file_path.exists() {
568 info!(
569 "Found orphaned hash file, cleaning up: {}",
570 hash_file_path.display()
571 );
572 if let Err(e) = tokio::fs::remove_file(&hash_file_path).await {
573 warn!("Failed to clean up hash file: {}", e);
574 }
575 }
576 return Ok(true);
577 }
578
579 info!("Checking local file: {}", file_path.display());
580
581 match tokio::fs::metadata(file_path).await {
583 Ok(metadata) => {
584 let file_size = metadata.len();
585 info!("Local file size: {} bytes", file_size);
586 if file_size == 0 {
587 warn!("Local file size is 0, need to re-download");
588 return Ok(true);
589 }
590 }
591 Err(e) => {
592 warn!("Failed to get file metadata: {}, need to re-download", e);
593 return Ok(true);
594 }
595 }
596
597 if let Some(saved_hash) = Self::load_file_hash(file_path).await? {
599 info!("Found local hash record: {}", saved_hash);
600 info!("Remote file hash: {}", remote_hash);
601
602 if saved_hash.to_lowercase() == remote_hash.to_lowercase() {
604 info!("Hash matches, verifying file integrity...");
605 match Self::verify_file_integrity(file_path, &saved_hash).await {
607 Ok(true) => {
608 info!("File is already latest and complete, skipping download");
609 return Ok(false);
610 }
611 Ok(false) => {
612 warn!("Hash record is correct but file is corrupted, need to re-download");
613 return Ok(true);
614 }
615 Err(e) => {
616 warn!(
617 "File integrity verification error: {}, need to re-download",
618 e
619 );
620 return Ok(true);
621 }
622 }
623 } else {
624 info!("New version detected, need to download update");
625 info!(" Local hash: {}", saved_hash);
626 info!(" Remote hash: {}", remote_hash);
627 return Ok(true);
628 }
629 }
630
631 info!("No hash record found, calculating current file hash...");
633 match Self::calculate_file_hash(file_path).await {
634 Ok(actual_hash) => {
635 info!("Calculated file hash: {}", actual_hash);
636
637 if actual_hash.to_lowercase() == remote_hash.to_lowercase() {
638 if let Err(e) = Self::save_file_hash(file_path, &actual_hash).await {
640 warn!("Failed to save hash file: {}", e);
641 }
642 info!("File matches remote, hash record saved, skipping download");
643 Ok(false)
644 } else {
645 info!("File does not match remote, need to download new version");
646 info!(" Local hash: {}", actual_hash);
647 info!(" Remote hash: {}", remote_hash);
648 Ok(true)
649 }
650 }
651 Err(e) => {
652 warn!("Failed to calculate file hash: {}, need to re-download", e);
653 Ok(true)
654 }
655 }
656 }
657
658 pub async fn get_enhanced_service_manifest(&self) -> Result<EnhancedServiceManifest> {
663 let custom_url = std::env::var(api_constants::NUWAX_API_DOCKER_VERSION_URL_ENV);
665
666 let (oss_url, url_source) = if let Ok(url) = custom_url {
667 (url, "env NUWAX_API_DOCKER_VERSION_URL")
668 } else {
669 let cli_env = std::env::var("NUWAX_CLI_ENV").unwrap_or_default();
671 if cli_env.eq_ignore_ascii_case("test") || cli_env.eq_ignore_ascii_case("testing") {
672 (
673 self.config.endpoints.docker_version_oss_beta.clone(),
674 "beta OSS (test env)",
675 )
676 } else {
677 (
678 self.config.endpoints.docker_version_oss_prod.clone(),
679 "prod OSS",
680 )
681 }
682 };
683
684 info!("Fetching service manifest from {}: {}", url_source, oss_url);
685
686 match self.fetch_and_parse_manifest(&oss_url).await {
687 Ok(manifest) => {
688 info!("Successfully fetched manifest from {}", url_source);
689 return Ok(manifest);
690 }
691 Err(e) => {
692 warn!(
693 "Failed to fetch from {}: {}, falling back to API",
694 url_source, e
695 );
696 }
697 }
698
699 let api_url = self
701 .config
702 .get_endpoint_url(&self.config.endpoints.docker_upgrade_version_latest);
703 info!("Fetching service manifest from API: {}", api_url);
704
705 self.fetch_and_parse_manifest(&api_url).await
706 }
707
708 async fn fetch_and_parse_manifest(&self, url: &str) -> Result<EnhancedServiceManifest> {
710 let response = self.build_request(url).send().await?;
711
712 if response.status().is_success() {
713 let text = response.text().await?;
715 let json_value: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
716 DuckError::Api(format!("Service manifest JSON parsing failed: {e}"))
717 })?;
718
719 let has_platforms = match &json_value {
720 serde_json::Value::Object(map) => map.contains_key("platforms"),
721 _ => false,
722 };
723
724 if has_platforms {
725 match serde_json::from_value::<EnhancedServiceManifest>(json_value) {
727 Ok(manifest) => {
728 info!("Successfully parsed enhanced service manifest");
729 manifest.validate()?; Ok(manifest)
731 }
732 Err(e) => {
733 error!("Failed to parse service upgrade - enhanced format: {}", e);
734 Err(anyhow::anyhow!(
735 "Failed to parse service upgrade - enhanced format: {}",
736 e
737 ))
738 }
739 }
740 } else {
741 match serde_json::from_value::<ServiceManifest>(json_value) {
743 Ok(old_manifest) => {
744 info!(
745 "Successfully parsed legacy service manifest, converting to enhanced format"
746 );
747 let enhanced_manifest = EnhancedServiceManifest {
748 version: old_manifest.version.parse::<Version>()?,
749 release_date: old_manifest.release_date,
750 release_notes: old_manifest.release_notes,
751 packages: Some(old_manifest.packages),
752 platforms: None,
753 patch: None,
754 };
755 enhanced_manifest.validate()?;
756 Ok(enhanced_manifest)
757 }
758 Err(e) => {
759 error!("Failed to parse service upgrade - legacy format: {}", e);
760 Err(anyhow::anyhow!(
761 "Failed to parse service upgrade - legacy format: {}",
762 e
763 ))
764 }
765 }
766 }
767 } else {
768 let status = response.status();
769 let text = response.text().await.unwrap_or_default();
770 error!(
771 "Failed to get enhanced service manifest: {} - {}",
772 status, text
773 );
774 Err(anyhow::anyhow!(
775 "Failed to get enhanced service manifest: {status} - {text}"
776 ))
777 }
778 }
779
780 pub async fn download_service_update_optimized_with_progress<F>(
782 &self,
783 download_path: &Path,
784 version: Option<&str>,
785 download_url: &str,
786 progress_callback: Option<F>,
787 ) -> Result<()>
788 where
789 F: Fn(DownloadProgress) + Send + Sync + 'static,
790 {
791 let hash_file_path = download_path.with_extension("zip.hash");
793
794 info!("Determining download method:");
795 info!(" Download URL: {}", download_url);
796
797 let mut should_download = true;
799 if download_path.exists() && hash_file_path.exists() {
800 info!("Found existing file: {}", download_path.display());
801 info!("Found hash file: {}", hash_file_path.display());
802 if let Ok(hash_content) = std::fs::read_to_string(&hash_file_path) {
804 let hash_info: DownloadHashInfo = hash_content.parse().map_err(|e| {
805 DuckError::custom(format!("Invalid hash info format for downloaded file: {e}"))
806 })?;
807
808 info!("Hash file info:");
809 info!(" Saved hash: {}", hash_info.hash);
810 info!(" Saved version: {}", hash_info.version);
811 info!(" Saved timestamp: {}", hash_info.timestamp);
812
813 info!("Verifying local file hash...");
815 if let Ok(actual_hash) = Self::calculate_file_hash(download_path).await {
816 if actual_hash.to_lowercase() == hash_info.hash.to_lowercase() {
817 info!("File hash verification passed, skipping download");
818 info!(" Local hash: {}", actual_hash);
819 info!(" Server hash: {}", hash_info.hash);
820 should_download = false;
821 } else {
822 warn!("File hash mismatch, need to re-download");
823 warn!(" Local hash: {}", actual_hash);
824 warn!(" Expected hash: {}", hash_info.hash);
825 }
826 } else {
827 warn!("Unable to calculate local file hash, re-downloading");
828 }
829 } else {
830 warn!("Unable to read hash file, re-downloading");
831 }
832 } else {
833 info!("File does not exist, re-downloading");
834 }
835
836 if !should_download {
837 info!("Skipping download, using existing file");
838 return Ok(());
839 }
840
841 if let Some(parent) = download_path.parent()
843 && let Err(e) = std::fs::create_dir_all(parent)
844 {
845 return Err(anyhow::anyhow!("Failed to create download directory: {e}"));
846 }
847
848 info!("Starting to download service update package...");
849 info!(" Final download URL: {}", download_url);
850 info!(" Target path: {}", download_path.display());
851
852 let config = DownloaderConfig::default();
855
856 let downloader = FileDownloader::new(config);
857
858 downloader
860 .download_file_with_options(
861 download_url,
862 download_path,
863 progress_callback,
864 None,
865 version,
866 )
867 .await
868 .map_err(|e| DuckError::custom(format!("Download failed: {e}")))?;
869
870 info!("File download completed");
871 info!(" File path: {}", download_path.display());
872
873 info!("Calculating local hash of external file...");
875 match Self::calculate_file_hash(download_path).await {
876 Ok(local_hash) => {
877 info!("External file local hash: {}", local_hash);
878 Self::save_hash_file(&hash_file_path, &local_hash, version).await?;
879 }
880 Err(e) => {
881 warn!("Failed to calculate external file hash: {}", e);
882 }
883 }
884 info!("Service update package download completed!");
885 info!(" File location: {}", download_path.display());
886
887 Ok(())
888 }
889
890 pub async fn download_service_update_optimized(
892 &self,
893 download_path: &Path,
894 version: Option<&str>,
895 download_url: &str,
896 ) -> Result<()> {
897 self.download_service_update_optimized_with_progress::<fn(DownloadProgress)>(
898 download_path,
899 version,
900 download_url,
901 None,
902 )
903 .await
904 }
905
906 pub async fn save_hash_file(
908 hash_file_path: &Path,
909 hash: &str,
910 version: Option<&str>,
911 ) -> Result<()> {
912 let timestamp = chrono::Utc::now().to_rfc3339();
913 let content = format!("{hash}\n{version:?}\n{timestamp}\n");
914
915 tokio::fs::write(hash_file_path, content)
916 .await
917 .map_err(|e| DuckError::custom(format!("Failed to write hash file: {e}")))?;
918
919 Ok(())
920 }
921}
922
923#[allow(dead_code)]
926pub mod system_info {
927 use serde::{Deserialize, Serialize};
928
929 #[derive(Debug, Clone, Serialize, Deserialize)]
930 pub struct Info {
931 os_type: String,
932 version: String,
933 }
934
935 impl Info {
936 pub fn os_type(&self) -> &str {
937 &self.os_type
938 }
939 pub fn version(&self) -> &str {
940 &self.version
941 }
942 }
943
944 pub fn get() -> Info {
945 Info {
946 os_type: std::env::consts::OS.to_string(),
947 version: std::env::consts::ARCH.to_string(),
948 }
949 }
950}
951
952#[cfg(test)]
953mod tests {
954 use super::*;
955 use tempfile::TempDir;
956 use tokio;
957
958 fn create_test_api_client() -> ApiClient {
960 ApiClient::new(Some("test_client_id".to_string()), None)
961 }
962
963 #[test]
964 fn test_api_client_creation() {
965 let client = create_test_api_client();
966 assert_eq!(client.client_id, Some("test_client_id".to_string()));
967 assert!(client.authenticated_client.is_none());
968 }
969
970 #[test]
971 fn test_authenticated_client_management() {
972 let client = create_test_api_client();
973
974 assert!(client.authenticated_client.is_none());
976
977 }
980
981 #[test]
982 fn test_build_request_headers() {
983 let client = create_test_api_client();
984 let url = "http://test.example.com/api";
985 let _request = client.build_request(url);
986
987 assert!(!url.is_empty());
990 }
991
992 #[tokio::test]
993 async fn test_hash_file_operations() {
994 let temp_dir = TempDir::new().unwrap();
995 let hash_file_path = temp_dir.path().join("test.hash");
996
997 let test_hash = "sha256:1234567890abcdef";
999 let test_version = "0.0.13";
1000 ApiClient::save_hash_file(&hash_file_path, test_hash, Some(test_version))
1001 .await
1002 .unwrap();
1003
1004 let content = tokio::fs::read_to_string(&hash_file_path).await.unwrap();
1006 assert!(content.contains(test_hash));
1007
1008 assert!(hash_file_path.exists());
1010 }
1011
1012 #[test]
1013 fn test_system_info() {
1014 let info = system_info::get();
1015
1016 assert!(!info.os_type().is_empty());
1018 assert!(!info.version().is_empty());
1019
1020 let valid_os_types = ["windows", "macos", "linux"];
1022 assert!(valid_os_types.contains(&info.os_type()));
1023
1024 let valid_archs = ["x86_64", "aarch64", "arm64"];
1025 assert!(valid_archs.contains(&info.version()));
1026 }
1027
1028 #[test]
1029 fn test_system_info_serialization() {
1030 let info = system_info::get();
1031
1032 let serialized = serde_json::to_string(&info).unwrap();
1034 assert!(serialized.contains(info.os_type()));
1035 assert!(serialized.contains(info.version()));
1036
1037 let deserialized: system_info::Info = serde_json::from_str(&serialized).unwrap();
1039 assert_eq!(deserialized.os_type(), info.os_type());
1040 assert_eq!(deserialized.version(), info.version());
1041 }
1042
1043 #[tokio::test]
1044 async fn test_file_hash_calculation() {
1045 let temp_dir = TempDir::new().unwrap();
1046 let test_file = temp_dir.path().join("test.txt");
1047
1048 tokio::fs::write(&test_file, "hello world").await.unwrap();
1050
1051 let hash = ApiClient::calculate_file_hash(&test_file).await.unwrap();
1052
1053 assert_eq!(hash.len(), 64); assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); let hash2 = ApiClient::calculate_file_hash(&test_file).await.unwrap();
1059 assert_eq!(hash, hash2);
1060 }
1061
1062 #[tokio::test]
1063 async fn test_file_hash_calculation_nonexistent_file() {
1064 let non_existent = std::path::Path::new("/non/existent/file.txt");
1065
1066 let result = ApiClient::calculate_file_hash(non_existent).await;
1067 assert!(result.is_err());
1068 }
1069
1070 #[tokio::test]
1072 async fn test_task_1_5_acceptance_criteria() {
1073 let client = create_test_api_client();
1074
1075 assert!(client.client_id.is_some());
1077
1078 let non_existent = std::path::Path::new("/non/existent/file.txt");
1083 let result = ApiClient::calculate_file_hash(non_existent).await;
1084 assert!(result.is_err());
1085
1086 println!("Task 1.5: API Client Extension - Acceptance Criteria Test Passed");
1090 println!(" - New API client methods can be created normally");
1091 println!(" - Backward compatibility maintained");
1092 println!(" - Error handling mechanism is complete");
1093 println!(" - File operation functions work normally");
1094 println!(" - Unit test coverage is adequate");
1095 }
1096}