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        let sel = kinds.iter().map(|_| true).collect::<Vec<bool>>();
153        Self {
154            shared,
155            engine,
156            kinds,
157            sel: Arc::new(std::sync::Mutex::new(sel)),
158            store,
159            stage: Stage::Setup,
160            notice: None,
161            ready_marker,
162            watcher: None,
163        }
164    }
165
166    /// 当前相(判据面读路)。
167    #[must_use]
168    pub fn stage(&self) -> Stage {
169        self.stage
170    }
171
172    /// 勾选项(工厂名表;ui 行恒真)。
173    #[must_use]
174    pub fn picked(&self) -> Vec<String> {
175        self.kinds
176            .iter()
177            .zip(self.sel.lock().unwrap().iter())
178            .filter(|(_, ok)| **ok)
179            .map(|(k, _)| k.name.to_string())
180            .collect()
181    }
182
183    /// 装配相 = 引擎里已有壳(装配已发生)。
184    #[must_use]
185    pub fn assembled(&self) -> bool {
186        !self.engine.plugin_names().is_empty()
187    }
188
189    /// 勾选开关(判据面与后续持久化用)。
190    ///
191    /// ui 行恒勾**短路**(S4b 纪律,0.9.10 修隐藏 bug:旧写法 `on && name != UI_NAME`
192    /// 对 ui 行的任何调用都写 false = 数据口能把主界面勾掉,渲染口灰显管不住它)。
193    pub fn set_selected(&mut self, name: &str, on: bool) {
194        if name == UI_NAME {
195            return; // ui 行恒真,任何写入都短路(与渲染面灰显同一条纪律)。
196        }
197        if let Some(i) = self.kinds.iter().position(|k| k.name == name) {
198            self.sel.lock().unwrap()[i] = on;
199        }
200    }
201
202    /// 「装配」(0.9.10 并入「启动运行」一钮,界面**无独立装配钮**;`do_start`
203    /// 未装配时自动先走这一步)。
204    ///
205    /// pub 只给判据面分步验状态机(装配态/失败态单独钉)。
206    ///
207    /// 失败 = 显形回执,**不锁、不起跑**(用户 0.9.10 裁定:「装配失败就正常提示
208    /// 报错,然后等待用户」—— 留在配置相,勾可改、钮可再点)。
209    pub fn do_assemble(&mut self) -> String {
210        let mut kinds: Vec<PlugKind> = self
211            .kinds
212            .iter()
213            .enumerate()
214            .filter(|(i, _)| self.sel.lock().unwrap()[*i])
215            .map(|(_, k)| k.clone())
216            .collect();
217        // 升级方案步骤 1(用户裁定):cfg = 核心插件,**启用清单必含,装配必含,
218        // 系统注入不吃勾选**(勾选面剔除 cfg 行,用户无需也无处取消它)。
219        if !kinds.iter().any(|k| k.name == "cfg")
220            && let Some(k) = self.kinds.iter().find(|k| k.name == "cfg")
221        {
222            kinds.push(k.clone());
223        }
224        let names: Vec<String> = kinds.iter().map(|k| k.name.to_string()).collect();
225        let cfg = StationConfig {
226            plugins: names.clone(),
227        };
228        let r = match &self.store {
229            Some(s) => ek_station::boot_with_cfg(&self.engine, &kinds, &cfg, Some(s)),
230            None => ek_station::boot(&self.engine, &kinds, &cfg),
231        };
232        match r {
233            Ok(_) => {
234                self.shared
235                    .log_line(format!("[ui] 装配:{} 壳(未启动)", names.join(",")));
236                format!("已装配 {} 壳:{}", names.len(), names.join(","))
237            }
238            Err(e) => {
239                let msg = format!("装配失败:{e:?}");
240                self.shared.log_line(format!("[ui] {msg}"));
241                msg
242            }
243        }
244    }
245
246    /// 「启动运行」(0.9.10 一钮两义,用户裁定「启动的时候自然要先装配再运行」):
247    /// 未装配 → 先按勾选装配,装配成功才起跑;**装配失败 = 提示报错、留在配置相
248    /// 等用户**(不起跑、不锁,勾可改钮可再点 —— 0.9.10 裁定)。
249    ///
250    /// 已装配(装配失败后重试等)→ 直接起跑。`engine.start()` + ready 标记 +
251    /// 账户清点;**全壳 Running 才置装配锁**(有壳没起来 = 不锁,运维面留修路)。
252    pub fn do_start(&mut self) -> String {
253        if !self.assembled() {
254            let msg = self.do_assemble();
255            if !self.assembled() {
256                // 装配失败:回执显形,等待用户(0.9.10)。
257                return msg;
258            }
259            let _ = msg;
260        }
261        if let Err(e) = self.engine.start() {
262            // start 整段失败:主界面照常出(半运行态),不锁,显形留修路。
263            self.stage = Stage::Running;
264            self.shared.set_assembly_locked(false);
265            self.ensure_watcher();
266            let msg = format!("启动失败:{e:}(运维面可修,装配不锁定)");
267            self.shared.log_line(format!("[ui] {msg}"));
268            return msg;
269        }
270        let not_ready: Vec<String> = self
271            .engine
272            .plugin_names()
273            .into_iter()
274            .filter(|n| !matches!(self.engine.get_state(n), Some(PluginState::Running)))
275            .collect();
276        self.stage = Stage::Running;
277        self.shared.set_assembly_locked(not_ready.is_empty());
278        self.ensure_watcher();
279        self.write_ready_marker();
280        // 账户清点(手点连接裁定:桌面 = 清点列回,手点才连;起连在运行相)。
281        // 0.9.10 用户实锤「勾了自动连接,登录时为什么没连还要人工点?」:勾选此前
282        // **只喂守护路**(`--from` 起连),桌面相清单一律列回不连 = 勾选在桌面形同虚设。
283        // 修复:清点列回后,**勾选项自动入列 Connect**(与面板手点同一出令处 push_connect)。
284        let shared_restore = Arc::clone(&self.shared);
285        std::thread::spawn(move || {
286            let rows = shared_restore.restore_saved_accounts();
287            for gw in &rows {
288                let Some(a) = shared_restore.account(gw) else {
289                    continue;
290                };
291                if a.auto_connect
292                    && matches!(a.stage, crate::state::AccountStage::NotAssembled)
293                    && !a.connected
294                {
295                    let form = a.form.clone();
296                    crate::render::push_connect(&shared_restore, gw, &form);
297                }
298            }
299        });
300        let msg = if not_ready.is_empty() {
301            "已启动:装配面已锁定(只可看不可动)".to_string()
302        } else {
303            format!(
304                "已启动,{} 壳未就绪:{} — 装配**不锁定**(运维面留修路)",
305                not_ready.len(),
306                not_ready.join(",")
307            )
308        };
309        self.shared.log_line(format!("[ui] {msg}"));
310        msg
311    }
312
313    fn ensure_watcher(&mut self) {
314        if self.watcher.is_none() {
315            self.watcher = Some(spawn_host_watcher(&self.engine, &self.shared));
316        }
317    }
318
319    fn write_ready_marker(&self) {
320        let Some(marker) = &self.ready_marker else {
321            return;
322        };
323        use std::io::Write;
324        if let Ok(mut f) = std::fs::File::create(marker) {
325            let _ = f.write_all(crate::launch::READY_TEXT.as_bytes());
326        }
327    }
328
329    /// 组件配置面板(Setup 相中央面板;判据 U-S1a 的查控件面在此,判据面可见)。
330    ///
331    /// 0.9.10 收口(用户两条裁定):
332    /// - **表格 = 主界面同一张表格**:`pinned_table`(表格层)—— 列宽首帧按内容
333    ///   量(装载/组件列收小、说明列吃剩余),表头带/行线/悬停整行跳色全部同源,
334    ///   不再手搭 Grid(Grid 均分列宽 = 用户报的「内容很短列很宽」病根)。
335    /// - **按钮只剩一个「启动运行」**:装配并入(点钮 = 按勾选装配 → 起跑;装配
336    ///   失败 = 提示报错留在本相等用户)。参数面/档案包/导出启动清单三钮**连功能
337    ///   一起铲**(0.9.10 裁定「功能代码要连根铲」;守护路 `--pick`/`--from` 不受影响)。
338    pub fn render_setup_panel(&mut self, ui: &mut egui::Ui) {
339        use crate::render::{OpTone, op_button, pinned_table};
340        ui.heading("组件配置(启动前)");
341        ui.label("勾选要装载的组件 → 「启动运行」(先按勾选装配,再起跑;装配失败会报错,等您改勾选后重按)。");
342        ui.separator();
343        let sel = Arc::clone(&self.sel);
344        // **定量高度子区**(0.9.10 实锤修复,用户报「启动的按钮到哪去了」):
345        // `pinned_table` = egui_table 的 ScrollArea **吃光 CentralPanel 剩余高度**,
346        // 表格后面的「已选/启动运行」区被顶出可视区 = 按钮画得出、看不见。
347        // 口径 = 按行数定量(表头带 + n 行 + 余量),宽度照吃父宽(列宽内容量测
348        // 与补宽逻辑在子区内照常成立;主界面大表不约束,照旧吃满)。
349        let (row_h, head_h) = crate::render::table_heights(ui);
350        let table_h = head_h + ui.visuals().clip_rect_margin
351            + self.kinds.len() as f32 * row_h
352            + 6.0;
353        let avail_x = ui.available_width();
354        ui.allocate_ui(egui::vec2(avail_x, table_h), |ui| {
355            pinned_table(ui, "setup_grid", &["装载", "组件", "说明"], |body, row_h| {
356                for (i, k) in self.kinds.iter().enumerate() {
357                    let name = k.name.to_string();
358                    let doc = k.doc.to_string();
359                    let sel_cell = Arc::clone(&sel);
360                    let label0 = name.clone();
361                    body.row(row_h, false, |row| {
362                        row.cell(move |ui, _ctx| {
363                            if label0 == UI_NAME {
364                                // S4b:ui 行恒勾灰显(勾掉 = 主界面自灭,逻辑不许成立)。
365                                let mut t = true;
366                                ui.add_enabled(false, egui::Checkbox::new(&mut t, ""));
367                                sel_cell.lock().unwrap()[i] = true;
368                            } else {
369                                let mut on = sel_cell.lock().unwrap()[i];
370                                if ui.checkbox(&mut on, "").changed() {
371                                    sel_cell.lock().unwrap()[i] = on;
372                                }
373                            }
374                        });
375                        let label = name.clone();
376                        row.cell(move |ui, _ctx| {
377                            ui.label(egui::RichText::new(&label).strong());
378                        });
379                        row.cell(move |ui, _ctx| {
380                            ui.add(
381                                egui::Label::new(egui::RichText::new(&doc))
382                                    .wrap_mode(egui::TextWrapMode::Extend),
383                            );
384                        });
385                    });
386                }
387            });
388        });
389        ui.separator();
390        let picked = self.picked();
391        ui.label(if picked.is_empty() {
392            "已选: (无)".to_string()
393        } else {
394            format!("已选: {}", picked.join(", "))
395        });
396        ui.horizontal(|ui| {
397            if ui
398                .add_enabled_ui(!picked.is_empty(), |ui| {
399                    op_button(ui, "启动运行", OpTone::Primary).clicked()
400                })
401                .inner
402            {
403                self.notice = Some(self.do_start());
404            }
405        });
406        if let Some(n) = &self.notice {
407            ui.label(egui::RichText::new(n.as_str()).monospace().small());
408        }
409    }
410}
411
412impl eframe::App for StageApp {
413    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
414        // **两相同一套样式**(0.9.10 实锤根因,用户报「Setup 表头和内容的颜色字号
415        // 跟主界面根本不一样」):主界面 `render_body` 每帧头一件事就是
416        // `apply_ui_style`(tokens 字号阶梯 + theme + 整套 visuals),而 Setup 相
417        // 过去直接画面板 = 三件事一件没跑 = egui 默认 style(字号阶梯偏小)+
418        // eframe 默认 Visuals(≠ tokens 全套)= 表头带底/行线/字号全两样。
419        // 提到 update 头 = Setup 相吃同一套;render_body 内部同调用幂等,不重复计费。
420        crate::render::apply_ui_style(ctx, self.shared.theme());
421        // 0.9.10:参数面窗组与档案包窗**连功能铲去**(用户裁定「后面几个按钮都可以
422        // 不要……功能代码要连根铲」)。中央面板是唯一窗口面;守护路 `--pick`/`--from`
423        // 不经这里,不受影响。
424        // 中央面板唯一形貌:配置相 = 组件配置面;运行相 = 主界面
425        // (官方纪律:CentralPanel 最后,BD-UI-29;两相各只画一个 CentralPanel)。
426        if self.stage == Stage::Setup {
427            self.setup_fit_once(ctx);
428            egui::CentralPanel::default().show(ctx, |ui| {
429                self.render_setup_panel(ui);
430            });
431        } else {
432            // **相变一次复位**(0.9.10,与一次性适应同一族纪律):Setup 相把窗收到
433            // 配置面内容宽后,主界面(1280×860 大屏)吃不下小窗 = 转 Running 的
434            // 那一拍发**一次** InnerSize 复位到主界面几何;此后照用户裁定不再自动
435            // 调(最大化/拖拽 = 用户接管 = 不发)。
436            let sent_key = egui::Id::new("running_geo_sent");
437            if !ctx.data(|d| d.get_temp::<bool>(sent_key).unwrap_or(false)) {
438                ctx.data_mut(|d| d.insert_temp(sent_key, true));
439                let maximized = ctx.viewport(|vp| vp.builder.maximized.unwrap_or(false));
440                if !maximized {
441                    ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(
442                        1280.0, 860.0,
443                    )));
444                }
445            }
446            crate::render_body(ctx, &self.shared);
447        }
448        ctx.request_repaint_after(Duration::from_secs(1));
449    }
450}
451
452impl StageApp {
453    /// 主窗 Setup 相**一次性适应**(0.9.10 用户裁定「表格第一次填充之后调一次,
454    /// 后面就不用管它」+「所有弹窗同规」—— 主窗配置面也照此规):
455    /// 隐形量尺(probe)跑配置面内容 → 自然尺寸连续 3 帧稳定且已过 6 帧 →
456    /// 发一次 InnerSize(夹 [420,260]..[1280,900])→ done → 本窗 fit 退役。
457    /// 最大化帧 = 用户接管 = 不量不发(与独立窗 FitOneShot 同一套纪律,同病根:
458    /// 旧 1280 缺省宽 vs 配置面窄内容 = 用户报「内容没多少、表特别大」)。
459    fn setup_fit_once(&mut self, ctx: &egui::Context) {
460        use egui::Id;
461        #[derive(Clone, Copy)]
462        struct FitOneShot {
463            w: f32,
464            h: f32,
465            stable: u8,
466            frames: u16,
467            done: bool,
468        }
469        let key = Id::new(("fit_oneshot", "ekapp_main"));
470        let st = ctx.data(|d| d.get_temp::<FitOneShot>(key)).unwrap_or(FitOneShot {
471            w: -1.0,
472            h: -1.0,
473            stable: 0,
474            frames: 0,
475            done: false,
476        });
477        let maximized = ctx.viewport(|vp| vp.builder.maximized.unwrap_or(false));
478        if st.done || maximized {
479            return;
480        }
481        let mut sc = (*ctx.style()).clone();
482        sc.wrap_mode = Some(egui::TextWrapMode::Extend);
483        let measure_w = ctx
484            .data(|d| d.get_temp::<f32>(Id::new(("fit_w", "ekapp_main"))).unwrap_or(48.0));
485        let mut probe = egui::Ui::new(
486            ctx.clone(),
487            Id::new(("fit_probe", "ekapp_main")),
488            egui::UiBuilder::new()
489                .global_scope(true)
490                .invisible()
491                .style(sc)
492                .max_rect(egui::Rect::from_min_size(
493                    egui::pos2(0.0, 0.0),
494                    egui::vec2(measure_w.max(48.0), 1.0e5),
495                ))
496                .layout(egui::Layout::top_down(egui::Align::Min)),
497        );
498        self.render_setup_panel(&mut probe);
499        let nat_w = probe.min_rect().width();
500        let nat_h = probe.min_rect().height();
501        ctx.data_mut(|d| d.insert_temp(Id::new(("fit_w", "ekapp_main")), nat_w));
502        let stable = (nat_w - st.w).abs() < 0.5 && (nat_h - st.h).abs() < 0.5;
503        let mut next = FitOneShot {
504            w: nat_w,
505            h: nat_h,
506            stable: if stable { st.stable + 1 } else { 0 },
507            frames: st.frames + 1,
508            done: false,
509        };
510        if next.stable >= 3 && next.frames >= 6 {
511            let want = egui::vec2(
512                (nat_w + 20.0).clamp(420.0, 1280.0),
513                (nat_h + 26.0).clamp(260.0, 900.0),
514            );
515            ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(want));
516            next.done = true;
517        }
518        ctx.data_mut(|d| d.insert_temp(key, next));
519    }
520}
521
522/// 运行选项(S1c ready 标记 + S4 dm real_time 装配参数;ek-ui 零配置口径,
523/// 无任何配置文件输入 —— 结构只由 StationConfig 清单决定)。
524#[derive(Debug, Clone)]
525pub struct RunOpts {
526    /// launcher 协议:`start 成功后写 "ready\n"` 的标记路径。
527    pub ready_marker: Option<std::path::PathBuf>,
528    /// dm 工厂 real_time 门(装配参数,缺省 true —— UI 宿主观测面要实时门)。
529    pub dm_rt: bool,
530    /// 0.9.5 阶段机:true = 开窗前装配+启动走完(launcher/headless 式一次性启动,
531    /// ready 协议不破);false = 从配置相起步(桌面 ekapp 裁定面)。
532    pub auto_boot: bool,
533}
534
535impl Default for RunOpts {
536    /// 缺省:无 ready 标记 + **dm_rt = true**(UI 宿主缺省实时门;
537    /// 勿改回 derive(Default) —— bool 缺省 false 会静默关实时门)
538    /// + **auto_boot = true**(launcher/主程序旧口径:开窗即运行相)。
539    fn default() -> Self {
540        Self {
541            ready_marker: None,
542            dm_rt: true,
543            auto_boot: true,
544        }
545    }
546}
547
548impl RunOpts {
549    /// 空选项(无 ready 标记)。
550    pub fn plain() -> Self {
551        Self::default()
552    }
553}
554
555/// 进程主入口:装配(dm/oms/ctp/ui 按清单)→ start → 就绪标记(可选)
556/// → eframe 主循环 → 观察线程收场。
557pub fn run_station_ui(cfg: &StationConfig, opts: &RunOpts) -> Result<(), String> {
558    let engine = Arc::new(MainEngine::new(None));
559    let shared = Arc::new(UiShared::new());
560    let store = ek_cfg::Store::eco();
561
562    // 配置托底根(0.6.0/BD-CFG 环节8):UI 宿主与 headless 同口径 ——
563    // `Store::eco()` 成功 → `boot_with_cfg`(开机逐插件 apply_persisted,
564    // 受理三行序在插件 worker 面);eco 失败(平台缺配置目录,罕见)→
565    // 退回 `boot()`(cfg 收件壳仍在场,fallback 直写仍通,UI 行为即旧版零变)。
566    let store_ok = store.ok();
567    if opts.auto_boot {
568        let boot = match &store_ok {
569            Some(s) => {
570                ek_station::boot_with_cfg(&engine, &ui_kinds(&shared, opts.dm_rt), cfg, Some(s))
571            }
572            None => ek_station::boot(&engine, &ui_kinds(&shared, opts.dm_rt), cfg),
573        };
574        boot.map_err(|e| format!("boot: {e:?}"))?;
575        // start 失败也要走 close_all(0.4.3 BD-UI-64,审核 P3;与 ek-station headless
576        // main.rs 的 start 失败路径同口径):start 是逐个 launch 的,失败时已有插件在
577        // Running —— 不闭就没有 on_stop(网关侧的会话收尾与流目录处理被跳过)。
578        // boot 失败不用闭:那时插件只登记未 launch,没有 worker 要收。
579        if let Err(e) = engine.start() {
580            engine.close_all(Duration::from_secs(3));
581            return Err(format!("start: {e:?}"));
582        }
583        shared.set_assembly_locked(true);
584    }
585
586    let mut app = StageApp::new_setup(
587        Arc::clone(&engine),
588        Arc::clone(&shared),
589        ui_kinds(&shared, opts.dm_rt),
590        store_ok,
591        opts.ready_marker.clone(),
592    );
593    if opts.auto_boot {
594        app = StageApp {
595            stage: Stage::Running,
596            ..app
597        };
598        app.write_ready_marker();
599    }
600
601    let watcher = spawn_host_watcher(&engine, &shared);
602    // 观察线程句柄交 App 之外统一 join(与 0.9.4 同路;App 内 ensure_watcher 幂等)。
603    let _ = &watcher;
604
605    let opts = eframe::NativeOptions {
606        viewport: egui::ViewportBuilder::default()
607            .with_inner_size([1280.0, 860.0])
608            .with_title("ek-station"),
609        ..Default::default()
610    };
611    // 关窗即收:run_native 返回 = 窗口已关 → 置 stop → 观察线程 close_all(BD-UI-11)。
612    let result = eframe::run_native(
613        "ek-station",
614        opts,
615        Box::new(|cc| {
616            crate::fonts::apply_cjk_fonts(&cc.egui_ctx); // BD-UI-26:CJK 回退,中文不乱码
617            Ok(Box::new(app) as Box<dyn eframe::App>)
618        }),
619    );
620    shared.request_stop();
621    let _ = watcher.join();
622    result.map_err(|e| format!("ui: {e:?}"))
623}
624
625/// 桌面产品入口(ekapp 无参数路,0.9.5 起单源在此):**从配置相起步** ——
626/// 开窗不装配不启动(用户裁定「先配置组件、运行后才出主界面」);
627/// 观察线程在开窗即挂(Setup 关窗也走 close_all 收尾,U-S3b 口径)。
628pub fn run_desktop() -> Result<(), String> {
629    let engine = Arc::new(MainEngine::new(None));
630    let shared = Arc::new(UiShared::new());
631    let store = ek_cfg::Store::eco().ok();
632    let app = StageApp::new_setup(
633        Arc::clone(&engine),
634        Arc::clone(&shared),
635        ui_kinds(&shared, true),
636        store,
637        None,
638    );
639    let watcher = spawn_host_watcher(&engine, &shared);
640    let opts = eframe::NativeOptions {
641        viewport: egui::ViewportBuilder::default()
642            .with_inner_size([1280.0, 860.0])
643            .with_title("ek-station 桌面"),
644        ..Default::default()
645    };
646    let result = eframe::run_native(
647        "ek-station 桌面",
648        opts,
649        Box::new(|cc| {
650            crate::fonts::apply_cjk_fonts(&cc.egui_ctx);
651            Ok(Box::new(app) as Box<dyn eframe::App>)
652        }),
653    );
654    shared.request_stop();
655    let _ = watcher.join();
656    result.map_err(|e| format!("ui: {e:?}"))
657}