par-term-settings-ui 0.17.1

Settings UI for par-term terminal emulator
Documentation
//! Import/Export Preferences section for the advanced settings tab.
//!
//! Covers: export to file, import from file (replace/merge), import from URL.
//! Also provides the public `merge_config` function used externally.

use crate::SettingsUI;
use crate::section::{INPUT_WIDTH, collapsing_section};
use par_term_config::Config;
use std::collections::HashSet;

// ============================================================================
// Import/Export Section
// ============================================================================

pub(super) fn show_import_export_section(
    ui: &mut egui::Ui,
    settings: &mut SettingsUI,
    changes_this_frame: &mut bool,
    collapsed: &mut HashSet<String>,
) {
    collapsing_section(
        ui,
        "Import/Export Preferences",
        "advanced_import_export",
        true,
        collapsed,
        |ui| {
            ui.label("Export your current configuration or import settings from a file or URL.");
            ui.add_space(8.0);

            // --- Export ---
            ui.label(egui::RichText::new("Export").strong());
            ui.add_space(4.0);

            if ui
                .button("Export Preferences to File")
                .on_hover_text("Save the current configuration to a YAML file")
                .clicked()
            {
                export_preferences(settings);
            }

            ui.add_space(12.0);

            // --- Import from File ---
            ui.label(egui::RichText::new("Import from File").strong());
            ui.add_space(4.0);

            ui.horizontal(|ui| {
                if ui
                    .button("Import & Replace")
                    .on_hover_text("Replace the entire configuration with settings from a file")
                    .clicked()
                {
                    import_preferences_from_file(settings, changes_this_frame, ImportMode::Replace);
                }

                if ui
                    .button("Import & Merge")
                    .on_hover_text(
                        "Merge settings from a file into the current configuration \
                         (only overrides non-default values)",
                    )
                    .clicked()
                {
                    import_preferences_from_file(settings, changes_this_frame, ImportMode::Merge);
                }
            });

            ui.add_space(12.0);

            // --- Import from URL ---
            ui.label(egui::RichText::new("Import from URL").strong());
            ui.add_space(4.0);

            ui.horizontal(|ui| {
                ui.label("URL:");
                ui.add(
                    egui::TextEdit::singleline(&mut settings.advanced_tab.temp_import_url)
                        .desired_width(INPUT_WIDTH)
                        .hint_text("https://example.com/config.yaml"),
                );
            });

            ui.horizontal(|ui| {
                // Only emptiness disables the buttons. The scheme is judged by
                // `validate_scheme` inside the fetch, which explains a rejection
                // in the status line — a silently greyed-out button for a
                // `file://` URL told the user nothing.
                let url_valid = !settings.advanced_tab.temp_import_url.trim().is_empty();

                if ui
                    .add_enabled(url_valid, egui::Button::new("Fetch & Replace"))
                    .on_hover_text("Download and replace the current configuration (https:// only)")
                    .clicked()
                {
                    import_preferences_from_url(settings, changes_this_frame, ImportMode::Replace);
                }

                if ui
                    .add_enabled(url_valid, egui::Button::new("Fetch & Merge"))
                    .on_hover_text(
                        "Download and merge into the current configuration (https:// only)",
                    )
                    .clicked()
                {
                    import_preferences_from_url(settings, changes_this_frame, ImportMode::Merge);
                }
            });

            // Show status/error messages
            if let Some(ref msg) = settings.advanced_tab.import_export_status {
                ui.add_space(4.0);
                let color = if settings.advanced_tab.import_export_is_error {
                    egui::Color32::from_rgb(255, 100, 100)
                } else {
                    egui::Color32::from_rgb(100, 200, 100)
                };
                ui.label(egui::RichText::new(msg.as_str()).color(color));
            }

            ui.add_space(4.0);
            ui.label(
                egui::RichText::new(
                    "Merge mode preserves your existing settings and only applies \
                     values that differ from defaults in the imported file.",
                )
                .small()
                .color(egui::Color32::GRAY),
            );
        },
    );
}

// ============================================================================
// Import/Export Helpers
// ============================================================================

/// Maximum size of a configuration fetched from a URL (SEC-020).
///
/// Matches the dynamic-profile fetch limit (`DynamicProfileSource`'s default
/// `max_size_bytes`, enforced in `src/profile/dynamic/fetch.rs`) so both
/// remote-config paths cap the same way.
const MAX_IMPORT_SIZE_BYTES: u64 = 1_048_576;

