Skip to main content

ek_ui/
runtime.rs

1//! 运行时装配:工厂表(ui 工厂注册)、宿主观察线程(关窗→close_all)、
2//! 进程主入口(eframe 主线程 + 宿主观察线程)、**启动阶段机**(0.9.5)。
3//!
4//! 线程形貌(S1b 双线程纪律):
5//! - 主线程:eframe run_native(App 只读 [`UiShared`]);
6//! - 观察线程:sleep 轮询 `UiShared::is_stop` → `engine.close_all`;
7//! - 等价 headless 形貌见 ek-station bin(不带 ui 工厂)。
8//!
9//! 阶段机(0.9.5,用户裁定「先配置组件、运行后才出主界面;运行后装配只可看」):
10//! - `Setup`:只画组件配置面(勾选/装配/参数面/档案包/导出启动清单),主界面不画;
11//! - `Running`:主界面(`render_body`)登场;装配面转**只读**(锁旗在界面层,底层零写死);
12//! - 锁只在**启动全成功**后置位(有壳没起来 = 不锁,运维面留修路);
13//! - launcher 路 `auto_boot = true` = 装配+启动在开窗前走完(ready 标记协议不破)。
14
15use std::sync::Arc;
16use std::time::Duration;
17
18use ek_core::main_engine::{MainEngine, PluginState};
19use ek_core::plugin::Plugin;
20use ek_dm::plugin::DmConfig;
21use ek_station::{PlugKind, StationConfig, default_kinds};
22
23use crate::plugin::{UI_NAME, UiPlugin};
24use crate::state::UiShared;
25
26/// ek-station 缺省工厂表(dm/oms/ctp;0.1.12 起 sim 已退场)+ `ui` 工厂 +
27/// **rt-on 的 dm 工厂**(S4:UI 宿主观测面需要 `eBarUpdate.` 实时门;
28/// ek-station headless 保持 rt-off 缺省不动 —— 两宿主的 dm 配置差异在各自
29/// 工厂表,互不影响)。
30///
31/// `dm_rt` 是装配期参数(非配置文件;ek-ui 零配置口径,缺省 true);
32/// 多次 build 同享一个 `UiShared`(ui 插件被 svctl 摘/再注,观测不丢)。
33pub fn ui_kinds(shared: &Arc<UiShared>, dm_rt: bool) -> Vec<PlugKind> {
34    let mut kinds: Vec<PlugKind> = default_kinds()
35        .into_iter()
36        .filter(|k| k.name != "dm")
37        .collect();
38    kinds.push(PlugKind {
39        name: "dm",
40        doc: "数据管理器(KlineManager;real_time 更新门开,行情板/K线页取 eBarUpdate./eBar.)",
41        build: Arc::new(move || -> Arc<dyn Plugin> {
42            Arc::new(ek_dm::plugin::DmPlugin::new(DmConfig {
43                real_time: dm_rt,
44                ..DmConfig::minute("dm")
45            }))
46        }),
47        build_named: None,
48        name_validator: None,
49    });
50    kinds.push(PlugKind {
51        name: UI_NAME,
52        doc: "常驻观测界面(eLog + eTimer 1s 快照;关窗即 stop)",
53        build: {
54            let shared = Arc::clone(shared);
55            Arc::new(move || -> Arc<dyn Plugin> { Arc::new(UiPlugin::new(Arc::clone(&shared))) })
56        },
57        build_named: None,
58        name_validator: None,
59    });
60    // 0.9.5 阶段机:天勤桥进勾选器(用户裁定并入本波)。
61    // 工厂住在 ek-ui(叶仓零链):tq 不在 ek-station 缺省表,装配由勾选器发起;
62    // 凭据面 = `ekapp tq-secret` 写密库 + `tq.ini` 引用行,开机 boot_with_cfg
63    // 直调 apply_config 记引用 → on_start 从密库解密灌桥(tq plugin.rs spawn_engine)。
64    kinds.push(PlugKind {
65        name: "tq",
66        doc: "天勤桥(行情/K线接入;凭据走密库引用,口令永不进档案)",
67        build: Arc::new(|| -> Arc<dyn Plugin> {
68            // 装配环补齐:此前这里给的是 BridgeConfig::default() —— program =
69            // "python"、script = **空串**,GUI 里桥永远 spawn 不起来(python -X utf8 "")。
70            // 现走 pyenv 择路(venv 优先 → EK_TQ_PYTHON → 系统 python),侧车 = 
71            // 仓内 engine_real.py 绝对路径;将来打包成侧车 exe 时在 pyenv 里换择路。
72            let bridge_cfg = ek_tq::bridge::BridgeConfig {
73                program: ek_tq::pyenv::python_program(),
74                script: ek_tq::pyenv::real_script(),
75                handshake_ms: ek_tq::consts::BRIDGE_HANDSHAKE_MS,
76                ..Default::default()
77            };
78            Arc::new(ek_tq::plugin::TqPlugin::new(
79                ek_tq::plugin::TqPluginSpec::new(bridge_cfg),
80            ))
81        }),
82        build_named: None,
83        name_validator: None,
84    });
85    kinds
86}
87
88/// 宿主观察线程:窗口不复用的引擎在这里按 stop 位收场(close_all join)。
89///
90/// 纪律:close 顺序也让宿主自己走(逆 start 序),观察线程只送信号不代走步骤。
91pub fn spawn_host_watcher(
92    engine: &Arc<MainEngine>,
93    shared: &Arc<UiShared>,
94) -> std::thread::JoinHandle<()> {
95    let engine = Arc::clone(engine);
96    let shared = Arc::clone(shared);
97    std::thread::spawn(move || {
98        while !shared.is_stop() {
99            std::thread::sleep(Duration::from_millis(120));
100        }
101        shared.log_line("[host] 窗口关闭,close_all …");
102        engine.close_all(Duration::from_secs(3));
103        shared.log_line("[host] 已收场");
104    })
105}
106
107/// 档案包操作(导出/导入)一行回执文本;根不可用 = 显形不炸。
108fn bundle_op(shared: &Arc<UiShared>, engine: &MainEngine, dir: &str, export: bool) -> String {
109    let host = crate::faces::CfgBridge::new(shared);
110    let dest = std::path::Path::new(dir);
111    if dir.trim().is_empty() {
112        return "目录为空(先填路径)".to_string();
113    }
114    if export {
115        let Some(root) = host.cfg.as_ref().map(|c| c.root().to_path_buf()) else {
116            return "导出失败:档案托底根不可用".to_string();
117        };
118        match crate::pluginwin::export_bundle(&root, dest) {
119            Ok((c, s)) => {
120                let msg = format!("导出完成:{c} 段 → {}(跳过非段文件 {s})", dest.display());
121                shared.log_line(format!("[ui] {msg}"));
122                msg
123            }
124            Err(e) => format!("导出失败:{e}"),
125        }
126    } else {
127        let lines = crate::pluginwin::import_bundle(&host, dest, &|ns, sec| {
128            crate::faces::apply_section(engine, ns, sec)
129        });
130        let msg = lines.join(" | ");
131        shared.log_line(format!("[ui] 档案包导入:{msg}"));
132        msg
133    }
134}
135
136/// 阶段机两相(0.9.5)。
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum Stage {
139    /// 组件配置相(主界面未登场)。
140    Setup,
141    /// 运行相(主界面登场;装配面只读)。
142    Running,
143}
144
145/// 启动阶段机 App(桌面 = ekapp 与 ek-ui 主程序共用的**唯一**窗口面;
146/// 0.9.5 收编:原 ekapp bin 自带的 DesktopApp 副本已删,单源在此)。
147pub struct StageApp {
148    shared: Arc<UiShared>,
149    /// 宿主引擎(0.9.2「插件即 App」:挂载表按装配名单走)。
150    engine: Arc<MainEngine>,
151    /// 工厂全表(勾选面的行;装配按勾选过滤)。
152    kinds: Vec<PlugKind>,
153    /// 勾选态(与 `kinds` 同长同序;ui 行恒真)。
154    sel: Vec<bool>,
155    /// 档案托底根(装配时 `boot_with_cfg` 直调;None = 退回纯装配)。
156    store: Option<ek_cfg::Store>,
157    /// 当前相。
158    stage: Stage,
159    /// 配置面回执(装配/启动/导出显形)。
160    notice: Option<String>,
161    /// 启动清单导出目录草稿(S4 两面对齐)。
162    list_dir: String,
163    /// 插件面状态包(各面自态 + 通用窗口态)。
164    fs: crate::faces::FaceStates,
165    /// 逐插件窗的 X 关合记忆(0.9.3)。
166    wins_open: std::collections::HashMap<String, bool>,
167    /// 「参数面」组钮的上帧态(组开沿时逐窗复位)。
168    pluginwin_prev: bool,
169    /// 档案包窗口目录草稿与回执。
170    bundle_dir: String,
171    bundle_notice: Option<String>,
172    /// launcher 协议 ready 标记(启动成功后写;无 = 不写)。
173    ready_marker: Option<std::path::PathBuf>,
174    /// 观察线程句柄(开窗即挂;关窗收场同一条路)。
175    watcher: Option<std::thread::JoinHandle<()>>,
176}
177
178impl StageApp {
179    /// 配置相起步(桌面 ekapp 用:开窗时引擎**未装配未启动**)。
180    #[must_use]
181    pub fn new_setup(
182        engine: Arc<MainEngine>,
183        shared: Arc<UiShared>,
184        kinds: Vec<PlugKind>,
185        store: Option<ek_cfg::Store>,
186        ready_marker: Option<std::path::PathBuf>,
187    ) -> Self {
188        let sel = kinds.iter().map(|_| true).collect::<Vec<bool>>();
189        Self {
190            shared,
191            engine,
192            kinds,
193            sel,
194            store,
195            stage: Stage::Setup,
196            notice: None,
197            list_dir: String::new(),
198            fs: crate::faces::FaceStates::new(),
199            wins_open: std::collections::HashMap::new(),
200            pluginwin_prev: false,
201            bundle_dir: String::new(),
202            bundle_notice: None,
203            ready_marker,
204            watcher: None,
205        }
206    }
207
208    /// 当前相(判据面读路)。
209    #[must_use]
210    pub fn stage(&self) -> Stage {
211        self.stage
212    }
213
214    /// 勾选项(工厂名表;ui 行恒真)。
215    #[must_use]
216    pub fn picked(&self) -> Vec<String> {
217        self.kinds
218            .iter()
219            .zip(self.sel.iter())
220            .filter(|(_, ok)| **ok)
221            .map(|(k, _)| k.name.to_string())
222            .collect()
223    }
224
225    /// 装配相 = 引擎里已有壳(装配已发生)。
226    #[must_use]
227    pub fn assembled(&self) -> bool {
228        !self.engine.plugin_names().is_empty()
229    }
230
231    /// 勾选开关(判据面与后续持久化用;ui 行恒真不受它影响)。
232    pub fn set_selected(&mut self, name: &str, on: bool) {
233        if let Some(i) = self.kinds.iter().position(|k| k.name == name) {
234            self.sel[i] = on && name != UI_NAME;
235        }
236    }
237
238    /// 「装配(按勾选)」:按勾选过滤工厂表 → boot(带 cfg 回灌口径)。
239    ///
240    /// 失败 = 显形回执,**不锁**(审核定案:装配失败留在配置相)。
241    pub fn do_assemble(&mut self) -> String {
242        let kinds: Vec<PlugKind> = self
243            .kinds
244            .iter()
245            .enumerate()
246            .filter(|(i, _)| self.sel[*i])
247            .map(|(_, k)| k.clone())
248            .collect();
249        let names: Vec<String> = kinds.iter().map(|k| k.name.to_string()).collect();
250        let cfg = StationConfig {
251            plugins: names.clone(),
252        };
253        let r = match &self.store {
254            Some(s) => ek_station::boot_with_cfg(&self.engine, &kinds, &cfg, Some(s)),
255            None => ek_station::boot(&self.engine, &kinds, &cfg),
256        };
257        match r {
258            Ok(_) => {
259                self.shared
260                    .log_line(format!("[ui] 装配:{} 壳(未启动)", names.join(",")));
261                format!(
262                    "已装配 {} 壳(未启动;参数面可改):{}",
263                    names.len(),
264                    names.join(",")
265                )
266            }
267            Err(e) => {
268                let msg = format!("装配失败:{e:?}");
269                self.shared.log_line(format!("[ui] {msg}"));
270                msg
271            }
272        }
273    }
274
275    /// 「启动运行」:engine.start() + ready 标记 + 账户清点;
276    /// **全壳 Running 才置装配锁**(有壳没起来 = 不锁,运维面留修路)。
277    pub fn do_start(&mut self) -> String {
278        if let Err(e) = self.engine.start() {
279            // start 整段失败:主界面照常出(半运行态),不锁,显形留修路。
280            self.stage = Stage::Running;
281            self.shared.set_assembly_locked(false);
282            self.ensure_watcher();
283            let msg = format!("启动失败:{e:}(运维面可修,装配不锁定)");
284            self.shared.log_line(format!("[ui] {msg}"));
285            return msg;
286        }
287        let not_ready: Vec<String> = self
288            .engine
289            .plugin_names()
290            .into_iter()
291            .filter(|n| !matches!(self.engine.get_state(n), Some(PluginState::Running)))
292            .collect();
293        self.stage = Stage::Running;
294        self.shared.set_assembly_locked(not_ready.is_empty());
295        self.ensure_watcher();
296        self.write_ready_marker();
297        // 账户清点(手点连接裁定:桌面 = 清点列回,手点才连;起连在运行相)。
298        let shared_restore = Arc::clone(&self.shared);
299        std::thread::spawn(move || {
300            let rows = shared_restore.restore_saved_accounts();
301            let _ = rows;
302        });
303        let msg = if not_ready.is_empty() {
304            "已启动:装配面已锁定(只可看不可动)".to_string()
305        } else {
306            format!(
307                "已启动,{} 壳未就绪:{} — 装配**不锁定**(运维面留修路)",
308                not_ready.len(),
309                not_ready.join(",")
310            )
311        };
312        self.shared.log_line(format!("[ui] {msg}"));
313        msg
314    }
315
316    /// 「导出启动清单」:勾选 → `--from` 可用文件(一行一名;ek-station 读路同形)。
317    ///
318    /// 只导**无界面面认得的行**(ek-station 工厂表 = `default_kinds`):`ui`/`tq` 是
319    /// ek-ui 宿主自带的工厂,headless 路线没有这两名 —— 混进清单会让 `--from` 直接报错。
320    /// 被跳过的行在回执里显形,不静默。
321    pub fn export_plugin_list(&mut self, dir: &str) -> String {
322        let factory_names: Vec<String> = default_kinds()
323            .into_iter()
324            .map(|k| k.name.to_string())
325            .collect();
326        let picked = self.picked();
327        let exportable: Vec<String> = picked
328            .iter()
329            .filter(|n| factory_names.iter().any(|f| f == *n))
330            .cloned()
331            .collect();
332        let skipped: Vec<String> = picked
333            .iter()
334            .filter(|n| !factory_names.iter().any(|f| f == *n))
335            .cloned()
336            .collect();
337        if exportable.is_empty() {
338            return "无可导出(勾选里没有无界面可用的组件)".to_string();
339        }
340        if dir.trim().is_empty() {
341            return "目录为空(先填路径)".to_string();
342        }
343        let path = std::path::Path::new(dir).join("ek-plugins.txt");
344        let header = if skipped.is_empty() {
345            "# ek-station 启动清单(ek-ui 阶段机导出,一行一名)".to_string()
346        } else {
347            format!(
348                "# ek-station 启动清单(ek-ui 阶段机导出,一行一名;跳过界面自带工厂:{})",
349                skipped.join(",")
350            )
351        };
352        let body = format!("{}\n{}\n", header, exportable.join("\n"));
353        match std::fs::File::create(&path).and_then(|mut f| {
354            use std::io::Write;
355            f.write_all(body.as_bytes())
356        }) {
357            Ok(()) => {
358                self.shared
359                    .log_line(format!("[ui] 启动清单已导出:{}", path.display()));
360                if skipped.is_empty() {
361                    format!("清单已导出:{}({} 项)", path.display(), exportable.len())
362                } else {
363                    format!(
364                        "清单已导出:{}({} 项;跳过界面自带工厂 {})",
365                        path.display(),
366                        exportable.len(),
367                        skipped.join(",")
368                    )
369                }
370            }
371            Err(e) => format!("导出失败:{e}"),
372        }
373    }
374
375    fn ensure_watcher(&mut self) {
376        if self.watcher.is_none() {
377            self.watcher = Some(spawn_host_watcher(&self.engine, &self.shared));
378        }
379    }
380
381    fn write_ready_marker(&self) {
382        let Some(marker) = &self.ready_marker else {
383            return;
384        };
385        use std::io::Write;
386        if let Ok(mut f) = std::fs::File::create(marker) {
387            let _ = f.write_all(crate::launch::READY_TEXT.as_bytes());
388        }
389    }
390
391    /// 组件配置面板(Setup 相中央面板;判据 U-S1a 的查控件面在此,判据面可见)。
392    pub fn render_setup_panel(&mut self, ui: &mut egui::Ui) {
393        ui.heading("组件配置(启动前)");
394        ui.label("勾选要装载的组件 → 装配 → 参数面可改 → 启动运行;启动后装配锁定(只可看不可动)。");
395        ui.separator();
396        egui::ScrollArea::vertical()
397            .max_height(210.0)
398            .show(ui, |ui| {
399                for (i, k) in self.kinds.iter().enumerate() {
400                    ui.horizontal(|ui| {
401                        if k.name == UI_NAME {
402                            // S4b:ui 行恒勾灰显(勾掉 = 主界面自灭,逻辑不许成立)。
403                            let mut t = true;
404                            ui.add_enabled(false, egui::Checkbox::new(&mut t, k.name));
405                            self.sel[i] = true;
406                        } else {
407                            ui.checkbox(&mut self.sel[i], k.name);
408                        }
409                        let _ = ui.label(egui::RichText::new(k.doc.to_string()).small().weak());
410                    });
411                }
412            });
413        ui.separator();
414        let picked = self.picked();
415        ui.label(if picked.is_empty() {
416            "已选: (无)".to_string()
417        } else {
418            format!("已选: {}", picked.join(", "))
419        });
420        ui.horizontal(|ui| {
421            let assembled = self.assembled();
422            if ui
423                .add_enabled(
424                    !assembled && !picked.is_empty(),
425                    egui::Button::new("装配(按勾选)"),
426                )
427                .clicked()
428            {
429                let msg = self.do_assemble();
430                self.notice = Some(msg);
431            }
432            if ui
433                .add_enabled(assembled, egui::Button::new("启动运行"))
434                .clicked()
435            {
436                let msg = self.do_start();
437                self.notice = Some(msg);
438            }
439            if ui.button("参数面(改参数)").clicked() {
440                self.shared
441                    .set_pluginwin_open(!self.shared.pluginwin_open());
442            }
443            if ui.button("档案包(导入参数)").clicked() {
444                self.shared.set_bundle_open(!self.shared.bundle_open());
445            }
446        });
447        ui.horizontal(|ui| {
448            ui.add(
449                egui::TextEdit::singleline(&mut self.list_dir)
450                    .desired_width(300.0)
451                    .hint_text("启动清单导出目录,如 C:\\Users\\<你>\\Documents"),
452            );
453            if ui.button("导出启动清单(--from 可用)").clicked() {
454                let dir = self.list_dir.clone();
455                let msg = self.export_plugin_list(&dir);
456                self.notice = Some(msg);
457            }
458        });
459        if self.assembled() && self.stage == Stage::Setup {
460            ui.label(
461                egui::RichText::new("已装配(未启动):参数面窗可改,启动时按档案回灌。")
462                    .color(crate::tokens::info(ui.ctx()))
463                    .small(),
464            );
465        }
466        if let Some(n) = &self.notice {
467            ui.label(egui::RichText::new(n.as_str()).monospace().small());
468        }
469    }
470}
471
472impl eframe::App for StageApp {
473    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
474        let host = crate::faces::CfgBridge::new(&self.shared);
475        let mounted = crate::faces::mounted(&self.engine);
476        // 「参数面」钮开 = 整组挂载窗亮;逐窗 X 关合**持久**(wins_open 记账,0.9.3)。
477        let pw = self.shared.pluginwin_open();
478        if pw && !self.pluginwin_prev {
479            self.wins_open.clear();
480        }
481        self.pluginwin_prev = pw;
482        if pw {
483            for (name, title) in mounted.iter() {
484                let mut open = *self.wins_open.get(name).unwrap_or(&true);
485                egui::Window::new(title.as_str())
486                    .id(egui::Id::new(format!("pluginwin-{name}")))
487                    .open(&mut open)
488                    .default_width(460.0)
489                    .show(ctx, |ui| {
490                        crate::faces::render_face(ui, &self.engine, name, &host, &mut self.fs);
491                    });
492                self.wins_open.insert(name.clone(), open);
493            }
494        }
495        // 档案包窗口:导出/导入 + 逐段回执显形;X 关合落回 shared(0.9.3)。
496        // Setup 相导出钮 disable(还没跑,导出无内容 = 多余钮,审核定案)。
497        if self.shared.bundle_open() {
498            let mut open = true;
499            let setup = self.stage == Stage::Setup;
500            egui::Window::new("档案包")
501                .id(egui::Id::new("bundlewin"))
502                .open(&mut open)
503                .default_width(430.0)
504                .show(ctx, |ui| {
505                    ui.label("目录(导出目标 / 导入来源,同一目录可两用):");
506                    ui.add(
507                        egui::TextEdit::singleline(&mut self.bundle_dir)
508                            .desired_width(400.0)
509                            .hint_text("C:\\Users\\<你>\\Documents\\ek-bundle"),
510                    );
511                    ui.horizontal(|ui| {
512                        if ui
513                            .add_enabled(
514                                !setup,
515                                egui::Button::new("导出(段文件整批复制)"),
516                            )
517                            .clicked()
518                        {
519                            self.bundle_notice =
520                                Some(bundle_op(&self.shared, &self.engine, &self.bundle_dir, true));
521                        }
522                        if ui.button("导入(逐段收编+受理)").clicked() {
523                            self.bundle_notice = Some(bundle_op(
524                                &self.shared,
525                                &self.engine,
526                                &self.bundle_dir,
527                                false,
528                            ));
529                        }
530                    });
531                    if let Some(n) = &self.bundle_notice {
532                        ui.label(egui::RichText::new(n.as_str()).monospace().small());
533                    }
534                    ui.label(
535                        egui::RichText::new("密码不落包(在 Windows 凭据管理器);换机后口令走 ekapp add-account(stdin)")
536                            .small()
537                            .weak(),
538                    );
539                });
540            self.shared.set_bundle_open(open);
541        }
542        // 中央面板唯一形貌:配置相 = 组件配置面;运行相 = 主界面
543        // (官方纪律:CentralPanel 最后,BD-UI-29;两相各只画一个 CentralPanel)。
544        if self.stage == Stage::Setup {
545            egui::CentralPanel::default().show(ctx, |ui| {
546                self.render_setup_panel(ui);
547            });
548        } else {
549            crate::render_body(ctx, &self.shared);
550        }
551        ctx.request_repaint_after(Duration::from_secs(1));
552    }
553}
554
555/// 运行选项(S1c ready 标记 + S4 dm real_time 装配参数;ek-ui 零配置口径,
556/// 无任何配置文件输入 —— 结构只由 StationConfig 清单决定)。
557#[derive(Debug, Clone)]
558pub struct RunOpts {
559    /// launcher 协议:`start 成功后写 "ready\n"` 的标记路径。
560    pub ready_marker: Option<std::path::PathBuf>,
561    /// dm 工厂 real_time 门(装配参数,缺省 true —— UI 宿主观测面要实时门)。
562    pub dm_rt: bool,
563    /// 0.9.5 阶段机:true = 开窗前装配+启动走完(launcher/headless 式一次性启动,
564    /// ready 协议不破);false = 从配置相起步(桌面 ekapp 裁定面)。
565    pub auto_boot: bool,
566}
567
568impl Default for RunOpts {
569    /// 缺省:无 ready 标记 + **dm_rt = true**(UI 宿主缺省实时门;
570    /// 勿改回 derive(Default) —— bool 缺省 false 会静默关实时门)
571    /// + **auto_boot = true**(launcher/主程序旧口径:开窗即运行相)。
572    fn default() -> Self {
573        Self {
574            ready_marker: None,
575            dm_rt: true,
576            auto_boot: true,
577        }
578    }
579}
580
581impl RunOpts {
582    /// 空选项(无 ready 标记)。
583    pub fn plain() -> Self {
584        Self::default()
585    }
586}
587
588/// 进程主入口:装配(dm/oms/ctp/ui 按清单)→ start → 就绪标记(可选)
589/// → eframe 主循环 → 观察线程收场。
590pub fn run_station_ui(cfg: &StationConfig, opts: &RunOpts) -> Result<(), String> {
591    let engine = Arc::new(MainEngine::new(None));
592    let shared = Arc::new(UiShared::new());
593    let store = ek_cfg::Store::eco();
594
595    // 配置托底根(0.6.0/BD-CFG 环节8):UI 宿主与 headless 同口径 ——
596    // `Store::eco()` 成功 → `boot_with_cfg`(开机逐插件 apply_persisted,
597    // 受理三行序在插件 worker 面);eco 失败(平台缺配置目录,罕见)→
598    // 退回 `boot()`(cfg 收件壳仍在场,fallback 直写仍通,UI 行为即旧版零变)。
599    let store_ok = store.ok();
600    if opts.auto_boot {
601        let boot = match &store_ok {
602            Some(s) => {
603                ek_station::boot_with_cfg(&engine, &ui_kinds(&shared, opts.dm_rt), cfg, Some(s))
604            }
605            None => ek_station::boot(&engine, &ui_kinds(&shared, opts.dm_rt), cfg),
606        };
607        boot.map_err(|e| format!("boot: {e:?}"))?;
608        // start 失败也要走 close_all(0.4.3 BD-UI-64,审核 P3;与 ek-station headless
609        // main.rs 的 start 失败路径同口径):start 是逐个 launch 的,失败时已有插件在
610        // Running —— 不闭就没有 on_stop(网关侧的会话收尾与流目录处理被跳过)。
611        // boot 失败不用闭:那时插件只登记未 launch,没有 worker 要收。
612        if let Err(e) = engine.start() {
613            engine.close_all(Duration::from_secs(3));
614            return Err(format!("start: {e:?}"));
615        }
616        shared.set_assembly_locked(true);
617    }
618
619    let mut app = StageApp::new_setup(
620        Arc::clone(&engine),
621        Arc::clone(&shared),
622        ui_kinds(&shared, opts.dm_rt),
623        store_ok,
624        opts.ready_marker.clone(),
625    );
626    if opts.auto_boot {
627        app = StageApp {
628            stage: Stage::Running,
629            ..app
630        };
631        app.write_ready_marker();
632    }
633
634    let watcher = spawn_host_watcher(&engine, &shared);
635    // 观察线程句柄交 App 之外统一 join(与 0.9.4 同路;App 内 ensure_watcher 幂等)。
636    let _ = &watcher;
637
638    let opts = eframe::NativeOptions {
639        viewport: egui::ViewportBuilder::default()
640            .with_inner_size([1280.0, 860.0])
641            .with_title("ek-station"),
642        ..Default::default()
643    };
644    // 关窗即收:run_native 返回 = 窗口已关 → 置 stop → 观察线程 close_all(BD-UI-11)。
645    let result = eframe::run_native(
646        "ek-station",
647        opts,
648        Box::new(|cc| {
649            crate::fonts::apply_cjk_fonts(&cc.egui_ctx); // BD-UI-26:CJK 回退,中文不乱码
650            Ok(Box::new(app) as Box<dyn eframe::App>)
651        }),
652    );
653    shared.request_stop();
654    let _ = watcher.join();
655    result.map_err(|e| format!("ui: {e:?}"))
656}
657
658/// 桌面产品入口(ekapp 无参数路,0.9.5 起单源在此):**从配置相起步** ——
659/// 开窗不装配不启动(用户裁定「先配置组件、运行后才出主界面」);
660/// 观察线程在开窗即挂(Setup 关窗也走 close_all 收尾,U-S3b 口径)。
661pub fn run_desktop() -> Result<(), String> {
662    let engine = Arc::new(MainEngine::new(None));
663    let shared = Arc::new(UiShared::new());
664    let store = ek_cfg::Store::eco().ok();
665    let app = StageApp::new_setup(
666        Arc::clone(&engine),
667        Arc::clone(&shared),
668        ui_kinds(&shared, true),
669        store,
670        None,
671    );
672    let watcher = spawn_host_watcher(&engine, &shared);
673    let opts = eframe::NativeOptions {
674        viewport: egui::ViewportBuilder::default()
675            .with_inner_size([1280.0, 860.0])
676            .with_title("ek-station 桌面"),
677        ..Default::default()
678    };
679    let result = eframe::run_native(
680        "ek-station 桌面",
681        opts,
682        Box::new(|cc| {
683            crate::fonts::apply_cjk_fonts(&cc.egui_ctx);
684            Ok(Box::new(app) as Box<dyn eframe::App>)
685        }),
686    );
687    shared.request_stop();
688    let _ = watcher.join();
689    result.map_err(|e| format!("ui: {e:?}"))
690}