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