/// Wall-clock cap on a preference-import fetch.
///
/// [`import_preferences_from_url`] runs on the winit main thread, so this bounds
/// how long the whole window can stop redrawing. The shared agent this replaced
/// set no timeout at all, which made an unresponsive endpoint an indefinite
/// freeze; 30 s matches the timeout the shader downloader and self-updater use.
const IMPORT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);

/// Build the agent used for preference imports.
///
/// The TLS provider must be named explicitly. ureq's default is
/// `TlsProvider::Rustls`, but the workspace builds ureq with
/// `default-features = false, features = ["native-tls", "gzip"]`, so the rustls
/// feature is absent — and ureq *panics* rather than erroring when the selected
/// provider's feature is missing ("uri scheme is https, provider is Rustls but
/// feature is not enabled"). The `Agent::new_with_defaults()` this replaced hit
/// exactly that, so importing from any https URL aborted the process. This
/// matches the configuration the shader downloader and self-updater already use.
fn import_agent() -> ureq::Agent {
    let tls_config = ureq::tls::TlsConfig::builder()
        .provider(ureq::tls::TlsProvider::NativeTls)
        .root_certs(ureq::tls::RootCerts::PlatformVerifier)
        .build();

    ureq::Agent::config_builder()
        .tls_config(tls_config)
        .timeout_global(Some(IMPORT_TIMEOUT))
        .build()
        .into()
}

/// Whether to replace or merge when importing preferences.
#[derive(Clone, Copy, PartialEq, Eq)]
enum ImportMode {
    /// Replace the entire configuration.
    Replace,
    /// Merge non-default values from the imported config.
    Merge,
}

/// Export the current configuration to a YAML file.
fn export_preferences(settings: &mut SettingsUI) {
    let path = rfd::FileDialog::new()
        .set_title("Export Preferences")
        .add_filter("YAML", &["yaml", "yml"])
        .set_file_name("par-term-config.yaml")
        .save_file();

    if let Some(path) = path {
        match serde_yaml_ng::to_string(&settings.config) {
            Ok(yaml) => {
                if let Err(e) = std::fs::write(&path, yaml) {
                    settings.advanced_tab.import_export_status =
                        Some(format!("Failed to write file: {}", e));
                    settings.advanced_tab.import_export_is_error = true;
                    log::error!("Failed to export preferences: {}", e);
                } else {
                    settings.advanced_tab.import_export_status =
                        Some(format!("Exported to {}", path.display()));
                    settings.advanced_tab.import_export_is_error = false;
                    log::info!("Exported preferences to {}", path.display());
                }
            }
            Err(e) => {
                settings.advanced_tab.import_export_status =
                    Some(format!("Failed to serialize config: {}", e));
                settings.advanced_tab.import_export_is_error = true;
                log::error!("Failed to serialize preferences: {}", e);
            }
        }
    }
}

/// Import preferences from a local file.
fn import_preferences_from_file(
    settings: &mut SettingsUI,
    changes_this_frame: &mut bool,
    mode: ImportMode,
) {
    let path = rfd::FileDialog::new()
        .set_title("Import Preferences")
        .add_filter("YAML", &["yaml", "yml"])
        .pick_file();

    if let Some(path) = path {
        match std::fs::read_to_string(&path) {
            Ok(content) => {
                apply_imported_config(settings, changes_this_frame, &content, mode);
            }
            Err(e) => {
                settings.advanced_tab.import_export_status =
                    Some(format!("Failed to read file: {}", e));
                settings.advanced_tab.import_export_is_error = true;
                log::error!("Failed to read preferences file: {}", e);
            }
        }
    }
}

