Skip to main content

ek_ui/
pluginwin.rs

1//! pluginwin —— 「插件即 App」挂载表 + 逐插件配置窗口(ek-ui 0.9.2)。
2//!
3//! 挂载纪律:
4//! - **只挂在册插件**:`engine` 里 `as_configurable() == None` 的(ui 插件、
5//!   纯观测件)在菜单与窗口里都不出现(判据 U-W1);
6//! - 字段与界**不硬编码**:按 [`Configurable::config_schema`] 渲染(空 schema =
7//!   退化为 `config_view` 形态,如 tq 段自持键);
8//! - 校验单源 [`ConfigField::validate`],坏值显形拒、不写档;
9//! - 存 = 写档案段 + **立即喂回受理面**(运行期生效 = 与 daemon 起链同一条 apply);
10//!   需重启才生效的键按受理报告显形(BD-CFG-6 回执口径);
11//! - 秘面**只读引用**(`secret://…`),改口令引导走 CLI(判据 U-W3);
12//! - 档案包导出 = 段文件整批复制到目录(密码在 Credential Manager,不随包走);
13//!   导入 = 逐段读入过受理面显形。
14
15use ek_core::Configurable;
16use ek_core::cfg_face::{CfgHost, ConfigField};
17use ek_core::plugin::Plugin;
18use ek_core::section::{Field, SectionMap};
19
20/// 窗口态(一 ns 一份)。
21#[derive(Default)]
22pub struct WinState {
23    /// 窗口开合(菜单勾)。
24    pub open: bool,
25    rows: Vec<(String, String)>,
26    seed: Vec<String>,
27    /// 上帧回执(保存/拒绝/导入行)。
28    pub notice: Option<String>,
29}
30
31impl WinState {
32    /// 新建(关窗态)。
33    #[must_use]
34    pub fn new() -> Self {
35        Self::default()
36    }
37}
38
39/// 挂载表:在册且自述配置的插件(注册名 + 段名)。
40#[must_use]
41pub fn mounted(plugins: &[std::sync::Arc<dyn Plugin>]) -> Vec<(String, String)> {
42    plugins
43        .iter()
44        .filter_map(|p| {
45            let c = p.as_configurable()?;
46            Some((p.name().to_string(), c.config_ns().to_string()))
47        })
48        .collect()
49}
50
51fn field_label(f: &ConfigField) -> String {
52    match &f.kind {
53        ek_core::ConfigKind::Int { unit: Some(u), .. }
54        | ek_core::ConfigKind::Float { unit: Some(u), .. } => format!("{}({})", f.label, u),
55        _ => f.label.clone(),
56    }
57}
58
59/// 渲染一个插件的配置窗口内容。返回 true = 本帧有交互。
60pub fn render_window(
61    ui: &mut egui::Ui,
62    plugin: &dyn Plugin,
63    host: &dyn CfgHost,
64    st: &mut WinState,
65) -> bool {
66    let Some(cfg) = plugin.as_configurable() else {
67        ui.label("该插件不在册(无配置面)");
68        return false;
69    };
70    let mut want = false;
71    let schema = cfg.config_schema();
72    let view = cfg.config_view();
73    let keys: Vec<String> = if schema.is_empty() {
74        view.iter().map(|(k, _)| k.clone()).collect()
75    } else {
76        schema.iter().map(|f| f.key.clone()).collect()
77    };
78    if st.seed != keys {
79        st.rows = keys
80            .iter()
81            .map(|k| {
82                let v = view
83                    .iter()
84                    .find(|(vk, _)| vk == k)
85                    .map(|(_, vv)| vv.clone())
86                    .unwrap_or_default();
87                (k.clone(), v)
88            })
89            .collect();
90        st.seed = keys;
91    }
92
93    let mut bad: Vec<String> = Vec::new();
94    egui::Grid::new("pw_grid")
95        .num_columns(3)
96        .spacing([10.0, 6.0])
97        .striped(true)
98        .show(ui, |ui| {
99            for (i, (k, v)) in st.rows.iter_mut().enumerate() {
100                let f = schema.iter().find(|f| &f.key == k);
101                let label = match &f {
102                    Some(f) => field_label(f),
103                    None => k.clone(),
104                };
105                ui.label(egui::RichText::new(label).strong());
106                if v.starts_with("secret://") {
107                    ui.label(egui::RichText::new(v.as_str()).weak());
108                } else {
109                    let mut buf = v.clone();
110                    ui.add(egui::TextEdit::singleline(&mut buf).desired_width(120.0));
111                    if buf != *v {
112                        *v = buf;
113                        want = true;
114                    }
115                }
116                match f.and_then(|f| f.help.as_deref()) {
117                    Some(h) if !h.is_empty() => {
118                        want |= ui.label(egui::RichText::new(h).small().weak()).hovered();
119                    }
120                    _ => {
121                        ui.label("");
122                    }
123                }
124                if let Some(f) = f
125                    && !v.starts_with("secret://")
126                    && let Err(e) = f.validate(v)
127                {
128                    bad.push(format!("行 {i}: {e}"));
129                }
130                ui.end_row();
131            }
132        });
133
134    ui.add_space(6.0);
135    ui.horizontal(|ui| {
136        if ui.button("保存(写档案段+即时生效)").clicked() {
137            want = true;
138            st.notice = Some(save(cfg, host, &st.rows, &schema));
139        }
140        if ui.button("按档案重读").clicked() {
141            st.seed = Vec::new();
142            st.notice = None;
143            want = true;
144        }
145    });
146    for line in bad.iter().take(3) {
147        ui.label(
148            egui::RichText::new(line.as_str())
149                .small()
150                .color(crate::tokens::warn(ui.ctx())),
151        );
152    }
153    if let Some(n) = &st.notice {
154        ui.label(egui::RichText::new(n.as_str()).monospace().small());
155    }
156    ui.label(
157        egui::RichText::new(
158            "秘面只读;口令走 ekapp add-account(stdin)。标「待重启」的键按插件重启取新值",
159        )
160        .small()
161        .weak(),
162    );
163    want
164}
165
166/// 存 + 即时喂回受理面;回执 = 一行显形。
167fn save(
168    cfg: &dyn Configurable,
169    host: &dyn CfgHost,
170    rows: &[(String, String)],
171    schema: &[ConfigField],
172) -> String {
173    let mut map = SectionMap::new(1 + rows.len() as u32);
174    let mut rejected: Vec<String> = Vec::new();
175    for (k, v) in rows {
176        if v.starts_with("secret://") {
177            continue;
178        }
179        if let Some(f) = schema.iter().find(|f| &f.key == k)
180            && let Err(e) = f.validate(v)
181        {
182            rejected.push(format!("{k}:{e}"));
183            continue;
184        }
185        map = map.set(k.clone(), Field::plain(v.trim().to_string()));
186    }
187    if !rejected.is_empty() {
188        return format!("拒 {} 项:{}", rejected.len(), rejected.join(" | "));
189    }
190    if map.is_empty() {
191        return "无可存项(秘面只读)".to_string();
192    }
193    if let Err(e) = host.cfg_save(cfg.config_ns(), &map) {
194        return format!("存档失败:{e}");
195    }
196    host.log(&format!(
197        "[ui] 档案段 {} 已存:{}",
198        cfg.config_ns(),
199        map.iter().map(|(k, _)| k).collect::<Vec<_>>().join(",")
200    ));
201    match cfg.apply_config(&map) {
202        Ok(rep) => {
203            let mut line = format!(
204                "已存 {} 键 · 即时生效 {} 键",
205                map.iter().count(),
206                rep.adopted.len()
207            );
208            if !rep.deferred.is_empty() {
209                line.push_str(&format!(";{} 键待插件重启生效", rep.deferred.len()));
210            }
211            line
212        }
213        Err(e) => format!("已存档,受理拒:{e}"),
214    }
215}
216
217/// 档案包导出:段文件整批复制到目标目录(只复制 `.ini`;密码在 Credential
218/// Manager 里不随包走)。返回 (复制段数, 跳过文件数)。
219pub fn export_bundle(
220    cfg_root: &std::path::Path,
221    dest_dir: &std::path::Path,
222) -> Result<(usize, usize), String> {
223    std::fs::create_dir_all(dest_dir).map_err(|e| format!("建目标目录失败:{e}"))?;
224    let mut copied = 0usize;
225    let mut skipped = 0usize;
226    let rd = std::fs::read_dir(cfg_root).map_err(|e| format!("读档案根失败:{e}"))?;
227    for ent in rd.flatten() {
228        let p = ent.path();
229        if p.extension().and_then(|e| e.to_str()) == Some("ini") {
230            let dst = dest_dir.join(ent.file_name());
231            std::fs::copy(&p, &dst).map_err(|e| format!("复制失败:{e}"))?;
232            copied += 1;
233        } else {
234            skipped += 1;
235        }
236    }
237    Ok((copied, skipped))
238}
239
240/// 档案包导入:逐 `.ini` 读入 → 写 store → 同名 ns 有在册插件则过受理面显形
241/// (`apply` = 宿主注入的按段派发口,见 `faces::apply_section`;取不到 = 只收编不受理)。
242/// 返回逐段回执行(口令只到引用形态 + CLI 引导行)。
243pub fn import_bundle(
244    host: &dyn CfgHost,
245    src_dir: &std::path::Path,
246    apply: &dyn Fn(&str, &SectionMap) -> Option<String>,
247) -> Vec<String> {
248    let mut lines = Vec::new();
249    let rd = match std::fs::read_dir(src_dir) {
250        Ok(r) => r,
251        Err(e) => {
252            lines.push(format!("读包目录失败:{e}"));
253            return lines;
254        }
255    };
256    let mut files: Vec<std::path::PathBuf> = rd
257        .flatten()
258        .map(|e| e.path())
259        .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("ini"))
260        .collect();
261    files.sort();
262    for p in files {
263        let Some(ns) = p.file_stem().and_then(|s| s.to_str()).map(String::from) else {
264            continue;
265        };
266        // 包内档案直接从文件读(不经 store 根)。
267        let text = match std::fs::read_to_string(&p) {
268            Ok(t) => t,
269            Err(e) => {
270                lines.push(format!("{ns}:读文件失败:{e}"));
271                continue;
272            }
273        };
274        let (parsed, warnings) = match ek_cfg::parse(&text, &ns) {
275            Ok(v) => v,
276            Err(e) => {
277                lines.push(format!("{ns}:解析失败:{e}"));
278                continue;
279            }
280        };
281        match host.cfg_save(&ns, &parsed) {
282            Ok(()) => {
283                let mut line = format!("{ns}:收编 {} 字段", parsed.len());
284                if !warnings.is_empty() {
285                    line.push_str(&format!(" · 告警 {}", warnings.len()));
286                }
287                if let Some(rep) = apply(&ns, &parsed) {
288                    line.push_str(&format!(" · {rep}"));
289                }
290                lines.push(line);
291            }
292            Err(e) => lines.push(format!("{ns}:写入失败:{e}")),
293        }
294    }
295    if lines.is_empty() {
296        lines.push("包内无档案段(.ini)".to_string());
297    }
298    lines.push("口令段只到引用形态;口令改动走 ekapp add-account(stdin)".to_string());
299    lines
300}