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//! 0.9.10 收口:装配并入「启动运行」一钮,参数面/档案包/导出清单连功能铲去):
11//! - `Setup`:只画组件配置面(勾选表格 + 「启动运行」一钮),主界面不画;
12//! - `Running`:主界面(`render_body`)登场;装配面转**只读**(锁旗在界面层,底层零写死);
13//! - 锁只在**启动全成功**后置位(有壳没起来 = 不锁,运维面留修路);
14//! - launcher 路 `auto_boot = true` = 装配+启动在开窗前走完(ready 标记协议不破)。
15
16use std::sync::Arc;
17use std::time::Duration;
18
19use ek_core::main_engine::{MainEngine, PluginState};
20use ek_core::plugin::Plugin;
21use ek_dm::plugin::DmConfig;
22use ek_station::{PlugKind, StationConfig, default_kinds};
23
24use crate::plugin::{UI_NAME, UiPlugin};
25use crate::state::UiShared;
26
27/// ek-station 缺省工厂表(dm/oms/ctp;0.1.12 起 sim 已退场)+ `ui` 工厂 +
28/// **rt-on 的 dm 工厂**(S4:UI 宿主观测面需要 `eBarUpdate.` 实时门;
29/// ek-station headless 保持 rt-off 缺省不动 —— 两宿主的 dm 配置差异在各自
30/// 工厂表,互不影响)。
31///
32/// `dm_rt` 是装配期参数(非配置文件;ek-ui 零配置口径,缺省 true);
33/// 多次 build 同享一个 `UiShared`(ui 插件被 svctl 摘/再注,观测不丢)。
34pub fn ui_kinds(shared: &Arc<UiShared>, dm_rt: bool) -> Vec<PlugKind> {
35    let mut kinds: Vec<PlugKind> = default_kinds()
36        .into_iter()
37        .filter(|k| k.name != "dm")
38        .collect();
39    kinds.push(PlugKind {
40        name: "dm",
41        doc: "数据管理器(KlineManager;real_time 更新门开,行情板/K线页取 eBarUpdate./eBar.)",
42        build: Arc::new(move || -> Arc<dyn Plugin> {
43            Arc::new(ek_dm::plugin::DmPlugin::new(DmConfig {
44                real_time: dm_rt,
45                ..DmConfig::minute("dm")
46            }))
47        }),
48        build_named: None,
49        name_validator: None,
50    });
51    kinds.push(PlugKind {
52        name: UI_NAME,
53        doc: "常驻观测界面(eLog + eTimer 1s 快照;关窗即 stop)",
54        build: {
55            let shared = Arc::clone(shared);
56            Arc::new(move || -> Arc<dyn Plugin> { Arc::new(UiPlugin::new(Arc::clone(&shared))) })
57        },
58        build_named: None,
59        name_validator: None,
60    });
61    // 0.9.5 阶段机:天勤桥进勾选器(用户裁定并入本波)。
62    // 工厂住在 ek-ui(叶仓零链):tq 不在 ek-station 缺省表,装配由勾选器发起;
63    // 凭据面 = `ekapp tq-secret` 写密库 + `tq.ini` 引用行,开机 boot_with_cfg
64    // 直调 apply_config 记引用 → on_start 从密库解密灌桥(tq plugin.rs spawn_engine)。
65    kinds.push(PlugKind {
66        name: "tq",
67        doc: "天勤桥(行情/K线接入;凭据走密库引用,口令永不进档案)",
68        build: Arc::new(|| -> Arc<dyn Plugin> {
69            // 装配环补齐:此前这里给的是 BridgeConfig::default() —— program =
70            // "python"、script = **空串**,GUI 里桥永远 spawn 不起来(python -X utf8 "")。
71            // 现走 pyenv 择路(venv 优先 → EK_TQ_PYTHON → 系统 python),侧车 =
72            // 仓内 engine_real.py 绝对路径;将来打包成侧车 exe 时在 pyenv 里换择路。
73            let bridge_cfg = ek_tq::bridge::BridgeConfig {
74                program: ek_tq::pyenv::python_program(),
75                script: ek_tq::pyenv::real_script(),
76                handshake_ms: ek_tq::consts::BRIDGE_HANDSHAKE_MS,
77                ..Default::default()
78            };
79            Arc::new(ek_tq::plugin::TqPlugin::new(
80                ek_tq::plugin::TqPluginSpec::new(bridge_cfg),
81            ))
82        }),
83        build_named: None,
84        name_validator: None,
85    });
86    kinds
87}
88
89/// 宿主观察线程:窗口不复用的引擎在这里按 stop 位收场(close_all join)。
90///
91/// 纪律:close 顺序也让宿主自己走(逆 start 序),观察线程只送信号不代走步骤。
92pub fn spawn_host_watcher(
93    engine: &Arc<MainEngine>,
94    shared: &Arc<UiShared>,
95) -> std::thread::JoinHandle<()> {
96    let engine = Arc::clone(engine);
97    let shared = Arc::clone(shared);
98    std::thread::spawn(move || {
99        while !shared.is_stop() {
100            std::thread::sleep(Duration::from_millis(120));
101        }
102        shared.log_line("[host] 窗口关闭,close_all …");
103        engine.close_all(Duration::from_secs(3));
104        shared.log_line("[host] 已收场");
105    })
106}
107
108/// 阶段机两相(0.9.5)。
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum Stage {
111    /// 组件配置相(主界面未登场)。
112    Setup,
113    /// 运行相(主界面登场;装配面只读)。
114    Running,
115}
116
117/// 启动阶段机 App(桌面 = ekapp 与 ek-ui 主程序共用的**唯一**窗口面;
118/// 0.9.5 收编:原 ekapp bin 自带的 DesktopApp 副本已删,单源在此)。
119pub struct StageApp {
120    shared: Arc<UiShared>,
121    /// 宿主引擎(0.9.2「插件即 App」:挂载表按装配名单走)。
122    engine: Arc<MainEngine>,
123    /// 工厂全表(勾选面的行;装配按勾选过滤)。
124    kinds: Vec<PlugKind>,
125    /// 勾选态(与 `kinds` 同长同序;ui 行恒真)。
126    /// 0.9.10 改 `Arc<Mutex<..>>`:勾选面迁进表格层(`pinned_table`)后,格画笔是
127    /// 延迟执行的 FnMut 闭包(收集期不能各借用 `&mut self.sel` 两次)→ 走**内部
128    /// 可变**共享句柄,每格 clone 一个 `Arc`(与账户表的 `UiShared` 同一手法)。
129    sel: Arc<std::sync::Mutex<Vec<bool>>>,
130    /// 档案托底根(装配时 `boot_with_cfg` 直调;None = 退回纯装配)。
131    store: Option<ek_cfg::Store>,
132    /// 当前相。
133    stage: Stage,
134    /// 配置面回执(装配/启动显形)。
135    notice: Option<String>,
136    /// launcher 协议 ready 标记(启动成功后写;无 = 不写)。
137    ready_marker: Option<std::path::PathBuf>,
138    /// 观察线程句柄(开窗即挂;关窗收场同一条路)。
139    watcher: Option<std::thread::JoinHandle<()>>,
140}
141
142impl StageApp {
143    /// 配置相起步(桌面 ekapp 用:开窗时引擎**未装配未启动**)。
144    #[must_use]
145    pub fn new_setup(
146        engine: Arc<MainEngine>,
147        shared: Arc<UiShared>,
148        kinds: Vec<PlugKind>,
149        store: Option<ek_cfg::Store>,
150        ready_marker: Option<std::path::PathBuf>,
151    ) -> Self {
152        // 0.9.36(用户实测):**引擎句柄在构造时入共享面**。挂载表(`Registry::sync`)
153        // 靠它认「引擎里有哪几类插件」——之前只有 launcher 路(`run_with_opts`)装过,
154        // 桌面 `run_desktop` 与判据面都没装,于是 `remount_modules()` 看到的是空引擎:
155        // dm/oms/tq 明明装配起来了,菜单里永远只有 always 四扇。
156        shared.install_engine(&engine);
157        // 0.9.37(用户实测「账户没了」):**构造时就把档案清点回来**。
158        // 以前只在 `do_start` 的线程里清点 —— 有了配置相菜单之后,用户在起跑前
159        // 打开「交易网关」看到的是一张空表,以为账户丢了(档案其实一直在盘上)。
160        shared.set_setup_phase(true);
161        if let Some(s) = &store {
162            shared.restore_saved_accounts_from(s);
163        }
164        let sel = kinds.iter().map(|_| true).collect::<Vec<bool>>();
165        Self {
166            shared,
167            engine,
168            kinds,
169            sel: Arc::new(std::sync::Mutex::new(sel)),
170            store,
171            stage: Stage::Setup,
172            notice: None,
173            ready_marker,
174            watcher: None,
175        }
176    }
177
178    /// 当前相(判据面读路)。
179    #[must_use]
180    pub fn stage(&self) -> Stage {
181        self.stage
182    }
183
184    /// 勾选项(工厂名表;ui 行恒真)。
185    #[must_use]
186    pub fn picked(&self) -> Vec<String> {
187        self.kinds
188            .iter()
189            .zip(self.sel.lock().unwrap().iter())
190            .filter(|(_, ok)| **ok)
191            .map(|(k, _)| k.name.to_string())
192            .collect()
193    }
194
195    /// 装配相 = 引擎里已有壳(装配已发生)。
196    #[must_use]
197    pub fn assembled(&self) -> bool {
198        !self.engine.plugin_names().is_empty()
199    }
200
201    /// 勾选开关(判据面与后续持久化用)。
202    ///
203    /// ui 行恒勾**短路**(S4b 纪律,0.9.10 修隐藏 bug:旧写法 `on && name != UI_NAME`
204    /// 对 ui 行的任何调用都写 false = 数据口能把主界面勾掉,渲染口灰显管不住它)。
205    pub fn set_selected(&mut self, name: &str, on: bool) {
206        if name == UI_NAME {
207            return; // ui 行恒真,任何写入都短路(与渲染面灰显同一条纪律)。
208        }
209        if let Some(i) = self.kinds.iter().position(|k| k.name == name) {
210            self.sel.lock().unwrap()[i] = on;
211        }
212    }
213
214    /// 「装配」(0.9.10 并入「启动运行」一钮,界面**无独立装配钮**;`do_start`
215    /// 未装配时自动先走这一步)。
216    ///
217    /// pub 只给判据面分步验状态机(装配态/失败态单独钉)。
218    ///
219    /// 失败 = 显形回执,**不锁、不起跑**(用户 0.9.10 裁定:「装配失败就正常提示
220    /// 报错,然后等待用户」—— 留在配置相,勾可改、钮可再点)。
221    pub fn do_assemble(&mut self) -> String {
222        let mut kinds: Vec<PlugKind> = self
223            .kinds
224            .iter()
225            .enumerate()
226            .filter(|(i, _)| self.sel.lock().unwrap()[*i])
227            .map(|(_, k)| k.clone())
228            .collect();
229        // 升级方案步骤 1(用户裁定):cfg = 核心插件,**启用清单必含,装配必含,
230        // 系统注入不吃勾选**(勾选面剔除 cfg 行,用户无需也无处取消它)。
231        if !kinds.iter().any(|k| k.name == "cfg")
232            && let Some(k) = self.kinds.iter().find(|k| k.name == "cfg")
233        {
234            kinds.push(k.clone());
235        }
236        let names: Vec<String> = kinds.iter().map(|k| k.name.to_string()).collect();
237        let cfg = StationConfig {
238            plugins: names.clone(),
239        };
240        let r = match &self.store {
241            Some(s) => ek_station::boot_with_cfg(&self.engine, &kinds, &cfg, Some(s)),
242            None => ek_station::boot(&self.engine, &kinds, &cfg),
243        };
244        match r {
245            Ok(_) => {
246                self.shared
247                    .log_line(format!("[ui] 装配:{} 壳(未启动)", names.join(",")));
248                // 0.9.36(用户实测):装配完就把模块挂上 —— 否则 dm/oms/tq 装进来了,
249                // 菜单里还是只有 always 四扇(「数据管理」等永远不出现)。
250                self.shared.remount_modules();
251                format!("已装配 {} 壳:{}", names.len(), names.join(","))
252            }
253            Err(e) => {
254                let msg = format!("装配失败:{e:?}");
255                self.shared.log_line(format!("[ui] {msg}"));
256                msg
257            }
258        }
259    }
260
261    /// 「启动运行」(0.9.10 一钮两义,用户裁定「启动的时候自然要先装配再运行」):
262    /// 未装配 → 先按勾选装配,装配成功才起跑;**装配失败 = 提示报错、留在配置相
263    /// 等用户**(不起跑、不锁,勾可改钮可再点 —— 0.9.10 裁定)。
264    ///
265    /// 已装配(装配失败后重试等)→ 直接起跑。`engine.start()` + ready 标记 +
266    /// 账户清点;**全壳 Running 才置装配锁**(有壳没起来 = 不锁,运维面留修路)。
267    pub fn do_start(&mut self) -> String {
268        if !self.assembled() {
269            let msg = self.do_assemble();
270            if !self.assembled() {
271                // 装配失败:回执显形,等待用户(0.9.10)。
272                return msg;
273            }
274            let _ = msg;
275        }
276        if let Err(e) = self.engine.start() {
277            // start 整段失败:主界面照常出(半运行态),不锁,显形留修路。
278            self.stage = Stage::Running;
279            self.shared.set_setup_phase(false);
280            self.shared.set_assembly_locked(false);
281            // 半运行态也要把**已经起来了**的模块挂上(运维面留修路的前提)。
282            self.shared.remount_modules();
283            self.ensure_watcher();
284            let msg = format!("启动失败:{e:}(运维面可修,装配不锁定)");
285            self.shared.log_line(format!("[ui] {msg}"));
286            return msg;
287        }
288        let not_ready: Vec<String> = self
289            .engine
290            .plugin_names()
291            .into_iter()
292            .filter(|n| !matches!(self.engine.get_state(n), Some(PluginState::Running)))
293            .collect();
294        self.stage = Stage::Running;
295        self.shared.set_setup_phase(false);
296        self.shared.set_assembly_locked(not_ready.is_empty());
297        // 0.9.36(用户实测):起跑后**必须**重挂模块表 —— 这是「数据管理/交易中枢/
298        // 天勤桥」出现在菜单里的唯一时机(它们在装配前根本不在册)。
299        self.shared.remount_modules();
300        self.ensure_watcher();
301        self.write_ready_marker();
302        // 账户清点(手点连接裁定:桌面 = 清点列回,手点才连;起连在运行相)。
303        // 0.9.10 用户实锤「勾了自动连接,登录时为什么没连还要人工点?」:勾选此前
304        // **只喂守护路**(`--from` 起连),桌面相清单一律列回不连 = 勾选在桌面形同虚设。
305        // 修复:清点列回后,**勾选项自动入列 Connect**(与面板手点同一出令处 push_connect)。
306        let shared_restore = Arc::clone(&self.shared);
307        std::thread::spawn(move || {
308            // 档案在 `new_setup` 已清点(0.9.37);这里补一次只为兼容「无 store 构造」
309            // 的老入口 —— 重复行由清册唯一性守卫挡掉。勾选自动连接的行照旧入列。
310            let _ = shared_restore.restore_saved_accounts();
311            let rows: Vec<String> = shared_restore
312                .accounts()
313                .iter()
314                .map(|a| a.gateway.clone())
315                .collect();
316            for gw in &rows {
317                let Some(a) = shared_restore.account(gw) else {
318                    continue;
319                };
320                if a.auto_connect
321                    && matches!(a.stage, crate::state::AccountStage::NotAssembled)
322                    && !a.connected
323                {
324                    let form = a.form.clone();
325                    crate::render::push_connect(&shared_restore, gw, &form);
326                }
327            }
328        });
329        let msg = if not_ready.is_empty() {
330            "已启动:装配面已锁定(只可看不可动)".to_string()
331        } else {
332            format!(
333                "已启动,{} 壳未就绪:{} — 装配**不锁定**(运维面留修路)",
334                not_ready.len(),
335                not_ready.join(",")
336            )
337        };
338        self.shared.log_line(format!("[ui] {msg}"));
339        msg
340    }
341
342    fn ensure_watcher(&mut self) {
343        if self.watcher.is_none() {
344            self.watcher = Some(spawn_host_watcher(&self.engine, &self.shared));
345        }
346    }
347
348    fn write_ready_marker(&self) {
349        let Some(marker) = &self.ready_marker else {
350            return;
351        };
352        use std::io::Write;
353        if let Ok(mut f) = std::fs::File::create(marker) {
354            let _ = f.write_all(crate::launch::READY_TEXT.as_bytes());
355        }
356    }
357
358    /// 组件配置面板(Setup 相中央面板;判据 U-S1a 的查控件面在此,判据面可见)。
359    ///
360    /// 0.9.10 收口(用户两条裁定):
361    /// - **表格 = 主界面同一张表格**:`pinned_table`(表格层)—— 列宽首帧按内容
362    ///   量(装载/组件列收小、说明列吃剩余),表头带/行线/悬停整行跳色全部同源,
363    ///   不再手搭 Grid(Grid 均分列宽 = 用户报的「内容很短列很宽」病根)。
364    /// - **按钮只剩一个「启动运行」**:装配并入(点钮 = 按勾选装配 → 起跑;装配
365    ///   失败 = 提示报错留在本相等用户)。参数面/档案包/导出启动清单三钮**连功能
366    ///   一起铲**(0.9.10 裁定「功能代码要连根铲」;守护路 `--pick`/`--from` 不受影响)。
367    pub fn render_setup_panel(&mut self, ui: &mut egui::Ui) {
368        use crate::render::{OpTone, op_button, pinned_table};
369        ui.heading("组件配置(启动前)");
370        ui.label("勾选要装载的组件 → 「启动运行」(先按勾选装配,再起跑;装配失败会报错,等您改勾选后重按)。");
371        ui.separator();
372        let sel = Arc::clone(&self.sel);
373        // **定量高度子区**(0.9.10 实锤修复,用户报「启动的按钮到哪去了」):
374        // `pinned_table` = egui_table 的 ScrollArea **吃光 CentralPanel 剩余高度**,
375        // 表格后面的「已选/启动运行」区被顶出可视区 = 按钮画得出、看不见。
376        // 口径 = 按行数定量(表头带 + n 行 + 余量),宽度照吃父宽(列宽内容量测
377        // 与补宽逻辑在子区内照常成立;主界面大表不约束,照旧吃满)。
378        let (row_h, head_h) = crate::render::table_heights(ui);
379        let table_h =
380            head_h + ui.visuals().clip_rect_margin + self.kinds.len() as f32 * row_h + 6.0;
381        let avail_x = ui.available_width();
382        ui.allocate_ui(egui::vec2(avail_x, table_h), |ui| {
383            pinned_table(
384                ui,
385                "setup_grid",
386                &["装载", "组件", "说明"],
387                |body, row_h| {
388                    for (i, k) in self.kinds.iter().enumerate() {
389                        let name = k.name.to_string();
390                        let doc = k.doc.to_string();
391                        let sel_cell = Arc::clone(&sel);
392                        let label0 = name.clone();
393                        body.row(row_h, false, |row| {
394                            row.cell(move |ui, _ctx| {
395                                if label0 == UI_NAME {
396                                    // S4b:ui 行恒勾灰显(勾掉 = 主界面自灭,逻辑不许成立)。
397                                    let mut t = true;
398                                    ui.add_enabled(false, egui::Checkbox::new(&mut t, ""));
399                                    sel_cell.lock().unwrap()[i] = true;
400                                } else {
401                                    let mut on = sel_cell.lock().unwrap()[i];
402                                    if ui.checkbox(&mut on, "").changed() {
403                                        sel_cell.lock().unwrap()[i] = on;
404                                    }
405                                }
406                            });
407                            let label = name.clone();
408                            row.cell(move |ui, _ctx| {
409                                ui.label(egui::RichText::new(&label).strong());
410                            });
411                            row.cell(move |ui, _ctx| {
412                                ui.add(
413                                    egui::Label::new(egui::RichText::new(&doc))
414                                        .wrap_mode(egui::TextWrapMode::Extend),
415                                );
416                            });
417                        });
418                    }
419                },
420            );
421        });
422        ui.separator();
423        let picked = self.picked();
424        ui.label(if picked.is_empty() {
425            "已选: (无)".to_string()
426        } else {
427            format!("已选: {}", picked.join(", "))
428        });
429        ui.horizontal(|ui| {
430            if ui
431                .add_enabled_ui(!picked.is_empty(), |ui| {
432                    op_button(ui, "启动运行", OpTone::Primary).clicked()
433                })
434                .inner
435            {
436                self.notice = Some(self.do_start());
437            }
438        });
439        if let Some(n) = &self.notice {
440            ui.label(egui::RichText::new(n.as_str()).monospace().small());
441        }
442    }
443}
444
445impl eframe::App for StageApp {
446    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
447        // **两相同一套样式**(0.9.10 实锤根因,用户报「Setup 表头和内容的颜色字号
448        // 跟主界面根本不一样」):主界面 `render_body` 每帧头一件事就是
449        // `apply_ui_style`(tokens 字号阶梯 + theme + 整套 visuals),而 Setup 相
450        // 过去直接画面板 = 三件事一件没跑 = egui 默认 style(字号阶梯偏小)+
451        // eframe 默认 Visuals(≠ tokens 全套)= 表头带底/行线/字号全两样。
452        // 提到 update 头 = Setup 相吃同一套;render_body 内部同调用幂等,不重复计费。
453        crate::render::apply_ui_style(ctx, self.shared.theme());
454        // 0.9.10:参数面窗组与档案包窗**连功能铲去**(用户裁定「后面几个按钮都可以
455        // 不要……功能代码要连根铲」)。中央面板是唯一窗口面;守护路 `--pick`/`--from`
456        // 不经这里,不受影响。
457        // 中央面板唯一形貌:配置相 = 组件配置面;运行相 = 主界面
458        // (官方纪律:CentralPanel 最后,BD-UI-29;两相各只画一个 CentralPanel)。
459        if self.stage == Stage::Setup {
460            self.setup_fit_once(ctx);
461            // 0.9.35(用户实测反馈):配置相**也要模块菜单**。
462            // 0.9.34 只把菜单挂进 `render_body`(运行相),于是产品默认开窗
463            // (Setup 相)顶栏是空的 —— 用户看到的界面上根本没有
464            // 「运维/查询合约/交易网关/…」那一行。模块菜单是**框架**的一部分,
465            // 不该随阶段消失:配置相中央仍是配置面,顶栏与模块窗照同一张挂载表出。
466            egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
467                crate::render::render_menu_bar(ui, &self.shared);
468            });
469            egui::CentralPanel::default().show(ctx, |ui| {
470                self.render_setup_panel(ui);
471            });
472            // 菜单点开的模块窗(未装配的插件模块此时不在册,只有 always 四扇)。
473            crate::render::render_windows(ctx, &self.shared);
474        } else {
475            // **相变一次复位**(0.9.10,与一次性适应同一族纪律):Setup 相把窗收到
476            // 配置面内容宽后,主界面(1280×860 大屏)吃不下小窗 = 转 Running 的
477            // 那一拍发**一次**几何指令;此后照用户裁定不再自动调
478            // (拖拽/还原 = 用户接管 = 不发)。
479            // 0.9.40(用户裁定「主界面显示出来之后直接最大化,看着方便」):
480            // 由旧的一次性 InnerSize(1280×860) 改成 **Maximized(true)** ——
481            // 大屏上小窗正是用户报的那条不方便。
482            let sent_key = egui::Id::new("running_geo_sent");
483            if !ctx.data(|d| d.get_temp::<bool>(sent_key).unwrap_or(false)) {
484                ctx.data_mut(|d| d.insert_temp(sent_key, true));
485                let maximized = ctx.viewport(|vp| vp.builder.maximized.unwrap_or(false));
486                if !maximized {
487                    ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(true));
488                }
489            }
490            crate::render_body(ctx, &self.shared);
491        }
492        ctx.request_repaint_after(Duration::from_secs(1));
493    }
494}
495
496impl StageApp {
497    /// 主窗 Setup 相**一次性适应**(0.9.10 用户裁定「表格第一次填充之后调一次,
498    /// 后面就不用管它」+「所有弹窗同规」—— 主窗配置面也照此规):
499    /// 隐形量尺(probe)跑配置面内容 → 自然尺寸连续 3 帧稳定且已过 6 帧 →
500    /// 发一次 InnerSize(夹 [420,260]..[1280,900])→ done → 本窗 fit 退役。
501    /// 最大化帧 = 用户接管 = 不量不发(与独立窗 FitOneShot 同一套纪律,同病根:
502    /// 旧 1280 缺省宽 vs 配置面窄内容 = 用户报「内容没多少、表特别大」)。
503    fn setup_fit_once(&mut self, ctx: &egui::Context) {
504        use egui::Id;
505        #[derive(Clone, Copy)]
506        struct FitOneShot {
507            w: f32,
508            h: f32,
509            stable: u8,
510            frames: u16,
511            done: bool,
512        }
513        let key = Id::new(("fit_oneshot", "ekapp_main"));
514        let st = ctx
515            .data(|d| d.get_temp::<FitOneShot>(key))
516            .unwrap_or(FitOneShot {
517                w: -1.0,
518                h: -1.0,
519                stable: 0,
520                frames: 0,
521                done: false,
522            });
523        let maximized = ctx.viewport(|vp| vp.builder.maximized.unwrap_or(false));
524        if st.done || maximized {
525            return;
526        }
527        let mut sc = (*ctx.style()).clone();
528        sc.wrap_mode = Some(egui::TextWrapMode::Extend);
529        let measure_w = ctx.data(|d| {
530            d.get_temp::<f32>(Id::new(("fit_w", "ekapp_main")))
531                .unwrap_or(48.0)
532        });
533        let mut probe = egui::Ui::new(
534            ctx.clone(),
535            Id::new(("fit_probe", "ekapp_main")),
536            egui::UiBuilder::new()
537                .global_scope(true)
538                .invisible()
539                .style(sc)
540                .max_rect(egui::Rect::from_min_size(
541                    egui::pos2(0.0, 0.0),
542                    egui::vec2(measure_w.max(48.0), 1.0e5),
543                ))
544                .layout(egui::Layout::top_down(egui::Align::Min)),
545        );
546        // 0.9.35:顶栏模块菜单也算「配置相的自然尺寸」。只量面板 = 菜单比内容宽时
547        // 被窄窗裁掉,用户第一眼看到的仍然是「菜单不存在」。
548        crate::render::render_menu_bar(&mut probe, &self.shared);
549        self.render_setup_panel(&mut probe);
550        let nat_w = probe.min_rect().width();
551        let nat_h = probe.min_rect().height();
552        ctx.data_mut(|d| d.insert_temp(Id::new(("fit_w", "ekapp_main")), nat_w));
553        let stable = (nat_w - st.w).abs() < 0.5 && (nat_h - st.h).abs() < 0.5;
554        let mut next = FitOneShot {
555            w: nat_w,
556            h: nat_h,
557            stable: if stable { st.stable + 1 } else { 0 },
558            frames: st.frames + 1,
559            done: false,
560        };
561        if next.stable >= 3 && next.frames >= 6 {
562            let want = egui::vec2(
563                (nat_w + 20.0).clamp(420.0, 1280.0),
564                (nat_h + 26.0).clamp(260.0, 900.0),
565            );
566            ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(want));
567            next.done = true;
568        }
569        ctx.data_mut(|d| d.insert_temp(key, next));
570    }
571}
572
573/// 运行选项(S1c ready 标记 + S4 dm real_time 装配参数;ek-ui 零配置口径,
574/// 无任何配置文件输入 —— 结构只由 StationConfig 清单决定)。
575#[derive(Debug, Clone)]
576pub struct RunOpts {
577    /// launcher 协议:`start 成功后写 "ready\n"` 的标记路径。
578    pub ready_marker: Option<std::path::PathBuf>,
579    /// dm 工厂 real_time 门(装配参数,缺省 true —— UI 宿主观测面要实时门)。
580    pub dm_rt: bool,
581    /// 0.9.5 阶段机:true = 开窗前装配+启动走完(launcher/headless 式一次性启动,
582    /// ready 协议不破);false = 从配置相起步(桌面 ekapp 裁定面)。
583    pub auto_boot: bool,
584}
585
586impl Default for RunOpts {
587    /// 缺省:无 ready 标记 + **dm_rt = true**(UI 宿主缺省实时门;
588    /// 勿改回 derive(Default) —— bool 缺省 false 会静默关实时门)
589    /// + **auto_boot = true**(launcher/主程序旧口径:开窗即运行相)。
590    fn default() -> Self {
591        Self {
592            ready_marker: None,
593            dm_rt: true,
594            auto_boot: true,
595        }
596    }
597}
598
599impl RunOpts {
600    /// 空选项(无 ready 标记)。
601    pub fn plain() -> Self {
602        Self::default()
603    }
604}
605
606/// 进程主入口:装配(dm/oms/ctp/ui 按清单)→ start → 就绪标记(可选)
607/// → eframe 主循环 → 观察线程收场。
608pub fn run_station_ui(cfg: &StationConfig, opts: &RunOpts) -> Result<(), String> {
609    let engine = Arc::new(MainEngine::new(None));
610    let shared = Arc::new(UiShared::new());
611    let store = ek_cfg::Store::eco();
612
613    // 配置托底根(0.6.0/BD-CFG 环节8):UI 宿主与 headless 同口径 ——
614    // `Store::eco()` 成功 → `boot_with_cfg`(开机逐插件 apply_persisted,
615    // 受理三行序在插件 worker 面);eco 失败(平台缺配置目录,罕见)→
616    // 退回 `boot()`(cfg 收件壳仍在场,fallback 直写仍通,UI 行为即旧版零变)。
617    let store_ok = store.ok();
618    if opts.auto_boot {
619        let boot = match &store_ok {
620            Some(s) => {
621                ek_station::boot_with_cfg(&engine, &ui_kinds(&shared, opts.dm_rt), cfg, Some(s))
622            }
623            None => ek_station::boot(&engine, &ui_kinds(&shared, opts.dm_rt), cfg),
624        };
625        boot.map_err(|e| format!("boot: {e:?}"))?;
626        // start 失败也要走 close_all(0.4.3 BD-UI-64,审核 P3;与 ek-station headless
627        // main.rs 的 start 失败路径同口径):start 是逐个 launch 的,失败时已有插件在
628        // Running —— 不闭就没有 on_stop(网关侧的会话收尾与流目录处理被跳过)。
629        // boot 失败不用闭:那时插件只登记未 launch,没有 worker 要收。
630        if let Err(e) = engine.start() {
631            engine.close_all(Duration::from_secs(3));
632            return Err(format!("start: {e:?}"));
633        }
634        shared.set_assembly_locked(true);
635    }
636
637    // 0.9.41 复审:句柄改由 `new_setup` 单点安装(0.9.36 起两边都装 = 重复);
638    // 这里只补挂载表 —— 一模块一菜单一窗;未 auto_boot(启动器阶段)也装,
639    // 选完模块起插件后由 `remount_modules()` 补。
640    shared.remount_modules();
641
642    let mut app = StageApp::new_setup(
643        Arc::clone(&engine),
644        Arc::clone(&shared),
645        ui_kinds(&shared, opts.dm_rt),
646        store_ok,
647        opts.ready_marker.clone(),
648    );
649    if opts.auto_boot {
650        app = StageApp {
651            stage: Stage::Running,
652            ..app
653        };
654        app.write_ready_marker();
655    }
656
657    let watcher = spawn_host_watcher(&engine, &shared);
658    // 观察线程句柄交 App 之外统一 join(与 0.9.4 同路;App 内 ensure_watcher 幂等)。
659    let _ = &watcher;
660
661    let opts = eframe::NativeOptions {
662        viewport: egui::ViewportBuilder::default()
663            .with_inner_size([1280.0, 860.0])
664            .with_title("ek-station"),
665        ..Default::default()
666    };
667    // 关窗即收:run_native 返回 = 窗口已关 → 置 stop → 观察线程 close_all(BD-UI-11)。
668    let result = eframe::run_native(
669        "ek-station",
670        opts,
671        Box::new(|cc| {
672            crate::fonts::apply_cjk_fonts(&cc.egui_ctx); // BD-UI-26:CJK 回退,中文不乱码
673            Ok(Box::new(app) as Box<dyn eframe::App>)
674        }),
675    );
676    shared.request_stop();
677    let _ = watcher.join();
678    result.map_err(|e| format!("ui: {e:?}"))
679}
680
681/// 桌面产品入口(ekapp 无参数路,0.9.5 起单源在此):**从配置相起步** ——
682/// 开窗不装配不启动(用户裁定「先配置组件、运行后才出主界面」);
683/// 观察线程在开窗即挂(Setup 关窗也走 close_all 收尾,U-S3b 口径)。
684pub fn run_desktop() -> Result<(), String> {
685    let engine = Arc::new(MainEngine::new(None));
686    let shared = Arc::new(UiShared::new());
687    let store = ek_cfg::Store::eco().ok();
688    let app = StageApp::new_setup(
689        Arc::clone(&engine),
690        Arc::clone(&shared),
691        ui_kinds(&shared, true),
692        store,
693        None,
694    );
695    let watcher = spawn_host_watcher(&engine, &shared);
696    let opts = eframe::NativeOptions {
697        viewport: egui::ViewportBuilder::default()
698            .with_inner_size([1280.0, 860.0])
699            .with_title("ek-station 桌面"),
700        ..Default::default()
701    };
702    let result = eframe::run_native(
703        "ek-station 桌面",
704        opts,
705        Box::new(|cc| {
706            crate::fonts::apply_cjk_fonts(&cc.egui_ctx);
707            Ok(Box::new(app) as Box<dyn eframe::App>)
708        }),
709    );
710    shared.request_stop();
711    let _ = watcher.join();
712    result.map_err(|e| format!("ui: {e:?}"))
713}