ek-ui 0.9.8

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
Documentation
//! pluginwin —— 「插件即 App」挂载表 + 逐插件配置窗口(ek-ui 0.9.2)。
//!
//! 挂载纪律:
//! - **只挂在册插件**:`engine` 里 `as_configurable() == None` 的(ui 插件、
//!   纯观测件)在菜单与窗口里都不出现(判据 U-W1);
//! - 字段与界**不硬编码**:按 [`Configurable::config_schema`] 渲染(空 schema =
//!   退化为 `config_view` 形态,如 tq 段自持键);
//! - 校验单源 [`ConfigField::validate`],坏值显形拒、不写档;
//! - 存 = 写档案段 + **立即喂回受理面**(运行期生效 = 与 daemon 起链同一条 apply);
//!   需重启才生效的键按受理报告显形(BD-CFG-6 回执口径);
//! - 秘面**只读引用**(`secret://…`),改口令引导走 CLI(判据 U-W3);
//! - 档案包导出 = 段文件整批复制到目录(密码在 Credential Manager,不随包走);
//!   导入 = 逐段读入过受理面显形。

use ek_core::Configurable;
use ek_core::cfg_face::{CfgHost, ConfigField};
use ek_core::plugin::Plugin;
use ek_core::section::{Field, SectionMap};

/// 窗口态(一 ns 一份)。
#[derive(Default)]
pub struct WinState {
    /// 窗口开合(菜单勾)。
    pub open: bool,
    rows: Vec<(String, String)>,
    seed: Vec<String>,
    /// 上帧回执(保存/拒绝/导入行)。
    pub notice: Option<String>,
}

impl WinState {
    /// 新建(关窗态)。
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }
}

/// 挂载表:在册且自述配置的插件(注册名 + 段名)。
#[must_use]
pub fn mounted(plugins: &[std::sync::Arc<dyn Plugin>]) -> Vec<(String, String)> {
    plugins
        .iter()
        .filter_map(|p| {
            let c = p.as_configurable()?;
            Some((p.name().to_string(), c.config_ns().to_string()))
        })
        .collect()
}

fn field_label(f: &ConfigField) -> String {
    match &f.kind {
        ek_core::ConfigKind::Int { unit: Some(u), .. }
        | ek_core::ConfigKind::Float { unit: Some(u), .. } => format!("{}({})", f.label, u),
        _ => f.label.clone(),
    }
}

/// 渲染一个插件的配置窗口内容。返回 true = 本帧有交互。
pub fn render_window(
    ui: &mut egui::Ui,
    plugin: &dyn Plugin,
    host: &dyn CfgHost,
    st: &mut WinState,
) -> bool {
    let Some(cfg) = plugin.as_configurable() else {
        ui.label("该插件不在册(无配置面)");
        return false;
    };
    let mut want = false;
    let schema = cfg.config_schema();
    let view = cfg.config_view();
    let keys: Vec<String> = if schema.is_empty() {
        view.iter().map(|(k, _)| k.clone()).collect()
    } else {
        schema.iter().map(|f| f.key.clone()).collect()
    };
    if st.seed != keys {
        st.rows = keys
            .iter()
            .map(|k| {
                let v = view
                    .iter()
                    .find(|(vk, _)| vk == k)
                    .map(|(_, vv)| vv.clone())
                    .unwrap_or_default();
                (k.clone(), v)
            })
            .collect();
        st.seed = keys;
    }

    let mut bad: Vec<String> = Vec::new();
    egui::Grid::new("pw_grid")
        .num_columns(3)
        .spacing([10.0, 6.0])
        .striped(true)
        .show(ui, |ui| {
            for (i, (k, v)) in st.rows.iter_mut().enumerate() {
                let f = schema.iter().find(|f| &f.key == k);
                let label = match &f {
                    Some(f) => field_label(f),
                    None => k.clone(),
                };
                ui.label(egui::RichText::new(label).strong());
                if v.starts_with("secret://") {
                    ui.label(egui::RichText::new(v.as_str()).weak());
                } else {
                    let mut buf = v.clone();
                    ui.add(egui::TextEdit::singleline(&mut buf).desired_width(120.0));
                    if buf != *v {
                        *v = buf;
                        want = true;
                    }
                }
                match f.and_then(|f| f.help.as_deref()) {
                    Some(h) if !h.is_empty() => {
                        want |= ui.label(egui::RichText::new(h).small().weak()).hovered();
                    }
                    _ => {
                        ui.label("");
                    }
                }
                if let Some(f) = f
                    && !v.starts_with("secret://")
                    && let Err(e) = f.validate(v)
                {
                    bad.push(format!("行 {i}: {e}"));
                }
                ui.end_row();
            }
        });

    ui.add_space(6.0);
    ui.horizontal(|ui| {
        if ui.button("保存(写档案段+即时生效)").clicked() {
            want = true;
            st.notice = Some(save(cfg, host, &st.rows, &schema));
        }
        if ui.button("按档案重读").clicked() {
            st.seed = Vec::new();
            st.notice = None;
            want = true;
        }
    });
    for line in bad.iter().take(3) {
        ui.label(
            egui::RichText::new(line.as_str())
                .small()
                .color(crate::tokens::warn(ui.ctx())),
        );
    }
    if let Some(n) = &st.notice {
        ui.label(egui::RichText::new(n.as_str()).monospace().small());
    }
    ui.label(
        egui::RichText::new(
            "秘面只读;口令走 ekapp add-account(stdin)。标「待重启」的键按插件重启取新值",
        )
        .small()
        .weak(),
    );
    want
}

