libplasmoid-updater 0.2.0

Library for updating KDE Plasma 6 components from the KDE Store. Meant for use in topgrade.
Documentation
// SPDX-License-Identifier: GPL-3.0-or-later

#[cfg(feature = "cli")]
use crate::cli::{self, progress::create_fetch_spinner};
#[cfg(feature = "cli")]
use inquire::InquireError;

use crate::{FailedUpdate, UnverifiedUpdate};
use rayon::iter::{IndexedParallelIterator, IntoParallelRefIterator, ParallelIterator};
use std::sync::Arc;

use crate::{
    Config, Error, RestartBehavior, UpdateResult,
    api::ApiClient,
    checker::{check_with_components, find_installed},
    installer,
    types::{AvailableUpdate, UpdateCheckResult},
};

pub(crate) fn validate_environment(skip_plasma_detection: bool) -> crate::Result<()> {
    if cfg!(not(target_os = "linux")) {
        return Err(Error::UnsupportedOS(std::env::consts::OS.to_string()));
    }
    let plasma_found = skip_plasma_detection || crate::paths::is_kde();
    if !plasma_found {
        return Err(Error::NotKDE);
    }
    check_dependency("bsdtar")?;
    Ok(())
}

fn check_dependency(name: &str) -> crate::Result<()> {
    use std::process::Command;
    match Command::new("which").arg(name).output() {
        Ok(output) if output.status.success() => Ok(()),
        _ => Err(Error::MissingDependency(name.to_string())),
    }
}

pub(crate) fn fetch_updates(
    api_client: &ApiClient,
    config: &Config,
) -> crate::Result<UpdateCheckResult> {
    #[cfg(feature = "cli")]
    let spinner = create_fetch_spinner();

    let components = find_installed(config.system)?;
    let result = check_with_components(config, api_client, components)?;

    #[cfg(feature = "cli")]
    spinner.finish_and_clear();

    Ok(result)
}

pub(crate) fn select_updates<'a>(
    updates: &'a [AvailableUpdate],
    config: &Config,
) -> crate::Result<Vec<&'a AvailableUpdate>> {
    #[cfg(feature = "cli")]
    if !config.auto_confirm && stdin_is_terminal() {
        return prompt_update_selection(updates, &config.excluded_packages);
    }

    Ok(filter_excluded(updates, &config.excluded_packages))
}

pub(crate) fn filter_excluded<'a>(
    updates: &'a [AvailableUpdate],
    excluded: &[String],
) -> Vec<&'a AvailableUpdate> {
    updates
        .iter()
        .filter(|u| !is_excluded(u, excluded))
        .collect()
}

pub(crate) fn is_excluded(update: &AvailableUpdate, excluded: &[String]) -> bool {
    excluded
        .iter()
        .any(|e| e == &update.installed.directory_name || e == &update.installed.name)
}

#[cfg(feature = "cli")]
pub(crate) fn stdin_is_terminal() -> bool {
    use is_terminal::IsTerminal;
    std::io::stdin().is_terminal()
}

#[cfg(feature = "cli")]
pub(crate) fn prompt_update_selection<'a>(
    updates: &'a [AvailableUpdate],
    excluded: &[String],
) -> crate::Result<Vec<&'a AvailableUpdate>> {
    let options = format_menu_options(updates);

    let defaults: Vec<usize> = updates
        .iter()
        .enumerate()
        .filter(|(_, u)| !is_excluded(u, excluded))
        .map(|(i, _)| i)
        .collect();

    let plural = if updates.len() == 1 { "" } else { "s" };
    let prompt = format!(
        "{} update{plural} available, select to apply:",
        updates.len()
    );

    match inquire::MultiSelect::new(&prompt, options)
        .with_default(&defaults)
        .with_page_size(15)
        .raw_prompt()
    {
        Ok(selected) => {
            let result: Vec<&AvailableUpdate> = selected
                .into_iter()
                .map(|opt| &updates[opt.index])
                .collect();

            use std::io::Write;
            print!("{}", cli::CLEAR_LINE_SEQUENCE);
            std::io::stdout().flush().ok();

            Ok(result)
        }
        Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(vec![]),
        Err(e) => Err(crate::Error::other(format!("prompt failed: {e}"))),
    }
}