/// Import preferences from a URL.
///
/// # Blocking
///
/// The fetch is synchronous and runs on the winit main thread, so the window
/// stops redrawing for its duration — bounded by [`IMPORT_TIMEOUT`]. Moving it
/// off-thread needs somewhere to park the in-flight result between frames,
/// which means a new field on `SettingsUI`; until then the timeout is what
/// keeps a dead endpoint from hanging the UI indefinitely.
fn import_preferences_from_url(
    settings: &mut SettingsUI,
    changes_this_frame: &mut bool,
    mode: ImportMode,
) {
    let url = settings.advanced_tab.temp_import_url.trim().to_string();
    if url.is_empty() {
        return;
    }

    // SEC-020: an imported config drives shell execution, startup commands and
    // profiles, so it must not be MITM-replaceable in transit.
    //
    // ARC-006: the same scheme allowlist the dynamic-profile fetch uses. The
    // previous `starts_with("https://")` test was a denylist in disguise — it
    // misclassified `HTTPS://` as insecure (RFC 3986 schemes are
    // case-insensitive) and, on the sibling path that opts into HTTP, let
    // `ftp:`, `data:` and single-slash `file:` through. No HTTP opt-in exists
    // here, so `allow_http` is false: this path is https-only.
    if let Err(message) = par_term_config::url_policy::validate_scheme(&url, false) {
        // The returned string is already a complete user-facing sentence.
        log::warn!("[SEC-020] refusing preference import: {}", message);
        settings.advanced_tab.import_export_status = Some(message);
        settings.advanced_tab.import_export_is_error = true;
        return;
    }

    let agent = import_agent();
    match agent.get(&url).call() {
        // SEC-020: cap the response so a hostile or broken endpoint cannot
        // stream an unbounded body into memory.
        Ok(mut response) => match response
            .body_mut()
            .with_config()
            .limit(MAX_IMPORT_SIZE_BYTES)
            .read_to_string()
        {
            Ok(body) => {
                apply_imported_config(settings, changes_this_frame, &body, mode);
            }
            Err(e) => {
                settings.advanced_tab.import_export_status = Some(format!(
                    "Failed to read response (limit {} bytes): {}",
                    MAX_IMPORT_SIZE_BYTES, e
                ));
                settings.advanced_tab.import_export_is_error = true;
                log::error!("Failed to read URL response body: {}", e);
            }
        },
        Err(e) => {
            settings.advanced_tab.import_export_status =
                Some(format!("Failed to fetch URL: {}", e));
            settings.advanced_tab.import_export_is_error = true;
            log::error!("Failed to fetch preferences from URL: {}", e);
        }
    }
}

/// Parse YAML content as a Config and apply it to the settings.
fn apply_imported_config(
    settings: &mut SettingsUI,
    changes_this_frame: &mut bool,
    content: &str,
    mode: ImportMode,
) {
    match serde_yaml_ng::from_str::<Config>(content) {
        Ok(imported) => {
            match mode {
                ImportMode::Replace => {
                    settings.config = imported;
                }
                ImportMode::Merge => {
                    merge_config(&mut settings.config, &imported);
                }
            }
            settings.sync_all_temps_from_config();
            settings.has_changes = true;
            *changes_this_frame = true;
            settings.advanced_tab.import_export_status = Some(match mode {
                ImportMode::Replace => "Configuration replaced successfully.".to_string(),
                ImportMode::Merge => "Configuration merged successfully.".to_string(),
            });
            settings.advanced_tab.import_export_is_error = false;
            log::info!(
                "Imported preferences (mode={:?})",
                match mode {
                    ImportMode::Replace => "replace",
                    ImportMode::Merge => "merge",
                }
            );
        }
        Err(e) => {
            settings.advanced_tab.import_export_status =
                Some(format!("Invalid config file: {}", e));
            settings.advanced_tab.import_export_is_error = true;
            log::error!("Failed to parse imported config: {}", e);
        }
    }
}

/// Merge an imported Config into the current config.
///
/// For each field, if the imported value differs from the default, it overwrites
/// the current value. This lets users share partial configs that only override
/// specific settings.
pub fn merge_config(current: &mut Config, imported: &Config) {
    let defaults = Config::default();

    // Serialize all three to serde_yaml_ng::Value for field-by-field comparison
    let default_val: serde_yaml_ng::Value =
        serde_yaml_ng::from_str(&serde_yaml_ng::to_string(&defaults).unwrap_or_default())
            .unwrap_or(serde_yaml_ng::Value::Null);
    let imported_val: serde_yaml_ng::Value =
        serde_yaml_ng::from_str(&serde_yaml_ng::to_string(imported).unwrap_or_default())
            .unwrap_or(serde_yaml_ng::Value::Null);
    let mut current_val: serde_yaml_ng::Value =
        serde_yaml_ng::from_str(&serde_yaml_ng::to_string(&*current).unwrap_or_default())
            .unwrap_or(serde_yaml_ng::Value::Null);

    if let (
        serde_yaml_ng::Value::Mapping(ref default_map),
        serde_yaml_ng::Value::Mapping(ref imported_map),
        serde_yaml_ng::Value::Mapping(current_map),
    ) = (default_val, imported_val, &mut current_val)
    {
        for (key, imported_field) in imported_map {
            let default_field = default_map.get(key);
            // Only override if the imported value differs from the default
            if default_field != Some(imported_field) {
                current_map.insert(key.clone(), imported_field.clone());
            }
        }
    }

    // Deserialize the merged value back into Config
    if let Ok(merged) = serde_yaml_ng::from_value::<Config>(current_val) {
        *current = merged;
    }
}