/// 存 + 即时喂回受理面;回执 = 一行显形。
fn save(
    cfg: &dyn Configurable,
    host: &dyn CfgHost,
    rows: &[(String, String)],
    schema: &[ConfigField],
) -> String {
    let mut map = SectionMap::new(1 + rows.len() as u32);
    let mut rejected: Vec<String> = Vec::new();
    for (k, v) in rows {
        if v.starts_with("secret://") {
            continue;
        }
        if let Some(f) = schema.iter().find(|f| &f.key == k)
            && let Err(e) = f.validate(v)
        {
            rejected.push(format!("{k}:{e}"));
            continue;
        }
        map = map.set(k.clone(), Field::plain(v.trim().to_string()));
    }
    if !rejected.is_empty() {
        return format!("拒 {} 项:{}", rejected.len(), rejected.join(" | "));
    }
    if map.is_empty() {
        return "无可存项(秘面只读)".to_string();
    }
    if let Err(e) = host.cfg_save(cfg.config_ns(), &map) {
        return format!("存档失败:{e}");
    }
    host.log(&format!(
        "[ui] 档案段 {} 已存:{}",
        cfg.config_ns(),
        map.iter().map(|(k, _)| k).collect::<Vec<_>>().join(",")
    ));
    match cfg.apply_config(&map) {
        Ok(rep) => {
            let mut line = format!(
                "已存 {} 键 · 即时生效 {} 键",
                map.iter().count(),
                rep.adopted.len()
            );
            if !rep.deferred.is_empty() {
                line.push_str(&format!(";{} 键待插件重启生效", rep.deferred.len()));
            }
            line
        }
        Err(e) => format!("已存档,受理拒:{e}"),
    }
}

/// 档案包导出:段文件整批复制到目标目录(只复制 `.ini`;密码在 Credential
/// Manager 里不随包走)。返回 (复制段数, 跳过文件数)。
pub fn export_bundle(
    cfg_root: &std::path::Path,
    dest_dir: &std::path::Path,
) -> Result<(usize, usize), String> {
    std::fs::create_dir_all(dest_dir).map_err(|e| format!("建目标目录失败:{e}"))?;
    let mut copied = 0usize;
    let mut skipped = 0usize;
    let rd = std::fs::read_dir(cfg_root).map_err(|e| format!("读档案根失败:{e}"))?;
    for ent in rd.flatten() {
        let p = ent.path();
        if p.extension().and_then(|e| e.to_str()) == Some("ini") {
            let dst = dest_dir.join(ent.file_name());
            std::fs::copy(&p, &dst).map_err(|e| format!("复制失败:{e}"))?;
            copied += 1;
        } else {
            skipped += 1;
        }
    }
    Ok((copied, skipped))
}

/// 档案包导入:逐 `.ini` 读入 → 写 store → 同名 ns 有在册插件则过受理面显形
/// (`apply` = 宿主注入的按段派发口,见 `faces::apply_section`;取不到 = 只收编不受理)。
/// 返回逐段回执行(口令只到引用形态 + CLI 引导行)。
pub fn import_bundle(
    host: &dyn CfgHost,
    src_dir: &std::path::Path,
    apply: &dyn Fn(&str, &SectionMap) -> Option<String>,
) -> Vec<String> {
    let mut lines = Vec::new();
    let rd = match std::fs::read_dir(src_dir) {
        Ok(r) => r,
        Err(e) => {
            lines.push(format!("读包目录失败:{e}"));
            return lines;
        }
    };
    let mut files: Vec<std::path::PathBuf> = rd
        .flatten()
        .map(|e| e.path())
        .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("ini"))
        .collect();
    files.sort();
    for p in files {
        let Some(ns) = p.file_stem().and_then(|s| s.to_str()).map(String::from) else {
            continue;
        };
        // 包内档案直接从文件读(不经 store 根)。
        let text = match std::fs::read_to_string(&p) {
            Ok(t) => t,
            Err(e) => {
                lines.push(format!("{ns}:读文件失败:{e}"));
                continue;
            }
        };
        let (parsed, warnings) = match ek_cfg::parse(&text, &ns) {
            Ok(v) => v,
            Err(e) => {
                lines.push(format!("{ns}:解析失败:{e}"));
                continue;
            }
        };
        match host.cfg_save(&ns, &parsed) {
            Ok(()) => {
                let mut line = format!("{ns}:收编 {} 字段", parsed.len());
                if !warnings.is_empty() {
                    line.push_str(&format!(" · 告警 {}", warnings.len()));
                }
                if let Some(rep) = apply(&ns, &parsed) {
                    line.push_str(&format!(" · {rep}"));
                }
                lines.push(line);
            }
            Err(e) => lines.push(format!("{ns}:写入失败:{e}")),
        }
    }
    if lines.is_empty() {
        lines.push("包内无档案段(.ini)".to_string());
    }
    lines.push("口令段只到引用形态;口令改动走 ekapp add-account(stdin)".to_string());
    lines
}