#[cfg(feature = "cli")]
pub(crate) fn format_menu_options(updates: &[AvailableUpdate]) -> Vec<String> {
    let name_width = updates
        .iter()
        .map(|u| u.installed.name.len())
        .max()
        .unwrap_or(10)
        .max(10);

    updates
        .iter()
        .map(|u| {
            format!(
                "{:<name_width$} {} \u{2192} {}",
                u.installed.name,
                cli::output::format_version(&u.installed.version),
                cli::output::format_version(&u.latest_version),
            )
        })
        .collect()
}

pub(crate) fn install_selected_updates(
    updates: &[&AvailableUpdate],
    api_client: &ApiClient,
    config: &Config,
) -> crate::Result<UpdateResult> {
    let result = Arc::new(parking_lot::Mutex::new(UpdateResult::default()));

    let _inhibit = if config.inhibit_idle {
        installer::InhibitGuard::acquire()
    } else {
        installer::InhibitGuard::None
    };

    #[cfg(feature = "cli")]
    let ui = cli::update_ui::UpdateUi::new(updates);

    // 0 = rayon default = number of logical CPUs
    let thread_count = config.threads.unwrap_or(0);
    let pool = rayon::ThreadPoolBuilder::new()
        .num_threads(thread_count)
        .build()
        .unwrap_or_else(|_| {
            rayon::ThreadPoolBuilder::new()
                .build()
                .expect("failed to build default thread pool")
        });

    let counter = api_client.request_counter();

    pool.install(|| {
        updates.par_iter().enumerate().for_each(|(index, update)| {
            #[cfg(not(feature = "cli"))]
            let _ = index;
            let name = update.installed.name.clone();

            #[cfg(feature = "cli")]
            let reporter = ui.reporter(index);
            #[cfg(not(feature = "cli"))]
            let reporter = |_: u8| {};

            match installer::update_component(update, api_client.http_client(), reporter, &counter)
            {
                Ok(outcome) => {
                    #[cfg(feature = "cli")]
                    ui.complete_task(index, true);
                    let mut r = result.lock();
                    if !outcome.verified {
                        r.unverified.push(UnverifiedUpdate {
                            name: name.clone(),
                            expected_version: outcome.expected_version,
                            actual_version: outcome.actual_version,
                        });
                    }
                    r.succeeded.push(name);
                }
                Err(e) => {
                    #[cfg(feature = "cli")]
                    ui.complete_task(index, false);
                    result.lock().failed.push(FailedUpdate {
                        name,
                        error: e.to_string(),
                    });
                }
            }
        });
    });

    #[cfg(feature = "cli")]
    ui.finish();

    Ok(Arc::try_unwrap(result)
        .expect("thread pool completed; Arc should have single owner")
        .into_inner())
}

pub(crate) fn handle_restart(config: &Config, updates: &[AvailableUpdate], result: &UpdateResult) {
    if result.succeeded.is_empty() {
        return;
    }

    let succeeded_updates: Vec<&AvailableUpdate> = updates
        .iter()
        .filter(|u| result.succeeded.contains(&u.installed.name))
        .collect();

    if !installer::any_requires_restart(&succeeded_updates) {
        return;
    }

    match config.restart {
        RestartBehavior::Never => {}
        RestartBehavior::Always => {
            if let Err(e) = installer::restart_plasmashell() {
                log::warn!(target: "restart", "failed to restart plasmashell: {e}");
            }
        }
        #[cfg(feature = "cli")]
        RestartBehavior::Prompt => {
            if stdin_is_terminal() {
                prompt_restart();
            }
        }
        #[cfg(not(feature = "cli"))]
        RestartBehavior::Prompt => {
            // Without CLI, cannot prompt — fall back to not restarting
            log::info!(target: "restart", "prompt restart requested but no CLI available, skipping");
        }
    }
}

#[cfg(feature = "cli")]
pub(crate) fn prompt_restart() {
    match inquire::Confirm::new("Restart plasmashell now?")
        .with_default(false)
        .prompt()
    {
        Ok(true) => {
            if let Err(e) = installer::restart_plasmashell() {
                log::warn!(target: "restart", "failed to restart plasmashell: {e}");
            }
        }
        Ok(false) | Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {}
        Err(e) => log::warn!(target: "restart", "prompt failed: {e}"),
    }
}

#[cfg(feature = "cli")]
pub(crate) fn display_check_results(result: &crate::types::UpdateCheckResult) {
    if result.updates.is_empty() {
        println!("no updates available");
        return;
    }

    cli::output::print_count_message(result.updates.len(), "update");
    cli::output::print_updates_table(&result.updates);
}