Skip to main content

cloudpub_client/upgrade/
mod.rs

1use crate::config::ClientConfig;
2use crate::shell::get_cache_dir;
3use anyhow::{Context, Result};
4use cloudpub_common::downloads::{release_file_name, ArtifactKind};
5use cloudpub_common::protocol::message::Message;
6use parking_lot::RwLock;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::sync::Arc;
10use tokio::sync::mpsc;
11
12// Include script templates at compile time
13#[cfg(unix)]
14const UNIX_SERVICE_UPDATE_SCRIPT: &str = include_str!("scripts/unix_service_update.sh");
15#[cfg(unix)]
16const UNIX_CLIENT_UPDATE_SCRIPT: &str = include_str!("scripts/unix_client_update.sh");
17#[cfg(windows)]
18const WINDOWS_SERVICE_UPDATE_SCRIPT: &str = include_str!("scripts/windows_service_update.bat");
19#[cfg(windows)]
20const WINDOWS_CLIENT_UPDATE_SCRIPT: &str = include_str!("scripts/windows_client_update.bat");
21#[cfg(windows)]
22const WINDOWS_MSI_INSTALL_SCRIPT: &str = include_str!("scripts/windows_msi_install.bat");
23#[cfg(target_os = "macos")]
24const MACOS_DMG_INSTALL_SCRIPT: &str = include_str!("scripts/macos_dmg_install.sh");
25#[cfg(target_os = "linux")]
26const LINUX_DEB_INSTALL_SCRIPT: &str = include_str!("scripts/linux_deb_install.sh");
27#[cfg(target_os = "linux")]
28const LINUX_RPM_INSTALL_SCRIPT: &str = include_str!("scripts/linux_rpm_install.sh");
29#[cfg(target_os = "linux")]
30const SYSTEMD_ONESHOT_TEMPLATE: &str = include_str!("scripts/systemd_oneshot.service");
31#[cfg(target_os = "macos")]
32const LAUNCHD_ONESHOT_TEMPLATE: &str = include_str!("scripts/launchd_oneshot.plist");
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct PlatformDownloads {
36    pub gui: Option<String>,
37    pub cli: Option<String>,
38    pub rpm: Option<String>,
39    pub deb: Option<String>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct OsDownloads {
44    pub name: String,
45    pub platforms: HashMap<String, PlatformDownloads>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct DownloadConfig {
50    pub macos: OsDownloads,
51    pub windows: OsDownloads,
52    pub linux: OsDownloads,
53    pub android: OsDownloads,
54}
55
56impl DownloadConfig {
57    pub fn new(base_url: &str, version: &str, branch: &str) -> Self {
58        // The file names come from cloudpub_common::downloads - one table
59        // shared with the server, which offers an upgrade only when the
60        // file this selection would fetch exists in the agent's channel
61        // (CLOUDPUB-1527). Built for every platform unconditionally: the
62        // selection below reads only the local OS's section, and a cfg-gate
63        // per host OS (what this used to be) is what kept the server from
64        // naming files at all.
65        fn os_downloads(
66            base_url: &str,
67            version: &str,
68            branch: &str,
69            name: &str,
70            os: &str,
71            arches: &[&str],
72        ) -> OsDownloads {
73            let mut platforms = HashMap::new();
74            for arch in arches {
75                let of_kind = |kind: ArtifactKind| {
76                    release_file_name(version, branch, os, arch, kind)
77                        .map(|file| format!("{base_url}{file}"))
78                };
79                platforms.insert(
80                    arch.to_string(),
81                    PlatformDownloads {
82                        gui: of_kind(ArtifactKind::Gui),
83                        cli: of_kind(ArtifactKind::Cli),
84                        rpm: of_kind(ArtifactKind::Rpm),
85                        deb: of_kind(ArtifactKind::Deb),
86                    },
87                );
88            }
89            OsDownloads {
90                name: name.to_string(),
91                platforms,
92            }
93        }
94
95        Self {
96            macos: os_downloads(
97                base_url,
98                version,
99                branch,
100                "macOS",
101                "macos",
102                &["aarch64", "x86_64"],
103            ),
104            windows: os_downloads(base_url, version, branch, "Windows", "windows", &["x86_64"]),
105            linux: os_downloads(
106                base_url,
107                version,
108                branch,
109                "Linux",
110                "linux",
111                &[
112                    "arm", "armv5te", "aarch64", "mips", "mipsel", "i686", "x86_64",
113                ],
114            ),
115            android: os_downloads(
116                base_url,
117                version,
118                branch,
119                "Android",
120                "android",
121                &["aarch64"],
122            ),
123        }
124    }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum DownloadType {
129    Gui,
130    Cli,
131    Rpm,
132    Deb,
133}
134
135pub fn get_current_os() -> String {
136    #[cfg(target_os = "macos")]
137    return "macos".to_string();
138    #[cfg(target_os = "windows")]
139    return "windows".to_string();
140    #[cfg(target_os = "linux")]
141    return "linux".to_string();
142    #[cfg(target_os = "android")]
143    return "android".to_string();
144    #[cfg(not(any(
145        target_os = "macos",
146        target_os = "windows",
147        target_os = "linux",
148        target_os = "android"
149    )))]
150    return std::env::consts::OS.to_string();
151}
152
153pub fn get_current_arch() -> String {
154    #[cfg(target_arch = "x86_64")]
155    return "x86_64".to_string();
156    #[cfg(target_arch = "aarch64")]
157    return "aarch64".to_string();
158    #[cfg(target_arch = "arm")]
159    return "arm".to_string();
160    #[cfg(target_arch = "x86")]
161    return "i686".to_string();
162    #[cfg(not(any(
163        target_arch = "x86_64",
164        target_arch = "aarch64",
165        target_arch = "arm",
166        target_arch = "x86"
167    )))]
168    return std::env::consts::ARCH.to_string();
169}
170
171#[cfg(target_os = "linux")]
172pub fn get_linux_package_type() -> Result<String> {
173    // Check for dpkg (Debian/Ubuntu systems)
174    if std::process::Command::new("which")
175        .arg("dpkg")
176        .output()
177        .map(|output| output.status.success())
178        .unwrap_or(false)
179    {
180        return Ok("deb".to_string());
181    }
182
183    // Check for rpm (Red Hat/SUSE systems)
184    if std::process::Command::new("which")
185        .arg("rpm")
186        .output()
187        .map(|output| output.status.success())
188        .unwrap_or(false)
189    {
190        return Ok("rpm".to_string());
191    }
192
193    Err(anyhow::anyhow!("Could not detect package type"))
194}
195
196pub fn get_download_url(
197    base_url: &str,
198    download_type: &DownloadType,
199    version: &str,
200    branch: &str,
201) -> Result<String> {
202    let base_url = format!("{}download/{}/", base_url, branch);
203    let config = DownloadConfig::new(&base_url, version, branch);
204    let os = get_current_os();
205    let arch = get_current_arch();
206
207    let os_downloads = match os.as_str() {
208        "macos" => &config.macos,
209        "windows" => &config.windows,
210        "linux" => &config.linux,
211        "android" => &config.android,
212        _ => {
213            return Err(anyhow::anyhow!("Unsupported OS: {}", os))
214                .context("Failed to get OS downloads")
215        }
216    };
217
218    let platform_downloads = os_downloads
219        .platforms
220        .get(&arch)
221        .with_context(|| format!("Unsupported architecture {} for OS {}", arch, os))?;
222
223    let download_url = match download_type {
224        DownloadType::Gui => platform_downloads
225            .gui
226            .as_ref()
227            .context("GUI download not available for this platform")?,
228        DownloadType::Cli => platform_downloads
229            .cli
230            .as_ref()
231            .context("CLI download not available for this platform")?,
232        DownloadType::Rpm => platform_downloads
233            .rpm
234            .as_ref()
235            .context("RPM download not available for this platform")?,
236        DownloadType::Deb => platform_downloads
237            .deb
238            .as_ref()
239            .context("DEB download not available for this platform")?,
240    };
241
242    Ok(download_url.clone())
243}
244
245/// Generic function to run a script with given template and replacements
246#[allow(unused_variables)]
247fn run_script(
248    script_name: &str,
249    script_template: &str,
250    replacements: Vec<(&str, String)>,
251    is_service: bool,
252) -> Result<()> {
253    use anyhow::Context;
254    use tracing::{debug, info};
255
256    // Use cache directory for updates instead of temp
257    let cache_dir = get_cache_dir("updates")?;
258    std::fs::create_dir_all(&cache_dir).context("Failed to create updates cache directory")?;
259
260    let timestamp = format!(
261        "{}_{}",
262        std::process::id(),
263        std::time::SystemTime::now()
264            .duration_since(std::time::UNIX_EPOCH)
265            .unwrap_or_default()
266            .as_secs()
267    );
268
269    #[cfg(windows)]
270    let script_path = cache_dir.join(format!("{}_{}.bat", script_name, timestamp));
271    #[cfg(unix)]
272    let script_path = cache_dir.join(format!("{}_{}.sh", script_name, timestamp));
273
274    debug!("Creating script: {}", script_path.display());
275
276    // Apply all replacements to the template
277    let mut script_content = script_template.to_string();
278    for (placeholder, value) in replacements {
279        script_content = script_content.replace(placeholder, &value);
280    }
281
282    std::fs::write(&script_path, script_content).context("Failed to create script")?;
283
284    info!("Script created at: {}", script_path.display());
285
286    // Always make script executable on Unix
287    #[cfg(unix)]
288    {
289        use std::os::unix::fs::PermissionsExt;
290        let mut perms = std::fs::metadata(&script_path)?.permissions();
291        perms.set_mode(0o755);
292        std::fs::set_permissions(&script_path, perms)?;
293    }
294
295    // Execute the script
296    #[cfg(windows)]
297    {
298        use std::os::windows::process::CommandExt;
299        std::process::Command::new("cmd")
300            .args(&["/C", &script_path.to_string_lossy()])
301            .creation_flags(0x08000000) // CREATE_NO_WINDOW
302            .spawn()
303            .context("Failed to execute script")?;
304    }
305
306    #[cfg(target_os = "linux")]
307    {
308        if is_service {
309            // Use systemd one-shot service for service updates
310            let service_name = format!("cloudpub-upgrade-{}.service", timestamp);
311            let service_path = format!("/etc/systemd/system/{}", service_name);
312
313            debug!("Creating systemd one-shot service: {}", service_name);
314
315            let service_content = SYSTEMD_ONESHOT_TEMPLATE
316                .replace("{TIMESTAMP}", &timestamp)
317                .replace("{SCRIPT_PATH}", &script_path.display().to_string());
318
319            std::fs::write(&service_path, service_content)
320                .context("Failed to create systemd one-shot service")?;
321
322            std::process::Command::new("systemctl")
323                .arg("daemon-reload")
324                .output()
325                .context("Failed to reload systemd")?;
326
327            std::process::Command::new("systemctl")
328                .arg("start")
329                .arg(&service_name)
330                .spawn()
331                .context("Failed to start upgrade service")?;
332
333            info!(
334                "Upgrade scheduled via systemd one-shot service: {}",
335                service_name
336            );
337        } else {
338            // Use nohup for regular executable updates
339            debug!("Executing script with nohup: {}", script_path.display());
340
341            std::process::Command::new("nohup")
342                .arg("/bin/bash")
343                .arg(&script_path)
344                .stdin(std::process::Stdio::null())
345                .stdout(std::process::Stdio::null())
346                .stderr(std::process::Stdio::null())
347                .spawn()
348                .context("Failed to spawn script with nohup")?;
349
350            info!("Script spawned with nohup");
351        }
352    }
353
354    #[cfg(target_os = "macos")]
355    {
356        if is_service {
357            // Use launchd one-shot job for service updates
358            let plist_name = format!("com.cloudpub.upgrade.{}.plist", timestamp);
359            let plist_path = format!("/Library/LaunchDaemons/{}", plist_name);
360
361            debug!("Creating launchd one-shot job: {}", plist_name);
362
363            let plist_content = LAUNCHD_ONESHOT_TEMPLATE
364                .replace("{TIMESTAMP}", &timestamp)
365                .replace("{SCRIPT_PATH}", &script_path.display().to_string());
366
367            std::fs::write(&plist_path, plist_content)
368                .context("Failed to create launchd one-shot plist")?;
369
370            std::process::Command::new("chmod")
371                .args(["644", &plist_path])
372                .output()
373                .context("Failed to set plist permissions")?;
374
375            std::process::Command::new("launchctl")
376                .args(["load", &plist_path])
377                .spawn()
378                .context("Failed to load upgrade job")?;
379
380            info!("Upgrade scheduled via launchd one-shot job: {}", plist_name);
381        } else {
382            // Use nohup for regular executable updates
383            debug!("Executing script with nohup: {}", script_path.display());
384
385            std::process::Command::new("nohup")
386                .arg("/bin/bash")
387                .arg(&script_path)
388                .stdin(std::process::Stdio::null())
389                .stdout(std::process::Stdio::null())
390                .stderr(std::process::Stdio::null())
391                .spawn()
392                .context("Failed to spawn script with nohup")?;
393
394            info!("Script spawned with nohup");
395        }
396    }
397
398    info!("Script execution initiated, exiting current process");
399    std::process::exit(0);
400}
401
402/// Run an upgrade script with logging support
403fn run_upgrade_script(
404    script_template: &str,
405    new_exe_path: &std::path::Path,
406    current_args: Vec<String>,
407    is_service: bool,
408) -> Result<()> {
409    use tracing::debug;
410
411    let current_exe = std::env::current_exe()?;
412    let cache_dir = get_cache_dir("updates")?;
413    std::fs::create_dir_all(&cache_dir).context("Failed to create updates cache directory")?;
414
415    let timestamp = format!(
416        "{}_{}",
417        std::process::id(),
418        std::time::SystemTime::now()
419            .duration_since(std::time::UNIX_EPOCH)
420            .unwrap_or_default()
421            .as_secs()
422    );
423
424    let log_file = cache_dir.join(format!("cloudpub_update_{}.log", timestamp));
425
426    debug!("Starting update process");
427    debug!("Current executable: {}", current_exe.display());
428    debug!("New executable: {}", new_exe_path.display());
429    debug!("Current args: {:?}", current_args);
430    debug!("Log file location: {}", log_file.display());
431    debug!("Is service: {}", is_service);
432
433    // Extract config path for service mode
434    // Note: The actual flag used is --conf, not --config
435    let config_path = if is_service {
436        current_args
437            .windows(2)
438            .find(|w| w[0] == "--conf" || w[0] == "--config")
439            .and_then(|w| w.get(1))
440            .map(|s| s.as_str())
441            .unwrap_or("")
442            .to_string()
443    } else {
444        String::new()
445    };
446
447    // Always provide all replacements - unused ones will be ignored
448    let replacements = vec![
449        ("{LOG_FILE}", log_file.display().to_string()),
450        ("{NEW_EXE}", new_exe_path.display().to_string()),
451        ("{CURRENT_EXE}", current_exe.display().to_string()),
452        ("{ARGS}", current_args.join(" ")),
453        ("{CONFIG_PATH}", config_path),
454        ("{SERVICE_ARGS}", current_args.join(" ")),
455    ];
456
457    run_script("cloudpub_update", script_template, replacements, is_service)
458}
459
460#[cfg(windows)]
461pub fn install_msi_package(msi_path: &std::path::Path) -> Result<()> {
462    let current_exe = std::env::current_exe()?;
463
464    run_script(
465        "cloudpub_install_msi",
466        WINDOWS_MSI_INSTALL_SCRIPT,
467        vec![
468            ("{MSI_PATH}", msi_path.display().to_string()),
469            ("{CURRENT_EXE}", current_exe.display().to_string()),
470        ],
471        false,
472    )
473}
474
475#[cfg(windows)]
476pub fn replace_and_restart_windows(
477    new_exe_path: &std::path::Path,
478    current_args: Vec<String>,
479) -> Result<()> {
480    let is_service = current_args.contains(&"--run-as-service".to_string());
481    let script_template = if is_service {
482        WINDOWS_SERVICE_UPDATE_SCRIPT
483    } else {
484        WINDOWS_CLIENT_UPDATE_SCRIPT
485    };
486    run_upgrade_script(script_template, new_exe_path, current_args, is_service)
487}
488
489#[cfg(not(windows))]
490pub fn replace_and_restart_unix(
491    new_exe_path: &std::path::Path,
492    current_args: Vec<String>,
493) -> Result<()> {
494    let is_service = current_args.contains(&"--run-as-service".to_string());
495    let script_template = if is_service {
496        UNIX_SERVICE_UPDATE_SCRIPT
497    } else {
498        UNIX_CLIENT_UPDATE_SCRIPT
499    };
500    run_upgrade_script(script_template, new_exe_path, current_args, is_service)
501}
502
503pub fn apply_update_and_restart(new_exe_path: &std::path::Path) -> Result<()> {
504    // Get current command line arguments (skip the program name)
505    let args: Vec<String> = std::env::args().skip(1).collect();
506
507    eprintln!("{}", crate::t!("applying-update"));
508
509    #[cfg(windows)]
510    replace_and_restart_windows(new_exe_path, args)?;
511
512    #[cfg(not(windows))]
513    replace_and_restart_unix(new_exe_path, args)?;
514
515    Ok(())
516}
517
518#[cfg(target_os = "macos")]
519async fn install_dmg_package(package_path: &std::path::Path) -> Result<()> {
520    eprintln!("Installing DMG package...");
521
522    let current_exe = std::env::current_exe()?;
523    let current_args: Vec<String> = std::env::args().skip(1).collect();
524
525    run_script(
526        "cloudpub_install_dmg",
527        MACOS_DMG_INSTALL_SCRIPT,
528        vec![
529            ("{PACKAGE_PATH}", package_path.display().to_string()),
530            ("{CURRENT_EXE}", current_exe.display().to_string()),
531            ("{CURRENT_ARGS}", current_args.join(" ")),
532        ],
533        false,
534    )
535}
536
537#[cfg(target_os = "linux")]
538async fn install_deb_package(package_path: &std::path::Path) -> Result<()> {
539    eprintln!("Installing DEB package...");
540
541    let current_exe = std::env::current_exe()?;
542    let current_args: Vec<String> = std::env::args().skip(1).collect();
543
544    run_script(
545        "cloudpub_install_deb",
546        LINUX_DEB_INSTALL_SCRIPT,
547        vec![
548            ("{PACKAGE_PATH}", package_path.display().to_string()),
549            ("{CURRENT_EXE}", current_exe.display().to_string()),
550            ("{CURRENT_ARGS}", current_args.join(" ")),
551        ],
552        false,
553    )
554}
555
556#[cfg(target_os = "linux")]
557async fn install_rpm_package(package_path: &std::path::Path) -> Result<()> {
558    eprintln!("Installing RPM package...");
559
560    let current_exe = std::env::current_exe()?;
561    let current_args: Vec<String> = std::env::args().skip(1).collect();
562
563    run_script(
564        "cloudpub_install_rpm",
565        LINUX_RPM_INSTALL_SCRIPT,
566        vec![
567            ("{PACKAGE_PATH}", package_path.display().to_string()),
568            ("{CURRENT_EXE}", current_exe.display().to_string()),
569            ("{CURRENT_ARGS}", current_args.join(" ")),
570        ],
571        false,
572    )
573}
574
575async fn handle_cli_update(
576    filename: &str,
577    output_path: &std::path::Path,
578    cache_dir: &std::path::Path,
579    command_rx: &mut mpsc::Receiver<Message>,
580    result_tx: &mpsc::Sender<Message>,
581) -> Result<()> {
582    let unpack_message = "Unpacking update".to_string();
583
584    if filename.ends_with(".zip") {
585        crate::shell::unzip(&unpack_message, output_path, cache_dir, 0, result_tx)
586            .await
587            .context("Failed to unpack zip update")?;
588    } else if filename.ends_with(".tar.gz") {
589        let tar_args = vec![
590            "-xzf".to_string(),
591            output_path.to_string_lossy().to_string(),
592        ];
593
594        let total_files = 1;
595        let progress = Some((unpack_message, result_tx.clone(), total_files));
596
597        crate::shell::execute(
598            std::path::PathBuf::from("tar"),
599            tar_args,
600            Some(cache_dir.to_path_buf()),
601            std::collections::HashMap::new(),
602            progress,
603            command_rx,
604        )
605        .await
606        .context("Failed to unpack tar.gz update")?;
607    } else {
608        return Err(anyhow::anyhow!("Unknown file format: {}", filename));
609    }
610
611    eprintln!(
612        "{}",
613        crate::t!("update-unpacked", "path" => cache_dir.display().to_string())
614    );
615
616    #[cfg(not(windows))]
617    let new_exe_path = cache_dir.join("clo");
618    #[cfg(windows)]
619    let new_exe_path = cache_dir.join("clo.exe");
620
621    apply_update_and_restart(&new_exe_path)?;
622    Ok(())
623}
624
625pub async fn handle_upgrade_download(
626    version: &str,
627    gui: bool,
628    config: Arc<RwLock<ClientConfig>>,
629    command_rx: &mut mpsc::Receiver<Message>,
630    result_tx: &mpsc::Sender<Message>,
631) -> Result<(std::path::PathBuf, DownloadType, String)> {
632    // Delete old update cache directory
633    let cache_dir = get_cache_dir("updates").unwrap();
634    std::fs::remove_dir_all(&cache_dir).ok();
635
636    // Download the upgrade
637    let cache_dir = get_cache_dir("updates").unwrap();
638
639    let download_type = if gui {
640        #[cfg(target_os = "linux")]
641        {
642            // Try to detect package type and use appropriate installer
643            match get_linux_package_type() {
644                Ok(pkg_type) if pkg_type == "deb" => DownloadType::Deb,
645                Ok(pkg_type) if pkg_type == "rpm" => DownloadType::Rpm,
646                _ => DownloadType::Cli, // Fallback to CLI
647            }
648        }
649        #[cfg(not(target_os = "linux"))]
650        DownloadType::Gui
651    } else {
652        DownloadType::Cli
653    };
654
655    // Use base URL from config server field
656    let base_url = config.read().server.to_string();
657    let download_url =
658        get_download_url(&base_url, &download_type, version, cloudpub_common::BRANCH)?;
659
660    // Extract file extension from download URL
661    let mut file_extension = std::path::Path::new(&download_url)
662        .extension()
663        .and_then(|ext| ext.to_str())
664        .map(|ext| format!(".{}", ext))
665        .unwrap_or_else(|| ".tar.gz".to_string());
666
667    if file_extension == ".gz" {
668        // If the file extension is .gz, we assume it's a tar.gz file
669        file_extension = ".tar.gz".to_string();
670    }
671
672    let filename = format!("cloudpub-{}{}", version, file_extension);
673    let output_path = cache_dir.join(&filename);
674
675    let message = crate::t!("downloading-update", "path" => output_path.display().to_string());
676
677    crate::shell::download(
678        &message,
679        config.clone(),
680        &download_url,
681        &output_path,
682        command_rx,
683        result_tx,
684    )
685    .await
686    .with_context(|| format!("Failed to download update ({})", download_url))?;
687
688    eprintln!(
689        "{}",
690        crate::t!("update-downloaded", "path" => output_path.display().to_string())
691    );
692
693    Ok((output_path, download_type, filename))
694}
695
696pub async fn handle_upgrade_install(
697    output_path: std::path::PathBuf,
698    download_type: DownloadType,
699    filename: String,
700    command_rx: &mut mpsc::Receiver<Message>,
701    result_tx: &mpsc::Sender<Message>,
702) -> Result<()> {
703    let cache_dir = output_path.parent().unwrap();
704    // change the current directory to the cache directory
705    std::env::set_current_dir(cache_dir)
706        .context("Failed to change current directory to cache directory")?;
707
708    // Handle different package types
709    match download_type {
710        DownloadType::Gui => {
711            #[cfg(target_os = "macos")]
712            {
713                if filename.ends_with(".dmg") {
714                    install_dmg_package(&output_path).await?;
715                } else {
716                    return Err(anyhow::anyhow!("Unsupported GUI package format"));
717                }
718            }
719            #[cfg(target_os = "windows")]
720            {
721                if filename.ends_with(".msi") {
722                    install_msi_package(&output_path)?;
723                } else {
724                    return Err(anyhow::anyhow!("Unsupported GUI package format"));
725                }
726            }
727            #[cfg(target_os = "linux")]
728            {
729                return Err(anyhow::anyhow!("GUI packages not supported on Linux"));
730            }
731        }
732        DownloadType::Deb => {
733            #[cfg(target_os = "linux")]
734            {
735                install_deb_package(&output_path).await?;
736            }
737            #[cfg(not(target_os = "linux"))]
738            {
739                return Err(anyhow::anyhow!("DEB packages only supported on Linux"));
740            }
741        }
742        DownloadType::Rpm => {
743            #[cfg(target_os = "linux")]
744            {
745                install_rpm_package(&output_path).await?;
746            }
747            #[cfg(not(target_os = "linux"))]
748            {
749                return Err(anyhow::anyhow!("RPM packages only supported on Linux"));
750            }
751        }
752        _ => {
753            // Handle CLI updates (tar.gz/zip extraction and replacement)
754            handle_cli_update(&filename, &output_path, cache_dir, command_rx, result_tx).await?;
755        }
756    }
757
758    Ok(())
759}
760
761pub async fn handle_upgrade_available(
762    version: &str,
763    config: Arc<RwLock<ClientConfig>>,
764    gui: bool,
765    command_rx: &mut mpsc::Receiver<Message>,
766    result_tx: &mpsc::Sender<Message>,
767) -> Result<()> {
768    let (output_path, download_type, filename) =
769        handle_upgrade_download(version, gui, config, command_rx, result_tx).await?;
770
771    handle_upgrade_install(output_path, download_type, filename, command_rx, result_tx).await?;
772
773    Ok(())
774}
775
776#[cfg(test)]
777mod download_config_tests {
778    use super::*;
779
780    // HYPOTHESIS: moving the file naming into cloudpub_common::downloads
781    //     (CLOUDPUB-1527) must not move a single URL - the client downloads
782    //     from addresses published long ago, and the server now offers
783    //     upgrades by building the very same names.
784    // SOLUTION: DownloadConfig::new builds its entries from the shared
785    //     table plus the base_url, for every platform unconditionally; this
786    //     test pins the composed URLs of every OS section against the
787    //     literals the old cfg-gated code produced.
788    // STATUS: FIXED - see CLOUDPUB-1527
789    #[test]
790    fn download_urls_are_unchanged_for_every_platform() {
791        let config = DownloadConfig::new("https://cloudpub.ru/download/beta/", "3.5.1126", "beta");
792
793        assert_eq!(
794            config.linux.platforms["x86_64"].cli.as_deref(),
795            Some("https://cloudpub.ru/download/beta/clo-3.5.1126-beta-linux-x86_64.tar.gz")
796        );
797        assert_eq!(
798            config.linux.platforms["x86_64"].deb.as_deref(),
799            Some("https://cloudpub.ru/download/beta/cloudpub-3.5.1126-beta-linux-x86_64.deb")
800        );
801        assert_eq!(
802            config.linux.platforms["arm"].cli.as_deref(),
803            Some("https://cloudpub.ru/download/beta/clo-3.5.1126-beta-linux-arm.tar.gz")
804        );
805        assert_eq!(config.linux.platforms["x86_64"].gui, None);
806        assert_eq!(
807            config.macos.platforms["aarch64"].gui.as_deref(),
808            Some("https://cloudpub.ru/download/beta/cloudpub-3.5.1126-beta-macos-aarch64.dmg")
809        );
810        assert_eq!(
811            config.windows.platforms["x86_64"].cli.as_deref(),
812            Some("https://cloudpub.ru/download/beta/clo-3.5.1126-beta-windows-x86_64.zip")
813        );
814        assert_eq!(
815            config.windows.platforms["x86_64"].gui.as_deref(),
816            Some("https://cloudpub.ru/download/beta/cloudpub-3.5.1126-beta-windows-x86_64.msi")
817        );
818        assert_eq!(
819            config.android.platforms["aarch64"].cli.as_deref(),
820            Some("https://cloudpub.ru/download/beta/clo-3.5.1126-beta-android-arm64.tar.gz")
821        );
822    }
823}